new entry dialog keyed (67 -> 57)

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.
This commit is contained in:
2026-08-23 16:20:24 +00:00
parent 8148023a1e
commit 198c6c916b
4 changed files with 120 additions and 35 deletions
@@ -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', () => {
@@ -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
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{title}
{t(titleKey)}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
<DialogDescription>{t(descriptionKey)}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-entry-name">{label}</Label>
<Label htmlFor="new-entry-name">{t(labelKey)}</Label>
<Input
id="new-entry-name"
autoFocus
value={name}
placeholder={placeholder}
placeholder={t(placeholderKey)}
onChange={(ev) => 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. */}
<p className="min-h-[1.25rem] text-xs text-destructive">{problem ?? ''}</p>
<p className="min-h-[1.25rem] text-xs text-destructive">{problemText}</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
{t('newEntry.cancel')}
</Button>
<Button type="submit" disabled={!canSubmit}>
{submit}
{t(submitKey)}
</Button>
</DialogFooter>
</form>
@@ -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"
}
}
}
@@ -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"
}
}
}