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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "2.3.3",
"version": "2.4.0",
"description": "A Clipboard manager for busy people",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down
16 changes: 7 additions & 9 deletions src/main/clipboard/storage-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});
Expand Down
7 changes: 5 additions & 2 deletions src/main/clipboard/storage-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export const getClipsSnapshot = async (): Promise<StoredClipsSnapshot> => {
}
};

// 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<number, boolean>
Expand All @@ -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;
}
};

Expand All @@ -44,7 +47,7 @@ export const saveSettings = async (settings: UserSettings): Promise<boolean> =>
return true;
} catch (error) {
console.error('Failed to save settings to storage:', error);
return false;
throw error;
}
};

Expand Down
40 changes: 40 additions & 0 deletions src/renderer/src/components/clips/Clips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}));

Expand Down Expand Up @@ -65,6 +66,7 @@ vi.mock('../../providers/clips', () => ({
isSearchVisible: state.isSearchVisible,
setIsSearchVisible: state.setIsSearchVisible,
loadError: state.loadError,
saveError: state.saveError,
}),
useQuickLook: () => ({ focusRequest: state.focusRequest }),
}));
Expand Down Expand Up @@ -94,6 +96,7 @@ beforeEach(() => {
state.isSearchVisible = false;
state.focusRequest = null;
state.loadError = null;
state.saveError = null;
});

afterEach(() => {
Expand Down Expand Up @@ -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(<Clips />);
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(<Clips />);
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(<Clips />);
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(<Clips />);
expect(screen.getByTestId('load-failed-banner')).toBeInTheDocument();
expect(screen.queryByTestId('save-failed-banner')).toBeNull();
});
});
114 changes: 102 additions & 12 deletions src/renderer/src/components/clips/Clips.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLDivElement>(null);

Expand Down Expand Up @@ -84,7 +84,7 @@ export function Clips(): React.JSX.Element {

return (
<div className={styles.clips}>
{loadError !== null && <LoadFailedBanner error={loadError} />}
<StorageBanner loadError={loadError} saveError={saveError} />
<div
ref={scrollContainerRef}
className={styles.clipsContainer}
Expand Down Expand Up @@ -137,6 +137,96 @@ const LOAD_FAILED_RETRY = 'Restart Clipless to try again.';
const LOAD_FAILED_UNREADABLE =
"The stored history can't be read with this computer's keystore, so it won't load.";
const LOAD_FAILED_RESET = 'To use Clipless again, clear all data in Settings and restart.';
const SAVE_FAILED_TITLE = "Couldn't save your clips";
const SAVE_FAILED_KEPT = 'Every clip is still in the list, and Clipless keeps trying to save.';
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:
* saving is off for the session, so a stale save failure would only confuse.
*/
function StorageBanner({
loadError,
saveError,
}: {
loadError: ClipsLoadError | null;
saveError: ClipsSaveError | null;
}): React.JSX.Element | null {
if (loadError !== null) {
return <LoadFailedBanner error={loadError} />;
}
if (saveError !== null) {
return <SaveFailedBanner error={saveError} />;
}
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 (
<div className={styles.loadFailed} role="alert" data-testid={testId}>
<div className={styles.loadFailedTitle}>{title}</div>
<ul className={styles.loadFailedDetail}>
{lines.map((line) => (
<li key={line}>{line}</li>
))}
<li className={styles.loadFailedError}>{reason}</li>
</ul>
</div>
);
}

/**
* 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 (
<StorageFailedBanner
testId="save-failed-banner"
title={SETTINGS_SAVE_FAILED_TITLE}
lines={[SETTINGS_SAVE_FAILED_KEPT, SETTINGS_SAVE_FAILED_RISK, SETTINGS_SAVE_FAILED_ADVICE]}
reason={error.message}
/>
);
}
return (
<StorageFailedBanner
testId="save-failed-banner"
title={SAVE_FAILED_TITLE}
lines={[SAVE_FAILED_KEPT, SAVE_FAILED_RISK, SAVE_FAILED_ADVICE]}
Comment thread
cb-jeeves marked this conversation as resolved.
reason={error.message}
/>
);
}

/**
* Shown above the list for as long as the stored history is unreadable. It stays because
Expand All @@ -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 (
<div className={styles.loadFailed} role="alert" data-testid="load-failed-banner">
<div className={styles.loadFailedTitle}>{LOAD_FAILED_TITLE}</div>
<ul className={styles.loadFailedDetail}>
<li>{LOAD_FAILED_PAUSED}</li>
<li>{error.recoverable ? LOAD_FAILED_RETRY : LOAD_FAILED_UNREADABLE}</li>
{!error.recoverable && <li>{LOAD_FAILED_RESET}</li>}
<li className={styles.loadFailedError}>{error.message}</li>
</ul>
</div>
<StorageFailedBanner
testId="load-failed-banner"
title={LOAD_FAILED_TITLE}
lines={lines}
reason={error.message}
/>
);
}
Loading
Loading