Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ electron.vite.config.*.mjs
updatesite.sh
test-results/
coverage/
screenshots/output/
screenshots/output/
graphify-out/
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,13 @@ One or two sentences describing what needs to happen and why.

Which modules are likely involved: clipboard/, storage/, hotkeys/, window/, renderer components, preload API, shared types.
```

## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "1.8.6",
"version": "1.8.7",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down
55 changes: 50 additions & 5 deletions src/main/hotkeys/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,16 @@ vi.mock('../clipboard/monitoring', () => ({
setSkipNextImageChange: vi.fn(),
}));

vi.mock('../clipboard/data', () => ({
getCurrentClipboardData: vi.fn().mockReturnValue(null),
}));

import { HotkeyActions } from './actions';
import { clipboard, nativeImage, app } from 'electron';
import { storage } from '../storage';
import { loadImage } from '../storage/image-store';
import { setSkipNextImageChange } from '../clipboard/monitoring';
import { getCurrentClipboardData } from '../clipboard/data';

describe('HotkeyActions', () => {
let actions: HotkeyActions;
Expand Down Expand Up @@ -331,29 +336,69 @@ describe('HotkeyActions', () => {
});

describe('openToolsLauncher', () => {
it('opens tools launcher with first clip content', async () => {
it('opens launcher with live clipboard content even when stored clip is stale', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'fresh' });
vi.mocked(storage.getClips).mockResolvedValue([
{ clip: { type: 'text', content: 'hello' }, isLocked: false, timestamp: 1 },
{ clip: { type: 'text', content: 'stale' }, isLocked: false, timestamp: 1 },
]);

await actions.openToolsLauncher();

const { createToolsLauncherWindow } = await import('../window/creation.js');
expect(createToolsLauncherWindow).toHaveBeenCalledWith('hello');
expect(createToolsLauncherWindow).toHaveBeenCalledWith('fresh');
});

it('does not read stored history when live clipboard has content', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'fresh' });

await actions.openToolsLauncher();

expect(storage.getClips).not.toHaveBeenCalled();
});

it('does nothing when no clips available', async () => {
it('falls back to most recent stored clip when live read is null', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue(null);
vi.mocked(storage.getClips).mockResolvedValue([
{ clip: { type: 'text', content: 'stored' }, isLocked: false, timestamp: 1 },
]);

await actions.openToolsLauncher();

const { createToolsLauncherWindow } = await import('../window/creation.js');
expect(createToolsLauncherWindow).toHaveBeenCalledWith('stored');
});

it('falls back to stored clip when live read content is empty', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: '' });
vi.mocked(storage.getClips).mockResolvedValue([
{ clip: { type: 'text', content: 'stored' }, isLocked: false, timestamp: 1 },
]);

await actions.openToolsLauncher();

const { createToolsLauncherWindow } = await import('../window/creation.js');
expect(createToolsLauncherWindow).toHaveBeenCalledWith('stored');
});

it('does nothing when live read is null and no clips available', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue(null);
vi.mocked(storage.getClips).mockResolvedValue([]);

await expect(actions.openToolsLauncher()).resolves.toBeUndefined();

const { createToolsLauncherWindow } = await import('../window/creation.js');
expect(createToolsLauncherWindow).not.toHaveBeenCalled();
});

it('does nothing when first clip is null', async () => {
it('does nothing when live read is null and first stored clip is null', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(storage.getClips).mockResolvedValue([null as any]);
await expect(actions.openToolsLauncher()).resolves.toBeUndefined();
});

it('handles error gracefully', async () => {
vi.mocked(getCurrentClipboardData).mockReturnValue(null);
vi.mocked(storage.getClips).mockRejectedValue(new Error('fail'));
await expect(actions.openToolsLauncher()).resolves.toBeUndefined();
});
Expand Down
36 changes: 21 additions & 15 deletions src/main/hotkeys/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { storage } from '../storage';
import { showNotification } from '../notifications';
import { loadImage } from '../storage/image-store';
import { setSkipNextImageChange } from '../clipboard/monitoring';
import { getCurrentClipboardData } from '../clipboard/data';
import type { StoredClip } from '../../shared/types';

/**
Expand Down Expand Up @@ -160,29 +161,34 @@ export class HotkeyActions {
}

/**
* Open tools launcher for the first (most recent) clip
* Open tools launcher for the content currently on the system clipboard.
*
* Reads the live clipboard directly rather than the most recent stored clip,
* because stored history trails the real clipboard by at least one 250ms poll
* plus an IPC/renderer roundtrip. If the user copies and immediately fires the
* hotkey, the live read reflects the just-copied content while history does not.
* Falls back to the most recent stored clip only when the live read is empty
* (e.g. empty clipboard or an unsupported format).
*/
async openToolsLauncher(): Promise<void> {
try {
const clips = await storage.getClips();
if (!clips || clips.length === 0) {
console.warn('No clips available for tools launcher');
return;
}

const firstClip = clips[0];
if (!firstClip) {
console.warn('No first clip found');
return;
const liveData = getCurrentClipboardData();
let content = liveData?.content;

if (!content) {
const clips = await storage.getClips();
const firstClip = clips[0];
if (!firstClip) {
console.warn('No clips available for tools launcher');
return;
}
content = firstClip.clip.content;
}

// Import the createToolsLauncherWindow function
const { createToolsLauncherWindow } = await import('../window/creation.js');

// Open the tools launcher with the first clip's content
createToolsLauncherWindow(firstClip.clip.content);

console.log('Hotkey: Opened tools launcher for first clip');
createToolsLauncherWindow(content);
} catch (error) {
console.error('Error opening tools launcher:', error);
}
Expand Down
Loading