feat(i18n): localize Settings → Workspace (#2212)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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 # 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 # 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 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 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} />
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -65,12 +65,14 @@
|
||||
"resetWatchTitle": "Ŕēśēţ {{label}}?",
|
||||
"resetting": "Ŕēśēţţĩńĝ…",
|
||||
"retryPreview": "Ŕēţŕŷ ƥŕēvĩēŵ",
|
||||
"save": "Śàvē",
|
||||
"settings": "Śēţţĩńĝś",
|
||||
"shell": "Śĥēĺĺ",
|
||||
"status": "Śţàţũś",
|
||||
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
|
||||
"system": "Śŷśţēḿ",
|
||||
"title": "Ţĩţĺē",
|
||||
"unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
|
||||
"workspace": "Ŵōŕķśƥàćē",
|
||||
"workspaces": "Ŵōŕķśƥàćēś"
|
||||
}
|
||||
|
||||
@@ -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": "Ŵōŕķţŕēē Śŷńć"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user