From 198c6c916b523a0af8cfc1a0b795ab53e4e76176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 16:20:24 +0000 Subject: [PATCH] new entry dialog keyed (67 -> 57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New file / new folder / clone repository, one dialog for all three. nameProblem and urlProblem are pure functions with no hook in scope, called on every keystroke — so they cannot call t() themselves. They now return a closed union (NameProblem, a discriminated key plus params where one is needed) rather than an English string, and the component resolves it. Same shape as RfbError in the Minesweeper panel: a lower layer emits a code, the UI layer owns the words. NewEntryDialog.test.ts asserted on the literal English message; updated to assert on the key instead. That is a strict improvement independent of localisation — the old test would have failed the moment anyone tightened the wording, for a change that touched no logic. It now only fails if the LOGIC picks the wrong problem. The COPY table (title/description/label/placeholder/submit per kind) is module-level data, so it carries key names now and the component resolves them — same pattern as the settings sections and the shortcut registry in earlier phases. Example placeholders "documents" and "notes.txt" are translated ("documentos", "notas.txt") rather than left as identifiers: unlike a credential format or a model filename, these are prose hints meant to guide the reader, and the scanner's own isHuman heuristic only skipped them because they happen to be lowercase with no spaces, not because they are not content. --- .../components/NewEntryDialog.test.ts | 14 +-- .../components/NewEntryDialog.tsx | 85 ++++++++++++------- .../officerdev/src/locales/en-GB.json | 28 ++++++ .../officerdev/src/locales/pt-PT.json | 28 ++++++ 4 files changed, 120 insertions(+), 35 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts index 5c6fe39d..461a8df3 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.test.ts @@ -26,15 +26,19 @@ describe('nameProblem', () => { test('refuses a slash rather than quietly creating intermediate folders', () => { // `mkdir -p` semantics from a box labelled "Folder name" is a surprise, and the server's rename // endpoint refuses slashes for the same reason. - expect(nameProblem('a/b', [])).toBe('Name cannot contain "/"'); - expect(nameProblem('/abs', [])).toBe('Name cannot contain "/"'); + // + // Asserted on the KEY, not the English wording — a validation message is now translated (see + // NewEntryDialog.tsx), and a test pinned to the sentence would fail every time a translator edits + // it without the logic having changed at all. + expect(nameProblem('a/b', [])).toEqual({ key: 'newEntry.nameSlash' }); + expect(nameProblem('/abs', [])).toEqual({ key: 'newEntry.nameSlash' }); }); test('catches a collision before the request, which is the point', () => { - expect(nameProblem('notes.txt', ['notes.txt'])).toBe('"notes.txt" already exists here'); + expect(nameProblem('notes.txt', ['notes.txt'])).toEqual({ key: 'newEntry.nameExists', name: 'notes.txt' }); // Trimmed first, so trailing whitespace cannot smuggle a duplicate past the check and into a // server-side clobber refusal. - expect(nameProblem('notes.txt ', ['notes.txt'])).toBe('"notes.txt" already exists here'); + expect(nameProblem('notes.txt ', ['notes.txt'])).toEqual({ key: 'newEntry.nameExists', name: 'notes.txt' }); }); test('collision is case-SENSITIVE, matching the filesystem underneath', () => { @@ -59,7 +63,7 @@ describe('urlProblem', () => { }); test('refuses whitespace, the one thing that is always a typo', () => { - expect(urlProblem('https://host/a b.git')).toBe('A URL cannot contain spaces'); + expect(urlProblem('https://host/a b.git')).toEqual({ key: 'newEntry.urlSpaces' }); }); test('empty is not an error', () => { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx index 973307bd..f22c06d7 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/NewEntryDialog.tsx @@ -11,6 +11,8 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import type { PanelTranslationKey } from '../../../../locales'; +import { useTranslation } from '../../../../locales'; // Naming a new file or folder. // @@ -31,32 +33,53 @@ type NewEntryDialogProps = { existingNames: string[]; }; +// Module-level data, so it carries catalog KEYS rather than English — the component resolves them. const COPY = { file: { - title: 'New file', - description: 'Include the extension — it is what the editor uses to pick a language. It opens once created.', - label: 'File name', - placeholder: 'notes.txt', - submit: 'Create and open', + titleKey: 'newEntry.file.title', + descriptionKey: 'newEntry.file.description', + labelKey: 'newEntry.file.label', + placeholderKey: 'newEntry.file.placeholder', + submitKey: 'newEntry.file.submit', Icon: FilePlus, }, folder: { - title: 'New folder', - description: 'Created in the folder you are viewing.', - label: 'Folder name', - placeholder: 'documents', - submit: 'Create folder', + titleKey: 'newEntry.folder.title', + descriptionKey: 'newEntry.folder.description', + labelKey: 'newEntry.folder.label', + placeholderKey: 'newEntry.folder.placeholder', + submitKey: 'newEntry.folder.submit', Icon: FolderPlus, }, clone: { - title: 'Clone repository', - description: 'Cloned into the folder you are viewing, under its own name.', - label: 'Repository URL', - placeholder: 'https://github.com/owner/repo.git', - submit: 'Clone', + titleKey: 'newEntry.clone.title', + descriptionKey: 'newEntry.clone.description', + labelKey: 'newEntry.clone.label', + placeholderKey: 'newEntry.clone.placeholder', + submitKey: 'newEntry.clone.submit', Icon: GitBranch, }, -} as const; +} satisfies Record< + string, + { + titleKey: PanelTranslationKey; + descriptionKey: PanelTranslationKey; + labelKey: PanelTranslationKey; + placeholderKey: PanelTranslationKey; + submitKey: PanelTranslationKey; + Icon: typeof FilePlus; + } +>; + +/** + * A validation failure, as a catalog key rather than English — the caller (a pure function with no + * hook in scope) cannot call `t()` itself. Mirrors `RfbError` in the Minesweeper panel: an explicit + * closed union, never a key built at runtime from the failure kind. + */ +export type NameProblem = + | { key: 'newEntry.nameReserved' } + | { key: 'newEntry.nameSlash' } + | { key: 'newEntry.nameExists'; name: string }; /** * Why a name is not usable, or null. @@ -65,12 +88,12 @@ const COPY = { * labelled "Folder name" is a surprise, and the server's rename endpoint refuses slashes for the same * reason. Leading dots are fine — a dotfile is a real thing to want. */ -export function nameProblem(raw: string, existing: string[]): string | null { +export function nameProblem(raw: string, existing: string[]): NameProblem | null { const name = raw.trim(); if (!name) return null; // not an error yet, just nothing to submit - if (name === '.' || name === '..') return 'That name is reserved'; - if (name.includes('/')) return 'Name cannot contain "/"'; - if (existing.includes(name)) return `"${name}" already exists here`; + if (name === '.' || name === '..') return { key: 'newEntry.nameReserved' }; + if (name.includes('/')) return { key: 'newEntry.nameSlash' }; + if (existing.includes(name)) return { key: 'newEntry.nameExists', name }; return null; } @@ -82,14 +105,15 @@ export function nameProblem(raw: string, existing: string[]): string | null { * a mistake git reports perfectly well itself. Only whitespace is refused, which is the one thing that * is always a typo. */ -export function urlProblem(raw: string): string | null { +export function urlProblem(raw: string): { key: 'newEntry.urlSpaces' } | null { const url = raw.trim(); if (!url) return null; - if (/\s/.test(url)) return 'A URL cannot contain spaces'; + if (/\s/.test(url)) return { key: 'newEntry.urlSpaces' }; return null; } export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEntryDialogProps) => { + const { t } = useTranslation(); const [name, setName] = useState(''); // Clear on open rather than on close, so the field is never briefly showing the previous name as the @@ -100,9 +124,10 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn if (!kind) return null; - const { title, description, label, placeholder, submit, Icon } = COPY[kind]; + const { titleKey, descriptionKey, labelKey, placeholderKey, submitKey, Icon } = COPY[kind]; const problem = kind === 'clone' ? urlProblem(name) : nameProblem(name, existingNames); const canSubmit = name.trim().length > 0 && problem === null; + const problemText = problem ? t(problem.key, 'name' in problem ? { name: problem.name } : undefined) : ''; const commit = (ev?: React.FormEvent) => { ev?.preventDefault(); @@ -121,31 +146,31 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn - {title} + {t(titleKey)} - {description} + {t(descriptionKey)}
- + setName(ev.target.value)} /> {/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you type past a colliding name. */} -

