From 41638d073eeeab69d36a8e6783a55c2833dd9492 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:04:37 +0000 Subject: [PATCH 1/7] chore: start surface-save-failures From 7da0c05037bc22926915d5014cb65e6d171da8be Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:23:14 +0000 Subject: [PATCH 2/7] feat(clips): report storage save failures from useClipsStorage Issue #169. The debounced clip and settings saves swallowed their errors into the console, so an unwritable store looked healthy until the next launch. The hook now returns saveError alongside loadError: the two save paths report independently and the list shows the first still failing, so a settings write that lands cannot mask a refused clip write. Saving is not disabled and no clips are dropped - the in-memory list is still the truth, and the next successful save clears the report. Files: providers/clips/storage.ts, storage.test.tsx. UI surfacing (toast plus banner) follows in the next commit. --- .../src/providers/clips/storage.test.tsx | 71 ++++++++++++++++++- src/renderer/src/providers/clips/storage.ts | 30 ++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index 259e13e..bbba34d 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -37,12 +37,14 @@ let observed: { maxClips: number; isInitiallyLoading: boolean; loadError: ClipsLoadError | null; + saveError: string | 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,69 @@ 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).toBe('Storage could not be loaded'); + }); + + 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).toBe('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).toBe('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).toBe('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..fd6a913 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -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`, the reason the last write was refused, 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,8 +24,21 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -): { loadError: ClipsLoadError | null } => { +): { loadError: ClipsLoadError | null; saveError: string | null } => { const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + + // The two save paths fail independently, so each reports its own outcome and the list + // shows the first one still failing: a settings write that lands does not imply the + // clip history did too. + const saveFailures = useRef<{ clips: string | null; settings: string | null }>({ + clips: null, + settings: null, + }); + const reportSave = useCallback((source: 'clips' | 'settings', message: string | null) => { + saveFailures.current = { ...saveFailures.current, [source]: message }; + setSaveError(saveFailures.current.clips ?? saveFailures.current.settings); + }, []); // Shared function to load all stored data (clips + settings). // Saving stays disabled (isInitiallyLoading) until this has applied a successfully loaded @@ -151,15 +168,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 +191,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 }; }; From 45ca5f098f4234ff299af335e376df0ba653bb25 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:27:22 +0000 Subject: [PATCH 3/7] feat(clips): surface storage save failures in the window Issue #169. A save that never lands now shows in the window instead of only the devtools console: the provider toasts "Clips could not be saved" with the reason on the transition into the failing state, and the list carries a persistent banner for as long as it lasts, since the debounced save retries on every change and a toast is easy to miss. Key decisions: LoadFailedBanner and the new SaveFailedBanner share one StorageFailedBanner shell (title, lines, reason) rather than a second component; the load banner wins when both are set, because that path pauses saving and its copy says so. A ref gates the toast so repeated failures cannot spam one per debounce tick, and it resets once a save lands so a later failure is announced again. Nothing is disabled and no clips are dropped. Files: components/clips/Clips.tsx (+test), providers/clips/index.tsx (+test), providers/clips/types.ts. Note for a resumed run: the provider test drives repeat saves through the settings-update listener, and several providers register one, so it fans the update out to all of them. --- .../src/components/clips/Clips.test.tsx | 27 ++++ src/renderer/src/components/clips/Clips.tsx | 77 +++++++++-- .../src/providers/clips/index.test.tsx | 120 +++++++++++++++++- src/renderer/src/providers/clips/index.tsx | 19 ++- src/renderer/src/providers/clips/types.ts | 2 + 5 files changed, 227 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 9e369f6..2ad8ade 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 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,27 @@ 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 = '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('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..46dd365 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -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,11 @@ export function Clips(): React.JSX.Element { return (
- {loadError !== null && } + {loadError !== null ? ( + + ) : ( + saveError !== null && + )}
+
{title}
+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
  • {reason}
  • +
