diff --git a/README.md b/README.md index 3f81efb..fb44601 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ The things you don't notice until you'd miss them. - **⌨️ Global hotkeys** — reach recent clips and quick look from anywhere, even when Clipless is minimized. Quick-clip hotkeys (1–5) grab your most recent items, and a focus hotkey snaps the window to you. Hotkeys are off by default — flip the master switch in Settings → Hotkeys to turn them on. - **🔒 Encrypted storage** — history is encrypted with your OS keystore (DPAPI, Keychain or Secret Service) and never leaves your machine. Data is split into domain-specific files for efficient saves, with images stored as separate encrypted files and fast-loading thumbnails. -- **🚀 Non-blocking startup** — the window appears immediately while your history loads in the background. Nothing is written back until that load succeeds, and if the history can't be read, a banner tells you saving is paused so the stored history isn't overwritten. +- **🚀 Non-blocking startup** — the window appears immediately while your history loads in the background. Nothing is written back until that load succeeds, and if the history can't be read, a banner tells you saving is paused so the stored history isn't overwritten. If a save is refused later (disk full, permissions), a toast and a banner say so while every clip stays in the list and saving keeps retrying. - **🖥️ Starts with you** — auto-launch on boot, start minimized to the tray, and update quietly in the background (auto-update works on Windows and Linux; macOS still needs a manual reinstall — see [Installing on macOS](#-installing-on-macos)). - **💾 Backup-friendly** — export and import your clips, patterns, tools and templates. diff --git a/package-lock.json b/package-lock.json index ddbc725..acbc1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "2.3.3", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "2.3.3", + "version": "2.4.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 3505ec4..9760fc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "2.3.3", + "version": "2.4.0", "description": "A Clipboard manager for busy people", "main": "./out/main/index.js", "author": "Daniel Essig", diff --git a/src/main/clipboard/storage-integration.test.ts b/src/main/clipboard/storage-integration.test.ts index 5dd2577..84a55d1 100644 --- a/src/main/clipboard/storage-integration.test.ts +++ b/src/main/clipboard/storage-integration.test.ts @@ -73,14 +73,12 @@ describe('saveClips', () => { expect(mocked.saveClips).toHaveBeenCalledWith(clips, { 0: true }); }); - it('returns false when the save is refused', async () => { - mocked.saveClips.mockRejectedValue(new Error('Storage has not finished loading')); + it('logs and rethrows when the save is refused, so the reason reaches the renderer', async () => { + const refused = new Error('Storage has not finished loading'); + mocked.saveClips.mockRejectedValue(refused); - expect(await saveClips([], {})).toBe(false); - expect(consoleError).toHaveBeenCalledWith( - 'Failed to save clips to storage:', - expect.any(Error) - ); + await expect(saveClips([], {})).rejects.toBe(refused); + expect(consoleError).toHaveBeenCalledWith('Failed to save clips to storage:', refused); }); }); @@ -110,10 +108,10 @@ describe('saveSettings', () => { expect(mocked.saveSettings).toHaveBeenCalledWith(DEFAULT_SETTINGS); }); - it('returns false when the save throws', async () => { + it('logs and rethrows when the save throws', async () => { mocked.saveSettings.mockRejectedValue(failure); - expect(await saveSettings(DEFAULT_SETTINGS)).toBe(false); + await expect(saveSettings(DEFAULT_SETTINGS)).rejects.toBe(failure); expect(consoleError).toHaveBeenCalledWith('Failed to save settings to storage:', failure); }); }); diff --git a/src/main/clipboard/storage-integration.ts b/src/main/clipboard/storage-integration.ts index 4af8bbe..d74db00 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -15,6 +15,9 @@ export const getClipsSnapshot = async (): Promise => { } }; +// A refused save is logged and rethrown, like exportData and importData: the handler then +// rejects and the renderer gets the reason, which is what its save-failed banner shows. +// A swallowed `false` would carry no reason and the window would look healthy. export const saveClips = async ( clips: ClipItem[], lockedIndices: Record @@ -24,7 +27,7 @@ export const saveClips = async ( return true; } catch (error) { console.error('Failed to save clips to storage:', error); - return false; + throw error; } }; @@ -44,7 +47,7 @@ export const saveSettings = async (settings: UserSettings): Promise => return true; } catch (error) { console.error('Failed to save settings to storage:', error); - return false; + throw error; } }; diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 9e369f6..17b1e19 100644 --- a/src/renderer/src/components/clips/Clips.test.tsx +++ b/src/renderer/src/components/clips/Clips.test.tsx @@ -19,6 +19,7 @@ const { virtual, state } = vi.hoisted(() => ({ setIsSearchVisible: vi.fn(), focusRequest: null as { index: number; seq: number } | null, loadError: null as { message: string; recoverable: boolean } | null, + saveError: null as { source: 'clips' | 'settings'; message: string } | null, }, })); @@ -65,6 +66,7 @@ vi.mock('../../providers/clips', () => ({ isSearchVisible: state.isSearchVisible, setIsSearchVisible: state.setIsSearchVisible, loadError: state.loadError, + saveError: state.saveError, }), useQuickLook: () => ({ focusRequest: state.focusRequest }), })); @@ -94,6 +96,7 @@ beforeEach(() => { state.isSearchVisible = false; state.focusRequest = null; state.loadError = null; + state.saveError = null; }); afterEach(() => { @@ -238,3 +241,40 @@ describe('Clips load failure', () => { expect(banner).not.toHaveTextContent(/clear all data/); }); }); + +describe('Clips save failure', () => { + it('shows a banner with the reason for as long as saves keep failing', () => { + state.saveError = { source: 'clips', message: 'Storage could not be loaded' }; + const { rerender } = render(); + const banner = screen.getByTestId('save-failed-banner'); + expect(banner).toHaveTextContent(/save your clips/i); + expect(banner).toHaveTextContent(/Storage could not be loaded/); + // Nothing is disabled or dropped: the list is still there and still the truth + expect(banner).not.toHaveTextContent(/Saving is paused/); + expect(screen.getByTestId('row-0')).toBeInTheDocument(); + + state.saveError = null; + rerender(); + expect(screen.queryByTestId('save-failed-banner')).toBeNull(); + }); + + it('says the clip limit is unsaved, not the clips, when only the settings save fails', () => { + state.saveError = { source: 'settings', message: 'no disk' }; + render(); + const banner = screen.getByTestId('save-failed-banner'); + expect(banner).toHaveTextContent(/save your settings/i); + expect(banner).toHaveTextContent(/clip limit/i); + expect(banner).toHaveTextContent(/no disk/); + // The clips are on disk: no clip-loss warning and no advice to copy them elsewhere + expect(banner).not.toHaveTextContent(/save your clips/i); + expect(banner).not.toHaveTextContent(/lose them/); + expect(banner).not.toHaveTextContent(/Copy anything/); + }); + + it('shows the load banner alone while the history is unreadable', () => { + state.loadError = { message: 'Error while decrypting', recoverable: false }; + render(); + expect(screen.getByTestId('load-failed-banner')).toBeInTheDocument(); + expect(screen.queryByTestId('save-failed-banner')).toBeNull(); + }); +}); diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index a0060b5..0f526f3 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useClipsData, useClipsMeta, useQuickLook } from '../../providers/clips'; -import type { ClipsLoadError } from '../../providers/clips/types'; +import type { ClipsLoadError, ClipsSaveError } from '../../providers/clips/types'; import { Clip } from './clip'; import { SEARCH_INPUT_ID } from '../SearchBar'; import styles from './Clips.module.css'; @@ -17,7 +17,7 @@ const isTypingTarget = (target: EventTarget | null): boolean => */ export function Clips(): React.JSX.Element { const { filteredClips, searchTerm, isFiltering, pinnedOnly } = useClipsData(); - const { clipCopyId, isSearchVisible, setIsSearchVisible, loadError } = useClipsMeta(); + const { clipCopyId, isSearchVisible, setIsSearchVisible, loadError, saveError } = useClipsMeta(); const { focusRequest } = useQuickLook(); const scrollContainerRef = useRef(null); @@ -84,7 +84,7 @@ export function Clips(): React.JSX.Element { return (
- {loadError !== null && } +
; + } + if (saveError !== null) { + return ; + } + return null; +} + +/** + * The shell both banners share: a title, a line per thing the reader should know, and the + * reason underneath in the error's own words. + */ +function StorageFailedBanner({ + testId, + title, + lines, + reason, +}: { + testId: string; + title: string; + lines: string[]; + reason: string; +}): React.JSX.Element { + return ( +
+
{title}
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
  • {reason}
  • +
+
+ ); +} + +/** + * Shown above the list for as long as a write keeps being refused. A toast announces the + * first failure, but the condition can last for hours: the banner is what still says so + * when the toast has gone. Nothing is paused or dropped: the list in the window is the + * truth until a save lands. + * + * The copy follows the file that was refused. The clip history is retried on every change + * and a restart loses it until a save lands, so that banner warns of clip loss. The + * settings file holds only the clip limit and is retried when the limit changes, so that + * banner says exactly that and does not claim the clips are at risk: they are on disk. + */ +function SaveFailedBanner({ error }: { error: ClipsSaveError }): React.JSX.Element { + if (error.source === 'settings') { + return ( + + ); + } + return ( + + ); +} /** * Shown above the list for as long as the stored history is unreadable. It stays because @@ -147,15 +237,15 @@ const LOAD_FAILED_RESET = 'To use Clipless again, clear all data in Settings and * restart is needed because saving stays off for the rest of this session. */ function LoadFailedBanner({ error }: { error: ClipsLoadError }): React.JSX.Element { + const lines = error.recoverable + ? [LOAD_FAILED_PAUSED, LOAD_FAILED_RETRY] + : [LOAD_FAILED_PAUSED, LOAD_FAILED_UNREADABLE, LOAD_FAILED_RESET]; return ( -
-
{LOAD_FAILED_TITLE}
-
    -
  • {LOAD_FAILED_PAUSED}
  • -
  • {error.recoverable ? LOAD_FAILED_RETRY : LOAD_FAILED_UNREADABLE}
  • - {!error.recoverable &&
  • {LOAD_FAILED_RESET}
  • } -
  • {error.message}
  • -
-
+ ); } diff --git a/src/renderer/src/providers/clips/index.test.tsx b/src/renderer/src/providers/clips/index.test.tsx index ca16d93..15ca1a9 100644 --- a/src/renderer/src/providers/clips/index.test.tsx +++ b/src/renderer/src/providers/clips/index.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act, cleanup } from '@testing-library/react'; import type { StoredClipsSnapshot } from '../../../../shared/types'; -import { ToastProvider } from '../../components/Toast'; +import { ToastContext, ToastProvider, type ToastFn } from '../../components/Toast'; import { LanguageDetectionProvider } from '../languageDetection'; import { ScanIndexProvider } from '../scan'; import { ClipsProvider, useClipsMeta } from './index'; @@ -18,11 +18,16 @@ const loaded = (): StoredClipsSnapshot => ({ }); function Probe() { - const { loadError } = useClipsMeta(); + const { loadError, saveError } = useClipsMeta(); return ( -
- {loadError === null ? 'none' : `${loadError.recoverable}:${loadError.message}`} -
+ <> +
+ {loadError === null ? 'none' : `${loadError.recoverable}:${loadError.message}`} +
+
+ {saveError === null ? 'no save error' : `${saveError.source}:${saveError.message}`} +
+ ); } @@ -76,3 +81,110 @@ describe('ClipsProvider load error', () => { expect(await screen.findByText('none')).toBeInTheDocument(); }); }); + +const SAVE_REFUSED = 'Storage could not be loaded'; + +const loadedEmpty = (): StoredClipsSnapshot => ({ + loadState: { complete: true, error: null }, + clips: [], +}); + +describe('ClipsProvider save error', () => { + let toast: ReturnType>; + // Several providers listen; the settings window's update reaches all of them + let settingsListeners: ((settings: unknown) => void)[]; + + const mountWithToastSpy = () => + render( + + + + + + + + + + ); + + // A change from the settings window is the cheapest way to make the debounced save run again + const changeLimit = async (maxClips: number) => { + await act(async () => { + settingsListeners.forEach((listener) => listener({ maxClips })); + }); + await settle(); + }; + + const settle = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + toast = vi.fn(); + settingsListeners = []; + api().storageGetClipsSnapshot.mockResolvedValue(loadedEmpty()); + api().storageSaveClips.mockReset().mockResolvedValue(true); + api() + .onSettingsUpdated.mockReset() + .mockImplementation((cb: (settings: unknown) => void) => { + settingsListeners.push(cb); + return () => { + settingsListeners = settingsListeners.filter((listener) => listener !== cb); + }; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('toasts the reason once when saves start failing and not again on every retry', async () => { + api().storageSaveClips.mockRejectedValue(new Error(SAVE_REFUSED)); + mountWithToastSpy(); + await settle(); + + expect(screen.getByTestId('save-error')).toHaveTextContent(`clips:${SAVE_REFUSED}`); + expect(toast).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith(expect.stringMatching(/could not be saved/i), SAVE_REFUSED); + + // The save is retried on every change, and a toast per debounce tick would be spam + await changeLimit(40); + await changeLimit(30); + + expect(api().storageSaveClips.mock.calls.length).toBeGreaterThan(1); + expect(toast).toHaveBeenCalledTimes(1); + }); + + it('toasts again when saves fail after recovering', async () => { + api().storageSaveClips.mockRejectedValue(new Error(SAVE_REFUSED)); + mountWithToastSpy(); + await settle(); + expect(toast).toHaveBeenCalledTimes(1); + + api().storageSaveClips.mockResolvedValue(true); + await changeLimit(40); + expect(screen.getByTestId('save-error')).toHaveTextContent('no save error'); + + api().storageSaveClips.mockRejectedValue(new Error('no disk')); + await changeLimit(30); + + expect(screen.getByTestId('save-error')).toHaveTextContent('clips:no disk'); + expect(toast).toHaveBeenCalledTimes(2); + }); + + it('says nothing while saves keep landing', async () => { + mountWithToastSpy(); + await settle(); + await changeLimit(40); + + expect(screen.getByTestId('save-error')).toHaveTextContent('no save error'); + expect(toast).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/providers/clips/index.tsx b/src/renderer/src/providers/clips/index.tsx index 7c0b79b..3c4d9a3 100644 --- a/src/renderer/src/providers/clips/index.tsx +++ b/src/renderer/src/providers/clips/index.tsx @@ -152,7 +152,7 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { const toast = useToast(); // Use storage hook for loading/saving data - const { loadError } = useClipsStorage( + const { loadError, saveError } = useClipsStorage( clips, lockedClips, maxClips, @@ -163,6 +163,23 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { setIsInitiallyLoading ); + // A refused save leaves the window looking healthy, so it gets a toast the moment it + // starts failing. The debounced save retries on every change, so only the transition + // into failing toasts; the list's banner carries the state for as long as it lasts. + const saveErrorNotified = useRef(false); + useEffect(() => { + if (saveError === null) { + saveErrorNotified.current = false; + return; + } + if (saveErrorNotified.current) return; + saveErrorNotified.current = true; + toast( + saveError.source === 'clips' ? 'Clips could not be saved' : 'Settings could not be saved', + saveError.message + ); + }, [saveError, toast]); + // Use state management hook for clip operations const { getClip, @@ -419,8 +436,9 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { setIsSearchVisible, hideSearch, loadError, + saveError, }), - [clipCopyId, maxClips, isSearchVisible, hideSearch, loadError] + [clipCopyId, maxClips, isSearchVisible, hideSearch, loadError, saveError] ); const pinsValue = useMemo( diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index 259e13e..91b5203 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -3,7 +3,7 @@ import { render, act, cleanup } from '@testing-library/react'; import { useState } from 'react'; import type { StoredClip, StoredClipsSnapshot } from '../../../../shared/types'; import { DEFAULT_MAX_CLIPS } from '../constants'; -import { ClipItem, ClipsLoadError } from './types'; +import { ClipItem, ClipsLoadError, ClipsSaveError } from './types'; import { updateClipsLength } from './utils'; import { useClipsStorage } from './storage'; @@ -37,12 +37,14 @@ let observed: { maxClips: number; isInitiallyLoading: boolean; loadError: ClipsLoadError | null; + saveError: ClipsSaveError | null; } = { clips: [], lockedClips: {}, maxClips: DEFAULT_MAX_CLIPS, isInitiallyLoading: true, loadError: null, + saveError: null, }; function Probe() { @@ -50,7 +52,7 @@ function Probe() { const [lockedClips, setLockedClips] = useState>({}); const [maxClips, setMaxClips] = useState(DEFAULT_MAX_CLIPS); const [isInitiallyLoading, setIsInitiallyLoading] = useState(true); - const { loadError } = useClipsStorage( + const { loadError, saveError } = useClipsStorage( clips, lockedClips, maxClips, @@ -60,7 +62,7 @@ function Probe() { setMaxClips, setIsInitiallyLoading ); - observed = { clips, lockedClips, maxClips, isInitiallyLoading, loadError }; + observed = { clips, lockedClips, maxClips, isInitiallyLoading, loadError, saveError }; return null; } @@ -321,4 +323,88 @@ describe('useClipsStorage save failures', () => { expect(error).toHaveBeenCalledWith('Failed to save clips to storage:', expect.any(Error)); expect(error).toHaveBeenCalledWith('Failed to save settings to storage:', expect.any(Error)); }); + + it('reports the reason a clip save was refused', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + api().storageSaveClips.mockRejectedValue(new Error('Storage could not be loaded')); + mount(); + await settle(); + + expect(observed.saveError).toEqual({ source: 'clips', message: 'Storage could not be loaded' }); + }); + + it('reports the reason without the wrapper a rejected IPC handler arrives in', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + // The main-process wrapper rethrows the storage error and Electron wraps it on the way + // over; only the storage error's own words belong in the banner + api().storageSaveClips.mockRejectedValue( + new Error( + "Error invoking remote method 'storage-save-clips': Error: ENOSPC: no space left on device" + ) + ); + mount(); + await settle(); + + expect(observed.saveError).toEqual({ + source: 'clips', + message: 'ENOSPC: no space left on device', + }); + }); + + it('reports the reason a settings save failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + api().storageSaveSettings.mockRejectedValue(new Error('no disk')); + mount(); + await settle(); + + expect(observed.saveError).toEqual({ source: 'settings', message: 'no disk' }); + }); + + it('keeps saving and clears the report once a save succeeds', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + api().storageSaveClips.mockRejectedValue(new Error('Storage could not be loaded')); + mount(); + await settle(); + expect(observed.saveError).toEqual({ source: 'clips', message: 'Storage could not be loaded' }); + const refused = api().storageSaveClips.mock.calls.length; + + // The in-memory list is still the truth, so the next change is saved as usual + api().storageSaveClips.mockResolvedValue(true); + await act(async () => { + settingsUpdated?.({ maxClips: DEFAULT_MAX_CLIPS - 1 }); + }); + await settle(); + + expect(api().storageSaveClips.mock.calls.length).toBeGreaterThan(refused); + expect(observed.saveError).toBeNull(); + }); + + it('leaves a failed clip save reported when only the settings save succeeds', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + api().storageSaveClips.mockRejectedValue(new Error('Storage could not be loaded')); + mount(); + await settle(); + + await act(async () => { + settingsUpdated?.({ maxClips: DEFAULT_MAX_CLIPS - 1 }); + }); + await settle(); + + expect(api().storageSaveSettings).toHaveBeenCalled(); + expect(observed.saveError).toEqual({ source: 'clips', message: 'Storage could not be loaded' }); + }); + + it('never reports a save failure while the history is unreadable', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue(failed()); + mount(); + await settle(); + + expect(observed.saveError).toBeNull(); + }); }); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index e6af0a2..90f68e9 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ClipItem, ClipsLoadError } from './types'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ClipItem, ClipsLoadError, ClipsSaveError } from './types'; import { DEFAULT_MAX_CLIPS } from '../constants'; import { shrinkClips, updateClipsLength } from './utils'; import { errorText } from '../../utils/errorText'; @@ -10,6 +10,10 @@ import { UserSettings, StoredClip } from '../../../../shared/types'; * * Returns `loadError`, the reason the stored history could not be read, or null. While it * is set the list shows a banner and saving stays off for the rest of the session. + * + * Returns `saveError`, which write is still being refused and why, or null. Unlike a failed + * load a failed save changes nothing: the in-memory list is still the truth, so the debounced + * saves keep running and the next one that succeeds clears it. */ export const useClipsStorage = ( clips: ClipItem[], @@ -20,9 +24,29 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -): { loadError: ClipsLoadError | null } => { +): { loadError: ClipsLoadError | null; saveError: ClipsSaveError | null } => { const [loadError, setLoadError] = useState(null); + // The two save paths fail independently, so each reports its own outcome and the list + // shows the clip history first: a settings write that lands does not imply the clip + // history did too, and a lost history matters more than a lost clip limit. + const [saveFailures, setSaveFailures] = useState<{ + clips: string | null; + settings: string | null; + }>({ clips: null, settings: null }); + const reportSave = useCallback((source: 'clips' | 'settings', message: string | null) => { + setSaveFailures((current) => + current[source] === message ? current : { ...current, [source]: message } + ); + }, []); + const saveError = useMemo(() => { + if (saveFailures.clips !== null) return { source: 'clips', message: saveFailures.clips }; + if (saveFailures.settings !== null) { + return { source: 'settings', message: saveFailures.settings }; + } + return null; + }, [saveFailures]); + // Shared function to load all stored data (clips + settings). // Saving stays disabled (isInitiallyLoading) until this has applied a successfully loaded // history: the main process serves empty defaults while its background load runs, and a @@ -151,15 +175,18 @@ export const useClipsStorage = ( // Save all clips, including empty ones to preserve array structure // Filter will be done on the storage side if needed await window.api.storageSaveClips(clips, lockedClips); + reportSave('clips', null); } catch (error) { console.error('Failed to save clips to storage:', error); + // Nothing is dropped and the next change retries; the list says so meanwhile + reportSave('clips', errorText(error)); } }; // Debounce saves to avoid excessive writes const timeoutId = setTimeout(saveClipsToStorage, 1000); return () => clearTimeout(timeoutId); - }, [clips, lockedClips, isInitiallyLoading]); + }, [clips, lockedClips, isInitiallyLoading, reportSave]); // Save settings whenever maxClips changes useEffect(() => { @@ -171,15 +198,17 @@ export const useClipsStorage = ( try { await window.api.storageSaveSettings({ maxClips }); + reportSave('settings', null); } catch (error) { console.error('Failed to save settings to storage:', error); + reportSave('settings', errorText(error)); } }; // Debounce saves const timeoutId = setTimeout(saveSettingsToStorage, 500); return () => clearTimeout(timeoutId); - }, [maxClips, isInitiallyLoading]); + }, [maxClips, isInitiallyLoading, reportSave]); - return { loadError }; + return { loadError, saveError }; }; diff --git a/src/renderer/src/providers/clips/types.ts b/src/renderer/src/providers/clips/types.ts index ea8c264..a722059 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -51,8 +51,17 @@ export type ClipsMetaContextType = { hideSearch: () => void; /** Why the stored history could not be read, or null; while set, saving stays off */ loadError: ClipsLoadError | null; + /** The save still being refused and why, or null */ + saveError: ClipsSaveError | null; }; +/** + * A refused save as the list reports it. `source` says which file was refused: the clip + * history, which is retried on every change and is lost on restart until a save lands, or + * the settings file, which holds only the clip limit and is retried when the limit changes. + */ +export type ClipsSaveError = { source: 'clips' | 'settings'; message: string }; + /** * A failed history load as the list reports it. `recoverable` is true when a restart may * read the history (the keystore was unavailable or the main process could not be reached) diff --git a/src/renderer/src/utils/errorText.test.ts b/src/renderer/src/utils/errorText.test.ts index d42c087..e358dff 100644 --- a/src/renderer/src/utils/errorText.test.ts +++ b/src/renderer/src/utils/errorText.test.ts @@ -7,4 +7,17 @@ describe('errorText', () => { expect(errorText('plain')).toBe('plain'); expect(errorText(42)).toBe('42'); }); + + it("strips Electron's wrapper from a rejected IPC handler", () => { + expect( + errorText( + new Error( + "Error invoking remote method 'storage-save-clips': Error: Storage could not be loaded: no keystore" + ) + ) + ).toBe('Storage could not be loaded: no keystore'); + expect(errorText(new Error("Error invoking remote method 'x': ENOSPC: no space left"))).toBe( + 'ENOSPC: no space left' + ); + }); }); diff --git a/src/renderer/src/utils/errorText.ts b/src/renderer/src/utils/errorText.ts index 1757dfa..3499a87 100644 --- a/src/renderer/src/utils/errorText.ts +++ b/src/renderer/src/utils/errorText.ts @@ -1,6 +1,11 @@ /** * The text of whatever was thrown, for inline failures and toasts. + * + * A main-process handler that rejects reaches the renderer wrapped by Electron as + * "Error invoking remote method 'channel': Error: reason". Only the reason is the user's + * business, so the wrapper is stripped. */ export function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); + const text = error instanceof Error ? error.message : String(error); + return text.replace(/^Error invoking remote method '[^']*': (?:\w*Error: )?/, ''); }