diff --git a/CLAUDE.md b/CLAUDE.md index c9d23f8..b2346af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,11 +27,13 @@ Clipless is an Electron clipboard manager built with React and TypeScript. It mo After making any code changes, always run the following before considering work complete: 1. **Lint and typecheck** — must produce zero errors and zero warnings: + ```bash npm run lint && npm run typecheck ``` 2. **Unit tests with coverage** — must maintain 100% code coverage across statements, branches, functions, and lines: + ```bash npx vitest run --coverage ``` diff --git a/README.md b/README.md index dff7139..e398338 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ A powerful and intelligent clipboard manager built with Electron, React, and Typ - Encrypted data storage using OS-native encryption - Windows: DPAPI, macOS: Keychain, Linux: Secret Service +- Domain-specific storage files (settings, clips, templates) for efficient per-domain saves +- Images stored as separate encrypted files with generated thumbnails for fast rendering +- Non-blocking startup: window displays immediately, data loads in background - Persistent clipboard history - Lock clips to prevent automatic removal - Export/import functionality for backup diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 81abfb9..5fb32f8 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -14,13 +14,16 @@ This document describes the persistent storage system implemented for Clipless u ### Data Persistence -- **Clips Storage**: All clipboard items are automatically saved with their lock status -- **Settings Storage**: User preferences and application settings are persisted +- **Domain-Specific Files**: Data is split into separate encrypted files by domain for efficient per-domain saves +- **Settings Storage**: User preferences stored in `settings.enc` +- **Clips Storage**: Clipboard items stored in `clips.enc` +- **Templates Storage**: Templates, search terms, and quick tools stored in `templates.enc` +- **Metadata**: Unencrypted `meta.json` tracks storage version for migration - **Automatic Sync**: Data is automatically loaded on startup and saved when changed ## Stored Data -### Clips +### Clips (`clips.enc`) Each clip is stored with: @@ -29,7 +32,7 @@ Each clip is stored with: - **Lock Status**: Whether the clip is locked to prevent removal - **Timestamp**: When the clip was captured -### Settings +### Settings (`settings.enc`) User preferences including: @@ -37,14 +40,49 @@ User preferences including: - **startMinimized**: Start application minimized (default: false) - **autoStart**: Start with system (default: false) - **theme**: UI theme preference (light/dark/system) +- **hotkeys**: Global hotkey configuration + +### Templates Data (`templates.enc`) + +- **Templates**: Text generation templates with token placeholders +- **Search Terms**: Regex patterns for Quick Clips pattern scanning +- **Quick Tools**: URL templates for opening web resources with extracted data + +### Metadata (`meta.json`) + +- **version**: Application version that last wrote the data +- **storageVersion**: Storage format version for future migrations + +### Window Bounds (`window-bounds.json`) + +- Unencrypted JSON storing window position and size +- Read directly at startup without waiting for encrypted storage initialization ## Storage Location Data is stored in the user's application data directory: -- **Windows**: `%APPDATA%\clipless\clipless-data\data.enc` -- **macOS**: `~/Library/Application Support/clipless/clipless-data/data.enc` -- **Linux**: `~/.config/clipless/clipless-data/data.enc` +- **Windows**: `%APPDATA%\clipless\clipless-data\` +- **macOS**: `~/Library/Application Support/clipless/clipless-data/` +- **Linux**: `~/.config/clipless/clipless-data/` + +Files: + +- `settings.enc` — encrypted user settings +- `clips.enc` — encrypted clipboard data +- `templates.enc` — encrypted templates, search terms, quick tools +- `meta.json` — unencrypted storage version metadata +- `window-bounds.json` — unencrypted window position/size +- `images/{id}.enc` — encrypted full-size clipboard images +- `images/{id}_thumb.enc` — encrypted 200px-wide image thumbnails + +## Startup Flow + +1. **Window bounds** are loaded directly from `window-bounds.json` (no encryption dependency) +2. **Window displays immediately** with default data +3. **Storage initializes in background**: migrates legacy data if needed, then loads domain files +4. **`storage-ready` IPC event** notifies the renderer to re-fetch real data +5. **Window settings** are re-applied (transparency, always-on-top) ## API Reference @@ -62,7 +100,7 @@ Data is stored in the user's application data directory: #### Data Management -- `storage-get-stats`: Get storage statistics +- `storage-get-stats`: Get storage statistics (sums all domain file sizes) - `storage-export-data`: Export data as JSON - `storage-import-data`: Import data from JSON - `storage-clear-all`: Clear all stored data @@ -72,6 +110,11 @@ Data is stored in the user's application data directory: #### Available through `window.api`: ```typescript +// Listen for storage ready (background load complete) +window.api.onStorageReady(() => { + /* re-fetch data */ +}); + // Get clips from storage const clips = await window.api.storageGetClips(); @@ -104,8 +147,9 @@ const success = await window.api.storageClearAll(); The storage system is automatically integrated into the clips provider: 1. **Startup**: Clips and settings are loaded from storage -2. **Real-time Sync**: Changes are automatically saved with debouncing -3. **Error Handling**: Graceful fallbacks when storage is unavailable +2. **Storage Ready**: When background load completes, renderer re-fetches real data +3. **Real-time Sync**: Changes are automatically saved with debouncing +4. **Error Handling**: Graceful fallbacks when storage is unavailable ### Manual Integration @@ -117,13 +161,13 @@ import { storage } from './main/storage'; // Initialize storage await storage.initialize(); -// Save clips +// Save clips (writes only clips.enc) await storage.saveClips(clips, lockedIndices); // Load clips const storedClips = await storage.getClips(); -// Manage settings +// Manage settings (writes only settings.enc) await storage.saveSettings({ maxClips: 20 }); const settings = await storage.getSettings(); ``` @@ -135,6 +179,7 @@ const settings = await storage.getSettings(); - Data is encrypted at rest using platform-native encryption - Encryption keys are managed by the operating system - No encryption key management required in application code +- JSON is serialized without pretty-printing to minimize encrypted payload size ### Access Control @@ -150,9 +195,18 @@ const settings = await storage.getSettings(); ## Data Migration -The storage system includes automatic data migration: +### Legacy Migration (v1 → v2) -- **Version Detection**: Checks data format version +When upgrading from the monolithic `data.enc` format: + +1. Detects `data.enc` exists but `clips.enc` does not +2. Reads and validates the legacy blob +3. Splits into domain-specific files (`settings.enc`, `clips.enc`, `templates.enc`, `meta.json`) +4. Renames `data.enc` to `data.enc.migrated` + +### Ongoing Migration + +- **Version Detection**: Checks data format version in `meta.json` - **Graceful Upgrades**: Migrates old data formats to new schemas - **Validation**: Ensures data integrity during migration - **Fallback**: Uses defaults for invalid or missing data @@ -163,8 +217,8 @@ The storage system includes automatic data migration: Users can export their data as unencrypted JSON: -- Includes all clips and settings -- Human-readable format +- Includes all clips, settings, templates, search terms, and quick tools +- Human-readable format (pretty-printed for export only) - Can be used for manual backup ### Import Data @@ -172,16 +226,19 @@ Users can export their data as unencrypted JSON: Users can import previously exported data: - Validates JSON format -- Merges with existing data +- Splits into domain-specific files - Preserves data integrity ## Performance ### Optimizations -- **Debounced Saves**: Prevents excessive disk writes -- **Lazy Loading**: Storage is initialized only when needed -- **Efficient Serialization**: Minimal data transformation +- **Domain-Specific Saves**: Each save writes only the affected file, not the entire dataset +- **No Pretty-Printing**: Internal storage uses compact JSON to minimize encrypted size +- **Per-Domain Save Queuing**: Prevents concurrent writes to the same file while allowing parallel saves across domains +- **Debounced Saves**: Prevents excessive disk writes from the renderer +- **Non-Blocking Startup**: Window displays immediately; data loads in background +- **Direct Window Bounds**: `window-bounds.json` is read without encryption for instant position restore - **Error Recovery**: Continues operation even if storage fails ### Storage Size @@ -212,21 +269,3 @@ Users can import previously exported data: - User data directory may not be accessible - App falls back to temporary storage - Consider running with proper permissions - -### Debug Information - -Enable debug logging by setting environment variable: - -```bash -DEBUG=clipless:storage -``` - -## Future Enhancements - -### Planned Features - -- **Cloud Sync**: Optional cloud storage synchronization -- **Encryption Options**: User-selectable encryption methods -- **Data Compression**: Reduce storage size for large datasets -- **Selective Backup**: Export/import specific data types -- **Storage Quotas**: Limit storage size with automatic cleanup diff --git a/e2e/fixtures/test-image.png b/e2e/fixtures/test-image.png new file mode 100644 index 0000000..4e9f4a0 Binary files /dev/null and b/e2e/fixtures/test-image.png differ diff --git a/e2e/image-clipboard.spec.ts b/e2e/image-clipboard.spec.ts new file mode 100644 index 0000000..b66c98a --- /dev/null +++ b/e2e/image-clipboard.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Image Clipboard', () => { + test('image clip appears after copying an image', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + + // Load a test image and write it to the clipboard via Electron's nativeImage + const testImagePath = resolve(__dirname, 'fixtures/test-image.png'); + await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => { + const image = nativeImage.createFromPath(imgPath); + clipboard.writeImage(image); + }, testImagePath); + + // Wait for clipboard polling to detect the image (250ms interval + processing) + await window.waitForTimeout(2000); + + // Verify an image clip appeared with the expected elements + const imgPreview = window.locator('img[alt="Clipboard image preview"]'); + await expect(imgPreview.first()).toBeVisible({ timeout: 5000 }); + + // Verify image metadata is displayed + const body = await window.textContent('body'); + expect(body).toContain('Image (PNG)'); + + await app.close(); + }); + + test('copying a second image adds another clip', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + + const testImagePath = resolve(__dirname, 'fixtures/test-image.png'); + + // Copy first image + await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => { + const image = nativeImage.createFromPath(imgPath); + clipboard.writeImage(image); + }, testImagePath); + + await window.waitForTimeout(2000); + await expect(window.locator('img[alt="Clipboard image preview"]').first()).toBeVisible({ + timeout: 5000, + }); + + // Clear clipboard with text to reset, then copy a different image + await app.evaluate(async ({ clipboard }) => { + clipboard.writeText('separator'); + }); + await window.waitForTimeout(1000); + + // Copy image again (will have different fingerprint due to fresh nativeImage instance) + await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => { + // Create a slightly different image by modifying pixels + const image = nativeImage.createFromPath(imgPath); + const size = image.getSize(); + const buf = image.toBitmap(); + // Flip a pixel to ensure different fingerprint + buf[0] = buf[0] === 0 ? 1 : 0; + const modified = nativeImage.createFromBitmap(buf, { width: size.width, height: size.height }); + clipboard.writeImage(modified); + }, testImagePath); + + await window.waitForTimeout(2000); + + // Should now have two image clips + const imgPreviews = window.locator('img[alt="Clipboard image preview"]'); + await expect(imgPreviews).toHaveCount(2, { timeout: 5000 }); + + await app.close(); + }); +}); diff --git a/package-lock.json b/package-lock.json index ae069c1..cd9f981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "1.7.1", + "version": "1.7.2", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-virtual": "^3.13.23", "classnames": "^2.5.1", "electron-updater": "^6.3.9", "react-outside-click-handler": "^1.3.0", @@ -2755,6 +2756,33 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index b923753..00e2fc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "1.7.1", + "version": "1.7.2", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "Daniel Essig", @@ -37,6 +37,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-virtual": "^3.13.23", "classnames": "^2.5.1", "electron-updater": "^6.3.9", "react-outside-click-handler": "^1.3.0", diff --git a/src/main/app/index.ts b/src/main/app/index.ts index 1eb3fe9..4c13831 100644 --- a/src/main/app/index.ts +++ b/src/main/app/index.ts @@ -28,12 +28,15 @@ export async function initializeApp(): Promise { }), ]); - // Set up callback to re-apply window settings after background storage loading completes + // Set up callback to re-apply window settings and notify renderer after background storage loading completes storage.setOnBackgroundLoadComplete(() => { const mainWindow = getMainWindow(); if (mainWindow) { console.log('Background storage loading complete, re-applying window settings'); applyWindowSettings(mainWindow); + + // Notify renderer that storage data is ready for re-fetching + mainWindow.webContents.send('storage-ready'); } }); diff --git a/src/main/clipboard/data.test.ts b/src/main/clipboard/data.test.ts index 36b25a4..d29a699 100644 --- a/src/main/clipboard/data.test.ts +++ b/src/main/clipboard/data.test.ts @@ -5,7 +5,12 @@ vi.mock('electron', () => ({ readText: vi.fn().mockReturnValue(''), readHTML: vi.fn().mockReturnValue(''), readRTF: vi.fn().mockReturnValue(''), - readImage: vi.fn().mockReturnValue({ isEmpty: () => true, toDataURL: () => '' }), + readImage: vi.fn().mockReturnValue({ + isEmpty: () => true, + toDataURL: () => '', + getSize: () => ({ width: 0, height: 0 }), + toBitmap: () => Buffer.from(''), + }), readBookmark: vi.fn().mockReturnValue({ title: '', url: '' }), writeText: vi.fn(), writeHTML: vi.fn(), @@ -33,20 +38,33 @@ import { setClipboardRTF, setClipboardImage, setClipboardBookmark, + clearImageCache, } from './data'; + +function createMockImage( + empty: boolean, + dataUrl = '', + width = 0, + height = 0 +): Electron.NativeImage { + return { + isEmpty: () => empty, + toDataURL: () => dataUrl, + getSize: () => ({ width, height }), + toBitmap: () => Buffer.from('mock-bitmap-data-for-testing'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} import { clipboard, nativeImage } from 'electron'; describe('getCurrentClipboardData', () => { beforeEach(() => { vi.clearAllMocks(); + clearImageCache(); vi.mocked(clipboard.readText).mockReturnValue(''); vi.mocked(clipboard.readRTF).mockReturnValue(''); vi.mocked(clipboard.readHTML).mockReturnValue(''); - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => true, - toDataURL: () => '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue(createMockImage(true)); vi.mocked(clipboard.readBookmark).mockReturnValue({ title: '', url: '' }); }); @@ -69,15 +87,61 @@ describe('getCurrentClipboardData', () => { }); it('returns image type when only image is available', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => false, - toDataURL: () => 'data:image/png;base64,abc', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue( + createMockImage(false, 'data:image/png;base64,abc', 100, 100) + ); const result = getCurrentClipboardData(); expect(result).toEqual({ type: 'image', content: 'data:image/png;base64,abc' }); }); + it('caches image data URL and skips toDataURL on unchanged image', () => { + const toDataURL = vi.fn().mockReturnValue('data:image/png;base64,abc'); + const mockImage = { + isEmpty: () => false, + toDataURL, + getSize: () => ({ width: 100, height: 100 }), + toBitmap: () => Buffer.from('same-bitmap-data'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage as any); + + // First call — should call toDataURL + getCurrentClipboardData(); + expect(toDataURL).toHaveBeenCalledTimes(1); + + // Second call with same image — should NOT call toDataURL again + getCurrentClipboardData(); + expect(toDataURL).toHaveBeenCalledTimes(1); + }); + + it('calls toDataURL when image fingerprint changes', () => { + const toDataURL1 = vi.fn().mockReturnValue('data:image/png;base64,first'); + const mockImage1 = { + isEmpty: () => false, + toDataURL: toDataURL1, + getSize: () => ({ width: 100, height: 100 }), + toBitmap: () => Buffer.from('bitmap-data-1'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage1 as any); + const result1 = getCurrentClipboardData(); + expect(result1?.content).toBe('data:image/png;base64,first'); + + // Different image + const toDataURL2 = vi.fn().mockReturnValue('data:image/png;base64,second'); + const mockImage2 = { + isEmpty: () => false, + toDataURL: toDataURL2, + getSize: () => ({ width: 200, height: 200 }), + toBitmap: () => Buffer.from('bitmap-data-2'), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(clipboard.readImage).mockReturnValue(mockImage2 as any); + const result2 = getCurrentClipboardData(); + expect(result2?.content).toBe('data:image/png;base64,second'); + expect(toDataURL2).toHaveBeenCalledTimes(1); + }); + it('returns bookmark type when only bookmark is available', () => { vi.mocked(clipboard.readBookmark).mockReturnValue({ title: 'Example', @@ -149,20 +213,14 @@ describe('getClipboardRTF', () => { describe('getClipboardImage', () => { it('returns data URL when image exists', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => false, - toDataURL: () => 'data:image/png;base64,abc', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue( + createMockImage(false, 'data:image/png;base64,abc', 100, 100) + ); expect(getClipboardImage()).toBe('data:image/png;base64,abc'); }); it('returns null when no image', () => { - vi.mocked(clipboard.readImage).mockReturnValue({ - isEmpty: () => true, - toDataURL: () => '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + vi.mocked(clipboard.readImage).mockReturnValue(createMockImage(true)); expect(getClipboardImage()).toBeNull(); }); }); diff --git a/src/main/clipboard/data.ts b/src/main/clipboard/data.ts index 5f79918..ba2ca91 100644 --- a/src/main/clipboard/data.ts +++ b/src/main/clipboard/data.ts @@ -1,8 +1,22 @@ import { clipboard, nativeImage } from 'electron'; +// Cached image fingerprint to avoid expensive toDataURL on every poll +let lastImageFingerprint = ''; +let lastImageDataUrl = ''; + +function getImageFingerprint(image: Electron.NativeImage): string { + const size = image.getSize(); + const bitmap = image.toBitmap(); + // Use dimensions + bitmap byte length + first 64 bytes as a fast fingerprint + const sample = bitmap.subarray(0, 64).toString('base64'); + return `${size.width}x${size.height}:${bitmap.length}:${sample}`; +} + // Helper function to determine the current clipboard type and content export const getCurrentClipboardData = (): { type: string; content: string } | null => { - // Priority: text > rtf > html > image > bookmark + // Priority: text > rtf > image > html > bookmark + // Image is checked before HTML because some apps (e.g. Discord) put both + // an HTML tag and the actual image binary on the clipboard. const text = clipboard.readText(); if (text?.trim()) { return { type: 'text', content: text }; @@ -13,16 +27,22 @@ export const getCurrentClipboardData = (): { type: string; content: string } | n return { type: 'rtf', content: rtf }; } + const image = clipboard.readImage(); + if (!image.isEmpty()) { + const fingerprint = getImageFingerprint(image); + if (fingerprint !== lastImageFingerprint) { + // Image changed — do the expensive toDataURL conversion + lastImageFingerprint = fingerprint; + lastImageDataUrl = image.toDataURL(); + } + return { type: 'image', content: lastImageDataUrl }; + } + const html = clipboard.readHTML(); if (html?.trim()) { return { type: 'html', content: html }; } - const image = clipboard.readImage(); - if (!image.isEmpty()) { - return { type: 'image', content: image.toDataURL() }; - } - try { const bookmark = clipboard.readBookmark(); if (bookmark?.url) { @@ -35,6 +55,11 @@ export const getCurrentClipboardData = (): { type: string; content: string } | n return null; }; +export function clearImageCache(): void { + lastImageFingerprint = ''; + lastImageDataUrl = ''; +} + // Clipboard read operations export const getClipboardText = (): string => clipboard.readText(); export const getClipboardHTML = (): string => clipboard.readHTML(); diff --git a/src/main/clipboard/ipc.ts b/src/main/clipboard/ipc.ts index 86bf69a..8d06e79 100644 --- a/src/main/clipboard/ipc.ts +++ b/src/main/clipboard/ipc.ts @@ -12,7 +12,11 @@ import { setClipboardImage, setClipboardBookmark, } from './data'; -import { startClipboardMonitoring, stopClipboardMonitoring } from './monitoring'; +import { + startClipboardMonitoring, + stopClipboardMonitoring, + setSkipNextImageChange, +} from './monitoring'; import { getClips, saveClips, @@ -63,6 +67,7 @@ import type { QuickClipsConfig, } from '../../shared/types'; import { showNotification } from '../notifications'; +import { loadImage } from '../storage/image-store'; let ipcHandlersRegistered = false; // Guard to prevent multiple IPC registrations @@ -88,9 +93,10 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { ipcMain.handle('set-clipboard-text', (_event, text: string) => setClipboardText(text)); ipcMain.handle('set-clipboard-html', (_event, html: string) => setClipboardHTML(html)); ipcMain.handle('set-clipboard-rtf', (_event, rtf: string) => setClipboardRTF(rtf)); - ipcMain.handle('set-clipboard-image', (_event, imageData: string) => - setClipboardImage(imageData) - ); + ipcMain.handle('set-clipboard-image', (_event, imageData: string) => { + setSkipNextImageChange(); + return setClipboardImage(imageData); + }); ipcMain.handle( 'set-clipboard-bookmark', (_event, bookmarkData: { text: string; html: string; title?: string; url?: string }) => @@ -122,6 +128,19 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { ipcMain.handle('storage-import-data', async (_event, jsonData: string) => importData(jsonData)); ipcMain.handle('storage-clear-all', async () => clearAllData()); + // Image storage handler - load full image on demand + ipcMain.handle('get-full-image', async (_event, imageId: string) => { + try { + const { app } = await import('electron'); + const { join } = await import('path'); + const dataPath = join(app.getPath('userData'), 'clipless-data'); + return await loadImage(imageId, dataPath); + } catch (error) { + console.error('Failed to load full image:', error); + return null; + } + }); + // Template management handlers ipcMain.handle('templates-get-all', async () => getAllTemplates()); ipcMain.handle('templates-create', async (_event, name: string, content: string) => diff --git a/src/main/clipboard/monitoring.test.ts b/src/main/clipboard/monitoring.test.ts new file mode 100644 index 0000000..6e7294b --- /dev/null +++ b/src/main/clipboard/monitoring.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/mock/userData'), + }, + BrowserWindow: vi.fn(), +})); + +vi.mock('./data', () => ({ + getCurrentClipboardData: vi.fn(), +})); + +vi.mock('../storage/image-store', () => ({ + saveImage: vi.fn(), +})); + +vi.mock('../storage/search-terms', () => ({ + generateId: vi.fn(), +})); + +import { getCurrentClipboardData } from './data'; +import { saveImage } from '../storage/image-store'; +import { generateId } from '../storage/search-terms'; +import { + initializeClipboardMonitoring, + checkClipboard, + startClipboardMonitoring, + stopClipboardMonitoring, + setSkipNextImageChange, +} from './monitoring'; + +function createMockWindow(destroyed = false): { + isDestroyed: () => boolean; + webContents: { send: ReturnType }; +} { + return { + isDestroyed: () => destroyed, + webContents: { send: vi.fn() }, + }; +} + +describe('monitoring', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Reset internal state by simulating a null clipboard read + // so lastClipboardContent/lastClipboardType become predictable + vi.mocked(getCurrentClipboardData).mockReturnValue(null); + }); + + afterEach(() => { + stopClipboardMonitoring(); + vi.useRealTimers(); + }); + + describe('initializeClipboardMonitoring', () => { + it('captures initial clipboard state when clipboard has data', () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'initial' }); + const mockWindow = createMockWindow(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // Verify it captured state by checking that checkClipboard + // does NOT fire for the same content + // eslint-disable-next-line @typescript-eslint/no-explicit-any + checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('handles null clipboard data during initialization', () => { + vi.mocked(getCurrentClipboardData).mockReturnValue(null); + const mockWindow = createMockWindow(); + + // Should not throw + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // After init with null, sending new text should trigger change + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'new' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'text', + content: 'new', + }); + }); + }); + + describe('checkClipboard', () => { + it('detects new text content and sends clipboard-changed', async () => { + // First, set a known baseline state + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'old-text' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // Now change the clipboard content + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'new-text' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'text', + content: 'new-text', + }); + }); + + it('skips when content has not changed', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'same' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // Same content, same type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('detects change when type changes but content is the same', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'data' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // Same content, different type + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'html', content: 'data' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'html', + content: 'data', + }); + }); + + it('handles image content - saves to image store and sends with imageId + thumbnailDataUrl', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'baseline' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,fulldata', + }); + vi.mocked(generateId).mockReturnValue('img-uuid-123'); + vi.mocked(saveImage).mockResolvedValue('data:image/png;base64,thumbnail'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(generateId).toHaveBeenCalled(); + expect(saveImage).toHaveBeenCalledWith( + 'img-uuid-123', + 'data:image/png;base64,fulldata', + expect.stringContaining('clipless-data') + ); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'image', + content: 'img-uuid-123', + imageId: 'img-uuid-123', + thumbnailDataUrl: 'data:image/png;base64,thumbnail', + }); + }); + + it('skips image when skipNextImageChange flag is set', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'base' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + setSkipNextImageChange(); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,skip-me', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + expect(saveImage).not.toHaveBeenCalled(); + }); + + it('resets skipNextImageChange flag after skipping once', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'base2' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + setSkipNextImageChange(); + + // First image - should be skipped + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,first', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + + // Second image - flag should be cleared, so this should be processed + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,second', + }); + vi.mocked(generateId).mockReturnValue('img-2'); + vi.mocked(saveImage).mockResolvedValue('data:image/png;base64,thumb2'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'image', + content: 'img-2', + imageId: 'img-2', + thumbnailDataUrl: 'data:image/png;base64,thumb2', + }); + }); + + it('handles image save failure by falling back to sending raw data', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'pre-fail' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,fallback', + }); + vi.mocked(generateId).mockReturnValue('fail-id'); + vi.mocked(saveImage).mockRejectedValue(new Error('disk full')); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to save image to image store:', + expect.any(Error) + ); + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'image', + content: 'data:image/png;base64,fallback', + }); + + consoleErrorSpy.mockRestore(); + }); + + it('does nothing when clipboard data is null', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'setup' }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + vi.mocked(getCurrentClipboardData).mockReturnValue(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('does nothing when mainWindow is null', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'null-win' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(null as any); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ type: 'text', content: 'changed' }); + await checkClipboard(null); + + // Should not throw; no send should occur + }); + + it('does nothing when mainWindow is destroyed', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'destroyed-win', + }); + const mockWindow = createMockWindow(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + const destroyedWindow = createMockWindow(true); + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'after-destroy', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(destroyedWindow as any); + + expect(destroyedWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('skipNextImageChange flag does not affect non-image types', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'skip-text-base', + }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + setSkipNextImageChange(); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'new-text-content', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + // Text changes should still be sent even with skip flag set + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'text', + content: 'new-text-content', + }); + }); + }); + + describe('setSkipNextImageChange', () => { + it('sets the flag so next image change is skipped', async () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'flag-test-base', + }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + setSkipNextImageChange(); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'image', + content: 'data:image/png;base64,flagtest', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await checkClipboard(mockWindow as any); + + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + }); + + describe('startClipboardMonitoring', () => { + it('starts interval polling that calls checkClipboard', () => { + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'poll-start', + }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = startClipboardMonitoring(mockWindow as any); + expect(result).toBe(true); + + // Change clipboard content + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'polled-text', + }); + + // Advance timer by 250ms (one interval) + vi.advanceTimersByTime(250); + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('clipboard-changed', { + type: 'text', + content: 'polled-text', + }); + }); + + it('clears previous interval when called again', () => { + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'double-start', + }); + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initializeClipboardMonitoring(mockWindow as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startClipboardMonitoring(mockWindow as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startClipboardMonitoring(mockWindow as any); + + // clearInterval should have been called for the first interval + expect(clearIntervalSpy).toHaveBeenCalled(); + + clearIntervalSpy.mockRestore(); + }); + + it('returns true', () => { + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = startClipboardMonitoring(mockWindow as any); + expect(result).toBe(true); + }); + }); + + describe('stopClipboardMonitoring', () => { + it('stops interval polling and returns true', () => { + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startClipboardMonitoring(mockWindow as any); + + const result = stopClipboardMonitoring(); + expect(result).toBe(true); + + // Change clipboard - should not trigger send since monitoring stopped + vi.mocked(getCurrentClipboardData).mockReturnValue({ + type: 'text', + content: 'after-stop', + }); + vi.advanceTimersByTime(500); + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('returns true even when no interval is running', () => { + const result = stopClipboardMonitoring(); + expect(result).toBe(true); + }); + + it('sets interval reference to null', () => { + const mockWindow = createMockWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startClipboardMonitoring(mockWindow as any); + stopClipboardMonitoring(); + + // Calling stop again should still work (no error on clearing null) + const result = stopClipboardMonitoring(); + expect(result).toBe(true); + }); + }); +}); diff --git a/src/main/clipboard/monitoring.ts b/src/main/clipboard/monitoring.ts index ec11520..792321c 100644 --- a/src/main/clipboard/monitoring.ts +++ b/src/main/clipboard/monitoring.ts @@ -1,10 +1,18 @@ -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow } from 'electron'; +import { join } from 'path'; import { getCurrentClipboardData } from './data'; +import { saveImage } from '../storage/image-store'; +import { generateId } from '../storage/search-terms'; // Clipboard monitoring state let lastClipboardContent = ''; let lastClipboardType = ''; let clipboardCheckInterval: NodeJS.Timeout | null = null; +let skipNextImageChange = false; + +function getDataPath(): string { + return join(app.getPath('userData'), 'clipless-data'); +} // Initialize clipboard monitoring export function initializeClipboardMonitoring(_mainWindow: BrowserWindow | null): void { @@ -16,8 +24,17 @@ export function initializeClipboardMonitoring(_mainWindow: BrowserWindow | null) } } +/** + * Set flag to skip the next image clipboard change detection. + * Used when copying an image clip back to the system clipboard + * to prevent re-detecting it as a new clip. + */ +export function setSkipNextImageChange(): void { + skipNextImageChange = true; +} + // Clipboard change detection function -export const checkClipboard = (mainWindow: BrowserWindow | null) => { +export const checkClipboard = async (mainWindow: BrowserWindow | null): Promise => { const currentClipData = getCurrentClipboardData(); // Check if clipboard content has changed @@ -25,14 +42,41 @@ export const checkClipboard = (mainWindow: BrowserWindow | null) => { currentClipData && (currentClipData.content !== lastClipboardContent || currentClipData.type !== lastClipboardType) ) { + // Update last known values before any async work + lastClipboardContent = currentClipData.content; + lastClipboardType = currentClipData.type; + + // For images, check skip flag (set when copying image clip back to clipboard) + if (currentClipData.type === 'image' && skipNextImageChange) { + skipNextImageChange = false; + return; + } + + let clipToSend: Record = currentClipData; + + // For images, save to image store and send thumbnail instead of full data URL + if (currentClipData.type === 'image') { + try { + const imageId = generateId(); + const dataPath = getDataPath(); + const thumbnailDataUrl = await saveImage(imageId, currentClipData.content, dataPath); + clipToSend = { + type: 'image', + content: imageId, + imageId, + thumbnailDataUrl, + }; + } catch (error) { + console.error('Failed to save image to image store:', error); + // Fallback: send the full data URL inline + clipToSend = currentClipData; + } + } + // Send clipboard change to renderer (renderer will handle duplicate detection) if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('clipboard-changed', currentClipData); + mainWindow.webContents.send('clipboard-changed', clipToSend); } - - // Update last known values - lastClipboardContent = currentClipData.content; - lastClipboardType = currentClipData.type; } }; diff --git a/src/main/hotkeys/actions.test.ts b/src/main/hotkeys/actions.test.ts index 7aa5b24..9060e6a 100644 --- a/src/main/hotkeys/actions.test.ts +++ b/src/main/hotkeys/actions.test.ts @@ -4,6 +4,7 @@ vi.mock('electron', () => ({ BrowserWindow: vi.fn(), app: { focus: vi.fn(), + getPath: vi.fn().mockReturnValue('/mock/userData'), }, clipboard: { writeText: vi.fn(), @@ -33,9 +34,19 @@ vi.mock('../window/creation.js', () => ({ createToolsLauncherWindow: vi.fn(), })); +vi.mock('../storage/image-store', () => ({ + loadImage: vi.fn().mockResolvedValue('data:image/png;base64,fullimage'), +})); + +vi.mock('../clipboard/monitoring', () => ({ + setSkipNextImageChange: vi.fn(), +})); + 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'; describe('HotkeyActions', () => { let actions: HotkeyActions; @@ -191,6 +202,30 @@ describe('HotkeyActions', () => { expect(clipboard.writeText).toHaveBeenCalledWith('bad-data'); }); + it('loads full image from image store when imageId is present', async () => { + vi.mocked(loadImage).mockResolvedValue('data:image/png;base64,fullimage'); + vi.mocked(nativeImage.createFromDataURL).mockReturnValue({ + isEmpty: () => false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + vi.mocked(storage.getClips).mockResolvedValue([ + { + clip: { + type: 'image', + content: 'data:image/png;base64,thumbnail', + imageId: 'img-123', + }, + isLocked: false, + timestamp: 1, + }, + ]); + await actions.copyQuickClip(0); + expect(loadImage).toHaveBeenCalledWith('img-123', expect.stringContaining('clipless-data')); + expect(setSkipNextImageChange).toHaveBeenCalled(); + expect(nativeImage.createFromDataURL).toHaveBeenCalledWith('data:image/png;base64,fullimage'); + expect(clipboard.writeImage).toHaveBeenCalled(); + }); + it('copies unknown type as text', async () => { vi.mocked(storage.getClips).mockResolvedValue([ // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/main/hotkeys/actions.ts b/src/main/hotkeys/actions.ts index 93d2efd..5702898 100644 --- a/src/main/hotkeys/actions.ts +++ b/src/main/hotkeys/actions.ts @@ -1,6 +1,9 @@ import { BrowserWindow, clipboard, nativeImage, app } from 'electron'; +import { join } from 'path'; import { storage } from '../storage'; import { showNotification } from '../notifications'; +import { loadImage } from '../storage/image-store'; +import { setSkipNextImageChange } from '../clipboard/monitoring'; import type { StoredClip } from '../../shared/types'; /** @@ -66,7 +69,7 @@ export class HotkeyActions { } // Copy the clip content with the appropriate format based on its type - this.copyClipToClipboard(clipToCopy); + await this.copyClipToClipboard(clipToCopy); console.log(`Hotkey: Copied clip ${index + 1} to clipboard`); showNotification('Clip Copied', `Clip ${index + 1} copied to clipboard`); @@ -78,7 +81,7 @@ export class HotkeyActions { /** * Copy a clip to the system clipboard based on its type */ - private copyClipToClipboard(clipToCopy: StoredClip): void { + private async copyClipToClipboard(clipToCopy: StoredClip): Promise { switch (clipToCopy.clip.type) { case 'text': clipboard.writeText(clipToCopy.clip.content); @@ -97,7 +100,7 @@ export class HotkeyActions { } break; case 'image': - this.copyImageClip(clipToCopy.clip.content); + await this.copyImageClip(clipToCopy.clip.content, clipToCopy.clip.imageId); break; default: clipboard.writeText(clipToCopy.clip.content); @@ -105,16 +108,26 @@ export class HotkeyActions { } /** - * Handle copying image clips with fallback + * Handle copying image clips with fallback. + * If imageId is present, loads full image from image store. */ - private copyImageClip(content: string): void { + private async copyImageClip(content: string, imageId?: string): Promise { try { - const image = nativeImage.createFromDataURL(content); + let dataUrl = content; + + // Load full image from image store if imageId is present + if (imageId) { + const dataPath = join(app.getPath('userData'), 'clipless-data'); + dataUrl = await loadImage(imageId, dataPath); + } + + setSkipNextImageChange(); + const image = nativeImage.createFromDataURL(dataUrl); if (!image.isEmpty()) { clipboard.writeImage(image); } else { // Fallback to copying data URL as text - clipboard.writeText(content); + clipboard.writeText(dataUrl); } } catch (error) { console.error('Failed to copy image, falling back to text:', error); diff --git a/src/main/storage/file-operations.test.ts b/src/main/storage/file-operations.test.ts index 4fb706e..0c90903 100644 --- a/src/main/storage/file-operations.test.ts +++ b/src/main/storage/file-operations.test.ts @@ -20,23 +20,27 @@ vi.mock('fs', () => ({ })); import { - saveToFile, - loadFromFile, + saveEncryptedJson, + loadEncryptedJson, + saveEncryptedBuffer, + loadEncryptedBuffer, + saveJsonFile, + loadJsonFile, ensureDataDirectory, isEncryptionAvailable, } from './file-operations'; import { safeStorage } from 'electron'; import { promises as fs } from 'fs'; -describe('saveToFile', () => { +describe('saveEncryptedJson', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('encrypts and writes data to file', async () => { - await saveToFile({ test: true }, '/path/data.enc'); + it('encrypts and writes data to file without pretty-printing', async () => { + await saveEncryptedJson({ test: true }, '/path/data.enc'); - expect(safeStorage.encryptString).toHaveBeenCalledWith(JSON.stringify({ test: true }, null, 2)); + expect(safeStorage.encryptString).toHaveBeenCalledWith(JSON.stringify({ test: true })); expect(fs.writeFile).toHaveBeenCalledWith('/path/data.enc.tmp', expect.any(Buffer)); expect(fs.rename).toHaveBeenCalledWith('/path/data.enc.tmp', '/path/data.enc'); }); @@ -44,12 +48,20 @@ describe('saveToFile', () => { it('cleans up temp file on error', async () => { vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('write failed')); - await expect(saveToFile({ test: true }, '/path/data.enc')).rejects.toThrow('write failed'); + await expect(saveEncryptedJson({ test: true }, '/path/data.enc')).rejects.toThrow( + 'write failed' + ); expect(fs.unlink).toHaveBeenCalledWith('/path/data.enc.tmp'); }); + + it('ignores temp file unlink error before write', async () => { + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('no temp file')); + await saveEncryptedJson({ test: true }, '/path/data.enc'); + expect(fs.writeFile).toHaveBeenCalled(); + }); }); -describe('loadFromFile', () => { +describe('loadEncryptedJson', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -61,14 +73,112 @@ describe('loadFromFile', () => { vi.mocked(fs.readFile).mockResolvedValue(buf); vi.mocked(safeStorage.decryptString).mockReturnValue(jsonStr); - const result = await loadFromFile('/path/data.enc'); + const result = await loadEncryptedJson('/path/data.enc'); expect(result).toEqual(testData); }); it('throws FILE_NOT_FOUND for missing file', async () => { vi.mocked(fs.access).mockRejectedValueOnce(Object.assign(new Error(), { code: 'ENOENT' })); - await expect(loadFromFile('/path/data.enc')).rejects.toThrow('FILE_NOT_FOUND'); + await expect(loadEncryptedJson('/path/data.enc')).rejects.toThrow('FILE_NOT_FOUND'); + }); + + it('throws original error for non-ENOENT errors', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('decrypt failed')); + + await expect(loadEncryptedJson('/path/data.enc')).rejects.toThrow('decrypt failed'); + }); +}); + +describe('saveJsonFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes plain JSON to file without pretty-printing', async () => { + await saveJsonFile({ version: '1.0', storageVersion: 1 }, '/path/meta.json'); + + expect(fs.writeFile).toHaveBeenCalledWith( + '/path/meta.json', + JSON.stringify({ version: '1.0', storageVersion: 1 }) + ); + }); +}); + +describe('loadJsonFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reads and parses plain JSON from file', async () => { + const testData = { version: '1.0', storageVersion: 1 }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(testData)); + + const result = await loadJsonFile('/path/meta.json'); + expect(result).toEqual(testData); + }); + + it('throws FILE_NOT_FOUND for missing file', async () => { + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error(), { code: 'ENOENT' })); + + await expect(loadJsonFile('/path/meta.json')).rejects.toThrow('FILE_NOT_FOUND'); + }); + + it('throws original error for non-ENOENT errors', async () => { + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('parse failed')); + + await expect(loadJsonFile('/path/meta.json')).rejects.toThrow('parse failed'); + }); +}); + +describe('saveEncryptedBuffer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('converts buffer to base64, encrypts, and writes to file', async () => { + const buf = Buffer.from('hello'); + await saveEncryptedBuffer(buf, '/path/image.enc'); + + expect(safeStorage.encryptString).toHaveBeenCalledWith(buf.toString('base64')); + expect(fs.writeFile).toHaveBeenCalledWith('/path/image.enc.tmp', expect.any(Buffer)); + expect(fs.rename).toHaveBeenCalledWith('/path/image.enc.tmp', '/path/image.enc'); + }); + + it('cleans up temp file on error', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('write failed')); + const buf = Buffer.from('hello'); + + await expect(saveEncryptedBuffer(buf, '/path/image.enc')).rejects.toThrow('write failed'); + expect(fs.unlink).toHaveBeenCalledWith('/path/image.enc.tmp'); + }); +}); + +describe('loadEncryptedBuffer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('decrypts and returns buffer from file', async () => { + const originalBuf = Buffer.from('test-data'); + const base64 = originalBuf.toString('base64'); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(base64)); + vi.mocked(safeStorage.decryptString).mockReturnValue(base64); + + const result = await loadEncryptedBuffer('/path/image.enc'); + expect(result).toEqual(originalBuf); + }); + + it('throws FILE_NOT_FOUND for missing file', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(Object.assign(new Error(), { code: 'ENOENT' })); + await expect(loadEncryptedBuffer('/path/image.enc')).rejects.toThrow('FILE_NOT_FOUND'); + }); + + it('throws original error for non-ENOENT errors', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('read failed')); + await expect(loadEncryptedBuffer('/path/image.enc')).rejects.toThrow('read failed'); }); }); @@ -89,20 +199,3 @@ describe('isEncryptionAvailable', () => { expect(isEncryptionAvailable()).toBe(false); }); }); - -describe('loadFromFile - non-ENOENT error', () => { - it('throws original error for non-ENOENT errors', async () => { - vi.mocked(fs.access).mockResolvedValue(undefined); - vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('decrypt failed')); - - await expect(loadFromFile('/path/data.enc')).rejects.toThrow('decrypt failed'); - }); -}); - -describe('saveToFile - temp file cleanup ignores missing', () => { - it('ignores temp file unlink error before write', async () => { - vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('no temp file')); - await saveToFile({ test: true }, '/path/data.enc'); - expect(fs.writeFile).toHaveBeenCalled(); - }); -}); diff --git a/src/main/storage/file-operations.ts b/src/main/storage/file-operations.ts index f8f7869..aacfdbe 100644 --- a/src/main/storage/file-operations.ts +++ b/src/main/storage/file-operations.ts @@ -2,14 +2,14 @@ import { safeStorage } from 'electron'; import { promises as fs } from 'fs'; /** - * Save data to encrypted storage + * Save data as encrypted JSON to a file (atomic write via temp file) */ -export async function saveToFile(data: unknown, encryptedDataPath: string): Promise { - const tempPath = encryptedDataPath + '.tmp'; +export async function saveEncryptedJson(data: T, filePath: string): Promise { + const tempPath = filePath + '.tmp'; try { - // Serialize data - const jsonData = JSON.stringify(data, null, 2); + // Serialize without pretty-printing for minimal size + const jsonData = JSON.stringify(data); // Encrypt data const encryptedData = safeStorage.encryptString(jsonData); @@ -23,7 +23,7 @@ export async function saveToFile(data: unknown, encryptedDataPath: string): Prom // Write to file atomically await fs.writeFile(tempPath, encryptedData); - await fs.rename(tempPath, encryptedDataPath); + await fs.rename(tempPath, filePath); console.log('Data saved to secure storage'); } catch (error) { @@ -40,22 +40,22 @@ export async function saveToFile(data: unknown, encryptedDataPath: string): Prom } /** - * Load data from encrypted storage + * Load and decrypt JSON from an encrypted file */ -export async function loadFromFile(encryptedDataPath: string): Promise { +export async function loadEncryptedJson(filePath: string): Promise { try { // Check if encrypted file exists - await fs.access(encryptedDataPath); + await fs.access(filePath); // Read encrypted data - const encryptedData = await fs.readFile(encryptedDataPath); + const encryptedData = await fs.readFile(filePath); // Decrypt data const decryptedBuffer = safeStorage.decryptString(encryptedData); const jsonData = Buffer.from(decryptedBuffer).toString('utf8'); // Parse and return data - return JSON.parse(jsonData); + return JSON.parse(jsonData) as T; } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { // File doesn't exist @@ -67,6 +67,73 @@ export async function loadFromFile(encryptedDataPath: string): Promise } } +/** + * Save plain JSON to a file (for unencrypted metadata) + */ +export async function saveJsonFile(data: T, filePath: string): Promise { + await fs.writeFile(filePath, JSON.stringify(data)); +} + +/** + * Load plain JSON from a file + */ +export async function loadJsonFile(filePath: string): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data) as T; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new Error('FILE_NOT_FOUND'); + } + throw error; + } +} + +/** + * Save a raw buffer as an encrypted file (atomic write via temp file) + */ +export async function saveEncryptedBuffer(data: Buffer, filePath: string): Promise { + const tempPath = filePath + '.tmp'; + + try { + const base64 = data.toString('base64'); + const encryptedData = safeStorage.encryptString(base64); + + try { + await fs.unlink(tempPath); + } catch { + // Ignore if temp file doesn't exist + } + + await fs.writeFile(tempPath, encryptedData); + await fs.rename(tempPath, filePath); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Load and decrypt a raw buffer from an encrypted file + */ +export async function loadEncryptedBuffer(filePath: string): Promise { + try { + await fs.access(filePath); + const encryptedData = await fs.readFile(filePath); + const base64 = safeStorage.decryptString(encryptedData); + return Buffer.from(base64, 'base64'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new Error('FILE_NOT_FOUND'); + } + throw error; + } +} + /** * Ensure data directory exists */ diff --git a/src/main/storage/image-store.test.ts b/src/main/storage/image-store.test.ts new file mode 100644 index 0000000..3fc1e5c --- /dev/null +++ b/src/main/storage/image-store.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('electron', () => ({ + nativeImage: { + createFromDataURL: vi.fn().mockReturnValue({ + getSize: () => ({ width: 400, height: 300 }), + resize: vi.fn().mockReturnValue({ + toDataURL: () => 'data:image/png;base64,thumb', + }), + }), + }, + safeStorage: { + isEncryptionAvailable: vi.fn().mockReturnValue(true), + encryptString: vi.fn((str: string) => Buffer.from(str)), + decryptString: vi.fn((buf: Buffer) => buf.toString()), + }, +})); + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { saveImage, loadImage, loadThumbnail, deleteImage, deleteAllImages } from './image-store'; +import { nativeImage } from 'electron'; +import { promises as fs } from 'fs'; + +describe('saveImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('saves full image and thumbnail, returns thumbnail data URL', async () => { + const result = await saveImage('img-123', 'data:image/png;base64,full', '/data'); + + expect(result).toBe('data:image/png;base64,thumb'); + expect(fs.mkdir).toHaveBeenCalled(); + // Full image + thumbnail = 2 encrypted saves + expect(fs.writeFile).toHaveBeenCalledTimes(2); + expect(fs.rename).toHaveBeenCalledTimes(2); + }); + + it('resizes image to 200px wide thumbnail', async () => { + await saveImage('img-456', 'data:image/png;base64,full', '/data'); + + expect(nativeImage.createFromDataURL).toHaveBeenCalledWith('data:image/png;base64,full'); + const mockImage = vi.mocked(nativeImage.createFromDataURL).mock.results[0].value; + expect(mockImage.resize).toHaveBeenCalledWith({ width: 200, height: 150 }); + }); + + it('caps thumbnail width to original width for small images', async () => { + vi.mocked(nativeImage.createFromDataURL).mockReturnValueOnce({ + getSize: () => ({ width: 100, height: 50 }), + resize: vi.fn().mockReturnValue({ + toDataURL: () => 'data:image/png;base64,smallthumb', + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const result = await saveImage('img-small', 'data:image/png;base64,small', '/data'); + expect(result).toBe('data:image/png;base64,smallthumb'); + const mockImage = vi.mocked(nativeImage.createFromDataURL).mock.results[0].value; + expect(mockImage.resize).toHaveBeenCalledWith({ width: 100, height: 50 }); + }); +}); + +describe('loadImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads and returns the full image data URL', async () => { + const dataUrl = 'data:image/png;base64,full'; + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(JSON.stringify(dataUrl))); + + const result = await loadImage('img-123', '/data'); + expect(result).toBe(dataUrl); + }); + + it('throws FILE_NOT_FOUND for missing file', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(Object.assign(new Error(), { code: 'ENOENT' })); + await expect(loadImage('missing', '/data')).rejects.toThrow('FILE_NOT_FOUND'); + }); +}); + +describe('loadThumbnail', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads and returns the thumbnail data URL', async () => { + const thumbUrl = 'data:image/png;base64,thumb'; + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(JSON.stringify(thumbUrl))); + + const result = await loadThumbnail('img-123', '/data'); + expect(result).toBe(thumbUrl); + }); +}); + +describe('deleteImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deletes both full and thumbnail files', async () => { + await deleteImage('img-123', '/data'); + expect(fs.unlink).toHaveBeenCalledTimes(2); + }); + + it('ignores errors when files do not exist', async () => { + vi.mocked(fs.unlink).mockRejectedValue(new Error('ENOENT')); + await expect(deleteImage('missing', '/data')).resolves.not.toThrow(); + }); +}); + +describe('deleteAllImages', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('removes the entire images directory', async () => { + await deleteAllImages('/data'); + expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining('images'), { + recursive: true, + force: true, + }); + }); + + it('ignores errors when directory does not exist', async () => { + vi.mocked(fs.rm).mockRejectedValue(new Error('ENOENT')); + await expect(deleteAllImages('/data')).resolves.not.toThrow(); + }); +}); diff --git a/src/main/storage/image-store.ts b/src/main/storage/image-store.ts new file mode 100644 index 0000000..c01a2ce --- /dev/null +++ b/src/main/storage/image-store.ts @@ -0,0 +1,89 @@ +import { nativeImage } from 'electron'; +import { join } from 'path'; +import { promises as fs } from 'fs'; +import { saveEncryptedJson, loadEncryptedJson, ensureDataDirectory } from './file-operations'; + +const THUMBNAIL_WIDTH = 200; + +/** + * Get the images directory path + */ +function getImagesDir(dataPath: string): string { + return join(dataPath, 'images'); +} + +/** + * Save a full image and its thumbnail as encrypted files. + * Returns the thumbnail data URL. + */ +export async function saveImage(id: string, dataUrl: string, dataPath: string): Promise { + const imagesDir = getImagesDir(dataPath); + await ensureDataDirectory(imagesDir); + + const fullPath = join(imagesDir, `${id}.enc`); + const thumbPath = join(imagesDir, `${id}_thumb.enc`); + + // Save full image + await saveEncryptedJson(dataUrl, fullPath); + + // Generate thumbnail via nativeImage.resize() + const image = nativeImage.createFromDataURL(dataUrl); + const size = image.getSize(); + const thumbWidth = Math.min(THUMBNAIL_WIDTH, size.width); + const thumbHeight = Math.round((thumbWidth / size.width) * size.height); + const thumbnail = image.resize({ width: thumbWidth, height: thumbHeight }); + const thumbnailDataUrl = thumbnail.toDataURL(); + + // Save thumbnail + await saveEncryptedJson(thumbnailDataUrl, thumbPath); + + return thumbnailDataUrl; +} + +/** + * Load the full image data URL + */ +export async function loadImage(id: string, dataPath: string): Promise { + const fullPath = join(getImagesDir(dataPath), `${id}.enc`); + return await loadEncryptedJson(fullPath); +} + +/** + * Load the thumbnail data URL + */ +export async function loadThumbnail(id: string, dataPath: string): Promise { + const thumbPath = join(getImagesDir(dataPath), `${id}_thumb.enc`); + return await loadEncryptedJson(thumbPath); +} + +/** + * Delete both the full image and thumbnail files + */ +export async function deleteImage(id: string, dataPath: string): Promise { + const imagesDir = getImagesDir(dataPath); + const fullPath = join(imagesDir, `${id}.enc`); + const thumbPath = join(imagesDir, `${id}_thumb.enc`); + + try { + await fs.unlink(fullPath); + } catch { + // File might not exist + } + try { + await fs.unlink(thumbPath); + } catch { + // File might not exist + } +} + +/** + * Delete the entire images directory (used in clearAllData) + */ +export async function deleteAllImages(dataPath: string): Promise { + const imagesDir = getImagesDir(dataPath); + try { + await fs.rm(imagesDir, { recursive: true, force: true }); + } catch { + // Directory might not exist + } +} diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 8e13477..9017f9f 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -11,14 +11,18 @@ import type { SearchTerm, QuickTool, QuickClipsConfig, + TemplatesData, + StorageMeta, } from '../../shared/types'; // Import utility modules -import { DEFAULT_DATA } from './defaults'; -import { migrateData } from './migration'; +import { DEFAULT_SETTINGS } from './defaults'; +import { migrateData, migrateLegacyStorage } from './migration'; import { - saveToFile, - loadFromFile, + saveEncryptedJson, + loadEncryptedJson, + saveJsonFile, + loadJsonFile, ensureDataDirectory, isEncryptionAvailable, } from './file-operations'; @@ -32,6 +36,7 @@ import { generateTextFromTemplate, } from './templates'; import { + generateId, createSearchTermObject, updateSearchTermObject, sortSearchTermsByOrder, @@ -45,22 +50,45 @@ import { processQuickClipsConfig, } from './quick-tools'; import { saveWindowBounds, getWindowBounds } from './window-bounds'; +import { saveImage, deleteImage, deleteAllImages } from './image-store'; + +const CURRENT_STORAGE_VERSION = 1; + +const DEFAULT_TEMPLATES_DATA: TemplatesData = { + templates: [], + searchTerms: [], + quickTools: [], +}; class SecureStorage { private dataPath: string; - private encryptedDataPath: string; + private settingsPath: string; + private clipsPath: string; + private templatesPath: string; + private metaPath: string; private isInitialized = false; private isBackgroundLoadComplete = false; - private data: AppData = DEFAULT_DATA; - private savePromise: Promise | null = null; + + // Domain-specific data stores + private settings: UserSettings = DEFAULT_SETTINGS; + private clips: StoredClip[] = []; + private templatesData: TemplatesData = { ...DEFAULT_TEMPLATES_DATA }; + private meta: StorageMeta = { version: __APP_VERSION__, storageVersion: CURRENT_STORAGE_VERSION }; + + // Per-domain save queuing + private savePromises: Map> = new Map(); + private onBackgroundLoadComplete?: () => void; constructor() { // Store data in the user data directory const userDataPath = app.getPath('userData'); this.dataPath = join(userDataPath, 'clipless-data'); - this.encryptedDataPath = join(this.dataPath, 'data.enc'); - console.log(`Secure storage initialized at: ${this.encryptedDataPath}`); + this.settingsPath = join(this.dataPath, 'settings.enc'); + this.clipsPath = join(this.dataPath, 'clips.enc'); + this.templatesPath = join(this.dataPath, 'templates.enc'); + this.metaPath = join(this.dataPath, 'meta.json'); + console.log(`Secure storage initialized at: ${this.dataPath}`); } /** @@ -70,7 +98,10 @@ class SecureStorage { if (this.isInitialized) return; // Start with default data immediately for fast startup - this.data = { ...DEFAULT_DATA }; + this.settings = { ...DEFAULT_SETTINGS }; + this.clips = []; + this.templatesData = { ...DEFAULT_TEMPLATES_DATA }; + this.meta = { version: __APP_VERSION__, storageVersion: CURRENT_STORAGE_VERSION }; this.isInitialized = true; // Load actual data in the background @@ -93,8 +124,11 @@ class SecureStorage { return; } - // Try to load existing data - await this.loadData(); + // Migrate legacy data.enc if needed + await migrateLegacyStorage(this.dataPath); + + // Load each domain independently + await this.loadAllDomains(); // Mark background loading as complete and notify this.isBackgroundLoadComplete = true; @@ -108,58 +142,156 @@ class SecureStorage { } /** - * Load data from encrypted storage + * Load all domain-specific data files */ - private async loadData(): Promise { + private async loadAllDomains(): Promise { + // Load settings + try { + const loadedSettings = await loadEncryptedJson(this.settingsPath); + if (loadedSettings && typeof loadedSettings === 'object') { + this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings }; + } + } catch (error) { + if ((error as Error).message !== 'FILE_NOT_FOUND') { + console.error('Failed to load settings:', error); + } + } + + // Load clips try { - const parsedData = await loadFromFile(this.encryptedDataPath); + const loadedClips = await loadEncryptedJson(this.clipsPath); + if (Array.isArray(loadedClips)) { + // Validate clips through migrateData + const validated = migrateData({ clips: loadedClips }); + this.clips = validated.clips; + } + } catch (error) { + if ((error as Error).message !== 'FILE_NOT_FOUND') { + console.error('Failed to load clips:', error); + } + } - // Validate data structure and migrate if necessary - this.data = migrateData(parsedData); + // Load templates data + try { + const loadedTemplates = await loadEncryptedJson(this.templatesPath); + if (loadedTemplates && typeof loadedTemplates === 'object') { + // Validate through migrateData + const validated = migrateData(loadedTemplates); + this.templatesData = { + templates: validated.templates, + searchTerms: validated.searchTerms, + quickTools: validated.quickTools, + }; + } + } catch (error) { + if ((error as Error).message !== 'FILE_NOT_FOUND') { + console.error('Failed to load templates data:', error); + } + } - console.log(`Loaded ${this.data.clips.length} clips from secure storage`); + // Load meta + try { + const loadedMeta = await loadJsonFile(this.metaPath); + if (loadedMeta && typeof loadedMeta === 'object') { + this.meta = { + version: loadedMeta.version || __APP_VERSION__, + storageVersion: loadedMeta.storageVersion || CURRENT_STORAGE_VERSION, + }; + } } catch (error) { - if ((error as Error).message === 'FILE_NOT_FOUND') { - // File doesn't exist, start with default data - console.log('No existing data file found, starting with defaults'); - this.data = { ...DEFAULT_DATA }; - } else { - console.error('Failed to load data from storage:', error); - // Use default data if decryption fails - this.data = { ...DEFAULT_DATA }; + if ((error as Error).message !== 'FILE_NOT_FOUND') { + console.error('Failed to load meta:', error); + } + } + + // Migrate inline base64 image clips to separate files + await this.migrateInlineImages(); + + console.log(`Loaded ${this.clips.length} clips from secure storage`); + } + + /** + * Migrate existing inline base64 image clips to separate encrypted files. + * Clips with type 'image' and content starting with 'data:image/' are legacy inline images. + */ + private async migrateInlineImages(): Promise { + let hasMigrated = false; + + for (const storedClip of this.clips) { + if ( + storedClip.clip.type === 'image' && + storedClip.clip.content.startsWith('data:image/') && + !storedClip.clip.imageId + ) { + try { + const imageId = generateId(); + const thumbnailDataUrl = await saveImage(imageId, storedClip.clip.content, this.dataPath); + storedClip.clip.imageId = imageId; + storedClip.clip.thumbnailDataUrl = thumbnailDataUrl; + storedClip.clip.content = imageId; + hasMigrated = true; + } catch (error) { + console.error('Failed to migrate inline image:', error); + } } } + + if (hasMigrated) { + await this.saveClipsData(); + console.log('Migrated inline base64 image clips to separate files'); + } } /** - * Save data to encrypted storage + * Save a specific domain file with queuing to prevent concurrent writes */ - private async saveData(): Promise { + private async saveDomain(key: string, data: unknown, filePath: string): Promise { if (!this.isInitialized) { throw new Error('Storage not initialized'); } - // If a save operation is already in progress, wait for it to complete - if (this.savePromise) { - await this.savePromise; + const existing = this.savePromises.get(key); + if (existing) { + await existing; return; } - // Start a new save operation - this.savePromise = this.performSave(); + const promise = saveEncryptedJson(data, filePath); + this.savePromises.set(key, promise); try { - await this.savePromise; + await promise; } finally { - this.savePromise = null; + this.savePromises.delete(key); } } /** - * Perform the actual save operation + * Save settings domain */ - private async performSave(): Promise { - await saveToFile(this.data, this.encryptedDataPath); + private async saveSettingsData(): Promise { + await this.saveDomain('settings', this.settings, this.settingsPath); + } + + /** + * Save clips domain + */ + private async saveClipsData(): Promise { + await this.saveDomain('clips', this.clips, this.clipsPath); + } + + /** + * Save templates domain (templates + search terms + quick tools) + */ + private async saveTemplatesData(): Promise { + await this.saveDomain('templates', this.templatesData, this.templatesPath); + } + + /** + * Save storage metadata + */ + private async saveMeta(): Promise { + await saveJsonFile(this.meta, this.metaPath); } /** @@ -183,19 +315,40 @@ class SecureStorage { if (!this.isInitialized) { await this.initialize(); } - return [...this.data.clips]; + return [...this.clips]; } /** - * Save clips to storage + * Save clips to storage. + * Cleans up orphaned image files for deleted image clips. */ async saveClips(clips: ClipItem[], lockedIndices: Record): Promise { if (!this.isInitialized) { await this.initialize(); } - this.data.clips = convertToStoredClips(clips, lockedIndices); - await this.saveData(); + // Collect image IDs from old clips for cleanup comparison + const oldImageIds = new Set( + this.clips.filter((c) => c.clip.imageId).map((c) => c.clip.imageId!) + ); + + this.clips = convertToStoredClips(clips, lockedIndices); + + // Collect image IDs from new clips + const newImageIds = new Set( + this.clips.filter((c) => c.clip.imageId).map((c) => c.clip.imageId!) + ); + + // Delete orphaned images (in old but not in new) + for (const oldId of oldImageIds) { + if (!newImageIds.has(oldId)) { + deleteImage(oldId, this.dataPath).catch((err) => + console.error('Failed to delete orphaned image:', err) + ); + } + } + + await this.saveClipsData(); } // ===== SETTINGS MANAGEMENT ===== @@ -208,12 +361,12 @@ class SecureStorage { await this.initialize(); } - const settings = normalizeSettings(this.data.settings); + const settings = normalizeSettings(this.settings); // Save if hotkeys were missing and added - if (!this.data.settings.hotkeys) { - this.data.settings = settings; - await this.saveData(); + if (!this.settings.hotkeys) { + this.settings = settings; + await this.saveSettingsData(); } return settings; @@ -227,8 +380,8 @@ class SecureStorage { await this.initialize(); } - this.data.settings = mergeSettings(this.data.settings, settings); - await this.saveData(); + this.settings = mergeSettings(this.settings, settings); + await this.saveSettingsData(); } /** @@ -247,7 +400,7 @@ class SecureStorage { if (!this.isInitialized) { await this.initialize(); } - return sortTemplatesByOrder(this.data.templates); + return sortTemplatesByOrder(this.templatesData.templates); } /** @@ -258,9 +411,9 @@ class SecureStorage { await this.initialize(); } - const template = createTemplateObject(name, content, this.data.templates.length); - this.data.templates.push(template); - await this.saveData(); + const template = createTemplateObject(name, content, this.templatesData.templates.length); + this.templatesData.templates.push(template); + await this.saveTemplatesData(); return template; } @@ -272,14 +425,17 @@ class SecureStorage { await this.initialize(); } - const templateIndex = this.data.templates.findIndex((t) => t.id === id); + const templateIndex = this.templatesData.templates.findIndex((t) => t.id === id); if (templateIndex === -1) { throw new Error('Template not found'); } - const updatedTemplate = updateTemplateObject(this.data.templates[templateIndex], updates); - this.data.templates[templateIndex] = updatedTemplate; - await this.saveData(); + const updatedTemplate = updateTemplateObject( + this.templatesData.templates[templateIndex], + updates + ); + this.templatesData.templates[templateIndex] = updatedTemplate; + await this.saveTemplatesData(); return updatedTemplate; } @@ -291,14 +447,14 @@ class SecureStorage { await this.initialize(); } - const templateIndex = this.data.templates.findIndex((t) => t.id === id); + const templateIndex = this.templatesData.templates.findIndex((t) => t.id === id); if (templateIndex === -1) { throw new Error('Template not found'); } - this.data.templates.splice(templateIndex, 1); - this.data.templates = reorderTemplatesArray(this.data.templates); - await this.saveData(); + this.templatesData.templates.splice(templateIndex, 1); + this.templatesData.templates = reorderTemplatesArray(this.templatesData.templates); + await this.saveTemplatesData(); } /** @@ -311,15 +467,15 @@ class SecureStorage { // Update order for each template templates.forEach((template, index) => { - const existingTemplate = this.data.templates.find((t) => t.id === template.id); + const existingTemplate = this.templatesData.templates.find((t) => t.id === template.id); if (existingTemplate) { existingTemplate.order = index; } }); // Sort templates by order - this.data.templates.sort((a, b) => a.order - b.order); - await this.saveData(); + this.templatesData.templates.sort((a, b) => a.order - b.order); + await this.saveTemplatesData(); } /** @@ -334,7 +490,7 @@ class SecureStorage { await this.initialize(); } - const template = this.data.templates.find((t) => t.id === templateId); + const template = this.templatesData.templates.find((t) => t.id === templateId); if (!template) { throw new Error('Template not found'); } @@ -351,7 +507,7 @@ class SecureStorage { if (!this.isInitialized) { await this.initialize(); } - return sortSearchTermsByOrder(this.data.searchTerms); + return sortSearchTermsByOrder(this.templatesData.searchTerms); } /** @@ -362,9 +518,9 @@ class SecureStorage { await this.initialize(); } - const searchTerm = createSearchTermObject(name, pattern, this.data.searchTerms.length); - this.data.searchTerms.push(searchTerm); - await this.saveData(); + const searchTerm = createSearchTermObject(name, pattern, this.templatesData.searchTerms.length); + this.templatesData.searchTerms.push(searchTerm); + await this.saveTemplatesData(); return searchTerm; } @@ -376,17 +532,17 @@ class SecureStorage { await this.initialize(); } - const searchTermIndex = this.data.searchTerms.findIndex((t) => t.id === id); + const searchTermIndex = this.templatesData.searchTerms.findIndex((t) => t.id === id); if (searchTermIndex === -1) { throw new Error('Search term not found'); } const updatedSearchTerm = updateSearchTermObject( - this.data.searchTerms[searchTermIndex], + this.templatesData.searchTerms[searchTermIndex], updates ); - this.data.searchTerms[searchTermIndex] = updatedSearchTerm; - await this.saveData(); + this.templatesData.searchTerms[searchTermIndex] = updatedSearchTerm; + await this.saveTemplatesData(); return updatedSearchTerm; } @@ -398,14 +554,14 @@ class SecureStorage { await this.initialize(); } - const searchTermIndex = this.data.searchTerms.findIndex((t) => t.id === id); + const searchTermIndex = this.templatesData.searchTerms.findIndex((t) => t.id === id); if (searchTermIndex === -1) { throw new Error('Search term not found'); } - this.data.searchTerms.splice(searchTermIndex, 1); - this.data.searchTerms = reorderSearchTermsArray(this.data.searchTerms); - await this.saveData(); + this.templatesData.searchTerms.splice(searchTermIndex, 1); + this.templatesData.searchTerms = reorderSearchTermsArray(this.templatesData.searchTerms); + await this.saveTemplatesData(); } /** @@ -417,15 +573,15 @@ class SecureStorage { } searchTerms.forEach((searchTerm, index) => { - const existingSearchTerm = this.data.searchTerms.find((t) => t.id === searchTerm.id); + const existingSearchTerm = this.templatesData.searchTerms.find((t) => t.id === searchTerm.id); if (existingSearchTerm) { existingSearchTerm.order = index; } }); // Sort search terms by order - this.data.searchTerms.sort((a, b) => a.order - b.order); - await this.saveData(); + this.templatesData.searchTerms.sort((a, b) => a.order - b.order); + await this.saveTemplatesData(); } // ===== QUICK TOOLS MANAGEMENT ===== @@ -437,7 +593,7 @@ class SecureStorage { if (!this.isInitialized) { await this.initialize(); } - return sortQuickToolsByOrder(this.data.quickTools); + return sortQuickToolsByOrder(this.templatesData.quickTools); } /** @@ -448,9 +604,14 @@ class SecureStorage { await this.initialize(); } - const quickTool = createQuickToolObject(name, url, captureGroups, this.data.quickTools.length); - this.data.quickTools.push(quickTool); - await this.saveData(); + const quickTool = createQuickToolObject( + name, + url, + captureGroups, + this.templatesData.quickTools.length + ); + this.templatesData.quickTools.push(quickTool); + await this.saveTemplatesData(); return quickTool; } @@ -462,14 +623,17 @@ class SecureStorage { await this.initialize(); } - const quickToolIndex = this.data.quickTools.findIndex((t) => t.id === id); + const quickToolIndex = this.templatesData.quickTools.findIndex((t) => t.id === id); if (quickToolIndex === -1) { throw new Error('Quick tool not found'); } - const updatedQuickTool = updateQuickToolObject(this.data.quickTools[quickToolIndex], updates); - this.data.quickTools[quickToolIndex] = updatedQuickTool; - await this.saveData(); + const updatedQuickTool = updateQuickToolObject( + this.templatesData.quickTools[quickToolIndex], + updates + ); + this.templatesData.quickTools[quickToolIndex] = updatedQuickTool; + await this.saveTemplatesData(); return updatedQuickTool; } @@ -481,14 +645,14 @@ class SecureStorage { await this.initialize(); } - const quickToolIndex = this.data.quickTools.findIndex((t) => t.id === id); + const quickToolIndex = this.templatesData.quickTools.findIndex((t) => t.id === id); if (quickToolIndex === -1) { throw new Error('Quick tool not found'); } - this.data.quickTools.splice(quickToolIndex, 1); - this.data.quickTools = reorderQuickToolsArray(this.data.quickTools); - await this.saveData(); + this.templatesData.quickTools.splice(quickToolIndex, 1); + this.templatesData.quickTools = reorderQuickToolsArray(this.templatesData.quickTools); + await this.saveTemplatesData(); } /** @@ -500,15 +664,15 @@ class SecureStorage { } quickTools.forEach((quickTool, index) => { - const existingQuickTool = this.data.quickTools.find((t) => t.id === quickTool.id); + const existingQuickTool = this.templatesData.quickTools.find((t) => t.id === quickTool.id); if (existingQuickTool) { existingQuickTool.order = index; } }); // Sort quick tools by order - this.data.quickTools.sort((a, b) => a.order - b.order); - await this.saveData(); + this.templatesData.quickTools.sort((a, b) => a.order - b.order); + await this.saveTemplatesData(); } /** @@ -527,10 +691,10 @@ class SecureStorage { if (searchTerms.length > 0) { searchTerms.forEach((searchTerm) => { searchTerm.order = - this.data.searchTerms.length > 0 - ? Math.max(...this.data.searchTerms.map((t) => t.order)) + 1 + this.templatesData.searchTerms.length > 0 + ? Math.max(...this.templatesData.searchTerms.map((t) => t.order)) + 1 : 0; - this.data.searchTerms.push(searchTerm); + this.templatesData.searchTerms.push(searchTerm); }); hasChanges = true; } @@ -539,10 +703,10 @@ class SecureStorage { if (quickTools.length > 0) { quickTools.forEach((quickTool) => { quickTool.order = - this.data.quickTools.length > 0 - ? Math.max(...this.data.quickTools.map((t) => t.order)) + 1 + this.templatesData.quickTools.length > 0 + ? Math.max(...this.templatesData.quickTools.map((t) => t.order)) + 1 : 0; - this.data.quickTools.push(quickTool); + this.templatesData.quickTools.push(quickTool); }); hasChanges = true; } @@ -554,11 +718,11 @@ class SecureStorage { const newTemplate = { ...template, order: - this.data.templates.length > 0 - ? Math.max(...this.data.templates.map((t) => t.order)) + 1 + this.templatesData.templates.length > 0 + ? Math.max(...this.templatesData.templates.map((t) => t.order)) + 1 : 0, }; - this.data.templates.push(newTemplate); + this.templatesData.templates.push(newTemplate); } }); hasChanges = true; @@ -566,7 +730,7 @@ class SecureStorage { // Save only once at the end if there were changes if (hasChanges) { - await this.saveData(); + await this.saveTemplatesData(); } } @@ -601,13 +765,24 @@ class SecureStorage { await this.initialize(); } - this.data = { ...DEFAULT_DATA }; + this.settings = { ...DEFAULT_SETTINGS }; + this.clips = []; + this.templatesData = { ...DEFAULT_TEMPLATES_DATA }; + this.meta = { version: __APP_VERSION__, storageVersion: CURRENT_STORAGE_VERSION }; - try { - await fs.unlink(this.encryptedDataPath); - } catch { - // File might not exist, that's okay + // Delete all domain files + const filesToDelete = [this.settingsPath, this.clipsPath, this.templatesPath, this.metaPath]; + + for (const filePath of filesToDelete) { + try { + await fs.unlink(filePath); + } catch { + // File might not exist, that's okay + } } + + // Delete all image files + await deleteAllImages(this.dataPath); } /** @@ -617,7 +792,17 @@ class SecureStorage { if (!this.isInitialized) { await this.initialize(); } - return JSON.stringify(this.data, null, 2); + + // Reconstruct AppData for export compatibility + const data: AppData = { + clips: this.clips, + settings: this.settings, + templates: this.templatesData.templates, + searchTerms: this.templatesData.searchTerms, + quickTools: this.templatesData.quickTools, + version: this.meta.version, + }; + return JSON.stringify(data, null, 2); } /** @@ -630,8 +815,28 @@ class SecureStorage { try { const importedData = JSON.parse(jsonData); - this.data = migrateData(importedData); - await this.saveData(); + const migrated = migrateData(importedData); + + // Split into domain stores + this.settings = migrated.settings; + this.clips = migrated.clips; + this.templatesData = { + templates: migrated.templates, + searchTerms: migrated.searchTerms, + quickTools: migrated.quickTools, + }; + this.meta = { + version: migrated.version, + storageVersion: CURRENT_STORAGE_VERSION, + }; + + // Save all domains + await Promise.all([ + this.saveSettingsData(), + this.saveClipsData(), + this.saveTemplatesData(), + this.saveMeta(), + ]); } catch (error) { console.error('Failed to import data:', error); throw new Error('Invalid data format'); @@ -646,14 +851,33 @@ class SecureStorage { await this.initialize(); } - const { clipCount, lockedCount } = getClipStats(this.data.clips); + const { clipCount, lockedCount } = getClipStats(this.clips); let dataSize = 0; + const filesToStat = [this.settingsPath, this.clipsPath, this.templatesPath, this.metaPath]; + for (const filePath of filesToStat) { + try { + const stats = await fs.stat(filePath); + dataSize += stats.size; + } catch { + // File might not exist yet + } + } + + // Include images directory size try { - const stats = await fs.stat(this.encryptedDataPath); - dataSize = stats.size; + const imagesDir = join(this.dataPath, 'images'); + const imageFiles = await fs.readdir(imagesDir); + for (const file of imageFiles) { + try { + const stats = await fs.stat(join(imagesDir, file)); + dataSize += stats.size; + } catch { + // Skip files that can't be stat'd + } + } } catch { - // File might not exist yet + // Images directory might not exist } return { clipCount, lockedCount, dataSize }; diff --git a/src/main/storage/migration.test.ts b/src/main/storage/migration.test.ts index d1862a3..11b7c3c 100644 --- a/src/main/storage/migration.test.ts +++ b/src/main/storage/migration.test.ts @@ -1,5 +1,28 @@ -import { describe, it, expect } from 'vitest'; -import { migrateData } from './migration'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn().mockReturnValue(true), + encryptString: vi.fn((str: string) => Buffer.from(str)), + decryptString: vi.fn((buf: Buffer) => buf.toString()), + }, +})); + +vi.mock('fs', () => ({ + promises: { + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { migrateData, migrateLegacyStorage } from './migration'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { safeStorage } from 'electron'; describe('migrateData', () => { it('returns default structure for empty object', () => { @@ -120,3 +143,134 @@ describe('migrateData', () => { expect(numberResult.clips).toEqual([]); }); }); + +describe('migrateLegacyStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns false if legacy data.enc does not exist', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await migrateLegacyStorage('/data'); + expect(result).toBe(false); + }); + + it('returns false if clips.enc already exists (already migrated)', async () => { + // First access (data.enc) succeeds + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + // Second access (clips.enc) succeeds + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + + const result = await migrateLegacyStorage('/data'); + expect(result).toBe(false); + }); + + it('performs migration when data.enc exists but clips.enc does not', async () => { + // data.enc exists + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + // clips.enc does not exist + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + // loadEncryptedJson reads data.enc + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + + const legacyData = { + clips: [{ clip: { type: 'text', content: 'test' }, isLocked: false, timestamp: 1 }], + settings: { maxClips: 100 }, + templates: [], + searchTerms: [], + quickTools: [], + version: '1.0.0', + }; + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(JSON.stringify(legacyData))); + vi.mocked(safeStorage.decryptString).mockReturnValueOnce(JSON.stringify(legacyData)); + + const result = await migrateLegacyStorage('/data'); + + expect(result).toBe(true); + // Should have written settings.enc, clips.enc, templates.enc (3 encrypted saves with temp+rename each) + // Plus meta.json (1 plain write) + // Each encrypted save: unlink temp, writeFile temp, rename temp → final = 3 calls per file + // 3 encrypted files × 3 calls = 9, plus 1 for meta.json write, plus 1 for rename data.enc + expect(fs.rename).toHaveBeenCalledWith( + join('/data', 'data.enc'), + join('/data', 'data.enc') + '.migrated' + ); + }); + + it('splits legacy data into correct domain files', async () => { + // data.enc exists + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + // clips.enc does not exist + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + // loadEncryptedJson accesses data.enc + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + + const legacyData = { + clips: [{ clip: { type: 'text', content: 'hello' }, isLocked: true, timestamp: 1 }], + settings: { maxClips: 500, theme: 'dark' }, + templates: [{ id: 't1', name: 'T1', content: 'c', createdAt: 1, updatedAt: 1, order: 0 }], + searchTerms: [ + { + id: 's1', + name: 'S1', + pattern: '.*', + enabled: true, + createdAt: 1, + updatedAt: 1, + order: 0, + }, + ], + quickTools: [ + { + id: 'q1', + name: 'Q1', + url: 'https://example.com', + captureGroups: [], + createdAt: 1, + updatedAt: 1, + order: 0, + }, + ], + version: '1.5.0', + }; + vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(JSON.stringify(legacyData))); + vi.mocked(safeStorage.decryptString).mockReturnValueOnce(JSON.stringify(legacyData)); + + await migrateLegacyStorage('/data'); + + // Verify settings.enc was written (encryptString called with settings data) + const encryptCalls = vi.mocked(safeStorage.encryptString).mock.calls; + const settingsCall = encryptCalls.find((call) => { + const parsed = JSON.parse(call[0]); + return parsed.maxClips === 500; + }); + expect(settingsCall).toBeDefined(); + + // Verify clips.enc was written + const clipsCall = encryptCalls.find((call) => { + const parsed = JSON.parse(call[0]); + return Array.isArray(parsed) && parsed.length === 1; + }); + expect(clipsCall).toBeDefined(); + + // Verify templates.enc was written with templates, searchTerms, quickTools + const templatesCall = encryptCalls.find((call) => { + const parsed = JSON.parse(call[0]); + return parsed.templates && parsed.searchTerms && parsed.quickTools; + }); + expect(templatesCall).toBeDefined(); + + // Verify meta.json was written + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const metaCall = writeFileCalls.find( + (call) => typeof call[0] === 'string' && call[0].includes('meta.json') + ); + expect(metaCall).toBeDefined(); + if (metaCall) { + const metaData = JSON.parse(metaCall[1] as string); + expect(metaData.version).toBe('1.5.0'); + expect(metaData.storageVersion).toBe(1); + } + }); +}); diff --git a/src/main/storage/migration.ts b/src/main/storage/migration.ts index c9def51..42f75ae 100644 --- a/src/main/storage/migration.ts +++ b/src/main/storage/migration.ts @@ -1,8 +1,20 @@ -import type { AppData, StoredClip, Template, SearchTerm, QuickTool } from '../../shared/types'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import type { + AppData, + StoredClip, + Template, + SearchTerm, + QuickTool, + StorageMeta, +} from '../../shared/types'; import { DEFAULT_DATA, DEFAULT_SETTINGS } from './defaults'; +import { saveEncryptedJson, loadEncryptedJson, saveJsonFile } from './file-operations'; + +const CURRENT_STORAGE_VERSION = 1; /** - * Migrate data from older versions + * Migrate data from older versions (validates and normalizes an AppData blob) */ export function migrateData(data: unknown): AppData { // Start with default data @@ -113,3 +125,64 @@ export function migrateData(data: unknown): AppData { return migratedData; } + +/** + * Check if legacy data.enc exists and needs migration to domain-specific files. + * If data.enc exists but clips.enc does not, splits legacy data into: + * - settings.enc, clips.enc, templates.enc, meta.json + * Then renames data.enc to data.enc.migrated. + * + * Returns true if migration was performed. + */ +export async function migrateLegacyStorage(dataPath: string): Promise { + const legacyPath = join(dataPath, 'data.enc'); + const clipsPath = join(dataPath, 'clips.enc'); + const settingsPath = join(dataPath, 'settings.enc'); + const templatesPath = join(dataPath, 'templates.enc'); + const metaPath = join(dataPath, 'meta.json'); + + // Check if legacy file exists + try { + await fs.access(legacyPath); + } catch { + return false; // No legacy file + } + + // Check if already migrated (clips.enc exists) + try { + await fs.access(clipsPath); + return false; // Already migrated + } catch { + // clips.enc doesn't exist, proceed with migration + } + + console.log('Migrating legacy data.enc to domain-specific files...'); + + // Load and validate legacy data + const legacyRaw = await loadEncryptedJson(legacyPath); + const data = migrateData(legacyRaw); + + // Split into domain files + await saveEncryptedJson(data.settings, settingsPath); + await saveEncryptedJson(data.clips, clipsPath); + await saveEncryptedJson( + { + templates: data.templates, + searchTerms: data.searchTerms, + quickTools: data.quickTools, + }, + templatesPath + ); + + const meta: StorageMeta = { + version: data.version, + storageVersion: CURRENT_STORAGE_VERSION, + }; + await saveJsonFile(meta, metaPath); + + // Rename legacy file + await fs.rename(legacyPath, legacyPath + '.migrated'); + + console.log('Legacy migration complete'); + return true; +} diff --git a/src/main/window/bounds.ts b/src/main/window/bounds.ts index 0fb5fc0..80e4e9a 100644 --- a/src/main/window/bounds.ts +++ b/src/main/window/bounds.ts @@ -1,19 +1,22 @@ -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow } from 'electron'; +import { promises as fs } from 'fs'; +import { join } from 'path'; import { storage } from '../storage'; let windowBounds: { x: number; y: number; width: number; height: number } | null = null; +/** + * Load window bounds directly from window-bounds.json — no SecureStorage dependency. + * The rememberWindowPosition setting is checked later when storage is ready. + */ export async function loadWindowBounds(): Promise { try { - const settings = await storage.getSettings(); - if (settings.rememberWindowPosition) { - const bounds = await storage.getWindowBounds(); - if (bounds) { - windowBounds = bounds; - } - } - } catch (error) { - console.error('Failed to load window bounds:', error); + const dataPath = join(app.getPath('userData'), 'clipless-data'); + const boundsPath = join(dataPath, 'window-bounds.json'); + const data = await fs.readFile(boundsPath, 'utf-8'); + windowBounds = JSON.parse(data); + } catch { + // File doesn't exist or is invalid, no saved bounds } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index da9b5f1..b53afa4 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -16,6 +16,7 @@ declare global { setClipboardText: (text: string) => Promise; setClipboardHTML: (html: string) => Promise; setClipboardRTF: (rtf: string) => Promise; + getFullImage: (imageId: string) => Promise; setClipboardImage: (imageData: string) => Promise; setClipboardBookmark: (bookmarkData: any) => Promise; notifyClipCopied: (index: number) => Promise; @@ -32,6 +33,8 @@ declare global { onSettingsUpdated: (callback: (settings: any) => void) => void; removeSettingsListeners: () => void; // Storage APIs + onStorageReady: (callback: () => void) => void; + removeStorageReadyListeners: () => void; storageGetClips: () => Promise; storageSaveClips: (clips: any[], lockedIndices: Record) => Promise; storageGetSettings: () => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index cf1c5f0..7122c9c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,7 @@ const api = { electronAPI.ipcRenderer.invoke('set-clipboard-image', imageData), setClipboardBookmark: (bookmarkData: BookmarkData) => electronAPI.ipcRenderer.invoke('set-clipboard-bookmark', bookmarkData), + getFullImage: (imageId: string) => electronAPI.ipcRenderer.invoke('get-full-image', imageId), notifyClipCopied: (index: number) => electronAPI.ipcRenderer.invoke('notify-clip-copied', index), startClipboardMonitoring: () => electronAPI.ipcRenderer.invoke('start-clipboard-monitoring'), stopClipboardMonitoring: () => electronAPI.ipcRenderer.invoke('stop-clipboard-monitoring'), @@ -59,6 +60,9 @@ const api = { removeSettingsListeners: () => electronAPI.ipcRenderer.removeAllListeners('settings-updated'), // Storage APIs + onStorageReady: (callback: () => void) => + electronAPI.ipcRenderer.on('storage-ready', () => callback()), + removeStorageReadyListeners: () => electronAPI.ipcRenderer.removeAllListeners('storage-ready'), storageGetClips: () => electronAPI.ipcRenderer.invoke('storage-get-clips'), storageSaveClips: (clips: StoredClip[], lockedIndices: Record) => electronAPI.ipcRenderer.invoke('storage-save-clips', clips, lockedIndices), diff --git a/src/renderer/src/components/SearchBar.tsx b/src/renderer/src/components/SearchBar.tsx index a9346c5..b211290 100644 --- a/src/renderer/src/components/SearchBar.tsx +++ b/src/renderer/src/components/SearchBar.tsx @@ -1,12 +1,13 @@ import React, { useRef, useEffect } from 'react'; -import { useClips } from '../providers/clips'; +import { useClipsData, useClipsMeta } from '../providers/clips'; import { useTheme } from '../providers/theme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import styles from './SearchBar.module.css'; export const SearchBar: React.FC = () => { - const { searchTerm, setSearchTerm, isSearchVisible, setIsSearchVisible } = useClips(); + const { searchTerm } = useClipsData(); + const { setSearchTerm, isSearchVisible, setIsSearchVisible } = useClipsMeta(); const { isLight } = useTheme(); const inputRef = useRef(null); diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index 9aeab2e..d54505d 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useClips } from '../providers/clips'; +import { useClipsData, useClipsActions, useClipsMeta } from '../providers/clips'; import { useTheme } from '../providers/theme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; @@ -10,7 +10,9 @@ interface StatusBarProps { } export const StatusBar: React.FC = ({ onOpenSettings }) => { - const { clips, maxClips, isClipLocked, isSearchVisible, setIsSearchVisible } = useClips(); + const { clips } = useClipsData(); + const { isClipLocked } = useClipsActions(); + const { maxClips, isSearchVisible, setIsSearchVisible } = useClipsMeta(); const { isLight } = useTheme(); // Count non-empty clips diff --git a/src/renderer/src/components/clips/Clips.module.css b/src/renderer/src/components/clips/Clips.module.css index 66da4ef..0e13721 100644 --- a/src/renderer/src/components/clips/Clips.module.css +++ b/src/renderer/src/components/clips/Clips.module.css @@ -22,7 +22,6 @@ width: 100%; margin: 0; padding: 0; - list-style: none; } .emptyState { diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 9d19628..bce8b2c 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -1,34 +1,70 @@ -import { useClips } from '../../providers/clips'; +import { useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useClipsData, useClipsMeta } from '../../providers/clips'; import { useTheme } from '../../providers/theme'; import { Clip } from './clip'; import classNames from 'classnames'; import styles from './Clips.module.css'; export function Clips(): React.JSX.Element { - const { clips, filteredClips, searchTerm } = useClips(); + const { clips, filteredClips, searchTerm } = useClipsData(); + const { clipCopyIndex } = useClipsMeta(); const { isLight } = useTheme(); + const scrollContainerRef = useRef(null); const isFiltering = searchTerm.trim().length > 0; const showEmpty = isFiltering && filteredClips.length === 0; + const items = isFiltering + ? filteredClips.map(({ clip, originalIndex }) => ({ clip, index: originalIndex })) + : clips.map((clip, index) => ({ clip, index })); + + const virtualizer = useVirtualizer({ + count: showEmpty ? 0 : items.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 40, + overscan: 5, + }); + return ( -
+
{showEmpty ? (
No clips match “{searchTerm}”
- ) : isFiltering ? ( -
    - {filteredClips.map(({ clip, originalIndex }) => ( - - ))} -
) : ( -
    - {clips.map((clip, index) => ( - - ))} -
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const { clip, index } = items[virtualRow.index]; + return ( +
+ +
+ ); + })} +
)}
); diff --git a/src/renderer/src/components/clips/clip/BookmarkClip.tsx b/src/renderer/src/components/clips/clip/BookmarkClip.tsx index 48f406b..cdd6e63 100644 --- a/src/renderer/src/components/clips/clip/BookmarkClip.tsx +++ b/src/renderer/src/components/clips/clip/BookmarkClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface BookmarkClipProps { clip: ClipItem; } -export const BookmarkClip = ({ clip }: BookmarkClipProps) => { +export const BookmarkClip = memo(function BookmarkClip({ clip }: BookmarkClipProps) { const { isLight } = useTheme(); return ( @@ -18,4 +19,4 @@ export const BookmarkClip = ({ clip }: BookmarkClipProps) => {
); -}; +}); diff --git a/src/renderer/src/components/clips/clip/Clip.module.css b/src/renderer/src/components/clips/clip/Clip.module.css index f0208cb..45ddb8d 100644 --- a/src/renderer/src/components/clips/clip/Clip.module.css +++ b/src/renderer/src/components/clips/clip/Clip.module.css @@ -25,20 +25,20 @@ background-color: #e0e0e0; } -/* Zebra striping for even rows */ -.clip:nth-child(even) .clipRow { +/* Zebra striping for even rows (class-based for virtualization compat) */ +.clip.evenRow .clipRow { background-color: #383838; } -.clip:nth-child(even) .clipRow.light { +.clip.evenRow .clipRow.light { background-color: #f0f0f0; } -.clip:nth-child(even) .clipRow:hover { +.clip.evenRow .clipRow:hover { background-color: #505050; } -.clip:nth-child(even) .clipRow.light:hover { +.clip.evenRow .clipRow.light:hover { background-color: #e0e0e0; } @@ -330,9 +330,6 @@ z-index: 9999; opacity: 0; visibility: hidden; - transition: - opacity 0.3s ease, - visibility 0.3s ease; pointer-events: none; max-width: 20rem; max-height: 20rem; @@ -344,18 +341,10 @@ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); } -.imagePreviewContainer:hover .imagePopover { +.imagePopoverVisible { opacity: 1; visibility: visible; - pointer-events: auto; - transition-delay: 0.5s; -} - -.imagePopover:hover { - opacity: 1; - visibility: visible; - pointer-events: auto; - transition-delay: 0s; + pointer-events: none; } .popoverImage { diff --git a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx index e600249..4cf8411 100644 --- a/src/renderer/src/components/clips/clip/ClipContextMenu.tsx +++ b/src/renderer/src/components/clips/clip/ClipContextMenu.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useClips } from '../../../providers/clips'; +import { useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import classNames from 'classnames'; import styles from './ClipContextMenu.module.css'; @@ -10,47 +10,18 @@ interface ClipContextMenuProps { x: number; y: number; onClose: () => void; + hasPatterns: boolean; } -export function ClipContextMenu({ index, x, y, onClose }: ClipContextMenuProps) { +export function ClipContextMenu({ index, x, y, onClose, hasPatterns }: ClipContextMenuProps) { const { isLight } = useTheme(); - const { isClipLocked, toggleClipLock, emptyClip, getClip, copyClipToClipboard } = useClips(); + const { isClipLocked, toggleClipLock, emptyClip, getClip, copyClipToClipboard } = + useClipsActions(); const menuRef = useRef(null); const clip = getClip(index); const isFirstClip = index === 0; - // Check for patterns - const [hasPatterns, setHasPatterns] = useState(false); - - useEffect(() => { - let isCancelled = false; - - const checkPatterns = async () => { - if (!clip.content || clip.content.trim().length === 0) { - setHasPatterns(false); - return; - } - - try { - const matches = await window.api.quickClipsScanText(clip.content); - if (!isCancelled) { - setHasPatterns(matches.length > 0); - } - } catch { - if (!isCancelled) { - setHasPatterns(false); - } - } - }; - - checkPatterns(); - - return () => { - isCancelled = true; - }; - }, [clip.content]); - // Handle clicks outside menu useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/src/renderer/src/components/clips/clip/ClipOptions.tsx b/src/renderer/src/components/clips/clip/ClipOptions.tsx index ba9aad9..e9847dc 100644 --- a/src/renderer/src/components/clips/clip/ClipOptions.tsx +++ b/src/renderer/src/components/clips/clip/ClipOptions.tsx @@ -1,61 +1,32 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import OutsideClickHandler from 'react-outside-click-handler'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useClips } from '../../../providers/clips'; +import { useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import styles from './ClipOptions.module.css'; import classNames from 'classnames'; -export function ClipOptions({ index }): React.JSX.Element { +interface ClipOptionsProps { + index: number; + hasPatterns: boolean; + clipContent: string; +} + +export function ClipOptions({ index, hasPatterns, clipContent }: ClipOptionsProps) { const [visible, setVisible] = useState(false); const { isLight } = useTheme(); const toggleVisibility = useCallback(() => { - setVisible(!visible); - }, [visible, setVisible]); - - const { isClipLocked, toggleClipLock, emptyClip, getClip } = useClips(); + setVisible((v) => !v); + }, []); - const clip = getClip(index); + const { isClipLocked, toggleClipLock, emptyClip } = useClipsActions(); // Check if this is the first clip (cannot be locked or emptied) const isFirstClip = index === 0; - // Check if this clip has patterns (we'll do a simple check) - const [hasPatterns, setHasPatterns] = useState(false); - - // Check for patterns when component mounts or clip content changes - useEffect(() => { - let isCancelled = false; - - const checkPatterns = async () => { - if (!clip.content || clip.content.trim().length === 0) { - setHasPatterns(false); - return; - } - - try { - const matches = await window.api.quickClipsScanText(clip.content); - if (!isCancelled) { - setHasPatterns(matches.length > 0); - } - } catch { - if (!isCancelled) { - setHasPatterns(false); - } - } - }; - - const timeoutId = setTimeout(checkPatterns, 200); - - return () => { - isCancelled = true; - clearTimeout(timeoutId); - }; - }, [clip.content]); - const handleScanClick = async () => { try { - await window.api.openToolsLauncher(clip.content); + await window.api.openToolsLauncher(clipContent); setVisible(false); // Close the options menu } catch (error) { console.error('Failed to open tools launcher:', error); diff --git a/src/renderer/src/components/clips/clip/ClipWrapper.tsx b/src/renderer/src/components/clips/clip/ClipWrapper.tsx index 1005518..c39bfcb 100644 --- a/src/renderer/src/components/clips/clip/ClipWrapper.tsx +++ b/src/renderer/src/components/clips/clip/ClipWrapper.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import { useState } from 'react'; -import { ClipItem, useClips } from '../../../providers/clips'; +import { memo, useState } from 'react'; +import { ClipItem, useClipsActions } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; import { usePatternDetection } from '../../../hooks/usePatternDetection'; import { useContextMenu } from '../../../hooks/useContextMenu'; @@ -17,10 +17,17 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; interface ClipProps { clip: ClipItem; index: number; + isCurrentCopiedClip: boolean; + isEvenRow?: boolean; } -export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { - const { copyClipToClipboard, clipCopyIndex, updateClip } = useClips(); +export const ClipWrapper = memo(function ClipWrapper({ + clip, + index, + isCurrentCopiedClip, + isEvenRow, +}: ClipProps): React.JSX.Element { + const { copyClipToClipboard, updateClip } = useClipsActions(); const { isLight } = useTheme(); const { hasPatterns } = usePatternDetection(clip.content); const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu(); @@ -60,10 +67,8 @@ export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { } }; - const isCurrentCopiedClip = clipCopyIndex === index; - return ( -
  • +
    - +
    {/* Context Menu */} @@ -109,8 +114,9 @@ export function ClipWrapper({ clip, index }: ClipProps): React.JSX.Element { x={contextMenu.x} y={contextMenu.y} onClose={closeContextMenu} + hasPatterns={hasPatterns} /> )} -
  • + ); -} +}); diff --git a/src/renderer/src/components/clips/clip/HtmlClip.tsx b/src/renderer/src/components/clips/clip/HtmlClip.tsx index 3057c21..ef8215e 100644 --- a/src/renderer/src/components/clips/clip/HtmlClip.tsx +++ b/src/renderer/src/components/clips/clip/HtmlClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface HtmlClipProps { clip: ClipItem; } -export const HtmlClip = ({ clip }: HtmlClipProps) => { +export const HtmlClip = memo(function HtmlClip({ clip }: HtmlClipProps) { const { isLight } = useTheme(); return (
    @@ -15,4 +16,4 @@ export const HtmlClip = ({ clip }: HtmlClipProps) => { {clip.content}
    ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/ImageClip.test.tsx b/src/renderer/src/components/clips/clip/ImageClip.test.tsx index 5d543f5..3c4df37 100644 --- a/src/renderer/src/components/clips/clip/ImageClip.test.tsx +++ b/src/renderer/src/components/clips/clip/ImageClip.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; import { ImageClip } from './ImageClip'; const { themeState } = vi.hoisted(() => ({ @@ -15,6 +15,7 @@ vi.mock('./Clip.module.css', () => ({ imagePreviewContainer: 'imagePreviewContainer', imagePreview: 'imagePreview', imagePopover: 'imagePopover', + imagePopoverVisible: 'imagePopoverVisible', popoverImage: 'popoverImage', imageInfo: 'imageInfo', imageFilename: 'imageFilename', @@ -26,11 +27,13 @@ vi.mock('./Clip.module.css', () => ({ describe('ImageClip', () => { afterEach(() => { cleanup(); + themeState.isLight = false; }); + it('renders image with correct src', () => { render(); - const images = screen.getAllByRole('img'); - expect(images[0]).toHaveAttribute('src', 'data:image/png;base64,abc123'); + const img = screen.getAllByRole('img')[0]; + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc123'); }); it('displays image format', () => { @@ -50,13 +53,10 @@ describe('ImageClip', () => { expect(screen.getByText(/Unknown format/)).toBeInTheDocument(); }); - it('handles mouse enter and positions popover', () => { - const { container } = render( - - ); + it('shows popover on mouse enter and hides on mouse leave', () => { + render(); const img = screen.getAllByRole('img')[0]; - // Mock getBoundingClientRect img.getBoundingClientRect = vi.fn().mockReturnValue({ top: 100, left: 50, @@ -66,21 +66,26 @@ describe('ImageClip', () => { height: 100, }); - // Mock viewport Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + // Popover should not exist before hover + expect(document.body.querySelector('.imagePopoverVisible')).toBeNull(); + fireEvent.mouseEnter(img); - // Popover should have been positioned - const popover = container.querySelector('.imagePopover'); + // Popover should be portaled to body + const popover = document.body.querySelector('.imagePopoverVisible'); expect(popover).toBeTruthy(); + + fireEvent.mouseLeave(img); + + // Popover should be removed + expect(document.body.querySelector('.imagePopoverVisible')).toBeNull(); }); it('positions popover to the left when right edge exceeded', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -96,14 +101,12 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.left).toBeTruthy(); }); it('clamps popover to top edge', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -119,14 +122,12 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.top).toBe('16px'); }); it('clamps popover to bottom edge', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; img.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -142,18 +143,14 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; - // Should be clamped to viewport - popoverHeight - 16 + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.top).toBe(`${800 - 320 - 16}px`); }); it('clamps popover to left edge when positioned left goes negative', () => { - const { container } = render( - - ); + render(); const img = screen.getAllByRole('img')[0]; - // right edge exceeds viewport width, left edge is very close to 0 img.getBoundingClientRect = vi.fn().mockReturnValue({ top: 100, left: 5, @@ -167,7 +164,7 @@ describe('ImageClip', () => { Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); fireEvent.mouseEnter(img); - const popover = container.querySelector('.imagePopover') as HTMLElement; + const popover = document.body.querySelector('.imagePopoverVisible') as HTMLElement; expect(popover?.style.left).toBe('16px'); }); @@ -176,14 +173,11 @@ describe('ImageClip', () => { render(); const img = screen.getAllByRole('img')[0]; - // Mock parentNode - const parent = document.createElement('div'); - Object.defineProperty(img, 'parentNode', { value: parent, writable: true }); - fireEvent.error(img); - expect(img.style.display).toBe('none'); - expect(parent.querySelector('span')?.textContent).toBe('Invalid image data'); + // Image should be replaced by fallback text (React state, no DOM manipulation) + expect(screen.queryByRole('img')).toBeNull(); + expect(screen.getByText('Invalid image data')).toBeInTheDocument(); }); it('uses light theme color in error fallback', () => { @@ -191,12 +185,273 @@ describe('ImageClip', () => { render(); const img = screen.getAllByRole('img')[0]; - const parent = document.createElement('div'); - Object.defineProperty(img, 'parentNode', { value: parent, writable: true }); - fireEvent.error(img); - const fallback = parent.querySelector('span'); - expect(fallback?.style.color).toBe('rgb(102, 102, 102)'); + const fallback = screen.getByText('Invalid image data'); + expect(fallback.style.color).toBe('rgb(102, 102, 102)'); + }); + + it('shows thumbnailDataUrl as img src when available', () => { + render( + + ); + const img = screen.getAllByRole('img')[0]; + expect(img).toHaveAttribute('src', 'data:image/png;base64,thumbnail'); + }); + + it('calls getFullImage on hover when clip has imageId', async () => { + const mockGetFullImage = vi.fn().mockResolvedValue('data:image/png;base64,fullimage'); + window.api = { getFullImage: mockGetFullImage } as unknown as typeof window.api; + + render( + + ); + const img = screen.getAllByRole('img')[0]; + + img.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + left: 50, + right: 150, + bottom: 200, + width: 100, + height: 100, + }); + + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + + await act(async () => { + fireEvent.mouseEnter(img); + }); + + await waitFor(() => { + expect(mockGetFullImage).toHaveBeenCalledWith('img-456'); + }); + + // Popover should show the full image + const popover = document.body.querySelector('.imagePopoverVisible'); + expect(popover).toBeTruthy(); + const popoverImg = popover?.querySelector('img'); + expect(popoverImg).toHaveAttribute('src', 'data:image/png;base64,fullimage'); + }); + + it('returns cached image without calling getFullImage again', async () => { + const mockGetFullImage = vi.fn().mockResolvedValue('data:image/png;base64,fullimage'); + window.api = { getFullImage: mockGetFullImage } as unknown as typeof window.api; + + const clip = { + type: 'image' as const, + content: 'data:image/png;base64,fallback', + thumbnailDataUrl: 'data:image/png;base64,thumb', + imageId: 'img-789', + }; + + const { unmount } = render(); + const img = screen.getAllByRole('img')[0]; + + img.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + left: 50, + right: 150, + bottom: 200, + width: 100, + height: 100, + }); + + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + + // First hover - should call getFullImage + await act(async () => { + fireEvent.mouseEnter(img); + }); + + await waitFor(() => { + expect(mockGetFullImage).toHaveBeenCalledTimes(1); + }); + + // Leave and re-enter + fireEvent.mouseLeave(img); + + mockGetFullImage.mockClear(); + + await act(async () => { + fireEvent.mouseEnter(img); + }); + + // Should not call getFullImage again since image is cached + expect(mockGetFullImage).not.toHaveBeenCalled(); + + // Popover should still show the full image from cache + const popover = document.body.querySelector('.imagePopoverVisible'); + const popoverImg = popover?.querySelector('img'); + expect(popoverImg).toHaveAttribute('src', 'data:image/png;base64,fullimage'); + + unmount(); + }); + + it('shows displaySrc in popover when getFullImage returns null', async () => { + const mockGetFullImage = vi.fn().mockResolvedValue(null); + window.api = { getFullImage: mockGetFullImage } as unknown as typeof window.api; + + render( + + ); + const img = screen.getAllByRole('img')[0]; + + img.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + left: 50, + right: 150, + bottom: 200, + width: 100, + height: 100, + }); + + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + + await act(async () => { + fireEvent.mouseEnter(img); + }); + + await waitFor(() => { + expect(mockGetFullImage).toHaveBeenCalledWith('img-null'); + }); + + // Popover should fall back to displaySrc (thumbnailDataUrl) + const popover = document.body.querySelector('.imagePopoverVisible'); + const popoverImg = popover?.querySelector('img'); + expect(popoverImg).toHaveAttribute('src', 'data:image/png;base64,thumb'); + }); + + it('handles getFullImage error gracefully', async () => { + const mockGetFullImage = vi.fn().mockRejectedValue(new Error('IPC error')); + window.api = { getFullImage: mockGetFullImage } as unknown as typeof window.api; + + render( + + ); + const img = screen.getAllByRole('img')[0]; + + img.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + left: 50, + right: 150, + bottom: 200, + width: 100, + height: 100, + }); + + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + + await act(async () => { + fireEvent.mouseEnter(img); + }); + + await waitFor(() => { + expect(mockGetFullImage).toHaveBeenCalledWith('img-err'); + }); + + // Should not crash; popover should still be visible + const popover = document.body.querySelector('.imagePopoverVisible'); + expect(popover).toBeTruthy(); + }); + + it('skips IPC call when getFullImage is not available', async () => { + const originalApi = window.api; + window.api = {} as unknown as typeof window.api; + + render( + + ); + const img = screen.getAllByRole('img')[0]; + + img.getBoundingClientRect = vi.fn().mockReturnValue({ + top: 100, + left: 50, + right: 150, + bottom: 200, + width: 100, + height: 100, + }); + + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1200, writable: true }); + + await act(async () => { + fireEvent.mouseEnter(img); + }); + + // Popover should still show using the thumbnail fallback + const popover = document.body.querySelector('.imagePopoverVisible'); + expect(popover).toBeTruthy(); + const popoverImg = popover?.querySelector('img'); + expect(popoverImg).toHaveAttribute('src', 'data:image/png;base64,thumb'); + + window.api = originalApi; + }); + + it('uses content for format detection when no thumbnailDataUrl', () => { + render( + + ); + const jpegElements = screen.getAllByText(/JPEG/); + expect(jpegElements.length).toBeGreaterThan(0); + }); + + it('uses thumbnailDataUrl for format detection when available', () => { + render( + + ); + const webpElements = screen.getAllByText(/WEBP/); + expect(webpElements.length).toBeGreaterThan(0); }); }); diff --git a/src/renderer/src/components/clips/clip/ImageClip.tsx b/src/renderer/src/components/clips/clip/ImageClip.tsx index f8af930..a982805 100644 --- a/src/renderer/src/components/clips/clip/ImageClip.tsx +++ b/src/renderer/src/components/clips/clip/ImageClip.tsx @@ -1,4 +1,5 @@ -import { useRef } from 'react'; +import { memo, useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -8,92 +9,136 @@ interface ImageClipProps { clip: ClipItem; } -export const ImageClip = ({ clip }: ImageClipProps) => { +export const ImageClip = memo(function ImageClip({ clip }: ImageClipProps) { const { isLight } = useTheme(); - const popoverRef = useRef(null); + const [popoverStyle, setPopoverStyle] = useState({}); + const [showPopover, setShowPopover] = useState(false); + const [fullImageUrl, setFullImageUrl] = useState(null); + const [hasError, setHasError] = useState(false); + const fullImageCache = useRef>(new Map()); - const handleImageMouseEnter = (e: React.MouseEvent) => { - const popover = popoverRef.current; - /* istanbul ignore else -- @preserve */ - if (popover) { - const rect = e.currentTarget.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const popoverHeight = 320; // 20rem max-height - const popoverWidth = 320; // 20rem max-width + // Determine display source: use thumbnail if available, otherwise content (legacy inline) + const displaySrc = clip.thumbnailDataUrl || clip.content; + // For size display, use thumbnail length as approximation or content length for legacy + const sizeSource = clip.thumbnailDataUrl || clip.content; - // Calculate preferred position (to the right of the image) - let left = rect.right + 16; - let top = rect.top + rect.height / 2 - popoverHeight / 2; // Center the popover vertically on the image + // Reset error state when the clip changes (prevents stale error from DOM reuse) + useEffect(() => { + setHasError(false); + }, [displaySrc]); - // Check if popover would extend beyond right edge of viewport - if (left + popoverWidth > viewportWidth) { - // Position to the left of the image instead - left = rect.left - popoverWidth - 16; - } + const loadFullImage = useCallback(async (imageId: string) => { + // Check cache first + const cached = fullImageCache.current.get(imageId); + if (cached) { + setFullImageUrl(cached); + return; + } - // Ensure popover doesn't go beyond left edge - if (left < 16) { - left = 16; + // Load via IPC + if (window.api?.getFullImage) { + try { + const fullUrl = await window.api.getFullImage(imageId); + if (fullUrl) { + fullImageCache.current.set(imageId, fullUrl); + setFullImageUrl(fullUrl); + } + } catch (error) { + console.error('Failed to load full image:', error); } + } + }, []); - // Check if popover would extend beyond bottom of viewport - if (top + popoverHeight > viewportHeight) { - // Position at bottom edge of viewport with padding - top = viewportHeight - popoverHeight - 16; - } + const handleImageMouseEnter = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const popoverHeight = 320; + const popoverWidth = 320; - // Check if popover would extend beyond top of viewport - if (top < 16) { - top = 16; - } + let left = rect.right + 16; + let top = rect.top + rect.height / 2 - popoverHeight / 2; - popover.style.left = `${left}px`; - popover.style.top = `${top}px`; - popover.style.transform = 'none'; // Always use none since we calculate exact position + if (left + popoverWidth > viewportWidth) { + left = rect.left - popoverWidth - 16; + } + if (left < 16) { + left = 16; + } + if (top + popoverHeight > viewportHeight) { + top = viewportHeight - popoverHeight - 16; + } + if (top < 16) { + top = 16; } + + setPopoverStyle({ left: `${left}px`, top: `${top}px` }); + setShowPopover(true); + + // Load full image for the popover if this is an imageId-backed clip + if (clip.imageId) { + loadFullImage(clip.imageId); + } + }; + + const handleImageMouseLeave = () => { + setShowPopover(false); }; + // Popover image source: prefer loaded full image, fall back to content (for legacy inline clips) + const popoverSrc = clip.imageId ? fullImageUrl || displaySrc : clip.content; + + // Determine format from content or thumbnail + const formatSource = clip.thumbnailDataUrl || clip.content; + const format = formatSource.startsWith('data:image/') + ? formatSource.split(';')[0].split('/')[1].toUpperCase() + : 'Unknown format'; + return (
    - Clipboard image preview { - // Fallback to text if image fails to load - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - const fallback = document.createElement('span'); - fallback.textContent = 'Invalid image data'; - fallback.style.color = isLight ? '#666666' : 'rgb(156 163 175)'; - fallback.style.fontSize = '0.75rem'; - target.parentNode?.appendChild(fallback); - }} - /> -
    + {hasError ? ( + + Invalid image data + + ) : ( Large image preview setHasError(true)} /> -
    + )} + {showPopover && + createPortal( +
    + Large image preview +
    , + document.body + )}
    - Image ( - {clip.content.startsWith('data:image/') - ? clip.content.split(';')[0].split('/')[1].toUpperCase() - : 'Unknown format'} - ) + Image ({format}) - {Math.round((clip.content.length * 0.75) / 1024)} KB + {Math.round((sizeSource.length * 0.75) / 1024)} KB
    ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/RtfClip.tsx b/src/renderer/src/components/clips/clip/RtfClip.tsx index 961baf1..f87557f 100644 --- a/src/renderer/src/components/clips/clip/RtfClip.tsx +++ b/src/renderer/src/components/clips/clip/RtfClip.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import classNames from 'classnames'; import { ClipItem } from '../../../providers/clips'; import { useTheme } from '../../../providers/theme'; @@ -7,7 +8,7 @@ interface RtfClipProps { clip: ClipItem; } -export const RtfClip = ({ clip }: RtfClipProps) => { +export const RtfClip = memo(function RtfClip({ clip }: RtfClipProps) { const { isLight } = useTheme(); return ( @@ -16,4 +17,4 @@ export const RtfClip = ({ clip }: RtfClipProps) => { {clip.content} ); -}; +}); diff --git a/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx new file mode 100644 index 0000000..97f34e8 --- /dev/null +++ b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { createRef } from 'react'; +import { SyntaxHighlightedCode } from './SyntaxHighlightedCode'; + +vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockHighlighter = ({ children, PreTag }: any) => { + const Tag = PreTag || 'pre'; + return ( + + {children} + + ); + }; + MockHighlighter.registerLanguage = vi.fn(); + MockHighlighter.alias = vi.fn(); + return { default: MockHighlighter }; +}); + +vi.mock('react-syntax-highlighter/dist/esm/styles/prism/material-dark', () => ({ + default: {}, +})); + +vi.mock('react-syntax-highlighter/dist/esm/styles/prism/material-light', () => ({ + default: {}, +})); + +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/javascript', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/typescript', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/python', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/java', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/csharp', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/cpp', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/c', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/markup', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/css', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/json', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/sql', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/bash', () => ({ + default: {}, +})); +vi.mock('react-syntax-highlighter/dist/esm/languages/prism/powershell', () => ({ + default: {}, +})); + +vi.mock('./Clip.module.css', () => ({ + default: { + textEditorWrapper: 'textEditorWrapper', + syntaxHighlightContainer: 'syntaxHighlightContainer', + textEditor: 'textEditor', + syntaxOverlay: 'syntaxOverlay', + light: 'light', + }, +})); + +describe('SyntaxHighlightedCode', () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + editValue: 'const x = 1;', + syntaxLanguage: 'javascript', + isLight: false, + onChange: vi.fn(), + onBlur: vi.fn(), + onKeyDown: vi.fn(), + }; + + it('renders the syntax highlighter and textarea', () => { + render(); + expect(screen.getByTestId('prism-highlighter')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays the edit value in the highlighter', () => { + render(); + expect(screen.getByTestId('prism-highlighter')).toHaveTextContent('const x = 1;'); + }); + + it('passes edit value to the textarea', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('const x = 1;'); + }); + + it('calls onChange when textarea value changes', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'const y = 2;' } }); + expect(onChange).toHaveBeenCalled(); + }); + + it('calls onBlur when textarea loses focus', () => { + const onBlur = vi.fn(); + render(); + fireEvent.blur(screen.getByRole('textbox')); + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onKeyDown on key press', () => { + const onKeyDown = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + expect(onKeyDown).toHaveBeenCalled(); + }); + + it('applies dark theme styles by default', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea.className).not.toContain('light'); + expect(textarea.style.caretColor).toBe('#fff'); + }); + + it('applies light theme styles when isLight is true', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea.className).toContain('light'); + expect(textarea.style.caretColor).toBe('#000'); + }); + + it('forwards ref to the textarea', () => { + const ref = createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('textbox')); + }); + + it('sets textarea to transparent color for overlay effect', () => { + render(); + expect(screen.getByRole('textbox').style.color).toBe('transparent'); + }); + + it('disables spellcheck on the textarea', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('spellcheck', 'false'); + }); +}); diff --git a/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx new file mode 100644 index 0000000..fe33010 --- /dev/null +++ b/src/renderer/src/components/clips/clip/SyntaxHighlightedCode.tsx @@ -0,0 +1,115 @@ +import { forwardRef } from 'react'; +import classNames from 'classnames'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; +import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark'; +import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light'; +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'; +import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; +import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; +import java from 'react-syntax-highlighter/dist/esm/languages/prism/java'; +import csharp from 'react-syntax-highlighter/dist/esm/languages/prism/csharp'; +import cpp from 'react-syntax-highlighter/dist/esm/languages/prism/cpp'; +import c from 'react-syntax-highlighter/dist/esm/languages/prism/c'; +import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'; +import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'; +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'; +import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'; +import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; +import powershell from 'react-syntax-highlighter/dist/esm/languages/prism/powershell'; +import styles from './Clip.module.css'; + +SyntaxHighlighter.registerLanguage('javascript', javascript); +SyntaxHighlighter.registerLanguage('typescript', typescript); +SyntaxHighlighter.registerLanguage('python', python); +SyntaxHighlighter.registerLanguage('java', java); +SyntaxHighlighter.registerLanguage('csharp', csharp); +SyntaxHighlighter.registerLanguage('cpp', cpp); +SyntaxHighlighter.registerLanguage('c', c); +SyntaxHighlighter.registerLanguage('markup', markup); +SyntaxHighlighter.registerLanguage('css', css); +SyntaxHighlighter.registerLanguage('json', json); +SyntaxHighlighter.registerLanguage('sql', sql); +SyntaxHighlighter.registerLanguage('bash', bash); +SyntaxHighlighter.registerLanguage('powershell', powershell); + +interface SyntaxHighlightedCodeProps { + editValue: string; + syntaxLanguage: string; + isLight: boolean; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +export const SyntaxHighlightedCode = forwardRef( + ({ editValue, syntaxLanguage, isLight, onChange, onBlur, onKeyDown }, ref) => { + const syntaxStyle = isLight ? materialLight : materialDark; + const borderColor = isLight ? '#d0d0d0' : '#404040'; + const backgroundColor = isLight ? '#f8f8f8' : '#404040'; + + return ( +
    +
    + ( +
    +                {children}
    +              
    + )} + > + {editValue} +
    +