+
+ ); +} + +/** + * Shown above the list for as long as writes keep being refused. A toast announces the + * first failure, but the debounced save retries on every change, so 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. + */ +function SaveFailedBanner({ reason }: { reason: string }): React.JSX.Element { + return ( + + ); +} /** * Shown above the list for as long as the stored history is unreadable. It stays because @@ -148,14 +202,15 @@ const LOAD_FAILED_RESET = 'To use Clipless again, clear all data in Settings and */ function LoadFailedBanner({ error }: { error: ClipsLoadError }): React.JSX.Element { 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..e562105 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,14 @@ 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 ?? 'no save error'}
+ ); } @@ -76,3 +79,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(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('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..e2ea0ef 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,20 @@ 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('Clips could not be saved', saveError); + }, [saveError, toast]); + // Use state management hook for clip operations const { getClip, @@ -419,8 +433,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/types.ts b/src/renderer/src/providers/clips/types.ts index ea8c264..950ddfd 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -51,6 +51,8 @@ export type ClipsMetaContextType = { hideSearch: () => void; /** Why the stored history could not be read, or null; while set, saving stays off */ loadError: ClipsLoadError | null; + /** Why the last save was refused, or null; saving keeps retrying while it is set */ + saveError: string | null; }; /** From 6259613c60cb0f6022b004236371cb4535b6d14d Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:29:51 +0000 Subject: [PATCH 4/7] refactor(clips): pick the storage banner with an explicit component Replace the ternary-and-&& expression in the list render with a small StorageBanner component that spells out the precedence between an unreadable history and a refused save. Build the load banner's lines with a plain conditional instead of a spread, and note the new save failure banner and toast in the README. --- README.md | 2 +- src/renderer/src/components/clips/Clips.tsx | 35 +++++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) 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/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 46dd365..69daf20 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -84,11 +84,7 @@ export function Clips(): React.JSX.Element { return (
- {loadError !== null ? ( - - ) : ( - saveError !== 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. @@ -201,15 +217,14 @@ function SaveFailedBanner({ reason }: { reason: string }): React.JSX.Element { * 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 ( ); From 98dc765c7ef40f0c00d2f502d99b5c1e90cbcf5a Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:33:39 +0000 Subject: [PATCH 5/7] chore(release): v2.4.0 (minor) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From d52cb8a6b2e1d3629b733b418c16ace2cd4cacd9 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:47:31 +0000 Subject: [PATCH 6/7] fix(storage): let a refused save reject so the renderer sees the reason saveClips and saveSettings in the main process caught every error and resolved false, so the renderer's catch never ran in the shipped app and the save-failed banner could not appear. They now log and rethrow like exportData and importData, so the handler rejects and the storage error's own message reaches the window. Electron wraps a rejected handler as "Error invoking remote method 'channel': Error: reason"; errorText strips that wrapper so banners and toasts show only the reason. --- src/main/clipboard/storage-integration.test.ts | 16 +++++++--------- src/main/clipboard/storage-integration.ts | 7 +++++-- src/renderer/src/utils/errorText.test.ts | 13 +++++++++++++ src/renderer/src/utils/errorText.ts | 7 ++++++- 4 files changed, 31 insertions(+), 12 deletions(-) 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/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: )?/, ''); } From 5eca56d169cd1ec954be512bb34db6ed76e299eb Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:47:31 +0000 Subject: [PATCH 7/7] fix(clips): word the save-failed banner for the file that was refused A failed settings save used to render the clip-loss copy even though every clip was on disk. saveError now carries its source, the list shows settings-specific lines for a refused clip limit, and the toast title follows suit. The hook keeps the two outcomes in one state value with a functional update instead of a ref mirrored into state. --- .../src/components/clips/Clips.test.tsx | 17 ++++++++- src/renderer/src/components/clips/Clips.tsx | 38 ++++++++++++++----- .../src/providers/clips/index.test.tsx | 8 ++-- src/renderer/src/providers/clips/index.tsx | 5 ++- .../src/providers/clips/storage.test.tsx | 31 ++++++++++++--- src/renderer/src/providers/clips/storage.ts | 35 ++++++++++------- src/renderer/src/providers/clips/types.ts | 11 +++++- 7 files changed, 108 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 2ad8ade..17b1e19 100644 --- a/src/renderer/src/components/clips/Clips.test.tsx +++ b/src/renderer/src/components/clips/Clips.test.tsx @@ -19,7 +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 string | null, + saveError: null as { source: 'clips' | 'settings'; message: string } | null, }, })); @@ -244,7 +244,7 @@ describe('Clips load failure', () => { describe('Clips save failure', () => { it('shows a banner with the reason for as long as saves keep failing', () => { - state.saveError = 'Storage could not be loaded'; + 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); @@ -258,6 +258,19 @@ describe('Clips save failure', () => { 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(); diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 69daf20..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'; @@ -142,6 +142,11 @@ const SAVE_FAILED_KEPT = 'Every clip is still in the list, and Clipless keeps tr const SAVE_FAILED_RISK = 'Until a save lands, a restart will lose them—locked clips included.'; const SAVE_FAILED_ADVICE = 'Copy anything you need elsewhere, and check disk space and permissions.'; +const SETTINGS_SAVE_FAILED_TITLE = "Couldn't save your settings"; +const SETTINGS_SAVE_FAILED_KEPT = 'Your clips are saved. Only the clip limit is not.'; +const SETTINGS_SAVE_FAILED_RISK = + 'The limit may go back to its old value after a restart. Changing it tries the save again.'; +const SETTINGS_SAVE_FAILED_ADVICE = 'Check disk space and permissions.'; /** * Picks which storage banner the list shows, if any. An unreadable history takes precedence: @@ -152,13 +157,13 @@ function StorageBanner({ saveError, }: { loadError: ClipsLoadError | null; - saveError: string | null; + saveError: ClipsSaveError | null; }): React.JSX.Element | null { if (loadError !== null) { return ; } if (saveError !== null) { - return ; + return ; } return null; } @@ -192,18 +197,33 @@ function StorageFailedBanner({ } /** - * Shown above the list for as long as writes keep being refused. A toast announces the - * first failure, but the debounced save retries on every change, so 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. + * 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({ reason }: { reason: string }): React.JSX.Element { +function SaveFailedBanner({ error }: { error: ClipsSaveError }): React.JSX.Element { + if (error.source === 'settings') { + return ( + + ); + } return ( ); } diff --git a/src/renderer/src/providers/clips/index.test.tsx b/src/renderer/src/providers/clips/index.test.tsx index e562105..15ca1a9 100644 --- a/src/renderer/src/providers/clips/index.test.tsx +++ b/src/renderer/src/providers/clips/index.test.tsx @@ -24,7 +24,9 @@ function Probe() {
{loadError === null ? 'none' : `${loadError.recoverable}:${loadError.message}`}
-
{saveError ?? 'no save error'}
+
+ {saveError === null ? 'no save error' : `${saveError.source}:${saveError.message}`} +
); } @@ -148,7 +150,7 @@ describe('ClipsProvider save error', () => { mountWithToastSpy(); await settle(); - expect(screen.getByTestId('save-error')).toHaveTextContent(SAVE_REFUSED); + 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); @@ -173,7 +175,7 @@ describe('ClipsProvider save error', () => { api().storageSaveClips.mockRejectedValue(new Error('no disk')); await changeLimit(30); - expect(screen.getByTestId('save-error')).toHaveTextContent('no disk'); + expect(screen.getByTestId('save-error')).toHaveTextContent('clips:no disk'); expect(toast).toHaveBeenCalledTimes(2); }); diff --git a/src/renderer/src/providers/clips/index.tsx b/src/renderer/src/providers/clips/index.tsx index e2ea0ef..3c4d9a3 100644 --- a/src/renderer/src/providers/clips/index.tsx +++ b/src/renderer/src/providers/clips/index.tsx @@ -174,7 +174,10 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { } if (saveErrorNotified.current) return; saveErrorNotified.current = true; - toast('Clips could not be saved', saveError); + 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 diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index bbba34d..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,7 +37,7 @@ let observed: { maxClips: number; isInitiallyLoading: boolean; loadError: ClipsLoadError | null; - saveError: string | null; + saveError: ClipsSaveError | null; } = { clips: [], lockedClips: {}, @@ -331,7 +331,26 @@ describe('useClipsStorage save failures', () => { mount(); await settle(); - expect(observed.saveError).toBe('Storage could not be loaded'); + 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 () => { @@ -341,7 +360,7 @@ describe('useClipsStorage save failures', () => { mount(); await settle(); - expect(observed.saveError).toBe('no disk'); + expect(observed.saveError).toEqual({ source: 'settings', message: 'no disk' }); }); it('keeps saving and clears the report once a save succeeds', async () => { @@ -350,7 +369,7 @@ describe('useClipsStorage save failures', () => { api().storageSaveClips.mockRejectedValue(new Error('Storage could not be loaded')); mount(); await settle(); - expect(observed.saveError).toBe('Storage could not be loaded'); + 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 @@ -377,7 +396,7 @@ describe('useClipsStorage save failures', () => { await settle(); expect(api().storageSaveSettings).toHaveBeenCalled(); - expect(observed.saveError).toBe('Storage could not be loaded'); + expect(observed.saveError).toEqual({ source: 'clips', message: 'Storage could not be loaded' }); }); it('never reports a save failure while the history is unreadable', async () => { diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index fd6a913..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'; @@ -11,8 +11,8 @@ 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`, the reason the last write was refused, or null. Unlike a failed load - * a failed save changes nothing: the in-memory list is still the truth, so the debounced + * 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 = ( @@ -24,21 +24,28 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -): { loadError: ClipsLoadError | null; saveError: string | null } => { +): { loadError: ClipsLoadError | null; saveError: ClipsSaveError | null } => { const [loadError, setLoadError] = useState(null); - const [saveError, setSaveError] = useState(null); // The two save paths fail independently, so each reports its own outcome and the list - // shows the first one still failing: a settings write that lands does not imply the - // clip history did too. - const saveFailures = useRef<{ clips: string | null; settings: string | null }>({ - clips: null, - settings: null, - }); + // 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) => { - saveFailures.current = { ...saveFailures.current, [source]: message }; - setSaveError(saveFailures.current.clips ?? saveFailures.current.settings); + 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 diff --git a/src/renderer/src/providers/clips/types.ts b/src/renderer/src/providers/clips/types.ts index 950ddfd..a722059 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -51,10 +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; - /** Why the last save was refused, or null; saving keeps retrying while it is set */ - saveError: string | 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)