feat(i18n): localize Settings → Workspace (#2212)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-03 20:52:09 +02:00
committed by GitHub
parent 8ae850956f
commit 957cbb910e
25 changed files with 1656 additions and 180 deletions
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
@@ -20,6 +21,7 @@ import {
DialogTitle,
} from "@kandev/ui/dialog";
import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/workspaces";
import type { TFunction } from "i18next";
import type { Executor } from "@/lib/types/http";
import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices";
@@ -38,15 +40,16 @@ export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) {
const workspace = useAppStore(
(state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null,
);
const { t } = useTranslation();
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<p className="text-muted-foreground">{t("workspaces:workspaceNotFound")}</p>
<Button className="mt-4" asChild>
<Link href="/settings/workspace">Back to Workspaces</Link>
<Link href="/settings/workspace">{t("workspaces:backToWorkspaces")}</Link>
</Button>
</CardContent>
</Card>
@@ -63,6 +66,11 @@ type WorkspaceEditFormProps = {
type SelectFieldProps = {
label: string;
// The placeholder was `Select ${label.toLowerCase()}`. Lowercasing a translated
// label is an English-only transformation, and building the sentence at the
// call site leaves a translator with no way to reorder it — so it is its own
// message, passed in by the caller.
placeholder: string;
value: string;
isDirty: boolean;
onChange: (v: string) => void;
@@ -73,6 +81,7 @@ type SelectFieldProps = {
function SelectField({
label,
placeholder,
value,
isDirty,
onChange,
@@ -80,15 +89,16 @@ function SelectField({
emptyLabel,
emptyValue,
}: SelectFieldProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label>{label}</Label>
<Select value={value || "none"} onValueChange={(v) => onChange(v === "none" ? "" : v)}>
<SelectTrigger className="w-full" data-settings-dirty={isDirty}>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No default</SelectItem>
<SelectItem value="none">{t("workspaces:noDefault")}</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name}
@@ -134,6 +144,7 @@ function WorkspaceSettingsCard({
onAgentProfileChange,
agentProfiles,
}: WorkspaceSettingsCardProps) {
const { t } = useTranslation();
const executorOptions = activeExecutors.map((e: Executor) => ({ id: e.id, name: e.name }));
const profileOptions = agentProfiles.map((p: AgentProfileOption) => ({
id: p.id,
@@ -142,12 +153,12 @@ function WorkspaceSettingsCard({
return (
<SettingsCard isDirty={nameIsDirty || executorIsDirty || agentProfileIsDirty}>
<CardHeader>
<CardTitle>Workspace Settings</CardTitle>
<CardTitle>{t("workspaces:workspaceSettings")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Name</Label>
<Label htmlFor="workspace-name">{t("workspaces:name")}</Label>
<Input
id="workspace-name"
value={workspaceNameDraft}
@@ -156,21 +167,23 @@ function WorkspaceSettingsCard({
/>
</div>
<SelectField
label="Default Executor"
label={t("workspaces:defaultExecutor")}
placeholder={t("workspaces:selectDefaultExecutor")}
value={defaultExecutorId}
isDirty={executorIsDirty}
onChange={onExecutorChange}
options={executorsEmpty ? [] : executorOptions}
emptyLabel="No executors available"
emptyLabel={t("workspaces:noExecutorsAvailable")}
emptyValue=""
/>
<SelectField
label="Default Agent Profile"
label={t("workspaces:defaultAgentProfile")}
placeholder={t("workspaces:selectDefaultAgentProfile")}
value={defaultAgentProfileId}
isDirty={agentProfileIsDirty}
onChange={onAgentProfileChange}
options={profileOptions}
emptyLabel="No agent profiles available"
emptyLabel={t("workspaces:noAgentProfilesAvailable")}
emptyValue="empty-agent-profiles"
/>
</div>
@@ -184,23 +197,24 @@ type WorkspaceLinksCardProps = {
};
function WorkspaceLinksCard({ workspaceId }: WorkspaceLinksCardProps) {
const { t } = useTranslation();
return (
<Card>
<CardHeader>
<CardTitle>Workspace Links</CardTitle>
<CardTitle>{t("workspaces:workspaceLinks")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${workspaceId}/repositories`}>
<IconGitBranch className="h-4 w-4" />
Repositories
{t("workspaces:repositories")}
</Link>
</Button>
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${workspaceId}/workflows`}>
<IconLayoutColumns className="h-4 w-4" />
Workflows
{t("workspaces:workflows")}
</Link>
</Button>
</div>
@@ -226,17 +240,20 @@ function DeleteWorkspaceCard({
setDeleteConfirmText,
onDelete,
}: DeleteWorkspaceCardProps) {
const { t } = useTranslation();
return (
<>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete Workspace</CardTitle>
<CardTitle className="text-destructive">{t("workspaces:deleteWorkspace")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Delete this workspace</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
<p className="text-sm font-medium">{t("workspaces:deleteThisWorkspace")}</p>
<p className="text-xs text-muted-foreground">
{t("workspaces:thisActionCannotBeUndone")}
</p>
</div>
<Button
variant="destructive"
@@ -245,7 +262,7 @@ function DeleteWorkspaceCard({
data-testid="workspace-settings-delete-button"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
{t("workspaces:delete")}
</Button>
</div>
</CardContent>
@@ -254,14 +271,21 @@ function DeleteWorkspaceCard({
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workspace</DialogTitle>
<DialogTitle>{t("workspaces:deleteWorkspace")}</DialogTitle>
<DialogDescription>
Type the workspace name <span className="font-medium">{workspaceName}</span> to
confirm deletion. This action cannot be undone.
{/* The workspace name is user data and is what the input is compared
against, so it travels as an interpolated value — never as part
of the message a translator edits. */}
<Trans
i18nKey="workspaces:typeWorkspaceNameToConfirm"
values={{ name: workspaceName }}
>
<span className="font-medium" />
</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="confirm-delete">Confirm Delete</Label>
<Label htmlFor="confirm-delete">{t("workspaces:confirmDelete")}</Label>
<Input
id="confirm-delete"
value={deleteConfirmText}
@@ -277,7 +301,7 @@ function DeleteWorkspaceCard({
onClick={() => setDeleteDialogOpen(false)}
className="cursor-pointer"
>
Cancel
{t("common:cancel")}
</Button>
<Button
variant="destructive"
@@ -286,7 +310,7 @@ function DeleteWorkspaceCard({
className="cursor-pointer"
data-testid="workspace-settings-delete-confirm-button"
>
Delete
{t("workspaces:delete")}
</Button>
</DialogFooter>
</DialogContent>
@@ -334,6 +358,7 @@ type WorkspaceSaveHandlerOptions = {
setWorkspaces: (items: Workspace[]) => void;
saveWorkspaceRequest: SaveRequestLike;
toast: ReturnType<typeof useToast>["toast"];
t: TFunction;
};
function buildSaveHandler({
@@ -347,6 +372,7 @@ function buildSaveHandler({
setWorkspaces,
saveWorkspaceRequest,
toast,
t,
}: WorkspaceSaveHandlerOptions) {
return async () => {
if (!isDirty) return;
@@ -381,8 +407,8 @@ function buildSaveHandler({
);
} catch (error) {
toast({
title: "Failed to save workspace",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToSaveWorkspace"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
throw error;
@@ -393,6 +419,7 @@ function buildSaveHandler({
function useWorkspaceEditForm(workspace: Workspace) {
const router = useRouter();
const { toast } = useToast();
const { t } = useTranslation();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? "");
const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? "");
@@ -432,6 +459,7 @@ function useWorkspaceEditForm(workspace: Workspace) {
setWorkspaces,
saveWorkspaceRequest,
toast,
t,
});
const handleDeleteWorkspace = async () => {
@@ -442,8 +470,8 @@ function useWorkspaceEditForm(workspace: Workspace) {
runWithNavigationBlockerBypassed(() => router.push("/settings/workspace"));
} catch (error) {
toast({
title: "Failed to delete workspace",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToDeleteWorkspace"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
@@ -506,6 +534,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
handleDiscard,
handleDeleteWorkspace,
} = useWorkspaceEditForm(workspace);
const { t } = useTranslation();
useSettingsSaveContributor({
id: `workspace:${currentWorkspace.id}`,
@@ -516,7 +545,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
}),
isDirty,
canSave: Boolean(workspaceNameDraft.trim()),
invalidReason: workspaceNameDraft.trim() ? undefined : "Workspace name is required.",
invalidReason: workspaceNameDraft.trim() ? undefined : t("workspaces:workspaceNameIsRequired"),
save: handleSave,
discard: handleDiscard,
});
@@ -526,7 +555,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
<div>
<h2 className="text-2xl font-bold">{currentWorkspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage workspace details and jump into workflows or repositories.
{t("workspaces:manageWorkspaceDetails")}
</p>
</div>
<Separator />
@@ -1,16 +1,18 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
export function WorkspaceNotFoundCard({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<p className="text-muted-foreground">{t("workspaces:workspaceNotFound")}</p>
<Button className="mt-4" onClick={onBack}>
Back to Workspaces
{t("workspaces:backToWorkspaces")}
</Button>
</CardContent>
</Card>
@@ -1,6 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import Link from "@/components/routing/app-link";
import { useRouter } from "@/lib/routing/client-router";
import { IconGitBranch } from "@tabler/icons-react";
@@ -57,6 +59,8 @@ function buildDraftRepo(
manualRepoPath: string,
): RepositoryItem {
const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim();
// "New Repository" is PERSISTED as the repository's name, not rendered copy —
// a stored name must not depend on the locale it was created in.
const name =
selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository";
return {
@@ -120,6 +124,8 @@ async function saveNewRepository(
dev_script: repo.dev_script,
copy_files: repo.copy_files,
});
// Like the repository name above, the seeded script name and command are
// PERSISTED and sent to a shell verbatim, so both stay in English.
const scripts = await Promise.all(
repo.scripts.map((script, index) =>
createRepositoryScriptAction({
@@ -305,6 +311,7 @@ function useRepositoryHandlers({
function useDiscoverDialog(
workspace: Workspace | null,
toast: ReturnType<typeof useToast>["toast"],
t: TFunction,
) {
const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false);
const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]);
@@ -330,8 +337,8 @@ function useDiscoverDialog(
setDiscoveredRepositories(result.repositories);
} catch (error) {
toast({
title: "Failed to discover repositories",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToDiscoverRepositories"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
@@ -355,21 +362,23 @@ function useDiscoverDialog(
setManualValidation({
status: "success",
isValid: true,
message: "Valid git repository",
message: t("workspaces:validGitRepository"),
path: result.path,
});
else
// `result.message` is the backend's diagnostic and stays English by
// design; only the fallback for a missing payload is copy.
setManualValidation({
status: "error",
isValid: false,
message: result.message || "Invalid repository path",
message: result.message || t("workspaces:invalidRepositoryPath"),
path: result.path,
});
} catch (error) {
setManualValidation({
status: "error",
isValid: false,
message: error instanceof Error ? error.message : "Request failed",
message: error instanceof Error ? error.message : t("common:requestFailed"),
});
}
};
@@ -413,6 +422,7 @@ function useWorkspaceRepositoriesPage(
) {
const router = useRouter();
const { toast } = useToast();
const { t } = useTranslation();
const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts);
const [repositoryItems, setRepositoryItems] = useState<RepositoryItem[]>(repositories);
const [savedRepositoryItems, setSavedRepositoryItems] =
@@ -439,7 +449,7 @@ function useWorkspaceRepositoriesPage(
handleDeleteRepository,
} = handlers;
const discover = useDiscoverDialog(workspace, toast);
const discover = useDiscoverDialog(workspace, toast, t);
const {
localRepoDialogOpen,
setLocalRepoDialogOpen,
@@ -507,6 +517,7 @@ export function WorkspaceRepositoriesClient({
repositories,
}: WorkspaceRepositoriesClientProps) {
const state = useWorkspaceRepositoriesPage(workspace, repositories);
const { t } = useTranslation();
const {
router,
repositoryItems,
@@ -544,21 +555,23 @@ export function WorkspaceRepositoriesClient({
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories connected to this workspace.
{t("workspaces:manageRepositoriesConnected")}
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/settings/workspace/${workspace.id}`}>Workspace settings</Link>
<Link href={`/settings/workspace/${workspace.id}`}>
{t("workspaces:workspaceSettingsLink")}
</Link>
</Button>
</div>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
title={t("workspaces:repositories")}
description={t("workspaces:repositoriesInThisWorkspace")}
action={
<Button size="sm" className="cursor-pointer" onClick={openDialog}>
Add Local Repository
{t("workspaces:addLocalRepository")}
</Button>
}
>
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
@@ -50,15 +51,18 @@ function RepoListContent({
selectedRepoPath: string | null;
onSelectRepoPath: (path: string) => void;
}) {
const { t } = useTranslation();
if (isLoading)
return (
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
Scanning repositories...
{t("workspaces:scanningRepositories")}
</div>
);
if (filteredRepositories.length === 0)
return <div className="p-3 text-sm text-muted-foreground">No repositories found.</div>;
return (
<div className="p-3 text-sm text-muted-foreground">{t("workspaces:noRepositoriesFound")}</div>
);
return (
<>
{filteredRepositories.map((repo) => (
@@ -93,20 +97,19 @@ export function DiscoverRepoDialog({
canSave,
onConfirm,
}: DiscoverRepoDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Local Repository</DialogTitle>
<DialogDescription>
Select a discovered repository or provide an absolute path to validate.
</DialogDescription>
<DialogTitle>{t("workspaces:addLocalRepository")}</DialogTitle>
<DialogDescription>{t("workspaces:discoverRepositoryDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Discovered repositories</Label>
<Label>{t("workspaces:discoveredRepositories")}</Label>
<Input
placeholder="Filter repositories..."
placeholder={t("workspaces:filterRepositories")}
value={repoSearch}
onChange={(e) => onRepoSearchChange(e.target.value)}
/>
@@ -120,8 +123,10 @@ export function DiscoverRepoDialog({
</div>
</div>
<div className="space-y-2">
<Label>Manual path</Label>
<Label>{t("workspaces:manualPath")}</Label>
<div className="flex items-center gap-2">
{/* An absolute filesystem path is the value shape this field
expects, not prose — left in English deliberately. */}
<Input
placeholder="/absolute/path/to/repository"
value={manualRepoPath}
@@ -133,7 +138,7 @@ export function DiscoverRepoDialog({
onClick={onValidateManualPath}
disabled={!manualRepoPath.trim() || isValidating}
>
{isValidating ? "Checking..." : "Validate"}
{isValidating ? t("workspaces:checking") : t("workspaces:validate")}
</Button>
</div>
{manualValidation.status === "error" && (
@@ -146,10 +151,10 @@ export function DiscoverRepoDialog({
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t("common:cancel")}
</Button>
<Button type="button" onClick={onConfirm} disabled={!canSave}>
Use Repository
{t("workspaces:useRepository")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { IconFolder, IconPlus, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -34,29 +35,30 @@ function AddWorkspaceForm({
isLoading,
status,
}: AddWorkspaceFormProps) {
const { t } = useTranslation();
return (
<Card>
<CardContent className="pt-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Workspace Name</Label>
<Label htmlFor="workspace-name">{t("workspaces:workspaceName")}</Label>
<Input
id="workspace-name"
value={newWorkspaceName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="My Workspace"
placeholder={t("workspaces:workspaceNamePlaceholder")}
required
autoFocus
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
{t("common:cancel")}
</Button>
<div className="flex items-center gap-2">
<RequestIndicator status={status} />
<Button type="submit" disabled={isLoading}>
Add Workspace
{t("workspaces:addWorkspace")}
</Button>
</div>
</div>
@@ -67,6 +69,7 @@ function AddWorkspaceForm({
}
function WorkspaceListItem({ workspace }: { workspace: Workspace }) {
const { t } = useTranslation();
return (
<Link href={`/settings/workspace/${workspace.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
@@ -79,7 +82,7 @@ function WorkspaceListItem({ workspace }: { workspace: Workspace }) {
<div className="flex-1 min-w-0">
<h4 className="font-medium">{workspace.name}</h4>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>Workflows managed in this workspace</span>
<span>{t("workspaces:workflowsManagedInThisWorkspace")}</span>
</div>
</div>
</div>
@@ -98,6 +101,7 @@ export function WorkspacesPageClient() {
const [newWorkspaceName, setNewWorkspaceName] = useState("");
const createRequest = useRequest(createWorkspaceAction);
const { toast } = useToast();
const { t } = useTranslation();
const handleAddWorkspace = async (e: React.FormEvent) => {
e.preventDefault();
@@ -132,8 +136,8 @@ export function WorkspacesPageClient() {
setIsAdding(false);
} catch (error) {
toast({
title: "Failed to create workspace",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToCreateWorkspace"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
@@ -143,12 +147,14 @@ export function WorkspacesPageClient() {
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Workspaces</h2>
<p className="text-sm text-muted-foreground mt-1">Manage your workspaces and workflows</p>
<h2 className="text-2xl font-bold">{t("common:workspaces")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("workspaces:manageYourWorkspacesAndWorkflows")}
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Workspace
{t("workspaces:addWorkspace")}
</Button>
</div>
@@ -175,7 +181,7 @@ export function WorkspacesPageClient() {
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No workspaces configured. Add your first workspace to get started.
{t("workspaces:noWorkspacesConfigured")}
</p>
</CardContent>
</Card>
@@ -0,0 +1,93 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "@/lib/i18n";
import { RepositoryBranchTemplateHelp } from "./repository-branch-template-help";
const TRIGGER_LABEL = "Branch template placeholders";
// Restoring the locale is load-bearing: `changeLanguage` mutates the shared
// instance `vitest.setup.ts` initializes, so leaving it on pseudo would leak
// into every test that runs after this file.
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
});
/**
* The six placeholder tokens are substituted by exact string match in
* `RenderTaskBranchName` (apps/backend/internal/worktree/config.go), so they must
* stay in English while the description beside each one is translated. The
* description now arrives through a catalog key held in the table, which means a
* mistyped key renders the key itself and nothing fails — these assertions pin
* the token-to-description pairing and the interpolated example values.
*/
function openHelp() {
render(<RepositoryBranchTemplateHelp />);
// HoverCard content is only mounted while open; the trigger is a button, so
// focus is enough in happy-dom.
screen.getByRole("button", { name: TRIGGER_LABEL }).focus();
}
/**
* The trigger is icon-only, so its `aria-label` is the ONLY copy a screen-reader
* user gets from it — and attribute copy has no second check. The pseudo-locale
* oracle walks text nodes (`docs/i18n.md`, "It cannot see copy that is not a
* text node"), so reverting this to an English literal leaves no trace on
* screen. The locale-switch assertion below covers the two failures lint cannot:
* a hardcoded literal, and a `t()` frozen at module scope. Both render the same
* English forever; only a switch tells them apart.
*/
describe("RepositoryBranchTemplateHelp", () => {
it("labels the trigger for screen readers", () => {
render(<RepositoryBranchTemplateHelp />);
expect(screen.getByRole("button", { name: TRIGGER_LABEL })).toBeTruthy();
});
it("resolves the trigger label through the catalog on a locale switch", async () => {
render(<RepositoryBranchTemplateHelp />);
await i18n.changeLanguage("pseudo");
const label = screen.getByRole("button").getAttribute("aria-label") ?? "";
expect(label).not.toBe(TRIGGER_LABEL);
expect(label.length).toBeGreaterThan(0);
});
it("pairs each untranslated token with its description and example", async () => {
openHelp();
const term = await screen.findByText("{title}");
const description = term.nextElementSibling;
expect(description?.textContent).toBe(
"Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: fix-login-flow.",
);
});
it("keeps the ticket examples as interpolated values", async () => {
openHelp();
const term = await screen.findByText("{ticket}");
expect(term.nextElementSibling?.textContent).toBe(
"Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: KAN-123, #42.",
);
});
it("renders the alias description that carries no example", async () => {
openHelp();
const term = await screen.findByText("{issue_key}");
expect(term.nextElementSibling?.textContent).toBe(
"Alias for ticket. Use whichever name reads better in your template.",
);
});
it("keeps the literal-prefix example inside a code element", async () => {
openHelp();
const code = await screen.findByText("feature/{ticket}-{title}");
expect(code.tagName).toBe("CODE");
expect(code.parentElement?.textContent).toBe(
"Write literal prefixes directly, for example feature/{ticket}-{title}.",
);
});
});
@@ -1,39 +1,55 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
import { IconInfoCircle } from "@tabler/icons-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
/**
* The placeholder tokens are PROTOCOL, not copy: `RenderTaskBranchName` in
* `apps/backend/internal/worktree/config.go` substitutes each one by exact
* string match, so a translated `{title}` would be emitted into the branch name
* verbatim. Only the description beside each token is copy, and it lives in the
* catalog under `descriptionKey` so it resolves at render rather than at import.
*
* The example values are branch-name output — sanitized lowercase ASCII, a
* ticket key, a UUID — so they travel as interpolated VALUES rather than sitting
* inside the message a translator edits.
*/
const branchTemplatePlaceholders = [
[
"{title}",
"Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: fix-login-flow.",
],
[
"{title_full}",
"Same sanitizing as title, but max 80 chars. Example: fix-login-flow-after-session-timeout.",
],
[
"{ticket}",
"Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: KAN-123, #42.",
],
["{issue_key}", "Alias for ticket. Use whichever name reads better in your template."],
[
"{task_id}",
"Kandev task UUID, sanitized for branch names. Example: 1f1cf094-db3c-4f42-b425-2cc14a2f7c74.",
],
[
"{suffix}",
"Short random suffix. Optional, but recommended to avoid branch name clashes. Example: x7p9.",
],
{
token: "{title}",
descriptionKey: "workspaces:branchTemplateTitle",
example: "fix-login-flow",
},
{
token: "{title_full}",
descriptionKey: "workspaces:branchTemplateTitleFull",
example: "fix-login-flow-after-session-timeout",
},
{
token: "{ticket}",
descriptionKey: "workspaces:branchTemplateTicket",
example: "KAN-123, #42",
},
{ token: "{issue_key}", descriptionKey: "workspaces:branchTemplateIssueKey", example: "" },
{
token: "{task_id}",
descriptionKey: "workspaces:branchTemplateTaskId",
example: "1f1cf094-db3c-4f42-b425-2cc14a2f7c74",
},
{ token: "{suffix}", descriptionKey: "workspaces:branchTemplateSuffix", example: "x7p9" },
] as const;
const LITERAL_PREFIX_EXAMPLE = "feature/{ticket}-{title}";
export function RepositoryBranchTemplateHelp() {
const { t } = useTranslation();
return (
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label="Branch template placeholders"
aria-label={t("workspaces:branchTemplatePlaceholders")}
className="cursor-help text-muted-foreground hover:text-foreground"
>
<IconInfoCircle className="h-3.5 w-3.5" />
@@ -42,17 +58,18 @@ export function RepositoryBranchTemplateHelp() {
<HoverCardContent align="start" className="w-96 text-xs">
<div className="space-y-2">
<p className="text-muted-foreground">
Write literal prefixes directly, for example{" "}
<code className="rounded bg-muted px-1 py-0.5">
feature/{"{ticket}"}-{"{title}"}
</code>
.
<Trans
i18nKey="workspaces:branchTemplateLiteralPrefix"
values={{ example: LITERAL_PREFIX_EXAMPLE }}
>
<code className="rounded bg-muted px-1 py-0.5" />
</Trans>
</p>
<dl className="space-y-1.5">
{branchTemplatePlaceholders.map(([name, description]) => (
<div key={name} className="grid grid-cols-[5.5rem_1fr] gap-2">
<dt className="font-mono text-foreground">{name}</dt>
<dd className="text-muted-foreground">{description}</dd>
{branchTemplatePlaceholders.map(({ token, descriptionKey, example }) => (
<div key={token} className="grid grid-cols-[5.5rem_1fr] gap-2">
<dt className="font-mono text-foreground">{token}</dt>
<dd className="text-muted-foreground">{t(descriptionKey, { example })}</dd>
</div>
))}
</dl>
@@ -0,0 +1,182 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@/lib/i18n";
import { RepositoryCard } from "./repository-card";
import { SettingsSaveProvider } from "./settings-save-provider";
import { ToastProvider } from "@/components/toast-provider";
import type { Repository, RepositoryScript } from "@/lib/types/http";
const CLOSE_EDITOR_LABEL = "Close repository editor";
// Restoring the locale is load-bearing: `changeLanguage` mutates the shared
// instance `vitest.setup.ts` initializes, so leaving it on pseudo would leak
// into every test that runs after this file.
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
});
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
/**
* The collapsed card's whole summary is built by `buildRepoPreviewData` /
* `buildRepoScriptsSummary` — plain functions holding no JSX, which
* `i18next/no-literal-string` never inspects. The script count also used to be
* inflected with an English `s` at the call site. Neither shape fails loudly, so
* these assertions pin the rendered text.
*/
function repository(overrides: Partial<RepositoryWithScripts> = {}): RepositoryWithScripts {
return {
id: "repo-1",
workspace_id: "ws-1",
name: "kandev",
source_type: "local",
local_path: "/home/dev/kandev",
provider: "",
provider_repo_id: "",
provider_host: "",
provider_owner: "",
provider_name: "",
default_branch: "main",
worktree_branch_prefix: "feature/",
worktree_branch_template: "feature/{title}-{suffix}",
pull_before_worktree: true,
setup_script: "",
cleanup_script: "",
dev_script: "",
copy_files: "",
created_at: "",
updated_at: "",
scripts: [],
...overrides,
} as RepositoryWithScripts;
}
function script(id: string): RepositoryScript {
return {
id,
repository_id: "repo-1",
name: id,
command: "echo hi",
position: 0,
created_at: "",
updated_at: "",
} as RepositoryScript;
}
function renderCard(repo: RepositoryWithScripts, autoOpen = false) {
return render(
<ToastProvider>
<SettingsSaveProvider>
<RepositoryCard
repository={repo}
savedRepository={repo}
isRepositoryDirty={false}
areScriptsDirty={false}
autoOpen={autoOpen}
onUpdate={vi.fn()}
onAddScript={vi.fn()}
onUpdateScript={vi.fn()}
onDeleteScript={vi.fn()}
onSave={vi.fn()}
onDelete={vi.fn()}
/>
</SettingsSaveProvider>
</ToastProvider>,
);
}
describe("RepositoryCard preview", () => {
it("uses the singular form for exactly one custom script", () => {
renderCard(repository({ scripts: [script("a")] }));
expect(screen.getByText("1 custom script")).toBeTruthy();
});
it("uses the plural form for more than one custom script", () => {
renderCard(repository({ scripts: [script("a"), script("b")] }));
expect(screen.getByText("2 custom scripts")).toBeTruthy();
});
it("labels a local repository and shows its path verbatim", () => {
renderCard(repository());
expect(screen.getByText("Local")).toBeTruthy();
expect(screen.getByText("/home/dev/kandev")).toBeTruthy();
});
it("falls back to translated copy when a local repository has no path", () => {
renderCard(repository({ local_path: "" }));
expect(screen.getByText("Local path not set")).toBeTruthy();
});
it("shows the owner/name slug for a remote repository", () => {
renderCard(
repository({
source_type: "remote",
provider_owner: "kdlbs",
provider_name: "kandev",
}),
);
expect(screen.getByText("Remote")).toBeTruthy();
expect(screen.getByText("kdlbs/kandev")).toBeTruthy();
});
it("falls back to translated copy when a remote repository has no slug", () => {
renderCard(repository({ source_type: "remote" }));
expect(screen.getByText("Remote repository")).toBeTruthy();
});
it("names an unnamed repository", () => {
renderCard(repository({ name: "" }));
expect(screen.getByText("Untitled repository")).toBeTruthy();
});
it("lists the three built-in script chips only when their scripts are set", () => {
renderCard(
repository({
setup_script: "npm ci",
cleanup_script: "rm -rf tmp",
dev_script: "npm run dev",
}),
);
expect(screen.getByText("build script")).toBeTruthy();
expect(screen.getByText("cleanup script")).toBeTruthy();
expect(screen.getByText("dev script")).toBeTruthy();
expect(screen.queryByText("No custom scripts")).toBeNull();
});
});
/**
* The editor's close control is icon-only, so its `aria-label` is the only copy
* a screen-reader user gets from it. Attribute copy has no second check — the
* pseudo-locale oracle walks text nodes, so reverting this to an English literal
* leaves nothing visible (`docs/i18n.md`, "It cannot see copy that is not a text
* node"). The locale-switch assertion catches the two shapes lint cannot: a
* hardcoded literal, and a `t()` frozen at module scope.
*/
describe("RepositoryCard editor close control", () => {
it("labels the close control for screen readers", () => {
renderCard(repository(), true);
expect(screen.getByRole("button", { name: CLOSE_EDITOR_LABEL })).toBeTruthy();
});
it("resolves the close label through the catalog on a locale switch", async () => {
const { container } = renderCard(repository(), true);
const close = container.querySelector(`[aria-label="${CLOSE_EDITOR_LABEL}"]`);
expect(close).toBeTruthy();
await i18n.changeLanguage("pseudo");
expect(container.querySelector(`[aria-label="${CLOSE_EDITOR_LABEL}"]`)).toBeNull();
const label = close?.getAttribute("aria-label") ?? "";
expect(label.length).toBeGreaterThan(0);
});
});
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconEdit, IconGitBranch, IconTrash, IconX } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { CardContent } from "@kandev/ui/card";
@@ -25,6 +27,23 @@ import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template";
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
/**
* The environment variable the dev script reads to learn its allocated port. It
* is an identifier the user types into a shell command verbatim, so it travels
* as an interpolated value rather than sitting inside the message.
*/
const DEV_SCRIPT_PORT_VAR = "$PORT";
/**
* Shell script samples. Each is code the user edits and the executor runs
* verbatim, so none of it is copy — the same call as `DEFAULT_DOCKERFILE` in the
* executor profile editor. Hoisted out of the JSX so the intent is explicit
* rather than resting on a guard exclusion pattern.
*/
const SETUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual setup you need";
const CLEANUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual clean up you need";
const DEV_SCRIPT_PLACEHOLDER = "#!/bin/bash\nnpm run dev -- --port $PORT";
type RepoFieldsBaseProps = {
repositoryId: string;
onUpdate: (repoId: string, updates: Partial<Repository>) => void;
@@ -49,11 +68,14 @@ function RepositoryBasicFields({
worktreeBranchTemplate,
pullBeforeWorktree,
}: RepositoryBasicFieldsProps) {
const { t } = useTranslation();
return (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Repository Name</Label>
<Label>{t("workspaces:repositoryName")}</Label>
{/* Both placeholders are value shapes — a git repository name and an
absolute filesystem path — so they stay in English. */}
<Input
value={repositoryName}
onChange={(e) => onUpdate(repositoryId, { name: e.target.value })}
@@ -62,7 +84,7 @@ function RepositoryBasicFields({
/>
</div>
<div className="space-y-2">
<Label>Local Path</Label>
<Label>{t("workspaces:localPath")}</Label>
<Input
value={repositoryLocalPath}
onChange={(e) => onUpdate(repositoryId, { local_path: e.target.value })}
@@ -75,7 +97,7 @@ function RepositoryBasicFields({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Worktree Branch Template</Label>
<Label>{t("workspaces:worktreeBranchTemplate")}</Label>
<RepositoryBranchTemplateHelp />
</div>
<Input
@@ -89,7 +111,7 @@ function RepositoryBasicFields({
/>
</div>
<div className="space-y-2">
<Label htmlFor={`repo-pull-before-${repositoryId}`}>Worktree Sync</Label>
<Label htmlFor={`repo-pull-before-${repositoryId}`}>{t("workspaces:worktreeSync")}</Label>
<div className="flex items-start gap-2 pt-2">
<Checkbox
id={`repo-pull-before-${repositoryId}`}
@@ -106,7 +128,7 @@ function RepositoryBasicFields({
htmlFor={`repo-pull-before-${repositoryId}`}
className="text-sm text-muted-foreground cursor-pointer"
>
Always pull before creating a new worktree
{t("workspaces:alwaysPullBeforeWorktree")}
</Label>
</div>
</div>
@@ -133,52 +155,52 @@ function RepositoryScriptFields({
devScript,
copyFiles,
}: RepositoryScriptFieldsProps) {
const { t } = useTranslation();
return (
<>
{/* Every script placeholder below is a shell sample — a shebang plus a
command the executor runs verbatim — so it is code, not copy. */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Setup Script</Label>
<Label>{t("workspaces:setupScript")}</Label>
<Textarea
value={setupScript}
onChange={(e) => onUpdate(repositoryId, { setup_script: e.target.value })}
placeholder="#!/bin/bash&#10;# any manual setup you need"
placeholder={SETUP_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={setupScript !== (savedRepository?.setup_script ?? "")}
/>
<p className="text-xs text-muted-foreground">
Runs when the repo is cloned or a git worktree is created.
</p>
<p className="text-xs text-muted-foreground">{t("workspaces:setupScriptHelp")}</p>
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Label>{t("workspaces:cleanupScript")}</Label>
<Textarea
value={cleanupScript}
onChange={(e) => onUpdate(repositoryId, { cleanup_script: e.target.value })}
placeholder="#!/bin/bash&#10;# any manual clean up you need"
placeholder={CLEANUP_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={cleanupScript !== (savedRepository?.cleanup_script ?? "")}
/>
<p className="text-xs text-muted-foreground">
Runs when the task is completed to clean up the workspace.
</p>
<p className="text-xs text-muted-foreground">{t("workspaces:cleanupScriptHelp")}</p>
</div>
</div>
<div className="space-y-2">
<Label>Dev Script</Label>
<Label>{t("workspaces:devScript")}</Label>
<Textarea
value={devScript}
onChange={(e) => onUpdate(repositoryId, { dev_script: e.target.value })}
placeholder="#!/bin/bash&#10;npm run dev -- --port $PORT"
placeholder={DEV_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={devScript !== (savedRepository?.dev_script ?? "")}
/>
<p className="text-xs text-muted-foreground">
Used to start the preview dev server for this repository. Use{" "}
<code className="px-1 py-0.5 bg-muted rounded">$PORT</code> for automatic port allocation.
<Trans i18nKey="workspaces:devScriptHelp" values={{ port: DEV_SCRIPT_PORT_VAR }}>
<code className="px-1 py-0.5 bg-muted rounded" />
</Trans>
</p>
</div>
@@ -219,6 +241,7 @@ function RepositoryEditView({
deleteLoading,
close,
}: RepositoryEditViewProps) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isDirty}>
<CardContent className="pt-6">
@@ -227,7 +250,7 @@ function RepositoryEditView({
<div className="flex items-center gap-2">
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
<Label className="flex items-center gap-2">
<span>Repository</span>
<span>{t("workspaces:repository")}</span>
{isDirty && <UnsavedChangesBadge />}
</Label>
</div>
@@ -236,7 +259,7 @@ function RepositoryEditView({
variant="ghost"
size="icon"
className="cursor-pointer"
aria-label="Close repository editor"
aria-label={t("workspaces:closeRepositoryEditor")}
onClick={close}
>
<IconX className="h-4 w-4" />
@@ -284,7 +307,7 @@ function RepositoryEditView({
disabled={deleteLoading}
>
<IconTrash className="h-4 w-4 mr-2" />
Delete Repository
{t("workspaces:deleteRepository")}
</Button>
</div>
</div>
@@ -301,7 +324,11 @@ type RepositoryPreviewProps = {
open: () => void;
};
function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
// These two build every string in the collapsed card, and neither holds JSX, so
// `i18next/no-literal-string` could never see any of it — the lint count for this
// file reported zero for the whole block. `t` is a parameter rather than the
// module-level import so each label resolves when the card renders.
function buildRepoScriptsSummary(t: TFunction, repository: RepositoryWithScripts) {
const setupScript = repository.setup_script ?? "";
const cleanupScript = repository.cleanup_script ?? "";
const devScript = repository.dev_script ?? "";
@@ -310,10 +337,12 @@ function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
const hasCleanupScript = Boolean(cleanupScript.trim());
const hasDevScript = Boolean(devScript.trim());
const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript || hasDevScript;
// Was `custom script${count === 1 ? "" : "s"}` — an English morpheme built at
// the call site, which leaves a translator no way to express a third form.
const scriptsLabel =
scriptsCount === 0
? "No custom scripts"
: `${scriptsCount} custom script${scriptsCount === 1 ? "" : "s"}`;
? t("workspaces:noCustomScripts")
: t("workspaces:customScripts", { count: scriptsCount });
return {
scriptsCount,
hasSetupScript,
@@ -324,20 +353,24 @@ function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
};
}
function buildRepoPreviewData(repository: RepositoryWithScripts) {
function buildRepoPreviewData(t: TFunction, repository: RepositoryWithScripts) {
const repositoryName = repository.name ?? "";
const sourceLabel = repository.source_type === "local" ? "Local" : "Remote";
// `source_type` is the wire value; only its badge label is copy.
const sourceLabel =
repository.source_type === "local" ? t("workspaces:sourceLocal") : t("workspaces:sourceRemote");
// The path and the `owner/name` slug are user data / git metadata; only the
// two "nothing recorded" fallbacks are copy.
const subtitle =
repository.source_type === "local"
? repository.local_path || "Local path not set"
? repository.local_path || t("workspaces:localPathNotSet")
: [repository.provider_owner, repository.provider_name].filter(Boolean).join("/") ||
repository.provider ||
"Remote repository";
t("workspaces:remoteRepository");
return {
repositoryName,
sourceLabel,
subtitle,
...buildRepoScriptsSummary(repository),
...buildRepoScriptsSummary(t, repository),
};
}
@@ -348,6 +381,7 @@ function RepositoryPreview({
onOpenDelete,
open,
}: RepositoryPreviewProps) {
const { t } = useTranslation();
const {
repositoryName,
scriptsCount,
@@ -358,7 +392,7 @@ function RepositoryPreview({
scriptsLabel,
sourceLabel,
subtitle,
} = buildRepoPreviewData(repository);
} = buildRepoPreviewData(t, repository);
return (
<SettingsCard isDirty={isDirty}>
@@ -370,7 +404,9 @@ function RepositoryPreview({
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium truncate">{repositoryName || "Untitled repository"}</h4>
<h4 className="font-medium truncate">
{repositoryName || t("workspaces:untitledRepository")}
</h4>
<Badge variant="secondary" className="text-xs">
{sourceLabel}
</Badge>
@@ -380,9 +416,9 @@ function RepositoryPreview({
{showScriptsSummary ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground mt-1">
{scriptsCount > 0 && <span>{scriptsLabel}</span>}
{hasSetupScript && <span>build script</span>}
{hasCleanupScript && <span>cleanup script</span>}
{hasDevScript && <span>dev script</span>}
{hasSetupScript && <span>{t("workspaces:buildScriptChip")}</span>}
{hasCleanupScript && <span>{t("workspaces:cleanupScriptChip")}</span>}
{hasDevScript && <span>{t("workspaces:devScriptChip")}</span>}
</div>
) : null}
</div>
@@ -399,7 +435,7 @@ function RepositoryPreview({
}}
>
<IconEdit className="h-4 w-4" />
Edit
{t("workspaces:edit")}
</Button>
<Button
type="button"
@@ -413,7 +449,7 @@ function RepositoryPreview({
disabled={deleteLoading}
>
<IconTrash className="h-4 w-4" />
Delete
{t("workspaces:delete")}
</Button>
</div>
</div>
@@ -442,6 +478,7 @@ function useRepositoryDelete(
onDeleted: () => void,
) {
const { toast } = useToast();
const { t } = useTranslation();
const [deleteOpen, setDeleteOpen] = useState(false);
const [activeSessionCount, setActiveSessionCount] = useState(0);
const [checkingCount, setCheckingCount] = useState(false);
@@ -465,8 +502,8 @@ function useRepositoryDelete(
setDeleteOpen(true);
} catch (error) {
toast({
title: "Failed to check repository sessions",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToCheckRepositorySessions"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
} finally {
@@ -481,8 +518,8 @@ function useRepositoryDelete(
onDeleted();
} catch (error) {
toast({
title: "Failed to delete repository",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToDeleteRepository"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
@@ -513,6 +550,7 @@ export function RepositoryCard({
onDelete,
}: RepositoryCardProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(() => autoOpen);
const saveRequest = useRequest(() => onSave(repository.id));
const isDirty = isRepositoryDirty || areScriptsDirty;
@@ -523,8 +561,8 @@ export function RepositoryCard({
await saveRequest.run();
} catch (error) {
toast({
title: "Failed to save repository",
description: error instanceof Error ? error.message : "Request failed",
title: t("workspaces:failedToSaveRepository"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
throw error;
@@ -0,0 +1,68 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CopyFilesField } from "./repository-copy-files-help";
afterEach(cleanup);
/**
* Every sentence here is a `<Trans>` whose `<n>` indices address `<code>`
* elements positionally, and each glob pattern travels as an interpolation VALUE
* so the pseudo-locale cannot accent it into a pattern that matches nothing.
* Both shapes fail silently: a drifted index renders duplicated fragments with
* empty tags, and a pattern moved into the message body would only show up in a
* translated build. These tests reconstruct the whole sentence and assert each
* pattern lands inside a `<code>`.
*/
function renderField() {
return render(<CopyFilesField repositoryId="repo-1" copyFiles="" onUpdate={vi.fn()} />).container;
}
function codeTexts(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll("code")).map((el) => el.textContent);
}
describe("CopyFilesField", () => {
it("renders the symlink help as one sentence with each suffix in a code element", () => {
const container = renderField();
expect(document.getElementById("copy-files-help-repo-1")?.textContent).toBe(
"Gitignored paths copied into new worktrees. Append :symlink to an entry to link it back to the main repo. Use ::symlink for a literal filename ending in :symlink.",
);
expect(codeTexts(container)).toEqual(expect.arrayContaining([":symlink", "::symlink"]));
});
it("keeps the glob patterns out of the translated message body", () => {
const container = renderField();
expect(codeTexts(container)).toEqual(
expect.arrayContaining(["*", "?", "[abc]", "**", "**/.env", "{a,b}", ".env{,.local}"]),
);
});
it("renders each supported-pattern bullet as a complete sentence", () => {
const container = renderField();
// Each bullet is assembled from `<code>` elements and text nodes, so it has
// to be reconstructed — a drifted `<n>` index would drop a fragment here
// while every individual `<code>` still rendered.
const bullets = Array.from(container.querySelectorAll("li")).map((li) =>
li.textContent?.replace(/\s+/g, " ").trim(),
);
expect(bullets).toEqual([
".env literal file or directory (directories copy recursively)",
"*, ?, [abc] single-segment wildcards",
"** matches any number of directories, e.g. **/.env",
"{a,b} brace alternation, e.g. .env{,.local}",
]);
});
it("interpolates the remote size cap rather than baking it into the message", () => {
renderField();
expect(
screen.getByText(
"Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them without a size cap.",
),
).toBeTruthy();
});
});
@@ -1,10 +1,32 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
import { IconChevronDown, IconInfoCircle } from "@tabler/icons-react";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import type { Repository } from "@/lib/types/http";
/**
* Glob/path syntax the backend parses and the user types verbatim. These are
* VALUES, not copy, so each is interpolated into its message rather than left in
* the tag body — the pseudo-locale would otherwise accent `**` or `:symlink`
* into a pattern that no longer matches anything.
*/
const SYMLINK_SUFFIX = ":symlink";
const ESCAPED_SYMLINK_SUFFIX = "::symlink";
const LITERAL_PATTERN = ".env";
const STAR_PATTERN = "*";
const QUESTION_PATTERN = "?";
const CHAR_CLASS_PATTERN = "[abc]";
const GLOBSTAR_PATTERN = "**";
const GLOBSTAR_EXAMPLE = "**/.env";
const BRACES_PATTERN = "{a,b}";
const BRACES_EXAMPLE = ".env{,.local}";
const REMOTE_COPY_SIZE_LIMIT = "5 MiB";
const COPY_FILES_PLACEHOLDER = ".env, .env.*, apps/**/.env, .env.local:symlink";
const codeClass = "px-1 py-0.5 bg-muted rounded";
type CopyFilesFieldProps = {
repositoryId: string;
copyFiles: string;
@@ -18,30 +40,36 @@ export function CopyFilesField({
isDirty = false,
onUpdate,
}: CopyFilesFieldProps) {
const { t } = useTranslation();
const inputId = `copy-files-${repositoryId}`;
const helpId = `copy-files-help-${repositoryId}`;
return (
<div className="space-y-2">
<Label htmlFor={inputId}>Copy Files</Label>
<Label htmlFor={inputId}>{t("workspaces:copyFiles")}</Label>
{/* The placeholder is a list of glob patterns — a value, not prose. */}
<Textarea
id={inputId}
data-testid={`copy-files-input-${repositoryId}`}
aria-describedby={helpId}
value={copyFiles}
onChange={(e) => onUpdate(repositoryId, { copy_files: e.target.value })}
placeholder=".env, .env.*, apps/**/.env, .env.local:symlink"
placeholder={COPY_FILES_PLACEHOLDER}
rows={2}
className="font-mono text-sm"
data-settings-dirty={isDirty}
/>
<p id={helpId} className="text-xs text-muted-foreground">
Gitignored paths copied into new worktrees. Append{" "}
<code className="px-1 py-0.5 bg-muted rounded">:symlink</code> to an entry to link it back
to the main repo. Use <code className="px-1 py-0.5 bg-muted rounded">::symlink</code> for a
literal filename ending in <code className="px-1 py-0.5 bg-muted rounded">:symlink</code>.
<Trans
i18nKey="workspaces:copyFilesHelp"
values={{ symlink: SYMLINK_SUFFIX, escapedSymlink: ESCAPED_SYMLINK_SUFFIX }}
>
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</p>
<p data-testid="copy-files-remote-fallback" className="text-xs text-muted-foreground">
Remote executors copy file contents instead of creating symlinks.
{t("workspaces:copyFilesRemoteFallback")}
</p>
<CopyFilesDetails />
</div>
@@ -49,44 +77,64 @@ export function CopyFilesField({
}
function CopyFilesDetails() {
const { t } = useTranslation();
return (
<details className="group text-xs text-muted-foreground">
<summary className="flex min-h-11 w-fit cursor-pointer list-none items-center gap-1.5 py-2 font-medium text-foreground">
<IconInfoCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
Pattern syntax
{t("workspaces:patternSyntax")}
<IconChevronDown
className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div className="max-w-sm space-y-2 pb-1">
<p>
Paths are resolved relative to the repository root and seeded into every new worktree,
preserving their relative location. Existing files in the worktree are not overwritten.
</p>
<p className="font-medium">Supported patterns:</p>
<p>{t("workspaces:copyFilesPathsResolved")}</p>
<p className="font-medium">{t("workspaces:supportedPatterns")}</p>
<ul className="space-y-1 pl-3 list-disc">
<li>
<code className="px-1 py-0.5 bg-muted rounded">.env</code> literal file or directory
(directories copy recursively)
<Trans
i18nKey="workspaces:copyFilesPatternLiteral"
values={{ pattern: LITERAL_PATTERN }}
>
<code className={codeClass} />
</Trans>
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">*</code>,{" "}
<code className="px-1 py-0.5 bg-muted rounded">?</code>,{" "}
<code className="px-1 py-0.5 bg-muted rounded">[abc]</code> single-segment wildcards
<Trans
i18nKey="workspaces:copyFilesPatternWildcards"
values={{
star: STAR_PATTERN,
question: QUESTION_PATTERN,
charClass: CHAR_CLASS_PATTERN,
}}
>
<code className={codeClass} />
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">**</code> matches any number of
directories, e.g. <code className="px-1 py-0.5 bg-muted rounded">**/.env</code>
<Trans
i18nKey="workspaces:copyFilesPatternGlobstar"
values={{ globstar: GLOBSTAR_PATTERN, example: GLOBSTAR_EXAMPLE }}
>
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</li>
<li>
<code className="px-1 py-0.5 bg-muted rounded">{"{a,b}"}</code> brace alternation, e.g.{" "}
<code className="px-1 py-0.5 bg-muted rounded">.env{"{,.local}"}</code>
<Trans
i18nKey="workspaces:copyFilesPatternBraces"
values={{ braces: BRACES_PATTERN, example: BRACES_EXAMPLE }}
>
<code className={codeClass} />
<code className={codeClass} />
</Trans>
</li>
</ul>
<p className="text-muted-foreground">
Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them
without a size cap.
{t("workspaces:copyFilesSizeLimit", { limit: REMOTE_COPY_SIZE_LIMIT })}
</p>
</div>
</details>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { IconPlus, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
@@ -6,6 +7,12 @@ import { Textarea } from "@kandev/ui/textarea";
import type { RepositoryScript } from "@/lib/types/http";
import { UnsavedChangesBadge } from "./unsaved-indicator";
/**
* A shell script sample: code the user edits and the executor runs verbatim,
* not copy.
*/
const SCRIPT_COMMAND_PLACEHOLDER = "#!/bin/bash\nnpm run dev";
type RepositoryCustomScriptsProps = {
repositoryId: string;
scripts: RepositoryScript[];
@@ -25,21 +32,22 @@ export function RepositoryCustomScripts({
onUpdateScript,
onDeleteScript,
}: RepositoryCustomScriptsProps) {
const { t } = useTranslation();
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label className="flex items-center gap-2">
<span>Custom Scripts</span>
<span>{t("workspaces:customScriptsLabel")}</span>
{areScriptsDirty && <UnsavedChangesBadge />}
</Label>
<Button type="button" variant="outline" size="sm" onClick={() => onAddScript(repositoryId)}>
<IconPlus className="h-4 w-4 mr-1" />
Add Script
{t("workspaces:addScript")}
</Button>
</div>
<div className="space-y-3">
{scripts.length === 0 ? (
<p className="text-sm text-muted-foreground">No scripts yet.</p>
<p className="text-sm text-muted-foreground">{t("workspaces:noScriptsYet")}</p>
) : (
scripts.map((script) => (
<RepositoryCustomScript
@@ -70,6 +78,7 @@ function RepositoryCustomScript({
onUpdate: RepositoryCustomScriptsProps["onUpdateScript"];
onDelete: RepositoryCustomScriptsProps["onDeleteScript"];
}) {
const { t } = useTranslation();
const nameIsDirty = !savedScript || script.name !== savedScript.name;
const commandIsDirty = !savedScript || script.command !== savedScript.command;
return (
@@ -82,7 +91,7 @@ function RepositoryCustomScript({
<Input
value={script.name ?? ""}
onChange={(event) => onUpdate(repositoryId, script.id, { name: event.target.value })}
placeholder="Script name"
placeholder={t("workspaces:scriptName")}
data-settings-dirty={nameIsDirty}
/>
<Button
@@ -97,7 +106,7 @@ function RepositoryCustomScript({
<Textarea
value={script.command ?? ""}
onChange={(event) => onUpdate(repositoryId, script.id, { command: event.target.value })}
placeholder="#!/bin/bash&#10;npm run dev"
placeholder={SCRIPT_COMMAND_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={commandIsDirty}
@@ -0,0 +1,70 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DeleteRepositoryDialog } from "./repository-delete-dialog";
afterEach(cleanup);
// `@kandev/ui`'s DialogContent renders its own sr-only "Close" control, so the
// footer has to be scoped explicitly or the dismiss button is ambiguous.
function footer(): HTMLElement {
return document.querySelector('[data-slot="dialog-footer"]') as HTMLElement;
}
/**
* The active-session sentence used to inflect both its noun and its pronoun with
* a ternary (`session`/`sessions`, `it`/`them`), so a bad `_one` / `_other` split
* would have rendered "1 active agent sessions … finish them" without failing
* anything. These assertions pin both forms and the no-sessions variant.
*/
function renderDialog(activeSessionCount: number) {
return render(
<DeleteRepositoryDialog
open
onOpenChange={vi.fn()}
onDelete={vi.fn()}
activeSessionCount={activeSessionCount}
deleteLoading={false}
/>,
);
}
describe("DeleteRepositoryDialog", () => {
it("uses the singular noun and pronoun for exactly one active session", () => {
renderDialog(1);
expect(
screen.getByText(
"This repository is used by 1 active agent session. Stop or finish it before deleting the repository.",
),
).toBeTruthy();
});
it("uses the plural noun and pronoun for more than one active session", () => {
renderDialog(3);
expect(
screen.getByText(
"This repository is used by 3 active agent sessions. Stop or finish them before deleting the repository.",
),
).toBeTruthy();
});
it("offers only a Close button while sessions are active", () => {
renderDialog(2);
expect(within(footer()).getByRole("button", { name: "Close" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Delete Repository" })).toBeNull();
});
it("offers Cancel and Delete Repository when no session is active", () => {
renderDialog(0);
expect(
screen.getByText(
"This will remove the repository and its scripts. This action cannot be undone.",
),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Delete Repository" })).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -25,18 +26,19 @@ export function DeleteRepositoryDialog({
activeSessionCount,
deleteLoading,
}: DeleteRepositoryDialogProps) {
const { t } = useTranslation();
const hasActiveSessions = activeSessionCount > 0;
const isOne = activeSessionCount === 1;
const sessionWord = isOne ? "session" : "sessions";
const pronoun = isOne ? "it" : "them";
// The noun and the pronoun used to be inflected here with a ternary, which put
// the plural rule at the call site — untranslatable in a language with more
// than two forms. Both now fold into the `_one` / `_other` catalog entries.
const description = hasActiveSessions
? `This repository is used by ${activeSessionCount} active agent ${sessionWord}. Stop or finish ${pronoun} before deleting the repository.`
: "This will remove the repository and its scripts. This action cannot be undone.";
? t("workspaces:deleteRepositoryActiveSessions", { count: activeSessionCount })
: t("workspaces:deleteRepositoryConfirm");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete repository</DialogTitle>
<DialogTitle>{t("workspaces:deleteRepositoryTitle")}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -46,7 +48,7 @@ export function DeleteRepositoryDialog({
className="cursor-pointer"
onClick={() => onOpenChange(false)}
>
{hasActiveSessions ? "Close" : "Cancel"}
{hasActiveSessions ? t("workspaces:close") : t("common:cancel")}
</Button>
{!hasActiveSessions && (
<Button
@@ -56,7 +58,7 @@ export function DeleteRepositoryDialog({
onClick={onDelete}
disabled={deleteLoading}
>
Delete Repository
{t("workspaces:deleteRepository")}
</Button>
)}
</DialogFooter>
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconAlertCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { RequestIndicator } from "@/components/request-indicator";
@@ -16,7 +17,8 @@ type UnsavedSaveButtonProps = {
const dirtyButtonClass = "border-success/60 text-success hover:bg-success/10";
export function UnsavedChangesBadge() {
return <span className="text-xs text-success">Unsaved changes</span>;
const { t } = useTranslation();
return <span className="text-xs text-success">{t("common:unsavedChanges")}</span>;
}
export function UnsavedSaveButton({
@@ -25,8 +27,12 @@ export function UnsavedSaveButton({
status,
onClick,
disabled,
cleanLabel = "Save",
cleanLabel,
}: UnsavedSaveButtonProps) {
const { t } = useTranslation();
// Defaulted here rather than in the parameter list: `t()` must resolve at
// render, and a default expression in the signature runs before the hook.
const resolvedCleanLabel = cleanLabel ?? t("common:save");
return (
<Button
type="button"
@@ -37,7 +43,7 @@ export function UnsavedSaveButton({
className={isDirty ? dirtyButtonClass : "cursor-pointer"}
>
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
{isDirty ? "Save" : cleanLabel}
{isDirty ? t("common:save") : resolvedCleanLabel}
{status !== "idle" && (
<span className="ml-2">
<RequestIndicator status={status} />
+80
View File
@@ -853,4 +853,84 @@ export const i18nGuardFiles = [
// and had its `group` converted here, but the rest of that file belongs to the
// components/task migration and is not listed.
"components/global-commands.tsx",
// Settings → Workspace: the workspace list and its inline create form, the
// workspace edit page (settings card, links card and delete dialog), the
// repositories page with its discover dialog, and the repository card with the
// branch-template help, copy-files help, custom-scripts editor and delete
// dialog it renders. Copy lives in a new `workspaces` namespace.
//
// This is a FILE list in `app/settings/workspace/`, not `**`, because the
// sibling `workspace-workflows-client.tsx` / `workspace-workflows-dialogs.tsx`
// and `use-workflow-creation.ts` belong to the workflow editor migrated in
// #2201 and are already listed above, while `[id]/automations/**` is a
// separate task.
//
// `components/settings/unsaved-indicator.tsx` is included: it is reached from
// nowhere else in the app today, so its "Unsaved changes" badge and its Save
// button were the only plain English left inside a repository card. Both
// strings went to `common` rather than `workspaces`, since the component is a
// generic settings primitive the remaining migrations will reuse.
//
// `components/settings/editable-card.tsx`, `settings-card.tsx` and
// `settings-section.tsx` are all in the closure and all already correct —
// the first two are pure render-prop/prop-forwarding shells and the third
// takes title/description as props. `editable-card.tsx` is already listed
// above from the Editors migration; the other two carry no literals at all.
//
// Two `.ts` files in this directory are deliberately ABSENT rather than
// listed. `workspace-repositories-validation.ts` is a single boolean predicate
// over the API response (`exists && is_git`) and
// `workspace-repositories-dirty.ts` is field-comparison logic — neither holds
// a string of any kind. Their user-facing validation MESSAGES live in
// `workspace-repositories-client.tsx`'s `useDiscoverDialog`, which is listed
// and migrated; `mode: "jsx-only"` could not have seen them there either,
// because they are `setManualValidation` arguments rather than JSX.
//
// Deliberately left in English, because each is PERSISTED rather than
// rendered: the seeded `New Repository` name in `buildDraftRepo`, and the
// `New Script` name and `echo ""` command the save path writes for a script
// left blank. Translating any of them would store a localized string in the
// database and render it later on surfaces this PR does not own — the same
// call as the `New Step` / `DEFAULT_CUSTOM_STEPS` names in #2201.
//
// Also English by design, each a VALUE the user types or a parser consumes,
// and each interpolated so the pseudo-locale cannot turn it into a dead
// pointer:
// - The six branch-template placeholders `{title}` / `{title_full}` /
// `{ticket}` / `{issue_key}` / `{task_id}` / `{suffix}`. `RenderTaskBranchName`
// in `apps/backend/internal/worktree/config.go` substitutes each by exact
// string match, so a translated token would land in the branch name
// verbatim. Only the description beside each one is copy. Same for the
// `feature/{ticket}-{title}` example and the default template
// `feature/{title}-{suffix}`.
// - Every copy-files glob: `.env`, `*`, `?`, `[abc]`, `**`, `**/.env`,
// `{a,b}`, `.env{,.local}`, and the `:symlink` / `::symlink` suffixes.
// The explanation around them is copy; the patterns are what the backend
// matches on.
// - `$PORT`, the environment variable a dev script reads.
// - Every script placeholder (`#!/bin/bash` plus a command). Shell samples
// the executor runs verbatim, the same call as `DEFAULT_DOCKERFILE` in the
// executor editor.
// - The `/absolute/path/to/repository`, `/path/to/repository` and `my-repo`
// placeholders — value shapes, not prose.
//
// Repository names, paths, `owner/name` slugs, branch names and workspace
// names are user data throughout and are never built into a message by
// concatenation. The workspace delete dialog compares the typed text with the
// workspace's own name, so there is no translatable type-to-confirm token
// here to break; the name travels into the sentence as an interpolated value.
"app/settings/workspace/page.tsx",
"app/settings/workspace/[id]/page.tsx",
"app/settings/workspace/[id]/repositories/page.tsx",
"app/settings/workspace/workspace-edit-client.tsx",
"app/settings/workspace/workspace-not-found-card.tsx",
"app/settings/workspace/workspace-repositories-client.tsx",
"app/settings/workspace/workspace-repositories-dialog.tsx",
"app/settings/workspace/workspaces-page-client.tsx",
"components/settings/repository-branch-template-help.tsx",
"components/settings/repository-card.tsx",
"components/settings/repository-copy-files-help.tsx",
"components/settings/repository-custom-scripts.tsx",
"components/settings/repository-delete-dialog.tsx",
"components/settings/unsaved-indicator.tsx",
];
+2 -1
View File
@@ -25,7 +25,8 @@
"licenses:gen": "tsx scripts/generate-licenses.ts",
"i18n:pseudo": "node scripts/generate-pseudo-locale.mjs",
"i18n:check": "node scripts/check-i18n-keys.mjs && node scripts/check-trans-indices.mjs && node scripts/check-inline-plurals.mjs && node scripts/check-module-scope-t.mjs",
"i18n:ratchet": "node scripts/run-i18n-ratchet.mjs"
"i18n:ratchet": "node scripts/run-i18n-ratchet.mjs",
"i18n:removed": "node scripts/check-removed-literals.mjs"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Migration fidelity: every string a change REMOVES from source must reappear
* verbatim in a catalog.
*
* A directory migration replaces literals with `t()` calls. If the English a
* user saw is gone from the source and is not a catalog value either, then the
* copy silently CHANGED — and nothing else in the toolchain notices:
*
* live surface : "Create a diagnostic ZIP with frontend and backend logs."
* reused key : "Download a bounded diagnostic ZIP containing frontend and backend logs."
*
* That shape survives `pnpm lint`, all four `i18n:check` gates, the ratchet and
* the whole unit suite, because every string IS externalized, the key DOES
* exist and the catalogs ARE in sync. It happens when two surfaces render the
* same copy, one of them was migrated earlier (often a dead route), and the
* later migration adopts the existing, plausible-looking key. The pseudo-locale
* cannot see it either: the text is translated, just into different words.
*
* This check compares the literals present before and after, so it reports at
* the point of the edit and covers every route in the change automatically,
* rather than relying on whichever one happens to have an e2e assertion
* pinning its sentence.
*
* NOT part of `i18n:check`, and deliberately not in CI: it is diff-based, so it
* needs a base ref, and a finding is a prompt to CLASSIFY rather than proof of
* a bug. Benign findings are normal and expected — a value extracted into an
* interpolation, a placeholder hoisted to a constant, a literal that was never
* copy. It narrows the diff for a human; it does not decide.
*
* COVERAGE IS PARTIAL. Rule 2 is only as tight as the literal text around a
* message's placeholders, which is why a message needs two words of prose to
* earn one at all (see `canAnchorPattern`). Simulating a rewrite of every
* removed string measured 89/96 caught on this migration. The remainder are
* messages too thin to distinguish a rewrite from anything else. A clean run is
* evidence, not proof — which is why the mutation probe in docs/i18n.md is a
* separate step.
*
* Usage: node scripts/check-removed-literals.mjs [--base <ref>] [--all]
*
* --all also report literals that look like identifiers/values, not just
* the ones that look like prose.
*/
import fs from "node:fs";
import path from "node:path";
import { git, resolveBase, REPO_ROOT } from "./lib/git-base.mjs";
import { changedFiles, WEB_PREFIX } from "./lib/changed-files.mjs";
import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./lib/removed-literals.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
const LOCALES = path.join(ROOT, "src", "locales", "en");
const REPORT_ALL = process.argv.includes("--all");
function catalogValues() {
const out = new Set();
for (const file of fs.readdirSync(LOCALES).filter((f) => f.endsWith(".json"))) {
for (const value of Object.values(
JSON.parse(fs.readFileSync(path.join(LOCALES, file), "utf8")),
))
if (typeof value === "string") out.add(value);
}
return out;
}
/** The committed content of a path at `ref`, or "" if absent there. */
function committedSide(ref, repoPath) {
try {
return git(["show", `${ref}:${repoPath}`], { quiet: true });
} catch {
return ""; // Absent on that side (added or deleted).
}
}
/**
* The CURRENT content of a path — working tree, not `HEAD`.
*
* This side must not be `git show HEAD:…`. `changedFiles` diffs the base against
* the working tree, so it lists files you have edited but not committed; reading
* the "after" side from HEAD compared those files against themselves and found
* nothing. The result was a no-op that printed a tick WITH THE CORRECT FILE
* COUNT, which is what made it convincing — and the natural workflow (finish,
* run checks, commit) walks straight into it.
*
* A missing file is a deletion, so its strings all count as removed.
*/
function workingTreeSide(repoPath) {
try {
return fs.readFileSync(path.join(REPO_ROOT, repoPath), "utf8");
} catch {
return "";
}
}
const resolved = resolveBase();
if (resolved.skip) {
console.log(`↷ skipped removed-literal check — ${resolved.skip}`);
process.exit(0);
}
const { base } = resolved;
const values = catalogValues();
const catalog = buildCatalog(values);
const files = [
...new Set([...changedFiles(base, "M", REPO_ROOT), ...changedFiles(base, "D", REPO_ROOT)]),
];
const findings = [];
let unparseable = 0;
for (const repoPath of files) {
const before = literals(committedSide(base, repoPath), repoPath);
const after = literals(workingTreeSide(repoPath), repoPath) ?? new Set();
if (before === null) {
unparseable += 1;
continue;
}
for (const value of before) {
if (after.has(value)) continue;
if (!REPORT_ALL && !looksLikeCopy(value)) continue;
if (accountedFor(value, catalog)) continue;
findings.push({ file: repoPath.slice(WEB_PREFIX.length), value });
}
}
if (unparseable) console.log(`ℹ ${unparseable} file(s) could not be parsed on the base side.`);
// A tick over nothing is the shape of every silent no-op this check has had.
// Say plainly that there was nothing to compare rather than reporting a verdict.
if (files.length === 0) {
console.log(
`↷ no changed .ts/.tsx under ${WEB_PREFIX} between ${base.slice(0, 9)} and the working tree — nothing to compare.`,
);
process.exit(0);
}
if (findings.length === 0) {
console.log(
`✓ removed literals OK — every string ${files.length} changed file(s) dropped is still a catalog value.`,
);
process.exit(0);
}
console.log(
`\nStrings removed from source that do NOT reappear verbatim in a catalog (${findings.length}):\n`,
);
for (const { file, value } of findings.sort(
(a, b) => a.file.localeCompare(b.file) || a.value.localeCompare(b.value),
))
console.log(` ${file}\n ${JSON.stringify(value)}\n`);
console.log(
"Each is one of:\n" +
" - a VALUE extracted into an interpolation (reconstruct it and compare — it should be byte-identical)\n" +
" - a literal hoisted to a constant, or one that was never copy\n" +
" - COPY THAT CHANGED, because a reused key's English differs from what this surface rendered\n" +
"\nThe last one is the bug this check exists for. Classify every entry before merging.\n",
);
process.exit(1);
+180
View File
@@ -0,0 +1,180 @@
/**
* Pure helpers for `check-removed-literals.mjs`: extracting every user-visible
* string from a source file, and deciding whether a string a change removed is
* still accounted for by the catalog.
*
* Split out so the matching rules can be unit-tested against the failure they
* exist to catch, without needing a git checkout or a real diff.
*/
import { parseSync } from "@babel/core";
export function walk(node, visit) {
if (!node || typeof node.type !== "string") return;
visit(node);
for (const key of Object.keys(node)) {
if (key === "loc" || key.endsWith("Comments")) continue;
const child = node[key];
if (Array.isArray(child)) {
for (const c of child) if (c && typeof c.type === "string") walk(c, visit);
} else if (child && typeof child.type === "string") walk(child, visit);
}
}
/**
* Every user-visible string shape in one file.
*
* JSX TEXT is the load-bearing case and the one a regex over quoted strings
* misses entirely: `<Link>Back to Workspaces</Link>` carries no quotes, and it
* is the majority of migrated copy. Template quasis matter because the guard
* validates them too.
*/
export function literals(source, filename) {
let ast;
try {
ast = parseSync(source, {
filename,
babelrc: false,
configFile: false,
sourceType: "module",
parserOpts: { plugins: ["typescript", "jsx"] },
});
} catch {
return null; // Unparseable side of the diff — reported by the caller.
}
const found = new Set();
walk(ast, (node) => {
if (node.type === "StringLiteral") found.add(node.value);
else if (node.type === "JSXText") found.add(node.value);
else if (node.type === "TemplateLiteral")
for (const q of node.quasis) found.add(q.value.cooked ?? q.value.raw);
});
return new Set([...found].map((s) => s.replace(/\s+/g, " ").trim()).filter(Boolean));
}
/**
* A message and its source prose compare equal once the markup a migration adds
* is removed: `<Trans>` tag indices, `{{interpolations}}`, and the whitespace
* JSX collapses. Without this the check is pure noise — every `<Trans>` and
* every value extraction reports.
*/
export function normalize(value) {
return value
.replace(/<\/?\d+>/g, "")
.replace(/\{\{[^}]*\}\}/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* A message as a pattern, with each `{{value}}` free to be anything.
*
* This is what accounts for a VALUE EXTRACTION — the commonest benign finding.
* `"Files over 5 MiB are skipped…"` in the source becomes
* `"Files over {{limit}} are skipped…"` in the catalog, which is the same
* sentence with the units pulled out, not a rewrite.
*/
export function messagePattern(value) {
const escaped = value
.replace(/<\/?\d+>/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\\{\\\{[^}]*\\\}\\\}/g, "[\\s\\S]+?");
return new RegExp(`^${escaped}$`);
}
/**
* Is this removed string still accounted for by some catalog message?
*
* Three ways, in increasing looseness:
* 1. it IS a message;
* 2. it is an INSTANCE of one, with interpolated values filled in;
* 3. it is a FRAGMENT of one — a `<Trans>` splits its sentence into a JSX text
* node per element boundary, so the halves either side of a `<code>` arrive
* here separately and can never match a whole message.
*
* (3) trades a little sensitivity for usability: a fragment could in principle
* be a coincidental substring of an unrelated message. That is a far better
* trade than the alternative, because a check nobody runs catches nothing — and
* the failure this exists for is not subtle. A rewritten sentence is not a
* substring of the sentence that replaced it.
*/
export function accountedFor(value, { exact, normalized, patterns }) {
if (exact.has(value)) return true;
const norm = normalize(value);
if (normalized.has(norm)) return true;
if (patterns.some((re) => re.test(norm) || re.test(value))) return true;
return [...normalized].some((message) => message.length >= norm.length && message.includes(norm));
}
/** Prose, as opposed to a classname, enum token, path or identifier. */
export function looksLikeCopy(value) {
if (value.length < 4) return false;
if (!/[A-Za-z]{3}/.test(value)) return false;
if (!/[A-Z]/.test(value) && !/[a-z]+ [a-z]+/.test(value)) return false;
if (/^[/~.]/.test(value)) return false; // paths
if (/^(https?|file|ssh|git):\/\//.test(value)) return false; // URLs
if (/^[a-z][a-zA-Z0-9]*$/.test(value)) return false; // camelCase identifier
if (/^[a-z0-9]+([-_][a-z0-9]+)+$/.test(value)) return false; // kebab/snake token
// A `namespace:key` catalog reference. Swapping one `t()` key for another
// registers as a removed literal, because the key is itself a StringLiteral —
// so every migration that re-points a call site reports its own old key. It is
// an identifier, never copy, and leaving it in reads alarmingly to someone
// expecting a clean run.
if (/^[a-z][a-zA-Z0-9]*:[a-zA-Z0-9_]+$/.test(value)) return false;
return true;
}
/**
* Can this message anchor a pattern, or would its pattern match anything?
*
* A message that is NOTHING BUT a placeholder — `settings:externalMcpConfigPath`
* is literally `"{{path}}"` — compiles to `/^[\s\S]+?$/`, which matches every
* string in the repository. Because rule 2 runs before rule 3, ONE such key
* makes `accountedFor` return true for everything and the whole check becomes a
* silent no-op that still prints ✓. That is strictly worse than not running it,
* because it converts "I verified" into "I ran something".
*
* The requirement is TWO WORDS of prose, not merely a non-empty remainder and
* not merely one word. The empty case is the extreme of a continuum, not a
* distinct bug — 44 interpolating messages have a residue of six characters or
* fewer — and one short word clears any "is there a word?" bar while leaving the
* pattern almost unbounded:
*
* "{{a}} —" -> /^[\s\S]+? —$/ matches anything ending " —"
* "{{count}}m" -> /^[\s\S]+?m$/ matches "Confirm", "Custom", "System"
* "Delete {{name}}" -> /^Delete [\s\S]+?$/ matches EVERY "Delete …" sentence
*
* Measured on two migrations, requiring two words dropped 340 pattern-bearing
* messages to 292, added ZERO findings to either diff, and made ten more
* rewrites detectable on one of them. It costs nothing real and closes the
* largest remaining hole.
*
* The honest limit of this rule: `"{{count}} of {{total}}"` (a legitimate value
* extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything)
* both reduce to the residue `"of"` and compile to the SAME regex, so nothing
* computed from the message can accept one and reject the other. Both are
* rejected here, which means a string like `"3 of 9"` is REPORTED and classified
* by a human. That is the correct direction for an advisory check: a false
* positive costs someone ten seconds, a false negative is the failure this
* exists to prevent.
*
* A rejected message is still matched exactly and as a fragment — it only loses
* the wildcard, which is all it could honestly support.
*/
function canAnchorPattern(message) {
const words = normalize(message)
.split(/\s+/)
.filter((word) => /[A-Za-z]{2,}/.test(word));
return words.length >= 2;
}
/** Build the three lookup shapes `accountedFor` needs from a set of messages. */
export function buildCatalog(values) {
const set = values instanceof Set ? values : new Set(values);
return {
exact: set,
normalized: new Set([...set].map(normalize)),
patterns: [...set].filter((v) => v.includes("{{") && canAnchorPattern(v)).map(messagePattern),
};
}
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./removed-literals.mjs";
import { isCandidate } from "./changed-files.mjs";
/**
* The failure this check exists for, reproduced exactly.
*
* A live route rendered one sentence; a dead route migrated earlier had left a
* plausible-looking key holding a DIFFERENT sentence. Pointing the live route at
* that key silently rewrote user-visible English, and lint, all four
* `i18n:check` gates, the ratchet and 8,144 unit tests were clean — every string
* was externalized, the key existed, the catalogs were in sync.
*/
const LIVE_COPY = "Create a diagnostic ZIP with frontend and backend logs.";
const REUSED_KEY_COPY = "Download a bounded diagnostic ZIP containing frontend and backend logs.";
describe("accountedFor", () => {
it("reports a removed sentence whose replacement key holds different English", () => {
const catalog = buildCatalog([REUSED_KEY_COPY]);
expect(accountedFor(LIVE_COPY, catalog)).toBe(false);
});
it("accepts a string that became a catalog message unchanged", () => {
expect(accountedFor(LIVE_COPY, buildCatalog([LIVE_COPY]))).toBe(true);
});
it("accepts a value extracted into an interpolation", () => {
const catalog = buildCatalog(["Files over {{limit}} are skipped when copying to remote."]);
expect(accountedFor("Files over 5 MiB are skipped when copying to remote.", catalog)).toBe(
true,
);
});
it("accepts either half of a <Trans> sentence split around an element", () => {
const catalog = buildCatalog([
"Type the workspace name <0>{{name}}</0> to confirm deletion. This action cannot be undone.",
]);
expect(accountedFor("Type the workspace name", catalog)).toBe(true);
expect(accountedFor("to confirm deletion. This action cannot be undone.", catalog)).toBe(true);
});
it("does not accept a rewrite merely because it shares words with the message", () => {
const catalog = buildCatalog(["Delete the repository and every script attached to it."]);
expect(accountedFor("Delete the repository and its scripts.", catalog)).toBe(false);
});
it("accepts a plural pair rendered from the singular source sentence", () => {
const catalog = buildCatalog(["{{count}} custom script", "{{count}} custom scripts"]);
expect(accountedFor("2 custom scripts", catalog)).toBe(true);
});
});
describe("literals", () => {
it("sees JSX text, which carries no quotes and is most migrated copy", () => {
const found: Set<string> | null = literals(
`export const A = () => <Link>Back to Workspaces</Link>;`,
"a.tsx",
);
expect(found?.has("Back to Workspaces")).toBe(true);
});
it("sees string literals in attributes and template quasis", () => {
const found: Set<string> | null = literals(
'export const A = () => <input placeholder="Filter repositories..." aria-label={`Select ${x}`} />;',
"a.tsx",
);
expect(found?.has("Filter repositories...")).toBe(true);
expect(found?.has("Select")).toBe(true);
});
it("returns null for an unparseable file rather than reporting it as empty", () => {
expect(literals("export const = ??", "a.tsx")).toBeNull();
});
});
describe("looksLikeCopy", () => {
it.each([
["Delete Repository", true],
["Manage your workspaces and workflows", true],
["/absolute/path/to/repository", false],
["https://example.com/docs", false],
["worktree_branch_template", false],
["repositoryName", false],
["move_to_next", false],
["px-1 py-0.5", false],
// A catalog reference. Re-pointing a t() call reports the OLD key as a
// removed literal, because the key is itself a StringLiteral.
["workspaces:noScriptsYet", false],
["common:requestFailed", false],
// Guard: prose containing a colon is still copy.
["Supported patterns:", true],
["Note: this cannot be undone", true],
])("classifies %j as copy=%s", (value, expected) => {
expect(looksLikeCopy(value)).toBe(expected);
});
});
/**
* Two false positives were reported from a large migration and traced to an
* earlier regex PROTOTYPE of this check rather than to this module. Pinned here
* because the reports were circulated as bugs, and the obvious "fix" for either
* would make the check worse: scanning catalogs would report keys as copy, and
* widening `{{…}}` further would weaken the rewrite detection that is the whole
* point.
*/
describe("reported false positives that this module does not have", () => {
it("matches a literal whose leading number moved into an interpolation", () => {
// Reported as "permanently unmatchable". Rule 2 turns {{…}} into a wildcard,
// so a placeholder at the START of the message matches just as well as an
// interior one — provided the message has two words to anchor on.
expect(accountedFor("3 of 9 selected", buildCatalog(["{{count}} of {{total}} selected"]))).toBe(
true,
);
expect(accountedFor("50 items remaining", buildCatalog(["{{count}} items remaining"]))).toBe(
true,
);
});
it("still reports a rewrite that only differs where a placeholder is not", () => {
// The guard on the rule above: a wildcard must not swallow changed prose.
expect(accountedFor("50 max", buildCatalog(["{{count}} maximum"]))).toBe(false);
});
it("never treats a catalog as a source of candidate literals", () => {
// Reported as "a whole-file JSON reformat floods the output with catalog
// KEYS". Catalogs are what the check compares *against*; `isCandidate`
// admits only .ts/.tsx, so reformatting one cannot produce a single
// candidate. Scanning them would report keys as removed copy.
expect(isCandidate("apps/web/src/locales/en/common.json")).toBe(false);
expect(isCandidate("apps/web/src/locales/pseudo/workspaces.json")).toBe(false);
expect(isCandidate("apps/web/components/settings/repository-card.tsx")).toBe(true);
});
});
/**
* The assertions the rest of this file structurally cannot make: they all assume
* a well-formed catalog, so none of them can tell a working check from one that
* accepts everything. A check that silently passes is worse than no check, so
* these prove it can still FAIL.
*/
describe("a degenerate catalog message cannot disable the check", () => {
it("does not let a message that is only a placeholder match everything", () => {
// `settings:externalMcpConfigPath` was literally "{{path}}", which compiled
// to /^[\s\S]+?$/ and made accountedFor return true for every string in the
// repo — the whole check became a no-op that still printed a tick.
expect(accountedFor("anything at all", buildCatalog(["{{x}}"]))).toBe(false);
expect(accountedFor("Delete a workflow and all its steps.", buildCatalog(["{{path}}"]))).toBe(
false,
);
});
it("does not let a message with no word in it anchor a pattern", () => {
// "{{a}} —" normalizes to "—" and would compile to /^[\s\S]+? —$/.
expect(accountedFor("totally unrelated prose —", buildCatalog(["{{a}} —"]))).toBe(false);
});
it("still matches such a message exactly and as a fragment", () => {
// Dropping its PATTERN must not drop the message from the catalog.
expect(accountedFor("{{path}}", buildCatalog(["{{path}}"]))).toBe(true);
});
it("reports a value extraction whose message is too thin to anchor", () => {
// "{{count}} of {{total}}" reduces to the residue "of" — BYTE-IDENTICAL to
// "{{a}} of {{b}}", which would match "Anything at all of anything else".
// Nothing computed from the message can accept one and reject the other, so
// both lose their pattern and "3 of 9" is reported for a human to classify.
// A false positive costs ten seconds; a false negative is the failure this
// check exists to prevent.
expect(accountedFor("3 of 9", buildCatalog(["{{count}} of {{total}}"]))).toBe(false);
});
it("does not let one short word anchor a nearly unbounded pattern", () => {
// "Delete {{name}}" compiled to /^Delete [\s\S]+?$/ and accepted every
// "Delete …" sentence — 19 of 43 strings in the file this check was first
// pointed at were unprotected by exactly this shape.
expect(
accountedFor("Delete a workflow and all its steps.", buildCatalog(["Delete {{name}}"])),
).toBe(false);
expect(accountedFor("Confirm", buildCatalog(["{{count}}m"]))).toBe(false);
});
it("keeps anchoring once a message has two words of prose", () => {
expect(
accountedFor(
"Files over 5 MiB are skipped",
buildCatalog(["Files over {{limit}} are skipped"]),
),
).toBe(true);
expect(
accountedFor("totally unrelated prose", buildCatalog(["Files over {{limit}} are skipped"])),
).toBe(false);
});
});
+2
View File
@@ -65,12 +65,14 @@
"resetWatchTitle": "Reset {{label}}?",
"resetting": "Resetting…",
"retryPreview": "Retry preview",
"save": "Save",
"settings": "Settings",
"shell": "Shell",
"status": "Status",
"subtaskTitle": "Subtask title",
"system": "System",
"title": "Title",
"unsavedChanges": "Unsaved changes",
"workspace": "Workspace",
"workspaces": "Workspaces"
}
+106
View File
@@ -0,0 +1,106 @@
{
"addLocalRepository": "Add Local Repository",
"addScript": "Add Script",
"addWorkspace": "Add Workspace",
"alwaysPullBeforeWorktree": "Always pull before creating a new worktree",
"backToWorkspaces": "Back to Workspaces",
"branchTemplateIssueKey": "Alias for ticket. Use whichever name reads better in your template.",
"branchTemplateLiteralPrefix": "Write literal prefixes directly, for example <0>{{example}}</0>.",
"branchTemplatePlaceholders": "Branch template placeholders",
"branchTemplateSuffix": "Short random suffix. Optional, but recommended to avoid branch name clashes. Example: {{example}}.",
"branchTemplateTaskId": "Kandev task UUID, sanitized for branch names. Example: {{example}}.",
"branchTemplateTicket": "Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: {{example}}.",
"branchTemplateTitle": "Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: {{example}}.",
"branchTemplateTitleFull": "Same sanitizing as title, but max 80 chars. Example: {{example}}.",
"buildScriptChip": "build script",
"checking": "Checking...",
"cleanupScript": "Cleanup Script",
"cleanupScriptChip": "cleanup script",
"cleanupScriptHelp": "Runs when the task is completed to clean up the workspace.",
"close": "Close",
"closeRepositoryEditor": "Close repository editor",
"confirmDelete": "Confirm Delete",
"copyFiles": "Copy Files",
"copyFilesHelp": "Gitignored paths copied into new worktrees. Append <0>{{symlink}}</0> to an entry to link it back to the main repo. Use <1>{{escapedSymlink}}</1> for a literal filename ending in <2>{{symlink}}</2>.",
"copyFilesPathsResolved": "Paths are resolved relative to the repository root and seeded into every new worktree, preserving their relative location. Existing files in the worktree are not overwritten.",
"copyFilesPatternBraces": "<0>{{braces}}</0> brace alternation, e.g. <1>{{example}}</1>",
"copyFilesPatternGlobstar": "<0>{{globstar}}</0> matches any number of directories, e.g. <1>{{example}}</1>",
"copyFilesPatternLiteral": "<0>{{pattern}}</0> literal file or directory (directories copy recursively)",
"copyFilesPatternWildcards": "<0>{{star}}</0>, <1>{{question}}</1>, <2>{{charClass}}</2> single-segment wildcards",
"copyFilesRemoteFallback": "Remote executors copy file contents instead of creating symlinks.",
"copyFilesSizeLimit": "Files over {{limit}} are skipped when copying to remote executors. Local worktrees copy them without a size cap.",
"customScriptsLabel": "Custom Scripts",
"customScripts_one": "{{count}} custom script",
"customScripts_other": "{{count}} custom scripts",
"defaultAgentProfile": "Default Agent Profile",
"defaultExecutor": "Default Executor",
"delete": "Delete",
"deleteRepository": "Delete Repository",
"deleteRepositoryConfirm": "This will remove the repository and its scripts. This action cannot be undone.",
"deleteRepositoryTitle": "Delete repository",
"deleteRepositoryActiveSessions_one": "This repository is used by {{count}} active agent session. Stop or finish it before deleting the repository.",
"deleteRepositoryActiveSessions_other": "This repository is used by {{count}} active agent sessions. Stop or finish them before deleting the repository.",
"deleteThisWorkspace": "Delete this workspace",
"deleteWorkspace": "Delete Workspace",
"devScript": "Dev Script",
"devScriptChip": "dev script",
"devScriptHelp": "Used to start the preview dev server for this repository. Use <0>{{port}}</0> for automatic port allocation.",
"discoverRepositoryDescription": "Select a discovered repository or provide an absolute path to validate.",
"discoveredRepositories": "Discovered repositories",
"edit": "Edit",
"failedToCheckRepositorySessions": "Failed to check repository sessions",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteRepository": "Failed to delete repository",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToDiscoverRepositories": "Failed to discover repositories",
"failedToSaveRepository": "Failed to save repository",
"failedToSaveWorkspace": "Failed to save workspace",
"filterRepositories": "Filter repositories...",
"invalidRepositoryPath": "Invalid repository path",
"localPath": "Local Path",
"localPathNotSet": "Local path not set",
"manageRepositoriesConnected": "Manage repositories connected to this workspace.",
"manageWorkspaceDetails": "Manage workspace details and jump into workflows or repositories.",
"manageYourWorkspacesAndWorkflows": "Manage your workspaces and workflows",
"manualPath": "Manual path",
"name": "Name",
"noAgentProfilesAvailable": "No agent profiles available",
"noCustomScripts": "No custom scripts",
"noDefault": "No default",
"noExecutorsAvailable": "No executors available",
"noRepositoriesFound": "No repositories found.",
"noScriptsYet": "No scripts yet.",
"noWorkspacesConfigured": "No workspaces configured. Add your first workspace to get started.",
"patternSyntax": "Pattern syntax",
"remoteRepository": "Remote repository",
"repositories": "Repositories",
"repositoriesInThisWorkspace": "Repositories in this workspace",
"repository": "Repository",
"repositoryName": "Repository Name",
"scanningRepositories": "Scanning repositories...",
"scriptName": "Script name",
"selectDefaultAgentProfile": "Select default agent profile",
"selectDefaultExecutor": "Select default executor",
"setupScript": "Setup Script",
"setupScriptHelp": "Runs when the repo is cloned or a git worktree is created.",
"sourceLocal": "Local",
"sourceRemote": "Remote",
"supportedPatterns": "Supported patterns:",
"thisActionCannotBeUndone": "This action cannot be undone.",
"typeWorkspaceNameToConfirm": "Type the workspace name <0>{{name}}</0> to confirm deletion. This action cannot be undone.",
"untitledRepository": "Untitled repository",
"useRepository": "Use Repository",
"validGitRepository": "Valid git repository",
"validate": "Validate",
"workflows": "Workflows",
"workflowsManagedInThisWorkspace": "Workflows managed in this workspace",
"workspaceLinks": "Workspace Links",
"workspaceName": "Workspace Name",
"workspaceNameIsRequired": "Workspace name is required.",
"workspaceNamePlaceholder": "My Workspace",
"workspaceNotFound": "Workspace not found",
"workspaceSettings": "Workspace Settings",
"workspaceSettingsLink": "Workspace settings",
"worktreeBranchTemplate": "Worktree Branch Template",
"worktreeSync": "Worktree Sync"
}
+2
View File
@@ -65,12 +65,14 @@
"resetWatchTitle": "Ŕēśēţ {{label}}?",
"resetting": "Ŕēśēţţĩńĝ…",
"retryPreview": "Ŕēţŕŷ ƥŕēvĩēŵ",
"save": "Śàvē",
"settings": "Śēţţĩńĝś",
"shell": "Śĥēĺĺ",
"status": "Śţàţũś",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"system": "Śŷśţēḿ",
"title": "Ţĩţĺē",
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś"
}
+106
View File
@@ -0,0 +1,106 @@
{
"addLocalRepository": "Àďď Ĺōćàĺ Ŕēƥōśĩţōŕŷ",
"addScript": "Àďď Śćŕĩƥţ",
"addWorkspace": "Àďď Ŵōŕķśƥàćē",
"alwaysPullBeforeWorktree": "Àĺŵàŷś ƥũĺĺ ƀēƒōŕē ćŕēàţĩńĝ à ńēŵ ŵōŕķţŕēē",
"backToWorkspaces": "Ɓàćķ ţō Ŵōŕķśƥàćēś",
"branchTemplateIssueKey": "Àĺĩàś ƒōŕ ţĩćķēţ. Ũśē ŵĥĩćĥēvēŕ ńàḿē ŕēàďś ƀēţţēŕ ĩń ŷōũŕ ţēḿƥĺàţē.",
"branchTemplateLiteralPrefix": "Ŵŕĩţē ĺĩţēŕàĺ ƥŕēƒĩxēś ďĩŕēćţĺŷ, ƒōŕ ēxàḿƥĺē <0>{{example}}</0>.",
"branchTemplatePlaceholders": "Ɓŕàńćĥ ţēḿƥĺàţē ƥĺàćēĥōĺďēŕś",
"branchTemplateSuffix": "Śĥōŕţ ŕàńďōḿ śũƒƒĩx. Ōƥţĩōńàĺ, ƀũţ ŕēćōḿḿēńďēď ţō àvōĩď ƀŕàńćĥ ńàḿē ćĺàśĥēś. Ēxàḿƥĺē: {{example}}.",
"branchTemplateTaskId": "Ķàńďēv ţàśķ ŨŨĨĎ, śàńĩţĩźēď ƒōŕ ƀŕàńćĥ ńàḿēś. Ēxàḿƥĺē: {{example}}.",
"branchTemplateTicket": "Ţàśķ ĩďēńţĩƒĩēŕ ƒĩŕśţ; ōţĥēŕŵĩśē Ĵĩŕà, Ĺĩńēàŕ, ĜĩţĤũƀ ĩśśũē, ōŕ ĜĩţĤũƀ ƤŔ ḿēţàďàţà. Ēxàḿƥĺēś: {{example}}.",
"branchTemplateTitle": "Ţàśķ ţĩţĺē śàńĩţĩźēď ţō ĺōŵēŕćàśē ÀŚĆĨĨ, ĥŷƥĥēń-śēƥàŕàţēď, ḿàx 20 ćĥàŕś. Ēxàḿƥĺē: {{example}}.",
"branchTemplateTitleFull": "Śàḿē śàńĩţĩźĩńĝ àś ţĩţĺē, ƀũţ ḿàx 80 ćĥàŕś. Ēxàḿƥĺē: {{example}}.",
"buildScriptChip": "ƀũĩĺď śćŕĩƥţ",
"checking": "Ćĥēćķĩńĝ...",
"cleanupScript": "Ćĺēàńũƥ Śćŕĩƥţ",
"cleanupScriptChip": "ćĺēàńũƥ śćŕĩƥţ",
"cleanupScriptHelp": "Ŕũńś ŵĥēń ţĥē ţàśķ ĩś ćōḿƥĺēţēď ţō ćĺēàń ũƥ ţĥē ŵōŕķśƥàćē.",
"close": "Ćĺōśē",
"closeRepositoryEditor": "Ćĺōśē ŕēƥōśĩţōŕŷ ēďĩţōŕ",
"confirmDelete": "Ćōńƒĩŕḿ Ďēĺēţē",
"copyFiles": "Ćōƥŷ Ƒĩĺēś",
"copyFilesHelp": "Ĝĩţĩĝńōŕēď ƥàţĥś ćōƥĩēď ĩńţō ńēŵ ŵōŕķţŕēēś. Àƥƥēńď <0>{{symlink}}</0> ţō àń ēńţŕŷ ţō ĺĩńķ ĩţ ƀàćķ ţō ţĥē ḿàĩń ŕēƥō. Ũśē <1>{{escapedSymlink}}</1> ƒōŕ à ĺĩţēŕàĺ ƒĩĺēńàḿē ēńďĩńĝ ĩń <2>{{symlink}}</2>.",
"copyFilesPathsResolved": "Ƥàţĥś àŕē ŕēśōĺvēď ŕēĺàţĩvē ţō ţĥē ŕēƥōśĩţōŕŷ ŕōōţ àńď śēēďēď ĩńţō ēvēŕŷ ńēŵ ŵōŕķţŕēē, ƥŕēśēŕvĩńĝ ţĥēĩŕ ŕēĺàţĩvē ĺōćàţĩōń. Ēxĩśţĩńĝ ƒĩĺēś ĩń ţĥē ŵōŕķţŕēē àŕē ńōţ ōvēŕŵŕĩţţēń.",
"copyFilesPatternBraces": "<0>{{braces}}</0> ƀŕàćē àĺţēŕńàţĩōń, ē.ĝ. <1>{{example}}</1>",
"copyFilesPatternGlobstar": "<0>{{globstar}}</0> ḿàţćĥēś àńŷ ńũḿƀēŕ ōƒ ďĩŕēćţōŕĩēś, ē.ĝ. <1>{{example}}</1>",
"copyFilesPatternLiteral": "<0>{{pattern}}</0> ĺĩţēŕàĺ ƒĩĺē ōŕ ďĩŕēćţōŕŷ (ďĩŕēćţōŕĩēś ćōƥŷ ŕēćũŕśĩvēĺŷ)",
"copyFilesPatternWildcards": "<0>{{star}}</0>, <1>{{question}}</1>, <2>{{charClass}}</2> śĩńĝĺē-śēĝḿēńţ ŵĩĺďćàŕďś",
"copyFilesRemoteFallback": "Ŕēḿōţē ēxēćũţōŕś ćōƥŷ ƒĩĺē ćōńţēńţś ĩńśţēàď ōƒ ćŕēàţĩńĝ śŷḿĺĩńķś.",
"copyFilesSizeLimit": "Ƒĩĺēś ōvēŕ {{limit}} àŕē śķĩƥƥēď ŵĥēń ćōƥŷĩńĝ ţō ŕēḿōţē ēxēćũţōŕś. Ĺōćàĺ ŵōŕķţŕēēś ćōƥŷ ţĥēḿ ŵĩţĥōũţ à śĩźē ćàƥ.",
"customScriptsLabel": "Ćũśţōḿ Śćŕĩƥţś",
"customScripts_one": "{{count}} ćũśţōḿ śćŕĩƥţ",
"customScripts_other": "{{count}} ćũśţōḿ śćŕĩƥţś",
"defaultAgentProfile": "Ďēƒàũĺţ Àĝēńţ Ƥŕōƒĩĺē",
"defaultExecutor": "Ďēƒàũĺţ Ēxēćũţōŕ",
"delete": "Ďēĺēţē",
"deleteRepository": "Ďēĺēţē Ŕēƥōśĩţōŕŷ",
"deleteRepositoryConfirm": "Ţĥĩś ŵĩĺĺ ŕēḿōvē ţĥē ŕēƥōśĩţōŕŷ àńď ĩţś śćŕĩƥţś. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteRepositoryTitle": "Ďēĺēţē ŕēƥōśĩţōŕŷ",
"deleteRepositoryActiveSessions_one": "Ţĥĩś ŕēƥōśĩţōŕŷ ĩś ũśēď ƀŷ {{count}} àćţĩvē àĝēńţ śēśśĩōń. Śţōƥ ōŕ ƒĩńĩśĥ ĩţ ƀēƒōŕē ďēĺēţĩńĝ ţĥē ŕēƥōśĩţōŕŷ.",
"deleteRepositoryActiveSessions_other": "Ţĥĩś ŕēƥōśĩţōŕŷ ĩś ũśēď ƀŷ {{count}} àćţĩvē àĝēńţ śēśśĩōńś. Śţōƥ ōŕ ƒĩńĩśĥ ţĥēḿ ƀēƒōŕē ďēĺēţĩńĝ ţĥē ŕēƥōśĩţōŕŷ.",
"deleteThisWorkspace": "Ďēĺēţē ţĥĩś ŵōŕķśƥàćē",
"deleteWorkspace": "Ďēĺēţē Ŵōŕķśƥàćē",
"devScript": "Ďēv Śćŕĩƥţ",
"devScriptChip": "ďēv śćŕĩƥţ",
"devScriptHelp": "Ũśēď ţō śţàŕţ ţĥē ƥŕēvĩēŵ ďēv śēŕvēŕ ƒōŕ ţĥĩś ŕēƥōśĩţōŕŷ. Ũśē <0>{{port}}</0> ƒōŕ àũţōḿàţĩć ƥōŕţ àĺĺōćàţĩōń.",
"discoverRepositoryDescription": "Śēĺēćţ à ďĩśćōvēŕēď ŕēƥōśĩţōŕŷ ōŕ ƥŕōvĩďē àń àƀśōĺũţē ƥàţĥ ţō vàĺĩďàţē.",
"discoveredRepositories": "Ďĩśćōvēŕēď ŕēƥōśĩţōŕĩēś",
"edit": "Ēďĩţ",
"failedToCheckRepositorySessions": "Ƒàĩĺēď ţō ćĥēćķ ŕēƥōśĩţōŕŷ śēśśĩōńś",
"failedToCreateWorkspace": "Ƒàĩĺēď ţō ćŕēàţē ŵōŕķśƥàćē",
"failedToDeleteRepository": "Ƒàĩĺēď ţō ďēĺēţē ŕēƥōśĩţōŕŷ",
"failedToDeleteWorkspace": "Ƒàĩĺēď ţō ďēĺēţē ŵōŕķśƥàćē",
"failedToDiscoverRepositories": "Ƒàĩĺēď ţō ďĩśćōvēŕ ŕēƥōśĩţōŕĩēś",
"failedToSaveRepository": "Ƒàĩĺēď ţō śàvē ŕēƥōśĩţōŕŷ",
"failedToSaveWorkspace": "Ƒàĩĺēď ţō śàvē ŵōŕķśƥàćē",
"filterRepositories": "Ƒĩĺţēŕ ŕēƥōśĩţōŕĩēś...",
"invalidRepositoryPath": "Ĩńvàĺĩď ŕēƥōśĩţōŕŷ ƥàţĥ",
"localPath": "Ĺōćàĺ Ƥàţĥ",
"localPathNotSet": "Ĺōćàĺ ƥàţĥ ńōţ śēţ",
"manageRepositoriesConnected": "Ḿàńàĝē ŕēƥōśĩţōŕĩēś ćōńńēćţēď ţō ţĥĩś ŵōŕķśƥàćē.",
"manageWorkspaceDetails": "Ḿàńàĝē ŵōŕķśƥàćē ďēţàĩĺś àńď ĵũḿƥ ĩńţō ŵōŕķƒĺōŵś ōŕ ŕēƥōśĩţōŕĩēś.",
"manageYourWorkspacesAndWorkflows": "Ḿàńàĝē ŷōũŕ ŵōŕķśƥàćēś àńď ŵōŕķƒĺōŵś",
"manualPath": "Ḿàńũàĺ ƥàţĥ",
"name": "Ńàḿē",
"noAgentProfilesAvailable": "Ńō àĝēńţ ƥŕōƒĩĺēś àvàĩĺàƀĺē",
"noCustomScripts": "Ńō ćũśţōḿ śćŕĩƥţś",
"noDefault": "Ńō ďēƒàũĺţ",
"noExecutorsAvailable": "Ńō ēxēćũţōŕś àvàĩĺàƀĺē",
"noRepositoriesFound": "Ńō ŕēƥōśĩţōŕĩēś ƒōũńď.",
"noScriptsYet": "Ńō śćŕĩƥţś ŷēţ.",
"noWorkspacesConfigured": "Ńō ŵōŕķśƥàćēś ćōńƒĩĝũŕēď. Àďď ŷōũŕ ƒĩŕśţ ŵōŕķśƥàćē ţō ĝēţ śţàŕţēď.",
"patternSyntax": "Ƥàţţēŕń śŷńţàx",
"remoteRepository": "Ŕēḿōţē ŕēƥōśĩţōŕŷ",
"repositories": "Ŕēƥōśĩţōŕĩēś",
"repositoriesInThisWorkspace": "Ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē",
"repository": "Ŕēƥōśĩţōŕŷ",
"repositoryName": "Ŕēƥōśĩţōŕŷ Ńàḿē",
"scanningRepositories": "Śćàńńĩńĝ ŕēƥōśĩţōŕĩēś...",
"scriptName": "Śćŕĩƥţ ńàḿē",
"selectDefaultAgentProfile": "Śēĺēćţ ďēƒàũĺţ àĝēńţ ƥŕōƒĩĺē",
"selectDefaultExecutor": "Śēĺēćţ ďēƒàũĺţ ēxēćũţōŕ",
"setupScript": "Śēţũƥ Śćŕĩƥţ",
"setupScriptHelp": "Ŕũńś ŵĥēń ţĥē ŕēƥō ĩś ćĺōńēď ōŕ à ĝĩţ ŵōŕķţŕēē ĩś ćŕēàţēď.",
"sourceLocal": "Ĺōćàĺ",
"sourceRemote": "Ŕēḿōţē",
"supportedPatterns": "Śũƥƥōŕţēď ƥàţţēŕńś:",
"thisActionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"typeWorkspaceNameToConfirm": "Ţŷƥē ţĥē ŵōŕķśƥàćē ńàḿē <0>{{name}}</0> ţō ćōńƒĩŕḿ ďēĺēţĩōń. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"untitledRepository": "Ũńţĩţĺēď ŕēƥōśĩţōŕŷ",
"useRepository": "Ũśē Ŕēƥōśĩţōŕŷ",
"validGitRepository": "Vàĺĩď ĝĩţ ŕēƥōśĩţōŕŷ",
"validate": "Vàĺĩďàţē",
"workflows": "Ŵōŕķƒĺōŵś",
"workflowsManagedInThisWorkspace": "Ŵōŕķƒĺōŵś ḿàńàĝēď ĩń ţĥĩś ŵōŕķśƥàćē",
"workspaceLinks": "Ŵōŕķśƥàćē Ĺĩńķś",
"workspaceName": "Ŵōŕķśƥàćē Ńàḿē",
"workspaceNameIsRequired": "Ŵōŕķśƥàćē ńàḿē ĩś ŕēqũĩŕēď.",
"workspaceNamePlaceholder": "Ḿŷ Ŵōŕķśƥàćē",
"workspaceNotFound": "Ŵōŕķśƥàćē ńōţ ƒōũńď",
"workspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś",
"workspaceSettingsLink": "Ŵōŕķśƥàćē śēţţĩńĝś",
"worktreeBranchTemplate": "Ŵōŕķţŕēē Ɓŕàńćĥ Ţēḿƥĺàţē",
"worktreeSync": "Ŵōŕķţŕēē Śŷńć"
}
+55
View File
@@ -159,6 +159,7 @@ pnpm run i18n:check # the four repo-wide gates (runs in CI: Frontend Tests)
pnpm run i18n:pseudo # regenerate the pseudo catalog from en
pnpm run lint:i18n <path> # preview the guard on a path that is NOT yet migrated
pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI)
pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory)
# Codemods — always inspect the diff; each declines what it cannot prove safe.
node scripts/externalize-strings.mjs --write components app hooks lib
@@ -643,6 +644,60 @@ the guard: a table of every route's title and description as English, asserted
byte-for-byte. It converts "I diffed them carefully" into something that stays
true after you leave.
`pnpm run i18n:removed` is the **mechanical form of that diff**, and it needs no
foresight about which strings to pin. It compares the strings present before and
after your change and reports any that left the source without reappearing as a
catalog value — so it covers every file in the change, not the one that happened
to have a spec. On the case above it reports the live sentence immediately,
because `Create a diagnostic ZIP…` is nowhere in any catalog.
It parses with Babel rather than matching quoted strings, because **JSX text
carries no quotes** — `<Link>Back to Workspaces</Link>` is most migrated copy and
a regex cannot see it. Three acceptance rules keep it quiet on a clean
migration: a value extracted into `{{interpolation}}`, either half of a `<Trans>`
sentence split around an element, and an `_one`/`_other` pair.
**The hazard has two shapes, and only one of them is above.** The case in this
section is an *attribute* — `description="…"` on a route shell — which is
quoted, so even a crude scan finds it. The other is *element text*, which is
not quoted and is where most migrated copy lives; a reused key that rewrites a
button or a link label leaves nothing for a quoted-string search to find. Do not
read this section as "route descriptions drift". Any copy rendered from two
places can.
It is **advisory and deliberately not in CI**: it is diff-based, so it needs a
base ref (`i18n:check` is repo-wide on purpose), and a finding is a prompt to
*classify* rather than proof of a bug — each is a value extraction, a literal
hoisted to a constant, or the real thing. It narrows the diff for a human; it
does not decide. `--base <ref>` overrides the `origin/main` fork point, and
`--all` widens past prose to identifier-shaped strings.
**Its coverage is partial, and a clean run is evidence rather than proof.** Rule
2 is only as tight as the literal text around a message's placeholders, so a
message with too little of it earns no pattern at all — it needs two words of
prose. That threshold is not arbitrary. One short word clears any "is there a
word?" bar while leaving the pattern almost unbounded: `"Delete {{name}}"`
compiled to `/^Delete [\s\S]+?$/` and accepted *every* sentence beginning
"Delete ", which left 19 of 43 strings unprotected in the first file this check
was pointed at.
Measured by simulating a rewrite of every removed string, requiring two words
took detection from 79 to **89 of 96** on one migration while adding **zero**
findings to either of two real diffs. The seven that remain are messages whose
prose is genuinely too thin to distinguish a rewrite from anything else, so
treat a clean run as evidence, not proof — that is why the mutation probe above
is a separate step, and why "the check was clean" is not on its own an answer to
"did the English change?".
One limit is structural rather than a tuning choice, and is worth knowing before
anyone tries to close it: `"{{count}} of {{total}}"` (a legitimate value
extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything) both
reduce to the residue `"of"` and compile to the *same* regex. Nothing computed
from the message can accept one and reject the other. Both are rejected, so a
string like `"3 of 9"` is reported and classified by hand — the right direction
for an advisory check, since a false positive costs someone ten seconds and a
false negative is the failure the check exists to prevent.
The underlying defect is the duplication itself. Unreferenced pages that
re-render live copy will drift again, and the next migration inherits the same
trap — which is why deleting a dead twin is a correctness change, not tidiness.