diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/DefaultActions.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/DefaultActions.tsx index 2157c4df..b7bd024a 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/DefaultActions.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/DefaultActions.tsx @@ -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(null); const folderInputRef = useRef(null); @@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => { /> + {/* + 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 && ( - +
+ + {clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')} + + + +
)} ); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index ed6edfd2..2706fc3d 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -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(null); const [selected, setSelected] = useState>(new Set()); - const [clipboard, setClipboard] = useState(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('fileClipboard', null); const [renamingName, setRenamingName] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(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 diff --git a/src/workspaces/officerdev/src/locales/de-DE.json b/src/workspaces/officerdev/src/locales/de-DE.json index 5aa115db..1f35b94b 100644 --- a/src/workspaces/officerdev/src/locales/de-DE.json +++ b/src/workspaces/officerdev/src/locales/de-DE.json @@ -542,7 +542,10 @@ "type": "Typ", "size": "Größe", "date": "Datum" - } + }, + "toCopy": "zu kopieren", + "toMove": "zu verschieben", + "clearClipboard": "Zwischenablage leeren" }, "taskRunner": { "stop": "Stopp", diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 24545941..7963959a 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -542,7 +542,10 @@ "type": "Type", "size": "Size", "date": "Date" - } + }, + "toCopy": "to copy", + "toMove": "to move", + "clearClipboard": "Clear clipboard" }, "taskRunner": { "stop": "Stop", diff --git a/src/workspaces/officerdev/src/locales/es-ES.json b/src/workspaces/officerdev/src/locales/es-ES.json index 041b1329..56028c0e 100644 --- a/src/workspaces/officerdev/src/locales/es-ES.json +++ b/src/workspaces/officerdev/src/locales/es-ES.json @@ -542,7 +542,10 @@ "type": "Tipo", "size": "Tamaño", "date": "Fecha" - } + }, + "toCopy": "para copiar", + "toMove": "para mover", + "clearClipboard": "Vaciar portapapeles" }, "taskRunner": { "stop": "Detener", diff --git a/src/workspaces/officerdev/src/locales/fr-FR.json b/src/workspaces/officerdev/src/locales/fr-FR.json index a49c2d45..4baab43a 100644 --- a/src/workspaces/officerdev/src/locales/fr-FR.json +++ b/src/workspaces/officerdev/src/locales/fr-FR.json @@ -542,7 +542,10 @@ "type": "Type", "size": "Taille", "date": "Date" - } + }, + "toCopy": "à copier", + "toMove": "à déplacer", + "clearClipboard": "Vider le presse-papiers" }, "taskRunner": { "stop": "Arrêter", diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index ef521468..879421e3 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -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", diff --git a/src/workspaces/officerdev/src/locales/sv-SE.json b/src/workspaces/officerdev/src/locales/sv-SE.json index 3a261f01..b80374a5 100644 --- a/src/workspaces/officerdev/src/locales/sv-SE.json +++ b/src/workspaces/officerdev/src/locales/sv-SE.json @@ -542,7 +542,10 @@ "type": "Typ", "size": "Storlek", "date": "Datum" - } + }, + "toCopy": "att kopiera", + "toMove": "att flytta", + "clearClipboard": "Töm urklipp" }, "taskRunner": { "stop": "Stoppa",