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
{problem ?? ''}
+{problemText}