diff --git a/.gitignore b/.gitignore index bfe0e56..56dcaaa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ electron.vite.config.*.mjs updatesite.sh test-results/ coverage/ -screenshots/output/ \ No newline at end of file +screenshots/output/ +graphify-out/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b2346af..559534c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` 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). diff --git a/package-lock.json b/package-lock.json index 1e001cd..e2d5053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "1.8.6", + "version": "1.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "1.8.6", + "version": "1.8.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3d88138..b564005 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/hotkeys/actions.test.ts b/src/main/hotkeys/actions.test.ts index 9060e6a..5487ef7 100644 --- a/src/main/hotkeys/actions.test.ts +++ b/src/main/hotkeys/actions.test.ts @@ -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; @@ -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(); }); diff --git a/src/main/hotkeys/actions.ts b/src/main/hotkeys/actions.ts index 5702898..cfa747d 100644 --- a/src/main/hotkeys/actions.ts +++ b/src/main/hotkeys/actions.ts @@ -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'; /** @@ -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 { 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); }