file browser actions keyed (171 -> 124)

useFileBrowserApp.ts, the largest single file in the phase at 47 hits — and contrary
to what I predicted, all 47 were convertible. I expected roughly half to be content
and it was none: every one is a toast the reader sees, and the file NAMES in them are
interpolated parameters inside a translated frame, which is where content belongs.
Worth recording because the guess was wrong in the useful direction.

Six hand-rolled plurals (`item${n !== 1 ? 's' : ''}` and friends) are real plural
entries now, checked at 0, 1 and 2 in both locales. Portuguese reorders these — "1
item eliminado" rather than "Deleted 1 item" — which a frame with the count spliced
in could not have expressed.
This commit is contained in:
2026-08-23 15:56:59 +00:00
parent 736a45e1ff
commit 7fd4d6c98e
3 changed files with 165 additions and 47 deletions
@@ -14,6 +14,7 @@ import { useAuth } from 'hooks/useAuth';
import { useFilesRefresh } from '../../../channels'; import { useFilesRefresh } from '../../../channels';
import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft'; import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft';
import { copyToClipboard, canReadClipboard } from 'helpers/clipboard'; import { copyToClipboard, canReadClipboard } from 'helpers/clipboard';
import { useTranslation } from '../../../locales';
/** /**
* The old query param. Kept only to recognise and redirect links made before 2026-08-15 — the folder * The old query param. Kept only to recognise and redirect links made before 2026-08-15 — the folder
@@ -22,6 +23,7 @@ import { copyToClipboard, canReadClipboard } from 'helpers/clipboard';
export const FILES_PATH_PARAM = 'path'; export const FILES_PATH_PARAM = 'path';
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => { export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => {
const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -186,7 +188,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// transient, and bouncing someone out of a deep folder over one bad response would be worse than // transient, and bouncing someone out of a deep folder over one bad response would be worse than
// the error they can already see. // the error they can already see.
if (firstListing.current && currentPathRef.current !== basePath) { if (firstListing.current && currentPathRef.current !== basePath) {
toast.error('That folder is gone — back to the start'); toast.error(t('files.folderGone'));
setCurrentPath(basePath); setCurrentPath(basePath);
rememberFolder({ path: basePath }); rememberFolder({ path: basePath });
} else { } else {
@@ -227,7 +229,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const data = await filesRef.current.search(q, currentPath); const data = await filesRef.current.search(q, currentPath);
setSearchResults(data.results); setSearchResults(data.results);
} catch { } catch {
toast.error('Search failed'); toast.error(t('files.searchFailed'));
setSearchResults(null); setSearchResults(null);
} finally { } finally {
setSearching(false); setSearching(false);
@@ -305,7 +307,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
if (selected.size > 0) { if (selected.size > 0) {
const paths = selectedPaths(); const paths = selectedPaths();
setClipboard({ paths, mode: 'copy' }); setClipboard({ paths, mode: 'copy' });
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`); toast.success(t('files.copiedItems', { count: paths.length }));
} }
return; return;
} }
@@ -315,7 +317,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
if (selected.size > 0) { if (selected.size > 0) {
const paths = selectedPaths(); const paths = selectedPaths();
setClipboard({ paths, mode: 'cut' }); setClipboard({ paths, mode: 'cut' });
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`); toast.success(t('files.cutItems', { count: paths.length }));
} }
return; return;
} }
@@ -429,9 +431,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.createDir(dirPath); await files.createDir(dirPath);
await refresh(); await refresh();
toast.success(`Created folder "${name}"`); toast.success(t('files.createdFolder', { name }));
} catch { } catch {
toast.error('Failed to create folder'); toast.error(t('files.createFolderFailed'));
} }
}; };
@@ -463,9 +465,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.writeFile(filePath, ''); await files.writeFile(filePath, '');
await refresh(); await refresh();
toast.success(`Created "${name}"`); toast.success(t('files.created', { name }));
} catch { } catch {
toast.error('Failed to create file'); toast.error(t('files.createFileFailed'));
} }
}; };
@@ -475,9 +477,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.uploadFiles(currentPath, fileList, (pct) => setUploadProgress(pct)); await files.uploadFiles(currentPath, fileList, (pct) => setUploadProgress(pct));
await refresh(); await refresh();
toast.success(`Uploaded ${count} file${count !== 1 ? 's' : ''}`); toast.success(t('files.uploadedFiles', { count }));
} catch { } catch {
toast.error('Failed to upload files'); toast.error(t('files.uploadFailed'));
} finally { } finally {
setUploadProgress(null); setUploadProgress(null);
} }
@@ -488,9 +490,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.rename(path, newName); await files.rename(path, newName);
await refresh(); await refresh();
toast.success(`Renamed to "${newName}"`); toast.success(t('files.renamedTo', { name: newName }));
} catch { } catch {
toast.error('Failed to rename'); toast.error(t('files.renameFailed'));
} }
}; };
@@ -499,7 +501,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
handleDeleteSelected(); handleDeleteSelected();
return; return;
} }
if (!confirm(`Delete "${entry.name}"?`)) return; if (!confirm(t('files.deleteQ', { name: entry.name }))) return;
const path = entryPath(entry.name); const path = entryPath(entry.name);
try { try {
await files.remove(path); await files.remove(path);
@@ -509,25 +511,25 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
return next; return next;
}); });
await refresh(); await refresh();
toast.success(`Deleted "${entry.name}"`); toast.success(t('files.deleted', { name: entry.name }));
} catch { } catch {
toast.error('Failed to delete'); toast.error(t('files.deleteFailed'));
} }
}; };
const handleDeleteSelected = async () => { const handleDeleteSelected = async () => {
const names = Array.from(selected); const names = Array.from(selected);
if (names.length === 0) return; if (names.length === 0) return;
if (!confirm(`Delete ${names.length} item${names.length !== 1 ? 's' : ''}?`)) return; if (!confirm(t('files.deleteItemsQ', { count: names.length }))) return;
try { try {
for (const name of names) { for (const name of names) {
await files.remove(entryPath(name)); await files.remove(entryPath(name));
} }
setSelected(new Set()); setSelected(new Set());
await refresh(); await refresh();
toast.success(`Deleted ${names.length} item${names.length !== 1 ? 's' : ''}`); toast.success(t('files.deletedItems', { count: names.length }));
} catch { } catch {
toast.error('Failed to delete some items'); toast.error(t('files.deleteSomeFailed'));
await refresh(); await refresh();
} }
}; };
@@ -560,7 +562,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.download(paths); await files.download(paths);
} catch { } catch {
toast.error('Failed to download'); toast.error(t('files.downloadFailed'));
} }
}; };
@@ -570,7 +572,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.download(paths); await files.download(paths);
} catch { } catch {
toast.error('Failed to download'); toast.error(t('files.downloadFailed'));
} }
}; };
@@ -596,7 +598,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)]; const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
// Newline-separated, which is what a shell, an editor and a chat box all accept as a list of paths. // Newline-separated, which is what a shell, an editor and a chat box all accept as a list of paths.
copyToClipboard(paths.map((p) => `~${p}`).join('\n')); copyToClipboard(paths.map((p) => `~${p}`).join('\n'));
toast.success(paths.length > 1 ? `${paths.length} paths copied` : 'Path copied'); toast.success(paths.length > 1 ? `${paths.length} paths copied` : t('files.pathCopied'));
}; };
/** /**
@@ -606,20 +608,20 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
*/ */
const handleCopyAbsPath = (path: string) => { const handleCopyAbsPath = (path: string) => {
copyToClipboard(`~${path}`); copyToClipboard(`~${path}`);
toast.success('Path copied'); toast.success(t('files.pathCopied'));
}; };
const handleDownloadPath = async (path: string) => { const handleDownloadPath = async (path: string) => {
try { try {
await files.download([path]); await files.download([path]);
} catch { } catch {
toast.error('Failed to download'); toast.error(t('files.downloadFailed'));
} }
}; };
const handleCopyCurrentPath = () => { const handleCopyCurrentPath = () => {
copyToClipboard(`~${currentPath}`); copyToClipboard(`~${currentPath}`);
toast.success('Path copied'); toast.success(t('files.pathCopied'));
}; };
const handleChatHere = () => { const handleChatHere = () => {
@@ -638,7 +640,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const handleReadAloud = async (entry: DirEntry) => { const handleReadAloud = async (entry: DirEntry) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Generating speech audio...'); const toastId = toast.loading(t('files.generatingSpeech'));
try { try {
const { audioPath } = await files.tts(filePath, { saveNextTo: true }); const { audioPath } = await files.tts(filePath, { saveNextTo: true });
toast.dismiss(toastId); toast.dismiss(toastId);
@@ -650,20 +652,20 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
ephemeral: relativeToFolder(currentPath, audioPath), ephemeral: relativeToFolder(currentPath, audioPath),
}); });
} catch { } catch {
toast.error('Failed to generate speech audio', { id: toastId }); toast.error(t('files.speechFailed'), { id: toastId });
} }
}; };
const handleExtract = async (entry: DirEntry) => { const handleExtract = async (entry: DirEntry) => {
const filePath = entryPath(entry.name); const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting archive...'); const toastId = toast.loading(t('files.extracting'));
try { try {
const { extractedPath } = await files.extract(filePath); const { extractedPath } = await files.extract(filePath);
const folderName = extractedPath.split('/').pop() ?? extractedPath; const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`, { id: toastId }); toast.success(t('files.extractedTo', { name: folderName }), { id: toastId });
await refresh(); await refresh();
} catch { } catch {
toast.error('Failed to extract archive', { id: toastId }); toast.error(t('files.extractFailed'), { id: toastId });
} }
}; };
@@ -674,9 +676,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.gitClone(url, currentPath); await files.gitClone(url, currentPath);
await refresh(); await refresh();
toast.success('Repository cloned'); toast.success(t('files.cloned'));
} catch { } catch {
toast.error('Failed to clone repository'); toast.error(t('files.cloneFailed'));
} finally { } finally {
setCloning(false); setCloning(false);
setCloneUrl(''); setCloneUrl('');
@@ -695,22 +697,22 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
try { try {
await files.copy([entryPath(entry.name)], currentPath); await files.copy([entryPath(entry.name)], currentPath);
await refresh(); await refresh();
toast.success(`Duplicated "${entry.name}"`); toast.success(t('files.duplicated', { name: entry.name }));
} catch { } catch {
toast.error('Failed to duplicate'); toast.error(t('files.duplicateFailed'));
} }
}; };
/** Zip the selection (or the clicked entry) into an archive beside it, and keep it. */ /** Zip the selection (or the clicked entry) into an archive beside it, and keep it. */
const handleCompress = async (entry: DirEntry) => { const handleCompress = async (entry: DirEntry) => {
const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)]; const paths = selected.size > 1 && selected.has(entry.name) ? selectedPaths() : [entryPath(entry.name)];
const toastId = toast.loading('Compressing...'); const toastId = toast.loading(t('files.compressing'));
try { try {
const { name } = await files.compress(paths); const { name } = await files.compress(paths);
await refresh(); await refresh();
toast.success(`Created "${name}"`, { id: toastId }); toast.success(t('files.created', { name }), { id: toastId });
} catch { } catch {
toast.error('Failed to compress', { id: toastId }); toast.error(t('files.compressFailed'), { id: toastId });
} }
}; };
@@ -731,9 +733,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
setClipboard(null); setClipboard(null);
} }
await refresh(); await refresh();
toast.success(`Pasted into "${entry.name}"`); toast.success(t('files.pastedInto', { name: entry.name }));
} catch { } catch {
toast.error('Failed to paste'); toast.error(t('files.pasteFailed'));
} }
}; };
@@ -741,13 +743,13 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const handleGitCloneUrl = async (url: string) => { const handleGitCloneUrl = async (url: string) => {
const trimmed = url.trim(); const trimmed = url.trim();
if (!trimmed) return; if (!trimmed) return;
const toastId = toast.loading('Cloning...'); const toastId = toast.loading(t('files.cloning'));
try { try {
await files.gitClone(trimmed, currentPath); await files.gitClone(trimmed, currentPath);
await refresh(); await refresh();
toast.success('Repository cloned', { id: toastId }); toast.success(t('files.cloned'), { id: toastId });
} catch { } catch {
toast.error('Failed to clone repository', { id: toastId }); toast.error(t('files.cloneFailed'), { id: toastId });
} }
}; };
@@ -765,21 +767,21 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const path = entryPath(entry.name); const path = entryPath(entry.name);
const wasPinned = isPinned(path); const wasPinned = isPinned(path);
togglePin(path, entry.name); togglePin(path, entry.name);
toast.success(wasPinned ? `Unpinned "${entry.name}"` : `Pinned "${entry.name}"`); toast.success(wasPinned ? t('files.unpinned', { name: entry.name }) : t('files.pinned', { name: entry.name }));
}; };
const handleCut = () => { const handleCut = () => {
const paths = selected.size > 0 ? selectedPaths() : []; const paths = selected.size > 0 ? selectedPaths() : [];
if (paths.length === 0) return; if (paths.length === 0) return;
setClipboard({ paths, mode: 'cut' }); setClipboard({ paths, mode: 'cut' });
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`); toast.success(t('files.cutItems', { count: paths.length }));
}; };
const handleCopy = () => { const handleCopy = () => {
const paths = selected.size > 0 ? selectedPaths() : []; const paths = selected.size > 0 ? selectedPaths() : [];
if (paths.length === 0) return; if (paths.length === 0) return;
setClipboard({ paths, mode: 'copy' }); setClipboard({ paths, mode: 'copy' });
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`); toast.success(t('files.copiedItems', { count: paths.length }));
}; };
const handlePaste = async () => { const handlePaste = async () => {
@@ -792,9 +794,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
setClipboard(null); setClipboard(null);
} }
await refresh(); await refresh();
toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`); toast.success(t('files.pastedItems', { count: clipboard.paths.length }));
} catch { } catch {
toast.error('Failed to paste'); toast.error(t('files.pasteFailed'));
} }
return; return;
} }
@@ -804,7 +806,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// is no way to pull clipboard contents on demand. Pasting an image has to come from a real paste // is no way to pull clipboard contents on demand. Pasting an image has to come from a real paste
// event instead. Refuse clearly rather than throwing "Cannot read properties of undefined". // event instead. Refuse clearly rather than throwing "Cannot read properties of undefined".
if (!canReadClipboard()) { if (!canReadClipboard()) {
toast.error('Pasting from the clipboard needs a secure context (https). Drag the file in instead.'); toast.error(t('files.pasteNeedsSecure'));
return; return;
} }
@@ -427,5 +427,63 @@
"peers": "Peers: {{names}}" "peers": "Peers: {{names}}"
}, },
"copy": "Copy" "copy": "Copy"
},
"files": {
"folderGone": "That folder is gone — back to the start",
"searchFailed": "Search failed",
"createFolderFailed": "Failed to create folder",
"createFileFailed": "Failed to create file",
"uploadFailed": "Failed to upload files",
"renameFailed": "Failed to rename",
"deleteFailed": "Failed to delete",
"deleteSomeFailed": "Failed to delete some items",
"downloadFailed": "Failed to download",
"pathCopied": "Path copied",
"generatingSpeech": "Generating speech audio…",
"speechFailed": "Failed to generate speech audio",
"extracting": "Extracting archive…",
"extractFailed": "Failed to extract archive",
"cloned": "Repository cloned",
"cloneFailed": "Failed to clone repository",
"cloning": "Cloning…",
"duplicateFailed": "Failed to duplicate",
"compressing": "Compressing…",
"compressFailed": "Failed to compress",
"pasteFailed": "Failed to paste",
"pasteNeedsSecure": "Pasting from the clipboard needs a secure context (https). Drag the file in instead.",
"copiedItems": {
"one": "Copied {{count}} item",
"other": "Copied {{count}} items"
},
"cutItems": {
"one": "Cut {{count}} item",
"other": "Cut {{count}} items"
},
"pastedItems": {
"one": "Pasted {{count}} item",
"other": "Pasted {{count}} items"
},
"uploadedFiles": {
"one": "Uploaded {{count}} file",
"other": "Uploaded {{count}} files"
},
"deleteItemsQ": {
"one": "Delete {{count}} item?",
"other": "Delete {{count}} items?"
},
"deletedItems": {
"one": "Deleted {{count}} item",
"other": "Deleted {{count}} items"
},
"createdFolder": "Created folder “{{name}}”",
"created": "Created “{{name}}”",
"renamedTo": "Renamed to “{{name}}”",
"deleteQ": "Delete “{{name}}”?",
"deleted": "Deleted “{{name}}”",
"extractedTo": "Extracted to “{{name}}”",
"duplicated": "Duplicated “{{name}}”",
"pastedInto": "Pasted into “{{name}}”",
"unpinned": "Unpinned “{{name}}”",
"pinned": "Pinned “{{name}}”"
} }
} }
@@ -427,5 +427,63 @@
"peers": "Pares: {{names}}" "peers": "Pares: {{names}}"
}, },
"copy": "Copiar" "copy": "Copiar"
},
"files": {
"folderGone": "Essa pasta desapareceu — de volta ao início",
"searchFailed": "A pesquisa falhou",
"createFolderFailed": "Falha ao criar a pasta",
"createFileFailed": "Falha ao criar o ficheiro",
"uploadFailed": "Falha ao carregar os ficheiros",
"renameFailed": "Falha ao renomear",
"deleteFailed": "Falha ao eliminar",
"deleteSomeFailed": "Falha ao eliminar alguns itens",
"downloadFailed": "Falha ao descarregar",
"pathCopied": "Caminho copiado",
"generatingSpeech": "A gerar áudio de fala…",
"speechFailed": "Falha ao gerar o áudio de fala",
"extracting": "A extrair o arquivo…",
"extractFailed": "Falha ao extrair o arquivo",
"cloned": "Repositório clonado",
"cloneFailed": "Falha ao clonar o repositório",
"cloning": "A clonar…",
"duplicateFailed": "Falha ao duplicar",
"compressing": "A comprimir…",
"compressFailed": "Falha ao comprimir",
"pasteFailed": "Falha ao colar",
"pasteNeedsSecure": "Colar da área de transferência exige um contexto seguro (https). Arraste o ficheiro para aqui em vez disso.",
"copiedItems": {
"one": "{{count}} item copiado",
"other": "{{count}} itens copiados"
},
"cutItems": {
"one": "{{count}} item cortado",
"other": "{{count}} itens cortados"
},
"pastedItems": {
"one": "{{count}} item colado",
"other": "{{count}} itens colados"
},
"uploadedFiles": {
"one": "{{count}} ficheiro carregado",
"other": "{{count}} ficheiros carregados"
},
"deleteItemsQ": {
"one": "Eliminar {{count}} item?",
"other": "Eliminar {{count}} itens?"
},
"deletedItems": {
"one": "{{count}} item eliminado",
"other": "{{count}} itens eliminados"
},
"createdFolder": "Pasta “{{name}}” criada",
"created": "“{{name}}” criado",
"renamedTo": "Renomeado para “{{name}}”",
"deleteQ": "Eliminar “{{name}}”?",
"deleted": "“{{name}}” eliminado",
"extractedTo": "Extraído para “{{name}}”",
"duplicated": "“{{name}}” duplicado",
"pastedInto": "Colado em “{{name}}”",
"unpinned": "“{{name}}” removido dos fixados",
"pinned": "“{{name}}” fixado"
} }
} }