the file browser's clipboard lives on the server

It was `useState`, so it existed in one tab and died with it. Copying in one window and pasting in
another — the obvious way to move a file between two folders you can both see — did nothing, and a
reload lost a pending cut with no sign that it had.

`user_state` is a per-user JSONB row that already exists for this kind of thing, and `useUserState`
already reads and writes it through React Query with an optimistic local update. So the clipboard is
one key in it: no new table, no new route, no socket.

Per user rather than per browser, deliberately. Cutting on the desktop and pasting on the phone is the
same gesture across two screens, and there is no reading of "my clipboard" where the two should
disagree.

── Other windows have to notice ──

USER_STATE is held with `staleTime: Infinity`, which is right for a dock layout and wrong for
something another window can change while this one is idle. It is invalidated on `focus` and on
`visibilitychange` — the second because a background tab brought forward by switching tabs fires only
that one in some browsers.

Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into the
other window means focusing it first, so the refresh happens exactly when it is needed and never while
nobody is looking.

── And it says what it is holding ──

An icon and a toast were enough while the clipboard died with the tab: you had just pressed the key,
so you knew what was on it. Now somebody can arrive at a folder with three files pending from
yesterday and paste them without ever having been told. So the toolbar says how many and whether they
will be copied or moved, and offers a way to let go of them. Invisible persistent state is how a file
gets moved by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:55:24 +01:00
co-authored by Claude Opus 5
parent 601849be11
commit e3071f5d50
8 changed files with 109 additions and 16 deletions
@@ -1,5 +1,5 @@
import { useRef } from 'react';
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
import { FolderPlus, Upload, FolderUp, ClipboardPaste, X } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
import { useTranslation } from '../../../../../locales';
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
const { t } = useTranslation();
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager;
const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
/>
</button>
{/*
What is on the clipboard, said out loud.
It used to be an icon and a toast. That was enough while the clipboard died with the tab — you
had just pressed the key, so you knew what was on it. Now it is per-user and survives reloads,
tabs and days, which means somebody can arrive at a folder with three files pending from
yesterday and paste them without ever having been told. Invisible persistent state is how a
file gets moved by accident.
So it says how many and whether they will be copied or moved, and offers a way to let go of
them. `cut` is worth distinguishing in the label rather than only in the icon: it is the one
that takes the originals with it.
*/}
{clipboard !== null && (
<div className="flex items-center gap-0.5 rounded-md bg-duck-dark/5 pl-2">
<span className="text-xs text-duck-teal whitespace-nowrap">
{clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')}
</span>
<button
onClick={handlePaste}
title={t('files.menu.paste')}
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
<button
onClick={() => setClipboard(null)}
title={t('files.clearClipboard')}
className="p-1.5 rounded-md text-duck-teal/60 hover:bg-duck-dark/10 hover:text-duck-teal cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
);
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { runTaskNow } from './components/runTaskNow';
@@ -197,7 +198,25 @@ export const useFileBrowserApp = (
const { togglePin, isPinned } = usePinnedFiles();
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [clipboard, setClipboard] = useState<ClipboardState>(null);
/*
The clipboard lives on the SERVER, not in this component.
── Why ──
It was `useState`, which meant it existed in one tab and died with it. Copying in one window and
pasting in another — the obvious way to move a file between two folders you can both see — did
nothing, and a reload lost a pending cut with no sign that it had.
`user_state` is a per-user JSONB row that already exists for exactly this kind of thing, and
`useUserState` already reads and writes it through React Query with an optimistic local update. So
a clipboard is one key in it and there is no new table, route or socket.
Per user rather than per browser, deliberately: cutting on the desktop and pasting on the phone is
the same gesture across two screens, and there is no reading of "my clipboard" where the two should
disagree.
*/
const queryClient = useQueryClient();
const [clipboard, setClipboard] = useUserState<ClipboardState>('fileClipboard', null);
const [renamingName, setRenamingName] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
@@ -307,6 +326,34 @@ export const useFileBrowserApp = (
};
}, [searchQuery]);
/*
Pull the clipboard again whenever this window comes forward.
`useUserState` holds USER_STATE with `staleTime: Infinity` — right for a dock layout, wrong for
something another window can change while this one is idle. Without this, window A cuts, window B
still shows nothing to paste, and the feature reads as broken in exactly the case it was built for.
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into
the other window means focusing the other window first, so the refresh happens at precisely the
moment it is needed and never while nobody is looking. A socket would be live for a case that does
not arise — two visible windows dragging between each other already carry the paths in the drag
itself.
`visibilitychange` as well as `focus`, because a background tab brought forward by switching tabs
fires only the second one in some browsers.
*/
useEffect(() => {
const refresh = () => {
if (document.visibilityState === 'visible') void queryClient.invalidateQueries({ queryKey: ['USER_STATE'] });
};
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', refresh);
return () => {
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', refresh);
};
}, [queryClient]);
// Clipboard image paste
useEffect(() => {
const handler = (ev: ClipboardEvent) => {
@@ -1109,6 +1156,9 @@ export const useFileBrowserApp = (
selected,
setSelected,
clipboard,
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
// clipboard is something a person has to be able to clear rather than wait out.
setClipboard,
renamingName,
setRenamingName,
// Search
@@ -542,7 +542,10 @@
"type": "Typ",
"size": "Größe",
"date": "Datum"
}
},
"toCopy": "zu kopieren",
"toMove": "zu verschieben",
"clearClipboard": "Zwischenablage leeren"
},
"taskRunner": {
"stop": "Stopp",
@@ -542,7 +542,10 @@
"type": "Type",
"size": "Size",
"date": "Date"
}
},
"toCopy": "to copy",
"toMove": "to move",
"clearClipboard": "Clear clipboard"
},
"taskRunner": {
"stop": "Stop",
@@ -542,7 +542,10 @@
"type": "Tipo",
"size": "Tamaño",
"date": "Fecha"
}
},
"toCopy": "para copiar",
"toMove": "para mover",
"clearClipboard": "Vaciar portapapeles"
},
"taskRunner": {
"stop": "Detener",
@@ -542,7 +542,10 @@
"type": "Type",
"size": "Taille",
"date": "Date"
}
},
"toCopy": "à copier",
"toMove": "à déplacer",
"clearClipboard": "Vider le presse-papiers"
},
"taskRunner": {
"stop": "Arrêter",
@@ -542,7 +542,10 @@
"type": "Tipo",
"size": "Tamanho",
"date": "Data"
}
},
"toCopy": "a copiar",
"toMove": "a mover",
"clearClipboard": "Limpar área de transferência"
},
"taskRunner": {
"stop": "Parar",
@@ -542,7 +542,10 @@
"type": "Typ",
"size": "Storlek",
"date": "Datum"
}
},
"toCopy": "att kopiera",
"toMove": "att flytta",
"clearClipboard": "Töm urklipp"
},
"taskRunner": {
"stop": "Stoppa",