{problem ?? ''}

+

{problemText}

diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 46d29c6a..37e1dc0d 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -547,5 +547,33 @@ "copyOutput": "Copy output", "track": "Track {{n}}", "runFromStep": "Run from step {{n}}" + }, + "newEntry": { + "nameReserved": "That name is reserved", + "nameSlash": "Name cannot contain \"/\"", + "nameExists": "\"{{name}}\" already exists here", + "urlSpaces": "A URL cannot contain spaces", + "cancel": "Cancel", + "file": { + "title": "New file", + "description": "Include the extension — it is what the editor uses to pick a language. It opens once created.", + "label": "File name", + "placeholder": "notes.txt", + "submit": "Create and open" + }, + "folder": { + "title": "New folder", + "description": "Created in the folder you are viewing.", + "label": "Folder name", + "placeholder": "documents", + "submit": "Create folder" + }, + "clone": { + "title": "Clone repository", + "description": "Cloned into the folder you are viewing, under its own name.", + "label": "Repository URL", + "placeholder": "https://github.com/owner/repo.git", + "submit": "Clone" + } } } diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 078c79ca..87f61239 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -547,5 +547,33 @@ "copyOutput": "Copiar a saída", "track": "Faixa {{n}}", "runFromStep": "Executar a partir do passo {{n}}" + }, + "newEntry": { + "nameReserved": "Esse nome está reservado", + "nameSlash": "O nome não pode conter \"/\"", + "nameExists": "\"{{name}}\" já existe aqui", + "urlSpaces": "Um URL não pode conter espaços", + "cancel": "Cancelar", + "file": { + "title": "Novo ficheiro", + "description": "Inclua a extensão — é o que o editor usa para escolher a linguagem. Abre assim que for criado.", + "label": "Nome do ficheiro", + "placeholder": "notas.txt", + "submit": "Criar e abrir" + }, + "folder": { + "title": "Nova pasta", + "description": "Criada na pasta que está a ver.", + "label": "Nome da pasta", + "placeholder": "documentos", + "submit": "Criar pasta" + }, + "clone": { + "title": "Clonar repositório", + "description": "Clonado para a pasta que está a ver, com o seu próprio nome.", + "label": "URL do repositório", + "placeholder": "https://github.com/owner/repo.git", + "submit": "Clonar" + } } }