From f2510dfcb5c80be4677d30ee3eb7afdd50d7d2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 5 Aug 2026 12:53:55 +0200 Subject: [PATCH] feat(i18n): localize Settings Plugins, Account and the SSH/executors tree (#2249) Co-authored-by: Kandev Agent --- .../settings/executors/[profileId]/page.tsx | 74 +++--- .../executors/new/[type]/executor-types.ts | 46 ++-- .../settings/executors/new/[type]/page.tsx | 85 ++++--- .../executors/new/[type]/ssh-create-page.tsx | 8 +- apps/web/app/settings/executors/page.tsx | 75 ++++--- .../executors/ssh/[executorId]/page.tsx | 24 +- .../plugins/mobile-plugin-nav-section.tsx | 4 +- .../plugins/plugin-error-boundary.tsx | 10 +- .../components/plugins/plugin-modal-host.tsx | 2 + .../account/account-route-copy.test.ts | 34 +++ .../settings/account/api-tokens.tsx | 63 +++--- .../settings/account/security-settings.tsx | 41 ++-- .../components/settings/executor-copy.test.ts | 212 ++++++++++++++++++ .../settings/executor-description.ts | 23 +- .../plugins/install-plugin-dialog.tsx | 22 +- .../settings/plugins/marketplace-browser.tsx | 52 +++-- .../plugins/marketplace-entry-row.tsx | 21 +- .../plugins/marketplace-sources-dialog.tsx | 26 ++- .../settings/plugins/plugin-config-form.tsx | 33 ++- .../settings/plugins/plugin-detail.tsx | 36 +-- .../plugins/plugin-error-diagnostic.tsx | 3 +- .../settings/plugins/plugin-manifest-card.tsx | 34 ++- .../settings/plugins/plugin-repo-link.tsx | 4 +- .../settings/plugins/plugin-row.tsx | 24 +- .../settings/plugins/plugin-status-badge.tsx | 18 +- .../settings/plugins/plugins-settings.tsx | 30 +-- .../plugins/uninstall-plugin-dialog.tsx | 23 +- .../settings/plugins/use-plugin-actions.ts | 29 ++- .../plugins/use-plugin-config-form.ts | 20 +- .../settings/profile-edit/mcp-policy-card.tsx | 13 +- .../profile-dirty-fields.test.tsx | 4 +- .../settings/settings-layout-client.tsx | 2 + .../settings/ssh-agent-readiness-card.tsx | 54 +++-- .../settings/ssh-connection-card.tsx | 45 ++-- .../settings/ssh-connection-form.tsx | 29 +-- .../settings/ssh-fingerprint-trust-block.tsx | 27 ++- .../components/settings/ssh-sessions-card.tsx | 32 +-- .../use-filter-value-options.ts | 8 +- .../e2e/tests/i18n/pseudo-coverage.spec.ts | 26 +++ apps/web/eslint.i18n.options.mjs | 145 ++++++++++++ .../plugins/use-auto-update-settings.ts | 3 +- .../hooks/domains/plugins/use-marketplace.ts | 3 +- apps/web/hooks/domains/plugins/use-plugins.ts | 3 +- apps/web/lib/executor-icons.test.ts | 3 + apps/web/lib/executor-icons.ts | 29 ++- apps/web/lib/plugins/sync-summary.ts | 14 +- apps/web/src/locales/en/account.json | 42 ++++ apps/web/src/locales/en/executors.json | 87 +++++++ apps/web/src/locales/en/plugins.json | 116 ++++++++++ apps/web/src/locales/en/settings.json | 2 + apps/web/src/locales/pseudo/account.json | 42 ++++ apps/web/src/locales/pseudo/executors.json | 87 +++++++ apps/web/src/locales/pseudo/plugins.json | 116 ++++++++++ apps/web/src/locales/pseudo/settings.json | 2 + apps/web/src/settings-routes.tsx | 46 ++-- 55 files changed, 1626 insertions(+), 430 deletions(-) create mode 100644 apps/web/components/settings/account/account-route-copy.test.ts create mode 100644 apps/web/components/settings/executor-copy.test.ts create mode 100644 apps/web/src/locales/en/account.json create mode 100644 apps/web/src/locales/en/plugins.json create mode 100644 apps/web/src/locales/pseudo/account.json create mode 100644 apps/web/src/locales/pseudo/plugins.json diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index 8c4bd8a1ad..c2c912b3f8 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; import { Button } from "@kandev/ui/button"; @@ -172,6 +173,7 @@ function useGitIdentityState(isRemote: boolean, profile: ExecutorProfile) { } export default function ProfileEditPage({ profileId }: { profileId: string }) { + const { t } = useTranslation(); const router = useRouter(); const result = useProfileFromStore(profileId); @@ -179,9 +181,9 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) { return ( -

Profile not found

+

{t("executors:profileNotFound")}

@@ -194,6 +196,7 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) { } function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { + const { t } = useTranslation(); const router = useRouter(); const { toast } = useToast(); const executors = useAppStore((state) => state.executors.items); @@ -217,18 +220,22 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { try { const updated = await updateExecutorProfile(executor.id, profile.id, data); setSaveStatus("success"); - toast({ title: "Profile saved", variant: "success" }); + toast({ title: t("executors:profileSaved"), variant: "success" }); setExecutors(upsertExecutorProfile(executors, executor, updated)); window.setTimeout(() => setSaveStatus("idle"), 1500); } catch (err) { - const message = err instanceof Error ? err.message : "Failed to save profile"; + const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile"); setError(message); setSaveStatus("error"); - toast({ title: "Failed to save profile", description: message, variant: "error" }); + toast({ + title: t("executors:failedToSaveProfile"), + description: message, + variant: "error", + }); throw err; } }, - [executor, profile.id, executors, setExecutors, toast], + [executor, profile.id, executors, setExecutors, toast, t], ); const remove = useCallback( @@ -257,6 +264,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { } function useProfileFormState(executor: Executor, profile: ExecutorProfile) { + const { t } = useTranslation(); const [name, setName] = useState(profile.name); const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? ""); const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? ""); @@ -272,7 +280,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { const flags = useRemoteExecutorFlags(executor.type); const remoteAuth = useRemoteAuthState(profile); const gitIdentity = useGitIdentityState(flags.isRemote, profile); - const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); + const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); useEffect(() => { listScriptPlaceholders() @@ -289,8 +297,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { }, [envVarRows, flags.isSprites, spritesSecretId]); const prepareDesc = flags.isRemote - ? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders." - : "Runs on the host machine before the agent starts."; + ? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" }) + : t("executors:prepareScriptDescriptionLocal"); return { name, @@ -332,7 +340,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { isDocker: flags.isDocker, isSprites: flags.isSprites, isSSH: executor.type === "ssh", - mcpPolicyError, + mcpPolicyErrorKey, buildEnvVars, prepareDesc, }; @@ -478,8 +486,29 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe onUpdate={form.updateEnvVar} onRemove={form.removeEnvVar} /> + + + + ); +} + +/** The prepare/cleanup script pair, split out to keep ProfileEditSections + * under the max-lines-per-function budget. */ +function ProfileScriptCards({ + executor, + profile, + form, +}: Omit) { + const { t } = useTranslation(); + return ( + <> {form.isRemote && ( )} - ); } function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) { + const { t } = useTranslation(); const router = useRouter(); const { items: secrets } = useSecrets(); const persistence = useProfilePersistence(executor, profile); @@ -527,7 +551,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E data-testid="ssh-connection-settings-link" > - Connection Settings + {t("executors:connectionSettings")} ) : undefined; @@ -555,15 +579,15 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E setSavedRevision(submittedRevision); }; let invalidReason: string | undefined; - if (!form.name.trim()) invalidReason = "Profile name is required."; - else if (form.mcpPolicyError) invalidReason = form.mcpPolicyError; - else if (spritesTokenMissing) invalidReason = "Sprites token is required."; + if (!form.name.trim()) invalidReason = t("executors:profileNameIsRequired"); + else if (form.mcpPolicyErrorKey) invalidReason = t(form.mcpPolicyErrorKey); + else if (spritesTokenMissing) invalidReason = t("executors:spritesTokenIsRequired"); useSettingsSaveContributor({ id: `executor-profile:${profile.id}`, revision: saveRevision, isDirty: baselineReady && saveRevision !== savedRevision, canSave: - baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyError && !spritesTokenMissing, + baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyErrorKey && !spritesTokenMissing, invalidReason, save: handleSave, discard: () => undefined, @@ -596,7 +620,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E {spritesTokenMissing && ( -

Sprites API key is required.

+

{t("executors:spritesApiKeyIsRequired")}

)} {persistence.error &&

{persistence.error}

} persistence.setDeleteDialogOpen(true)} /> diff --git a/apps/web/app/settings/executors/new/[type]/executor-types.ts b/apps/web/app/settings/executors/new/[type]/executor-types.ts index 7798092356..c4d956ad49 100644 --- a/apps/web/app/settings/executors/new/[type]/executor-types.ts +++ b/apps/web/app/settings/executors/new/[type]/executor-types.ts @@ -1,42 +1,60 @@ // Registry of executor types presented in the "new executor profile" flow. // Keep entries here (not inline in the page) so the page file stays under // the 600-line lint cap and new types can be added without touching layout. +// +// The map keys (`local`, `worktree`, `local_docker`, `remote_docker`, +// `sprites`, `ssh`) are the persisted executor enum and the create route's +// path segment, and `executorId` is a backend row id — none of them is copy. +// Labels and descriptions travel as catalog keys and resolve at render; +// `brandLabel` carries a brand/protocol name that reads the same in every +// locale, so it is a value rather than a message. export type ExecutorTypeInfo = { executorId: string; - label: string; - description: string; + /** Rendered verbatim. Set on brand/protocol names only. */ + brandLabel?: string; + /** Catalog key for the label. Set when `brandLabel` is not. */ + labelKey?: string; + descriptionKey: string; }; export const EXECUTOR_TYPE_MAP: Record = { local: { executorId: "exec-local", - label: "Local", - description: "Runs agents directly in the repository folder.", + labelKey: "executors:typeLocal", + descriptionKey: "executors:descriptionLocalPc", }, worktree: { executorId: "exec-worktree", - label: "Worktree", - description: "Creates git worktrees for isolated agent sessions.", + labelKey: "executors:typeWorktree", + descriptionKey: "executors:descriptionWorktree", }, local_docker: { executorId: "exec-local-docker", - label: "Docker", - description: "Runs Docker containers on this machine.", + brandLabel: "Docker", + descriptionKey: "executors:descriptionLocalDocker", }, remote_docker: { executorId: "exec-remote-docker", - label: "Remote Docker", - description: "Connects to a remote Docker host.", + labelKey: "executors:remoteDocker", + descriptionKey: "executors:descriptionRemoteDocker", }, sprites: { executorId: "exec-sprites", - label: "Sprites.dev", - description: "Runs agents in Sprites.dev cloud sandboxes.", + brandLabel: "Sprites.dev", + descriptionKey: "executors:descriptionSprites", }, + // `CreateProfilePage` short-circuits `ssh` to `SSHCreatePage`, which renders + // its own header — this entry exists so the unknown-type guard accepts the + // route, and its label/description are never rendered. ssh: { executorId: "exec-ssh", - label: "SSH", - description: "Connects to a remote host over SSH and runs agentctl there.", + brandLabel: "SSH", + descriptionKey: "executors:descriptionSshCreate", }, }; + +/** The label for a type card: a brand name verbatim, or the translated label. */ +export function executorTypeLabel(info: ExecutorTypeInfo, t: (key: string) => string): string { + return info.brandLabel ?? (info.labelKey ? t(info.labelKey) : ""); +} diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index 26aa33b6fd..a25b4787b2 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; import { Badge } from "@kandev/ui/badge"; @@ -44,7 +45,7 @@ import { import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; import type { Executor, ExecutorType, ProfileEnvVar } from "@/lib/types/http"; -import { EXECUTOR_TYPE_MAP } from "./executor-types"; +import { EXECUTOR_TYPE_MAP, executorTypeLabel, type ExecutorTypeInfo } from "./executor-types"; import { SSHCreatePage } from "./ssh-create-page"; const EXECUTORS_ROUTE = "/settings/executors"; @@ -72,28 +73,22 @@ export default function CreateProfilePage({ executorType }: { executorType: stri } function InvalidTypeFallback() { + const { t } = useTranslation(); const router = useRouter(); return ( -

Unknown executor type

+

{t("executors:unknownExecutorType")}

); } -function CreateProfileHeader({ - type, - label, - description, -}: { - type: string; - label: string; - description: string; -}) { +function CreateProfileHeader({ type, typeInfo }: { type: string; typeInfo: ExecutorTypeInfo }) { + const { t } = useTranslation(); const router = useRouter(); return ( <> @@ -101,12 +96,14 @@ function CreateProfileHeader({
-

New {label} Profile

+

+ {t("executors:newTypeProfile", { type: executorTypeLabel(typeInfo, t) })} +

{getExecutorLabel(type)}
-

{description}

+

{t(typeInfo.descriptionKey)}

@@ -280,6 +277,7 @@ function useCreateGitIdentityState(isRemote: boolean) { } function useCreateProfileFormState(executorType: ExecutorType) { + const { t } = useTranslation(); const [name, setName] = useState(() => (executorType === "local_docker" ? "Docker" : "")); const [mcpPolicy, setMcpPolicy] = useState(""); const [prepareScript, setPrepareScript] = useState(""); @@ -293,7 +291,7 @@ function useCreateProfileFormState(executorType: ExecutorType) { const [builtDockerImage, setBuiltDockerImage] = useState(null); const flags = useCreateRemoteFlags(executorType); const gitIdentity = useCreateGitIdentityState(flags.isRemote); - const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); + const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); useEffect(() => { listScriptPlaceholders() @@ -323,8 +321,8 @@ function useCreateProfileFormState(executorType: ExecutorType) { builtDockerImage?.imageTag === imageTag.trim()); const prepareDesc = flags.isRemote - ? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders." - : "Runs on the host machine before the agent starts."; + ? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" }) + : t("executors:prepareScriptDescriptionLocal"); return { name, @@ -364,13 +362,14 @@ function useCreateProfileFormState(executorType: ExecutorType) { isRemote: flags.isRemote, isDocker: flags.isDocker, isSprites: flags.isSprites, - mcpPolicyError, + mcpPolicyErrorKey, buildEnvVars, prepareDesc, }; } function useCreateProfileSave(executorId: string) { + const { t } = useTranslation(); const router = useRouter(); const executors = useAppStore((state) => state.executors.items); const setExecutors = useAppStore((state) => state.setExecutors); @@ -390,13 +389,13 @@ function useCreateProfileSave(executorId: string) { ); runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`)); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create profile"); + setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile")); throw err; } finally { setSaving(false); } }, - [executorId, executors, setExecutors, router], + [executorId, executors, setExecutors, router, t], ); return { saving, error, handleSave }; @@ -411,6 +410,7 @@ function CreateProfileSections({ form: ReturnType; secrets: ReturnType["items"]; }) { + const { t } = useTranslation(); return ( <> @@ -471,7 +471,7 @@ function CreateProfileSections({ onRemove={form.removeEnvVar} /> {form.isRemote && ( ); } -function getCreateDisabledReason( +// Returns a catalog key (or null when the form is submittable) so the reason +// resolves at render rather than at module load. +function getCreateDisabledReasonKey( form: ReturnType, spritesTokenMissing: boolean, saving: boolean, ) { - if (saving) return "Creating profile..."; - if (!form.name.trim()) return "Enter a profile name."; - if (form.mcpPolicyError) return form.mcpPolicyError; - if (spritesTokenMissing) return "Add a Sprites API key before creating the profile."; + if (saving) return "executors:creatingProfile"; + if (!form.name.trim()) return "executors:enterAProfileName"; + if (form.mcpPolicyErrorKey) return form.mcpPolicyErrorKey; + if (spritesTokenMissing) return "executors:addASpritesApiKeyBeforeCreating"; if (form.isDocker) { - if (!form.imageTag.trim()) return "Enter an image tag before creating the profile."; - if (!form.dockerfile.trim()) return "Add Dockerfile content before creating the profile."; - if (!form.dockerImageBuilt) return "Build this Docker image before creating the profile."; + if (!form.imageTag.trim()) return "executors:enterAnImageTagBeforeCreating"; + if (!form.dockerfile.trim()) return "executors:addDockerfileContentBeforeCreating"; + if (!form.dockerImageBuilt) return "executors:buildThisDockerImageBeforeCreating"; } return null; } @@ -548,37 +550,34 @@ function CreateProfileForm({ typeInfo, }: { executorType: ExecutorType; - typeInfo: { executorId: string; label: string; description: string }; + typeInfo: ExecutorTypeInfo; }) { + const { t } = useTranslation(); const { items: secrets } = useSecrets(); const form = useCreateProfileFormState(executorType); const { saving, error, handleSave } = useCreateProfileSave(typeInfo.executorId); const spritesTokenMissing = form.isSprites && !form.spritesSecretId; - const disabledReason = getCreateDisabledReason(form, spritesTokenMissing, saving); + const disabledReasonKey = getCreateDisabledReasonKey(form, spritesTokenMissing, saving); const savePayload = buildCreateProfilePayload(form); const saveRevision = serializeSettingsRevision(savePayload); useSettingsSaveContributor({ id: `executor-profile:new:${typeInfo.executorId}`, revision: saveRevision, isDirty: true, - canSave: !disabledReason, - invalidReason: disabledReason ?? undefined, + canSave: !disabledReasonKey, + invalidReason: disabledReasonKey ? t(disabledReasonKey) : undefined, save: () => handleSave(savePayload), discard: () => undefined, }); return (
- +
{spritesTokenMissing && ( -

Sprites API key is required.

+

{t("executors:spritesApiKeyIsRequired")}

)} {error &&

{error}

}
diff --git a/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx index 9a243f3107..5848340bea 100644 --- a/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx +++ b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; @@ -80,6 +81,7 @@ export function SSHCreatePage() { } function SSHCreateHeader() { + const { t } = useTranslation(); const router = useRouter(); return ( <> @@ -87,13 +89,13 @@ function SSHCreateHeader() {
-

New SSH Executor

+

{t("executors:newSshExecutor")}

{getExecutorLabel("ssh")}

- Connect to a remote Linux amd64 or macOS host and run agentctl there. + {t("executors:sshCreateDescription")}

diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index bdec98430c..9fb5158ae7 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; @@ -44,25 +45,42 @@ function useAllProfiles(): ProfileWithExecutor[] { const DefaultIcon = EXECUTOR_ICON_MAP.local; -const EXECUTOR_TYPES = [ - { type: "local", label: "Local", description: "Run agents directly in the repository folder." }, +// `type` is the persisted executor enum and is also the create route's path +// segment — never copy. `brandLabel` is a brand/protocol name rendered +// verbatim; every other label and description travels as a catalog key and +// resolves at render. These sentences are imperative and deliberately differ +// from the third-person ones the create and edit headers show; they are not +// twins of `executors:description*` and must not be folded into them. +type ExecutorTypeCard = { + type: string; + labelKey?: string; + brandLabel?: string; + descriptionKey: string; +}; + +const EXECUTOR_TYPES: readonly ExecutorTypeCard[] = [ + { + type: "local", + labelKey: "executors:typeLocal", + descriptionKey: "executors:hubDescriptionLocal", + }, { type: "worktree", - label: "Worktree", - description: "Create git worktrees for isolated agent sessions.", + labelKey: "executors:typeWorktree", + descriptionKey: "executors:hubDescriptionWorktree", + }, + { + type: "local_docker", + brandLabel: "Docker", + descriptionKey: "executors:hubDescriptionDocker", }, - { type: "local_docker", label: "Docker", description: "Run Docker containers on this machine." }, { type: "sprites", - label: "Sprites.dev", - description: "Run agents in Sprites.dev cloud sandboxes.", + brandLabel: "Sprites.dev", + descriptionKey: "executors:hubDescriptionSprites", }, - { - type: "ssh", - label: "SSH", - description: "Connect to a remote host over SSH and run agentctl there.", - }, -] as const; + { type: "ssh", brandLabel: "SSH", descriptionKey: "executors:hubDescriptionSsh" }, +]; function ExecutorIconBadge({ type }: { type: string }) { const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon; @@ -80,6 +98,9 @@ function ProfileCard({ profile: ProfileWithExecutor; onDelete: (id: string) => void; }) { + // Subscribes so the `getExecutorLabel` badge below re-renders on a locale + // switch; the helper resolves at call time but does not itself notify React. + useTranslation(); const router = useRouter(); return ( void; }) { + const { t } = useTranslation(); return (
-

{execType.label}

-

{execType.description}

+

{execType.brandLabel ?? t(execType.labelKey as string)}

+

{t(execType.descriptionKey)}

@@ -147,18 +169,19 @@ function DeleteProfileDialog({ onDelete: () => void; deleting: boolean; }) { + const { t } = useTranslation(); return ( - Delete Profile + {t("executors:deleteProfile")} - Are you sure you want to delete "{profileName}"? This action cannot be undone. + {t("executors:deleteProfileConfirm", { name: profileName ?? "" })} @@ -175,6 +198,7 @@ function DeleteProfileDialog({ } export default function ExecutorsHubPage() { + const { t } = useTranslation(); const router = useRouter(); const allProfiles = useAllProfiles(); const executors = useAppStore((state) => state.executors.items); @@ -207,15 +231,12 @@ export default function ExecutorsHubPage() { return (
-

Executors

-

- Executor profiles define how and where agents run. Each profile configures an execution - environment with scripts, environment variables, and MCP policies. -

+

{t("common:executors")}

+

{t("executors:hubDescription")}

-

Create New Profile

+

{t("executors:createNewProfile")}

{EXECUTOR_TYPES.map((execType) => ( {allProfiles.length > 0 && (
-

Profiles

+

{t("executors:profiles")}

{allProfiles.map((profile) => ( diff --git a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx index f5702f4e59..13da95f574 100644 --- a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx +++ b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; @@ -34,27 +35,31 @@ export default function SSHExecutorPage({ executorId }: { executorId: string }) } function SSHExecutorPageContent({ executorId }: { executorId: string }) { + const { t } = useTranslation(); const { executor, loading, error, reload } = useExecutor(executorId); if (loading) { return ( - Loading executor... + {t("executors:loadingExecutor")} ); } if (error || !executor) { - return ; + return ; } if (executor.type !== "ssh") { - return ; + // The executor id is an identifier the user may need to look up, so it is + // interpolated as a value rather than written into the message. + return ; } return ; } function useExecutor(executorId: string) { + const { t } = useTranslation(); const [executor, setExecutor] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -72,14 +77,14 @@ function useExecutor(executorId: string) { } } catch (e) { if (generation === requestGeneration.current) { - setError(e instanceof Error ? e.message : "Failed to load executor"); + setError(e instanceof Error ? e.message : t("executors:failedToLoadExecutor")); } } finally { if (generation === requestGeneration.current) { setLoading(false); } } - }, [executorId]); + }, [executorId, t]); useEffect(() => { void load(); @@ -92,13 +97,14 @@ function useExecutor(executorId: string) { } function NotFoundCard({ message }: { message: string }) { + const { t } = useTranslation(); const router = useRouter(); return (

{message}

@@ -134,6 +140,7 @@ function SSHExecutorView({ } function SSHExecutorHeader({ executorName }: { executorName: string }) { + const { t } = useTranslation(); const router = useRouter(); return ( <> @@ -147,8 +154,7 @@ function SSHExecutorHeader({ executorName }: { executorName: string }) {

- Edit the connection settings or re-trust the host. Existing sessions keep their snapshot - of the previous config. + {t("executors:sshExecutorPageDescription")}

diff --git a/apps/web/components/plugins/mobile-plugin-nav-section.tsx b/apps/web/components/plugins/mobile-plugin-nav-section.tsx index 586764d857..d9ae910bb9 100644 --- a/apps/web/components/plugins/mobile-plugin-nav-section.tsx +++ b/apps/web/components/plugins/mobile-plugin-nav-section.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { DestinationRows } from "@/components/navigation/destination-rows"; import { resolveDestinations } from "@/lib/navigation/resolve-destinations"; import { NO_WORKSPACE_CONTEXT } from "@/lib/navigation/surface-policy"; @@ -19,6 +20,7 @@ type MobilePluginNavSectionProps = { * `MobileIntegrationsSection`. */ export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionProps) { + const { t } = useTranslation(); const registry = usePluginRegistry(); // Resolved directly rather than through `useStaticDestinations`: this group's // hrefs are static plugin paths, so it needs neither workspace context nor the @@ -34,7 +36,7 @@ export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionPro return (
-
Plugins
+
{t("common:plugins")}
- This plugin page failed to load. + {t("plugins:pluginPageFailedToLoad")}
); } diff --git a/apps/web/components/plugins/plugin-modal-host.tsx b/apps/web/components/plugins/plugin-modal-host.tsx index 5cf0bdb2d7..05da40ee59 100644 --- a/apps/web/components/plugins/plugin-modal-host.tsx +++ b/apps/web/components/plugins/plugin-modal-host.tsx @@ -42,6 +42,8 @@ function PluginModalInstance({ modal }: { modal: OpenPluginModal }) { onEscapeKeyDown={guardClose} onInteractOutside={guardClose} > + {/* `options.title` is supplied by the plugin author, so it is + third-party data rather than host copy. */} {options.title && ( {options.title} diff --git a/apps/web/components/settings/account/account-route-copy.test.ts b/apps/web/components/settings/account/account-route-copy.test.ts new file mode 100644 index 0000000000..65672abd9a --- /dev/null +++ b/apps/web/components/settings/account/account-route-copy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { t } from "@/lib/i18n"; + +/** + * Byte-for-byte English for the two Account route headers, as `SETTINGS_ROUTES` + * in `src/settings-routes.tsx` rendered them before this migration. They were + * the last two inline English entries in that table. + * + * Same reasoning as `components/settings/system/system-route-copy.test.ts`: + * every gate in the repo verifies that a key *resolves*, not that it resolves + * to the sentence the route used to render, so a plausible-looking key reuse + * can rewrite shipping copy with nothing failing. This table is the check. + */ +const ROUTE_COPY: Array<{ route: string; titleKey: string; title: string; description: string }> = [ + { + route: "security", + titleKey: "account:securityPageTitle", + title: "Profile & password", + description: "Change your password and review devices signed in to your account.", + }, + { + route: "tokens", + titleKey: "account:tokensPageTitle", + title: "API tokens", + description: "Personal access tokens for scripts and CLIs acting as you.", + }, +]; + +describe("account route copy", () => { + it.each(ROUTE_COPY)("renders /settings/account/$route unchanged", (entry) => { + expect(t(entry.titleKey)).toBe(entry.title); + expect(t(`account:${entry.route}PageDescription`)).toBe(entry.description); + }); +}); diff --git a/apps/web/components/settings/account/api-tokens.tsx b/apps/web/components/settings/account/api-tokens.tsx index ce0d1cd68e..08a9d8cb85 100644 --- a/apps/web/components/settings/account/api-tokens.tsx +++ b/apps/web/components/settings/account/api-tokens.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { @@ -18,8 +19,10 @@ import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react"; import { ApiError } from "@/lib/api/client"; import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { formatDateTime } from "@/lib/i18n/formats"; function useTokensList() { + const { t } = useTranslation(); const [tokens, setTokens] = useState([]); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); @@ -31,9 +34,9 @@ function useTokensList() { setTokens(res.tokens); setLoaded(true); } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to load tokens."); + setError(err instanceof ApiError ? err.message : t("account:failedToLoadTokens")); } - }, []); + }, [t]); useEffect(() => { void reload(); @@ -53,13 +56,12 @@ function MintTokenResult({ onCopy: () => void; onDone: () => void; }) { + const { t } = useTranslation(); return ( <> - Token created - - This is shown only once — copy it now and store it securely. - + {t("account:tokenCreated")} + {t("account:tokenShownOnlyOnce")}
@@ -102,18 +104,16 @@ function MintTokenForm({ onCancel: () => void; onSubmit: () => void; }) { + const { t } = useTranslation(); return ( <> - Create API token - - Grants the same access as your account to any script or CLI that uses it. Give it a name - so you can recognize it later. - + {t("account:createApiToken")} + {t("account:createApiTokenDescription")}
@@ -153,6 +153,7 @@ function MintTokenDialog({ onOpenChange: (open: boolean) => void; onCreated: () => void; }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -174,7 +175,7 @@ function MintTokenDialog({ setRawToken(res.token); onCreated(); } catch (err) { - setError(err instanceof ApiError ? err.message : "Could not create token."); + setError(err instanceof ApiError ? err.message : t("account:couldNotCreateToken")); } finally { setSubmitting(false); } @@ -216,6 +217,7 @@ function MintTokenDialog({ } export function ApiTokens() { + const { t } = useTranslation(); const { tokens, loaded, error, reload } = useTokensList(); const [mintOpen, setMintOpen] = useState(false); @@ -228,7 +230,7 @@ export function ApiTokens() { - API tokens + {t("account:apiTokens")} -

- Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if - it may have leaked. -

+

{t("account:apiTokensBlurb")}

{error && (

{error} @@ -251,28 +250,26 @@ export function ApiTokens() { )} {!loaded && !error && (

- Loading tokens... + {t("account:loadingTokens")}
)} {loaded && tokens.length > 0 && ( - Name - Created - Last used - Actions + {t("account:name")} + {t("account:created")} + {t("account:lastUsed")} + {t("account:actions")} {tokens.map((token) => ( {token.name} + {formatDateTime(token.created_at)} - {new Date(token.created_at).toLocaleString()} - - - {token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"} + {token.last_used_at ? formatDateTime(token.last_used_at) : t("account:never")} @@ -292,7 +289,7 @@ export function ApiTokens() { )} {loaded && tokens.length === 0 && !error && (

- No tokens yet. + {t("account:noTokensYet")}

)} diff --git a/apps/web/components/settings/account/security-settings.tsx b/apps/web/components/settings/account/security-settings.tsx index 555c43fb57..d01ec3f9f8 100644 --- a/apps/web/components/settings/account/security-settings.tsx +++ b/apps/web/components/settings/account/security-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; @@ -15,8 +16,10 @@ import { revokeSession, type AuthSession, } from "@/lib/api/domains/auth-api"; +import { formatDateTime } from "@/lib/i18n/formats"; function ChangePasswordCard() { + const { t } = useTranslation(); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [error, setError] = useState(null); @@ -34,7 +37,7 @@ function ChangePasswordCard() { setNext(""); setSuccess(true); } catch (err) { - setError(err instanceof ApiError ? err.message : "Could not change password."); + setError(err instanceof ApiError ? err.message : t("account:couldNotChangePassword")); } finally { setSubmitting(false); } @@ -44,14 +47,14 @@ function ChangePasswordCard() { - Password + {t("account:password")}
void onSubmit(e)}>
- Password updated. + {t("account:passwordUpdated")}

)} @@ -99,6 +102,7 @@ function ChangePasswordCard() { } function useSessionsList() { + const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); @@ -110,9 +114,9 @@ function useSessionsList() { setSessions(res.sessions); setLoaded(true); } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to load sessions."); + setError(err instanceof ApiError ? err.message : t("account:failedToLoadSessions")); } - }, []); + }, [t]); useEffect(() => { void reload(); @@ -122,6 +126,7 @@ function useSessionsList() { } function SessionsCard() { + const { t } = useTranslation(); const { sessions, loaded, error, reload } = useSessionsList(); const onRevoke = async (id: string) => { @@ -133,7 +138,7 @@ function SessionsCard() { - Active sessions + {t("account:activeSessions")} @@ -144,17 +149,17 @@ function SessionsCard() { )} {!loaded && !error && (
- Loading sessions... + {t("account:loadingSessions")}
)} {loaded && sessions.length > 0 && (
- Device - IP - Last seen - Actions + {t("account:device")} + {t("account:ipAddress")} + {t("account:lastSeen")} + {t("account:actions")} @@ -164,14 +169,12 @@ function SessionsCard() { {session.user_agent} {session.current && ( - This device + {t("account:thisDevice")} )} {session.ip} - - {new Date(session.last_seen_at).toLocaleString()} - + {formatDateTime(session.last_seen_at)} {!session.current && ( )} diff --git a/apps/web/components/settings/executor-copy.test.ts b/apps/web/components/settings/executor-copy.test.ts new file mode 100644 index 0000000000..f7a4077fea --- /dev/null +++ b/apps/web/components/settings/executor-copy.test.ts @@ -0,0 +1,212 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { activateLocale, i18n, t } from "@/lib/i18n"; +import { getExecutorLabel } from "@/lib/executor-icons"; +import { + EXECUTOR_TYPE_MAP, + executorTypeLabel, +} from "@/app/settings/executors/new/[type]/executor-types"; +import { validateMcpPolicy } from "@/components/settings/profile-edit/mcp-policy-card"; +import { getExecutorDescription } from "./executor-description"; + +/** + * Byte-for-byte English for the executor type labels and descriptions, as they + * rendered before this migration. + * + * This exists because the copy is duplicated across THREE surfaces that had + * already drifted from each other: + * + * - `/settings/executors` (the hub cards) uses the imperative mood + * ("Run agents directly in the repository folder."); + * - `/settings/executors/new/:type` (the create header) uses the third + * person ("Runs agents directly in the repository folder."); + * - `/settings/executors/:profileId` (the edit header) uses the third person + * too, and shares those five sentences — but its SSH sentence is a + * different one again. + * + * Folding them onto one key set would have rewritten user-visible English on + * two of the three, and every gate would still have passed. The identical + * sentences share a key; the ones that differ keep their own. This table is + * what says so after the reviewer leaves. + * + * De-duplicating the three tables is a behaviour change and belongs in its own + * PR — see the guard comment in eslint.i18n.options.mjs. + */ +const SPRITES_DEV = "Sprites.dev"; +const SSH = "SSH"; + +const HUB_CARD_COPY: Array<{ type: string; label: string; description: string }> = [ + { type: "local", label: "Local", description: "Run agents directly in the repository folder." }, + { + type: "worktree", + label: "Worktree", + description: "Create git worktrees for isolated agent sessions.", + }, + { + type: "local_docker", + label: "Docker", + description: "Run Docker containers on this machine.", + }, + { + type: "sprites", + label: SPRITES_DEV, + description: "Run agents in Sprites.dev cloud sandboxes.", + }, + { + type: "ssh", + label: SSH, + description: "Connect to a remote host over SSH and run agentctl there.", + }, +]; + +const HUB_CARD_KEYS: Record = + { + local: { labelKey: "executors:typeLocal", descriptionKey: "executors:hubDescriptionLocal" }, + worktree: { + labelKey: "executors:typeWorktree", + descriptionKey: "executors:hubDescriptionWorktree", + }, + local_docker: { brand: "Docker", descriptionKey: "executors:hubDescriptionDocker" }, + sprites: { brand: SPRITES_DEV, descriptionKey: "executors:hubDescriptionSprites" }, + ssh: { brand: SSH, descriptionKey: "executors:hubDescriptionSsh" }, + }; + +describe("executors hub card copy", () => { + it.each(HUB_CARD_COPY)("renders $type unchanged", ({ type, label, description }) => { + const entry = HUB_CARD_KEYS[type]; + expect(entry.brand ?? t(entry.labelKey as string)).toBe(label); + expect(t(entry.descriptionKey)).toBe(description); + }); +}); + +describe("create-page executor type registry", () => { + const CREATE_COPY: Array<{ type: string; label: string; description: string }> = [ + { + type: "local", + label: "Local", + description: "Runs agents directly in the repository folder.", + }, + { + type: "worktree", + label: "Worktree", + description: "Creates git worktrees for isolated agent sessions.", + }, + { + type: "local_docker", + label: "Docker", + description: "Runs Docker containers on this machine.", + }, + { + type: "remote_docker", + label: "Remote Docker", + description: "Connects to a remote Docker host.", + }, + { + type: "sprites", + label: SPRITES_DEV, + description: "Runs agents in Sprites.dev cloud sandboxes.", + }, + { + type: "ssh", + label: SSH, + description: "Connects to a remote host over SSH and runs agentctl there.", + }, + ]; + + it.each(CREATE_COPY)("renders $type unchanged", ({ type, label, description }) => { + const info = EXECUTOR_TYPE_MAP[type]; + expect(executorTypeLabel(info, t)).toBe(label); + expect(t(info.descriptionKey)).toBe(description); + }); + + it("keeps the persisted executor enum and backend ids out of the catalog", () => { + expect(Object.keys(EXECUTOR_TYPE_MAP).sort()).toEqual([ + "local", + "local_docker", + "remote_docker", + "sprites", + "ssh", + "worktree", + ]); + expect(EXECUTOR_TYPE_MAP.ssh.executorId).toBe("exec-ssh"); + expect(EXECUTOR_TYPE_MAP.local_docker.executorId).toBe("exec-local-docker"); + }); +}); + +describe("getExecutorDescription", () => { + it.each([ + ["local_pc", "Runs agents directly in the repository folder."], + ["worktree", "Creates git worktrees for isolated agent sessions."], + ["local_docker", "Runs Docker containers on this machine."], + ["remote_docker", "Connects to a remote Docker host."], + ["sprites", "Runs agents in Sprites.dev cloud sandboxes."], + ["ssh", "Runs agents on a trusted Linux amd64 or macOS host over SSH."], + ] as const)("renders %s unchanged", (type, expected) => { + expect(getExecutorDescription(type)).toBe(expected); + }); + + it("falls back to the custom-executor sentence", () => { + expect(getExecutorDescription("something-else" as never)).toBe("Custom executor."); + }); + + it("differs from the create page on SSH, which is why they do not share a key", () => { + expect(getExecutorDescription("ssh")).not.toBe(t(EXECUTOR_TYPE_MAP.ssh.descriptionKey)); + }); +}); + +describe("getExecutorLabel", () => { + it("returns brand and protocol names verbatim rather than from the catalog", () => { + expect(getExecutorLabel("sprites")).toBe(SPRITES_DEV); + expect(getExecutorLabel("ssh")).toBe(SSH); + }); + + it("renders the translated labels unchanged", () => { + expect(getExecutorLabel("local")).toBe("Local"); + expect(getExecutorLabel("worktree")).toBe("Worktree"); + expect(getExecutorLabel("local_docker")).toBe("Local Docker"); + expect(getExecutorLabel("remote_docker")).toBe("Remote Docker"); + }); + + it("echoes an unmapped wire value", () => { + expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist"); + }); + + // The label used to be a module-scope constant. It resolves from the catalog + // now, so this pins that it resolves at CALL time rather than at import — the + // property every consumer's re-render depends on. + it("follows a locale switch", async () => { + const before = getExecutorLabel("local"); + await activateLocale("pseudo"); + expect(getExecutorLabel("local")).not.toBe(before); + expect(getExecutorLabel("local")).toBe(t("executors:typeLocal")); + // Brand names are not catalog values, so they are unaffected. + expect(getExecutorLabel("ssh")).toBe(SSH); + await activateLocale("en"); + expect(getExecutorLabel("local")).toBe(before); + }); + + afterAll(async () => { + if (i18n.language !== "en") await activateLocale("en"); + }); +}); + +describe("validateMcpPolicy", () => { + it("returns null for empty and valid policies", () => { + expect(validateMcpPolicy(undefined)).toBeNull(); + expect(validateMcpPolicy(" ")).toBeNull(); + expect(validateMcpPolicy('{"allow_stdio":true}')).toBeNull(); + }); + + it("returns a catalog key rather than English for unparseable JSON", () => { + const key = validateMcpPolicy("{oops"); + expect(key).toBe("executors:invalidJson"); + expect(t(key as string)).toBe("Invalid JSON"); + }); + + it("returns a catalog key rather than English for a non-object policy", () => { + for (const raw of ["[1,2]", '"nope"', "42"]) { + const key = validateMcpPolicy(raw); + expect(key).toBe("executors:mcpPolicyMustBeAJsonObject"); + expect(t(key as string)).toBe("MCP policy must be a JSON object"); + } + }); +}); diff --git a/apps/web/components/settings/executor-description.ts b/apps/web/components/settings/executor-description.ts index 9af01d934d..85be4967b8 100644 --- a/apps/web/components/settings/executor-description.ts +++ b/apps/web/components/settings/executor-description.ts @@ -1,11 +1,20 @@ +import { t } from "@/lib/i18n"; import type { ExecutorType } from "@/lib/types/http"; +/** + * Resolves at call time rather than module load, so the description follows a + * locale switch. The five non-SSH sentences are byte-identical to the ones the + * `/settings/executor/:id` route already renders, so they share its keys; + * `descriptionSsh` is this surface's own sentence and deliberately differs from + * the one `app/settings/executors/new/[type]/executor-types.ts` shows on the + * create page — see the guard comment in eslint.i18n.options.mjs. + */ export function getExecutorDescription(type: ExecutorType): string { - if (type === "local_pc") return "Runs agents directly in the repository folder."; - if (type === "worktree") return "Creates git worktrees for isolated agent sessions."; - if (type === "local_docker") return "Runs Docker containers on this machine."; - if (type === "remote_docker") return "Connects to a remote Docker host."; - if (type === "sprites") return "Runs agents in Sprites.dev cloud sandboxes."; - if (type === "ssh") return "Runs agents on a trusted Linux amd64 or macOS host over SSH."; - return "Custom executor."; + if (type === "local_pc") return t("executors:descriptionLocalPc"); + if (type === "worktree") return t("executors:descriptionWorktree"); + if (type === "local_docker") return t("executors:descriptionLocalDocker"); + if (type === "remote_docker") return t("executors:descriptionRemoteDocker"); + if (type === "sprites") return t("executors:descriptionSprites"); + if (type === "ssh") return t("executors:descriptionSsh"); + return t("executors:descriptionCustom"); } diff --git a/apps/web/components/settings/plugins/install-plugin-dialog.tsx b/apps/web/components/settings/plugins/install-plugin-dialog.tsx index 73383cdfcc..8c5f2a9323 100644 --- a/apps/web/components/settings/plugins/install-plugin-dialog.tsx +++ b/apps/web/components/settings/plugins/install-plugin-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -40,6 +41,7 @@ export function InstallPluginDialog({ onSubmitUrl, onSubmitFile, }: InstallPluginDialogProps) { + const { t } = useTranslation(); const [tab, setTab] = useState("url"); const [url, setUrl] = useState(""); const [file, setFile] = useState(null); @@ -79,9 +81,11 @@ export function InstallPluginDialog({ - Install plugin + {t("plugins:installPlugin")} - Install a plugin package from a URL, or upload a .tar.gz file directly. + {/* `.tar.gz` is a file extension, interpolated so the + pseudo-locale cannot transliterate it. */} + {t("plugins:installPluginDescription", { extension: ".tar.gz" })} @@ -92,14 +96,14 @@ export function InstallPluginDialog({ className="cursor-pointer" data-testid="install-plugin-tab-url" > - From URL + {t("plugins:fromUrl")} - Upload file + {t("plugins:uploadFile")} @@ -123,7 +127,7 @@ export function InstallPluginDialog({ onClick={() => onOpenChange(false)} className="cursor-pointer" > - Cancel + {t("plugins:cancel")} @@ -141,9 +145,10 @@ export function InstallPluginDialog({ } function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) { + const { t } = useTranslation(); return (
- + void }) { } function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File | null) => void }) { + const { t } = useTranslation(); const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -202,7 +208,7 @@ function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File {file ? ( {file.name} ) : ( - "Drag and drop a .tar.gz package here, or click to browse" + t("plugins:dropzoneHint", { extension: ".tar.gz" }) )} props.onText(e.target.value)} className="max-w-xs" @@ -118,10 +120,10 @@ function MarketplaceToolbar(props: ToolbarProps) { />
@@ -166,15 +169,29 @@ function DegradedSourcesBanner({ catalog }: { catalog: MarketplaceCatalog }) { className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 space-y-1" > {degraded.map((s) => ( -
- {s.name} is unreachable - {s.error ? `: ${s.error}` : ""} — its plugins are hidden. -
+ ))} ); } +/** + * The source name comes from its index.json and `detail` is the backend's own + * network diagnostic, so both are interpolated as values rather than written + * into the message. + */ +function DegradedSourceLine({ source }: { source: MarketplaceSource }) { + const detail = source.error ? `: ${source.error}` : ""; + return ( +
+ + {source.name} is unreachable + {detail} — its plugins are hidden. + +
+ ); +} + type ListProps = { entries: MarketplaceEntry[]; loading: boolean; @@ -192,6 +209,7 @@ function MarketplaceList({ installingId, onInstall, }: ListProps) { + const { t } = useTranslation(); if (error) { return (
@@ -202,23 +220,21 @@ function MarketplaceList({ if (loading && entries.length === 0) { return (
- Loading marketplace… + {t("plugins:loadingMarketplace")}
); } if (entries.length === 0) { return (
- {filtered - ? "No plugins match your search. Try clearing the filters." - : "No plugins available yet. Check back soon, or add a marketplace source with your own."} + {filtered ? t("plugins:noPluginsMatchSearch") : t("plugins:noPluginsAvailable")}
); } return (

- {entries.length} {entries.length === 1 ? "plugin" : "plugins"} available + {t("plugins:pluginsAvailable", { count: entries.length })}

{entries.map((entry) => ( diff --git a/apps/web/components/settings/plugins/marketplace-entry-row.tsx b/apps/web/components/settings/plugins/marketplace-entry-row.tsx index 681a25c903..22e4c9780c 100644 --- a/apps/web/components/settings/plugins/marketplace-entry-row.tsx +++ b/apps/web/components/settings/plugins/marketplace-entry-row.tsx @@ -1,9 +1,11 @@ "use client"; import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { IconArrowUpCircle, IconCheck, IconStar } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; +import { formatNumber } from "@/lib/i18n/formats"; import type { MarketplaceEntry } from "@/lib/types/plugins"; import { PluginRepoLink } from "./plugin-repo-link"; @@ -48,9 +50,17 @@ export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntry
- {entry.stars === null ? "—" : entry.stars.toLocaleString()} + {entry.stars === null ? "—" : formatNumber(entry.stars)} - {entry.author && by {entry.author}} + {/* The name, description, author and categories all come from the + catalog's index.json — third-party data, not our copy. */} + {entry.author && ( + + + by {entry.author} + + + )} {entry.categories.map((cat) => ( {cat} @@ -92,6 +102,7 @@ function PluginTile({ entry }: { entry: MarketplaceEntry }) { } function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowProps) { + const { t } = useTranslation(); if (entry.install_state === "installed") { return ( - Installed + {t("plugins:installed")} ); } @@ -115,7 +126,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP className="shrink-0 gap-1 cursor-pointer" > - {busy ? "Updating…" : "Update"} + {busy ? t("plugins:updating") : t("plugins:update")} ); } @@ -128,7 +139,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP data-testid={`marketplace-install-${entry.id}`} className="shrink-0 cursor-pointer" > - {busy ? "Installing…" : "Install"} + {busy ? t("plugins:installing") : t("plugins:install")} ); } diff --git a/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx b/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx index f1c630306c..7a308c731f 100644 --- a/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx +++ b/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { IconTrash } from "@tabler/icons-react"; import { toast } from "@/lib/toast/sonner"; import { Button } from "@kandev/ui/button"; @@ -39,14 +40,15 @@ export function MarketplaceSourcesDialog({ onOpenChange, onChanged, }: MarketplaceSourcesDialogProps) { + const { t } = useTranslation(); return ( - Marketplace sources + {t("plugins:marketplaceSources")} - Add a team or corporate registry to browse its plugins alongside the official catalog. - Each source serves an index.json document. + {/* `index.json` is the document name a source must serve. */} + {t("plugins:marketplaceSourcesDescription", { document: "index.json" })} @@ -63,6 +65,7 @@ export function MarketplaceSourcesDialog({ } function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChanged: () => void }) { + const { t } = useTranslation(); const [busy, setBusy] = useState(false); const toggle = async (enabled: boolean) => { @@ -71,7 +74,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange await updateMarketplaceSource(source.id, { enabled }); onChanged(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to update source"); + toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateSource")); } finally { setBusy(false); } @@ -83,7 +86,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange await deleteMarketplaceSource(source.id); onChanged(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to remove source"); + toast.error(err instanceof Error ? err.message : t("plugins:failedToRemoveSource")); } finally { setBusy(false); } @@ -99,12 +102,12 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange {source.name} {source.builtin && ( - Official + {t("plugins:sourceOfficial")} )} {source.healthy === false && ( - Unreachable + {t("plugins:sourceUnreachable")} )}
@@ -119,7 +122,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange disabled={busy} onClick={remove} className="cursor-pointer" - aria-label={`Remove ${source.name}`} + aria-label={t("plugins:removeSource", { name: source.name })} > @@ -130,6 +133,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange } function AddSourceForm({ onChanged }: { onChanged: () => void }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [busy, setBusy] = useState(false); @@ -143,7 +147,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) { setUrl(""); onChanged(); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to add source"); + toast.error(err instanceof Error ? err.message : t("plugins:failedToAddSource")); } finally { setBusy(false); } @@ -152,7 +156,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) { return (
setName(e.target.value)} data-testid="marketplace-add-source-name" @@ -170,7 +174,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) { className="cursor-pointer shrink-0" data-testid="marketplace-add-source-submit" > - Add + {t("plugins:add")}
diff --git a/apps/web/components/settings/plugins/plugin-config-form.tsx b/apps/web/components/settings/plugins/plugin-config-form.tsx index eff0994a29..be5b4652fe 100644 --- a/apps/web/components/settings/plugins/plugin-config-form.tsx +++ b/apps/web/components/settings/plugins/plugin-config-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; @@ -58,6 +59,8 @@ type ConfigFieldRowProps = { }; function ConfigFieldRow({ field, value, isDirty, disabled, onChange }: ConfigFieldRowProps) { + // `field.label` and `field.description` come from the plugin's manifest — + // third-party data, not our copy. const inputId = `plugin-config-${field.name}`; return (
@@ -88,6 +91,7 @@ function ConfigFieldControl({ disabled, onChange, }: ConfigFieldControlProps) { + const { t } = useTranslation(); if (field.type === "boolean") { return (
@@ -117,7 +121,7 @@ function ConfigFieldControl({ className="max-w-md cursor-pointer" data-settings-dirty={isDirty} > - + {!field.required && ( @@ -125,7 +129,7 @@ function ConfigFieldControl({ value={ENUM_UNSET_SENTINEL} className="cursor-pointer text-muted-foreground" > - Not set + {t("plugins:notSet")} )} {(field.enumValues ?? []).map((option) => ( @@ -176,11 +180,13 @@ function UtilityAgentSelect({ disabled, onChange, }: ConfigFieldControlProps) { + const { t } = useTranslation(); const { agents, loading, error } = useUtilityAgents(); const selectedID = typeof value === "string" ? value : ""; const selectedAgent = agents.find((agent) => agent.id === selectedID); - const selectedLabel = selectedAgent?.name ?? selectedUtilityAgentFallback(selectedID, loading); - const placeholder = utilityAgentPlaceholder(loading, error); + const selectedLabelKey = selectedUtilityAgentFallbackKey(selectedID, loading); + const selectedLabel = selectedAgent?.name ?? (selectedLabelKey ? t(selectedLabelKey) : undefined); + const placeholder = t(utilityAgentPlaceholderKey(loading, error)); return ( void onChange(v)} disabled={loading}> {s} {shells && !shells.includes(s) ? ( - (not detected) + {t("executors:sshShellNotDetected")} ) : null} ))} @@ -286,6 +291,7 @@ function ReadinessContent({ hasProbed: boolean; rows: SSHAgentReadinessRow[]; }) { + const { t } = useTranslation(); if (error) { return (

@@ -296,25 +302,26 @@ function ReadinessContent({ if (!hasProbed) { return (

- Click {`"Probe agents"`} to check which agents are installed on the remote. + {t("executors:sshProbeHint", { button: t("executors:sshProbeAgents") })}

); } if (rows.length === 0) { - return

No enabled agents to probe.

; + return

{t("executors:sshNoEnabledAgents")}

; } return ; } function ReadinessTable({ rows }: { rows: SSHAgentReadinessRow[] }) { + const { t } = useTranslation(); return (
- Agent - Binary - Status - Install hint + {t("executors:agent")} + {t("executors:sshBinary")} + {t("executors:status")} + {t("executors:sshInstallHint")} @@ -343,40 +350,43 @@ function ReadinessRow({ row }: { row: SSHAgentReadinessRow }) { } function StatusBadge({ row }: { row: SSHAgentReadinessRow }) { + const { t } = useTranslation(); if (row.error) { return ( - Probe error + {t("executors:sshProbeError")} ); } if (row.available) { return ( - Installed + {t("executors:sshInstalled")} ); } return ( - Missing + {t("executors:sshMissing")} ); } function InstallHint({ hint, available }: { hint?: string; available: boolean }) { + const { t } = useTranslation(); if (available) return —; - if (!hint) return No hint available; + if (!hint) + return {t("executors:sshNoHintAvailable")}; return (
{hint}
@@ -316,6 +320,7 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) { } function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) { + const { t } = useTranslation(); return (
- Pinned fingerprint:{" "} + {t("executors:sshPinnedFingerprint")}{" "} + {/* Never translated — the user compares it against their own host. */} {fingerprint} @@ -349,6 +355,7 @@ function SSHConnectionActions({ onSave: () => void; showSave: boolean; }) { + const { t } = useTranslation(); return (
{showSave && ( )}
@@ -383,10 +390,11 @@ function SSHConnectionActions({ } function ConnectionBadge({ fingerprint }: { fingerprint?: string }) { + const { t } = useTranslation(); if (!fingerprint) { return ( - Unverified + {t("executors:sshUnverified")} ); } @@ -397,7 +405,7 @@ function ConnectionBadge({ fingerprint }: { fingerprint?: string }) { variant="default" className="bg-green-600" > - Trusted + {t("executors:sshTrusted")} ); } @@ -444,6 +452,7 @@ function TestResultDisplay({ } function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: number }) { + const { t } = useTranslation(); return (
)} - {success ? "Connection test passed" : "Connection test failed"} + {success ? t("executors:sshConnectionTestPassed") : t("executors:sshConnectionTestFailed")} ({totalMs}ms)
); diff --git a/apps/web/components/settings/ssh-connection-form.tsx b/apps/web/components/settings/ssh-connection-form.tsx index 3845632726..746563de00 100644 --- a/apps/web/components/settings/ssh-connection-form.tsx +++ b/apps/web/components/settings/ssh-connection-form.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; @@ -26,13 +27,14 @@ function fieldIsDirty( } export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFormProps) { + const { t } = useTranslation(); return (
onChange("name", value)} @@ -40,8 +42,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor void; }) { + const { t } = useTranslation(); return ( - + diff --git a/apps/web/components/settings/ssh-fingerprint-trust-block.tsx b/apps/web/components/settings/ssh-fingerprint-trust-block.tsx index 38a6bbe105..3963cbce1a 100644 --- a/apps/web/components/settings/ssh-fingerprint-trust-block.tsx +++ b/apps/web/components/settings/ssh-fingerprint-trust-block.tsx @@ -1,5 +1,7 @@ "use client"; +import { Trans, useTranslation } from "react-i18next"; + // Trust-confirmation block shown under the SSH test result. Disabled when the // caller's last test ran against a different form, to keep stale fingerprints // from being trusted for a new target. @@ -16,26 +18,33 @@ export function FingerprintTrustBlock({ resultStale: boolean; onTrustChange: (v: boolean) => void; }) { + const { t } = useTranslation(); const fingerprintChanged = !!currentlyPinned && currentlyPinned !== fingerprint; return (
- Host fingerprint observed: + {/* The fingerprints below are never translated: the user compares them + character-for-character against what their own host reports. */} + {t("executors:sshFingerprintObserved")} {fingerprint}
{fingerprintChanged && (

- Warning: this fingerprint differs from the one currently pinned ( - {currentlyPinned}). Trusting it overwrites the pinned - key. If you didn’t expect a host re-key, stop here. + + Warning: this fingerprint differs from the one currently pinned ( + {currentlyPinned}). Trusting it overwrites the pinned + key. If you didn’t expect a host re-key, stop here. +

)} {resultStale && (

- You changed a connection field after this test ran. Run Test Connection again before - trusting and saving. + {t("executors:sshTestResultStale")}

)}
diff --git a/apps/web/components/settings/ssh-sessions-card.tsx b/apps/web/components/settings/ssh-sessions-card.tsx index aaf06bb684..eb9a4e8061 100644 --- a/apps/web/components/settings/ssh-sessions-card.tsx +++ b/apps/web/components/settings/ssh-sessions-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; @@ -16,6 +17,7 @@ export interface SSHSessionsCardProps { const REFRESH_INTERVAL_MS = 90_000; export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) { + const { t } = useTranslation(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -34,11 +36,11 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) { setSessions(rows); } catch (e) { if (seq !== seqRef.current) return; - setError(e instanceof Error ? e.message : "Failed to load sessions"); + setError(e instanceof Error ? e.message : t("executors:sshFailedToLoadSessions")); } finally { if (seq === seqRef.current) setLoading(false); } - }, [executorId]); + }, [executorId, t]); useEffect(() => { // Reset sequence so a previous executor's pending response can't land @@ -59,10 +61,8 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
- Active sessions - - Sessions currently running on this SSH host. Refreshes every 90 seconds. - + {t("executors:sshActiveSessions")} + {t("executors:sshActiveSessionsDescription")}
@@ -93,6 +93,7 @@ function SSHSessionsBody({ error: string | null; sessions: SSHSession[]; }) { + const { t } = useTranslation(); if (error) { return (

@@ -103,7 +104,7 @@ function SSHSessionsBody({ if (sessions.length === 0 && !loading) { return (

- No active sessions. + {t("executors:sshNoActiveSessions")}

); } @@ -112,17 +113,18 @@ function SSHSessionsBody({ } function SSHSessionsTable({ sessions }: { sessions: SSHSession[] }) { + const { t } = useTranslation(); return (
- Task - Session - Host - Remote port - Local fwd - Uptime - Status + {t("executors:task")} + {t("executors:session")} + {t("executors:host")} + {t("executors:sshRemotePort")} + {t("executors:sshLocalForward")} + {t("executors:uptime")} + {t("executors:status")} diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts index 2cceddf631..16a3c3fbb2 100644 --- a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import type { AppState } from "@/lib/state/store"; import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types"; @@ -57,6 +58,11 @@ export function repositoryOptions(repositoriesByWorkspace: ReposByWorkspace): Op export function useFilterValueOptions(dimension: FilterDimension): Option[] { const snapshots = useAppStore((s) => s.kanbanMulti.snapshots); const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + // `executorTypeOptions` resolves its labels through `getExecutorLabel`, which + // now reads the catalog. Subscribing here — and keeping the language in the + // memo's deps — is what makes those labels follow a runtime locale switch; + // without it the memo only recomputes when store data changes. + const { i18n } = useTranslation(); return useMemo(() => { if (dimension === "workflow") return workflowOptions(snapshots); @@ -64,5 +70,5 @@ export function useFilterValueOptions(dimension: FilterDimension): Option[] { if (dimension === "executorType") return executorTypeOptions(snapshots); if (dimension === "repository") return repositoryOptions(repositoriesByWorkspace); return []; - }, [dimension, snapshots, repositoriesByWorkspace]); + }, [dimension, snapshots, repositoriesByWorkspace, i18n.language]); } diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts index 905b381a9f..f9a1f9eabb 100644 --- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts +++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts @@ -158,6 +158,32 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [ ], }, { name: "settings — task actions", url: "/settings/general/task-actions" }, + { name: "settings — plugins", url: "/settings/plugins" }, + // NOT YET: "settings — executors", "settings — account security", + // "settings — account tokens". All three routes' own copy is fully migrated + // and was verified by walking them under pseudo — including every dialog and + // both marketplace tabs, since a static page scan never opens a Radix portal. + // What stops them being entries here is what the fixture renders BESIDE that + // copy, and in each case an `allow` entry would be the wrong fix: + // - `/settings/executors` renders the seeded executor profiles' NAMES, and + // the e2e fixture happens to name them `Local` and `Worktree`. That is + // user data on exactly the footing as the workspace names below, so it + // needs `findUnlocalizedText` to stop treating user data as eligible — + // listing the two values would fix this fixture and leave every developer + // instance broken under different names. The hub cards' own `Docker` and + // `Sprites.dev` labels are brand nouns; they are in the guard's + // `words.exclude` but not in `ALLOWED`, which is a real sync gap in that + // list rather than something this migration should widen it to paper over. + // - Both account routes render `not found` — the router's 404 body, because + // the e2e profile does not register the auth routes, surfaced in the + // sessions/tokens error region. It is a backend diagnostic, English by + // design (docs/i18n.md, "What the backend deliberately does NOT + // translate"), and allowlisting a payload string would hide the next real + // miss in the same region. + // The `plugins` entry above has neither problem and passes with no `allow`. + // Its marketplace entries — names, descriptions, categories, source names and + // index.json URLs — come from the catalog rather than the DOM on load. + // // NOT YET: "settings — integrations github", "… gitlab", "… jira", // "… linear", "… sentry". Each page's // own copy is fully migrated (verified by running this oracle against it — diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index 4c36883e8c..0901440df5 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -111,6 +111,12 @@ export const noLiteralStringOptions = { "to", "htmlFor", "data-.*", + // `PluginErrorBoundary`'s console-log identifier (`slot "task-sidebar"`, + // `route "/plugins/hello"`). It is written to console.error in + // componentDidCatch and never reaches the DOM, so it is a developer + // diagnostic rather than copy. The only literal `context=` values in the + // tree are that boundary's four call sites. + "context", // Identifiers and prefixes the caller composes into ids/testids. "id", "k", @@ -1200,4 +1206,143 @@ export const i18nGuardFiles = [ "app/settings/automations/page.tsx", "app/settings/workspace/[[]id[]]/automations/**/*.tsx", "hooks/domains/settings/use-automation-runs.ts", + // Settings → Plugins, Settings → Account, and the plural + // `app/settings/executors/` tree (the executor list, the typed create + // routes, and the SSH connection pages). Copy lives in two new namespaces, + // `plugins` and `account`, plus additions to `executors`. + // + // `components/plugins/**` is reached from OUTSIDE Settings — the app + // sidebar, the mobile menu sheet, the chat top bar, the chat input, the task + // sidebar and the kanban top bar all render `PluginSlot`, and + // `PluginModalHost` mounts at the app root. It is listed here because this + // migration owns the Plugins feature end to end, not because those routes + // are migrated; only the two strings those files own (`PluginRouteFallback` + // and the mobile nav heading) went through `t()`. Everything else they + // render — a slot component, a modal title, a nav item's label — is + // plugin-authored and is third-party data, not our copy. + // + // Nine entries hold no JSX, or none that carries copy, so `mode: "jsx-only"` + // never inspects them and the string count reported them as ZERO while they + // owned copy that reaches the user. Each records that it was read by eye, + // and only the pseudo-locale can prove it stays that way: + // - `app/settings/executors/new/[type]/executor-types.ts` — reported 0 and + // carried ELEVEN strings, the six type labels and their descriptions. + // - `lib/executor-icons.ts` and `components/settings/executor-description.ts` + // — plain functions returning copy, the shape one step out from a config + // table and just as invisible. + // - `components/settings/plugins/use-plugin-actions.ts` (eight toasts), + // `use-plugin-config-form.ts` (five toasts plus the required-fields + // reason), `lib/plugins/sync-summary.ts` (the sync toast), and the three + // `hooks/domains/plugins/*.ts` error fallbacks. All import the + // module-level `t` rather than the hook, so each resolves when its + // callback fires and stays out of the dependency array. + // - `app/settings/executors/new/[type]/ssh-config.ts` carries no copy at + // all (snake_case config mapping); it is listed to pin that. + // + // This PR also closes the residual #2218 recorded against it: + // `validateMcpPolicy` in `profile-edit/mcp-policy-card.tsx` returned English + // ("Invalid JSON", "MCP policy must be a JSON object") because its only + // callers were the un-migrated plural tree. It now returns catalog keys and + // the card's prop is `mcpPolicyErrorKey`, matching the local validator in + // `app/settings/executor/[id]/page.tsx`. + // + // `src/settings-routes.tsx` is listed for the first time. docs/i18n.md's + // "Copy that belongs to no directory" rule says the LAST area to migrate + // adds it, because allowlisting it earlier claims a completeness nobody has: + // the two Account routes were the final inline English entries in + // `SETTINGS_ROUTES`, so every entry in that table now resolves from a + // catalog. Read the entry as a claim about that FILE, not about all of + // Settings — `app/settings/automations/page.tsx`, + // `app/settings/integrations/page.tsx` and `components/settings/agent-card.tsx` + // still hold English and belong to their own migrations. They render no + // route-table copy, which is why they do not block this entry. + // + // THREE surfaces describe the same six executor types and had already + // drifted from one another: the hub cards use the imperative mood ("Run + // agents directly…"), while the create and edit headers use the third person + // ("Runs agents directly…") — and the edit header's SSH sentence differs + // from the create header's again. Sentences that are byte-identical share a + // key (the create page reuses `executors:description*`, which + // `/settings/executor/:id` already owned); the ones that differ keep their + // own. Folding them together would have rewritten shipping English on two of + // the three with every gate green. + // `components/settings/executor-copy.test.ts` pins all three tables + // byte-for-byte, and `account/account-route-copy.test.ts` does the same for + // the two route headers. De-duplicating the tables is a behaviour change and + // belongs in its own PR. + // + // Deliberately left in English, none of it copy: + // - The executor type keys `local` / `worktree` / `local_docker` / + // `remote_docker` / `sprites` / `ssh`. They are the persisted enum, the + // create route's path segment, and a `===` comparison in four places. + // The `exec-*` executorIds are backend row ids. Only the labels beside + // them are copy. + // - The brand and protocol names `Docker`, `Sprites.dev` and `SSH` when + // they ARE the whole label. They read the same in every locale (the + // guard's own `words.exclude` lists all three), and keeping them out of + // the catalog stops the pseudo-locale transliterating a name the user + // must match against their SSH config or Sprites dashboard. + // - Every SSH fingerprint and known-hosts value. `ssh-fingerprint-trust-block` + // is a security surface: the user compares those strings + // character-for-character against what their own host reports, so both + // the observed and the pinned fingerprint are interpolated as values. + // - The SSH example values `prod`, `dev.example.com`, `22`, `ubuntu`, + // `~/.ssh/id_ed25519` and `bastion.example.com`, and the + // `ssh-agent (SSH_AUTH_SOCK)` option — a program name plus the + // environment variable it reads. `My VPS` beside them IS copy and does + // go through `t()`. `~/.ssh/config`, `$PATH`, `.tar.gz` and `index.json` + // are interpolated into their sentences for the same reason. + // - The throw in `ssh-connection-card`'s `handleSave`. It is unreachable + // while `canSave` gates the button and it signals the settings save + // coordinator, not the user — the same shape docs/i18n.md records for + // the migrated GitHub and Jira settings. + // - Plugin ids, versions, manifest keys (`config_schema`), permission + // scopes (`events:*`, `read:*`, `write:*`), webhook keys, marketplace + // source URLs, and the raw manifest JSON dump. Those are the contract. + // A plugin's display name, description, categories, icon, config-field + // labels and modal titles come from its manifest or an untrusted + // index.json, so they are third-party data — interpolated as values, + // never written into a message. + // - Account data: emails, usernames, user agents, IP addresses, and every + // API token VALUE, which never passes through `t()` at all. Token names + // are user data; only the column headers and role labels are copy. + // - `PluginErrorBoundary`'s `context` prop, now in `jsx-attributes.exclude` + // above — it is a console.error identifier that never reaches the DOM. + // + // `getExecutorLabel` returns catalog text now, so a caller that does not + // subscribe to i18n renders the previous locale until something else makes it + // re-render. Every consumer this PR owns subscribes, and + // `use-filter-value-options.ts` (the task sidebar's executor filter) gained + // `i18n.language` in its memo deps for the same reason. One consumer is left: + // `lib/sidebar/apply-view.ts` builds a group label from it inside the sidebar + // view pipeline, which this PR does not own — its labels resolve correctly on + // load and lag a runtime locale switch until the view recomputes. It belongs + // to whoever migrates `lib/sidebar`. + // + // One deliberate English change, the only one in this PR: the SSH + // running-sessions confirm read "This executor has 3 running session(s)." + // The `(s)` is the inline-plural shape docs/i18n.md rejects — the plural rule + // cannot be expressed at the call site in a language with three or six forms + // — so it is now `_one`/`_other` and reads "1 running session" / + // "3 running sessions". + // + // Four dates moved off `toLocaleString()` onto `lib/i18n/formats`' + // `formatDateTime`, and the marketplace star count off `toLocaleString()` + // onto `formatNumber`, so they follow the active locale rather than the + // browser's — the same call `storage-quarantine.ts` made. + "app/settings/executors/**/*.{ts,tsx}", + "components/settings/account/**/*.{ts,tsx}", + "components/settings/executor-description.ts", + "components/settings/plugins/**/*.{ts,tsx}", + "components/settings/ssh-agent-readiness-card.tsx", + "components/settings/ssh-connection-card.tsx", + "components/settings/ssh-connection-form.tsx", + "components/settings/ssh-fingerprint-trust-block.tsx", + "components/settings/ssh-sessions-card.tsx", + "components/settings/ssh-settings.tsx", + "components/plugins/**/*.{ts,tsx}", + "hooks/domains/plugins/*.ts", + "lib/executor-icons.ts", + "lib/plugins/sync-summary.ts", + "src/settings-routes.tsx", ]; diff --git a/apps/web/hooks/domains/plugins/use-auto-update-settings.ts b/apps/web/hooks/domains/plugins/use-auto-update-settings.ts index 65750d5ce8..ca24a0ec3b 100644 --- a/apps/web/hooks/domains/plugins/use-auto-update-settings.ts +++ b/apps/web/hooks/domains/plugins/use-auto-update-settings.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { toast } from "@/lib/toast/sonner"; import { getPluginSettings, updatePluginSettings } from "@/lib/api/domains/plugins-api"; +import { t } from "@/lib/i18n"; export type AutoUpdateSettings = { /** The instance-wide auto-update default (applies to plugins without an override). */ @@ -49,7 +50,7 @@ export function useAutoUpdateSettings(): AutoUpdateSettings { setState(res.auto_update_default); } catch (err) { setState(prev); // roll back - toast.error(err instanceof Error ? err.message : "Failed to update auto-update setting"); + toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateAutoUpdate")); } }; diff --git a/apps/web/hooks/domains/plugins/use-marketplace.ts b/apps/web/hooks/domains/plugins/use-marketplace.ts index 8951063d3e..9e83f92518 100644 --- a/apps/web/hooks/domains/plugins/use-marketplace.ts +++ b/apps/web/hooks/domains/plugins/use-marketplace.ts @@ -6,6 +6,7 @@ import { refreshMarketplace, type CatalogQuery, } from "@/lib/api/domains/marketplace-api"; +import { t } from "@/lib/i18n"; import type { MarketplaceCatalog } from "@/lib/types/plugins"; const EMPTY_CATALOG: MarketplaceCatalog = { plugins: [], sources: [] }; @@ -37,7 +38,7 @@ export function useMarketplace(query: CatalogQuery) { }) .catch((err) => { if (cancelled) return; - setError(err instanceof Error ? err.message : "Failed to load marketplace"); + setError(err instanceof Error ? err.message : t("plugins:failedToLoadMarketplace")); }) .finally(() => { if (!cancelled) setLoading(false); diff --git a/apps/web/hooks/domains/plugins/use-plugins.ts b/apps/web/hooks/domains/plugins/use-plugins.ts index cace2eafc5..cb87e2306b 100644 --- a/apps/web/hooks/domains/plugins/use-plugins.ts +++ b/apps/web/hooks/domains/plugins/use-plugins.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { listPlugins } from "@/lib/api/domains/plugins-api"; +import { t } from "@/lib/i18n"; import { useAppStore } from "@/components/state-provider"; /** @@ -27,7 +28,7 @@ export function usePlugins() { setPlugins(response); }) .catch((err) => { - setPluginsError(err instanceof Error ? err.message : "Failed to load plugins"); + setPluginsError(err instanceof Error ? err.message : t("plugins:failedToLoadPlugins")); }) .finally(() => { setPluginsLoading(false); diff --git a/apps/web/lib/executor-icons.test.ts b/apps/web/lib/executor-icons.test.ts index 216e403789..83bd1808dd 100644 --- a/apps/web/lib/executor-icons.test.ts +++ b/apps/web/lib/executor-icons.test.ts @@ -22,9 +22,12 @@ describe("executor icons", () => { }); it("returns human-readable labels for known executor types", () => { + expect(getExecutorLabel("local")).toBe("Local"); + expect(getExecutorLabel("worktree")).toBe("Worktree"); expect(getExecutorLabel("local_docker")).toBe("Local Docker"); expect(getExecutorLabel("remote_docker")).toBe("Remote Docker"); expect(getExecutorLabel("sprites")).toBe("Sprites.dev"); + expect(getExecutorLabel("ssh")).toBe("SSH"); expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist"); }); }); diff --git a/apps/web/lib/executor-icons.ts b/apps/web/lib/executor-icons.ts index aee2516886..37df95bdcf 100644 --- a/apps/web/lib/executor-icons.ts +++ b/apps/web/lib/executor-icons.ts @@ -10,6 +10,8 @@ import { IconTerminal2, } from "@tabler/icons-react"; +import { t } from "@/lib/i18n"; + export const EXECUTOR_ICON_MAP: Record = { local: IconFolder, worktree: IconFolders, @@ -23,17 +25,32 @@ export function getExecutorIcon(type: string): typeof IconFolder { return EXECUTOR_ICON_MAP[type] ?? IconFolder; } -const EXECUTOR_LABEL_MAP: Record = { - local: "Local", - worktree: "Worktree", - local_docker: "Local Docker", - remote_docker: "Remote Docker", +// Brand and protocol names read the same in every locale, so they are values +// rather than copy — the guard's `words.exclude` lists both. Keeping them out +// of the catalog also keeps the pseudo-locale from transliterating them into +// something the user cannot match against their own SSH config or Sprites +// dashboard. +const EXECUTOR_BRAND_LABEL_MAP: Record = { sprites: "Sprites.dev", ssh: "SSH", }; +// Keys, not messages: `t` resolves inside getExecutorLabel at call time, so +// the label follows a locale switch. Assigning t() here would freeze it at the +// boot locale. +const EXECUTOR_LABEL_KEY_MAP: Record = { + local: "executors:typeLocal", + worktree: "executors:typeWorktree", + local_docker: "executors:localDocker", + remote_docker: "executors:remoteDocker", +}; + export function getExecutorLabel(type: string): string { - return EXECUTOR_LABEL_MAP[type] ?? type; + const brand = EXECUTOR_BRAND_LABEL_MAP[type]; + if (brand) return brand; + const key = EXECUTOR_LABEL_KEY_MAP[type]; + // An unmapped type echoes the raw wire value, which is an identifier. + return key ? t(key) : type; } /** diff --git a/apps/web/lib/plugins/sync-summary.ts b/apps/web/lib/plugins/sync-summary.ts index d66f5d7c6a..3518690f95 100644 --- a/apps/web/lib/plugins/sync-summary.ts +++ b/apps/web/lib/plugins/sync-summary.ts @@ -5,14 +5,18 @@ * surfaced separately (an inline `plugins-sync-errors` region) — they do * not affect this summary line. */ +import { t } from "@/lib/i18n"; import type { SyncResult } from "@/lib/types/plugins"; export function summarizeSyncResult(result: SyncResult): string { const parts: string[] = []; - if (result.added.length > 0) parts.push(`${result.added.length} sideloaded`); - if (result.installed.length > 0) parts.push(`${result.installed.length} installed`); - if (result.missing.length > 0) parts.push(`${result.missing.length} missing`); + if (result.added.length > 0) + parts.push(t("plugins:syncSideloaded", { count: result.added.length })); + if (result.installed.length > 0) + parts.push(t("plugins:syncInstalled", { count: result.installed.length })); + if (result.missing.length > 0) + parts.push(t("plugins:syncMissing", { count: result.missing.length })); - if (parts.length === 0) return "Everything up to date"; - return `Sync: ${parts.join(", ")}`; + if (parts.length === 0) return t("plugins:syncUpToDate"); + return t("plugins:syncSummary", { parts: parts.join(", ") }); } diff --git a/apps/web/src/locales/en/account.json b/apps/web/src/locales/en/account.json new file mode 100644 index 0000000000..fdc1c118f7 --- /dev/null +++ b/apps/web/src/locales/en/account.json @@ -0,0 +1,42 @@ +{ + "actions": "Actions", + "activeSessions": "Active sessions", + "apiTokens": "API tokens", + "apiTokensBlurb": "Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if it may have leaked.", + "cancel": "Cancel", + "changePassword": "Change password", + "couldNotChangePassword": "Could not change password.", + "couldNotCreateToken": "Could not create token.", + "createApiToken": "Create API token", + "createApiTokenDescription": "Grants the same access as your account to any script or CLI that uses it. Give it a name so you can recognize it later.", + "createToken": "Create token", + "created": "Created", + "creating": "Creating...", + "currentPassword": "Current password", + "device": "Device", + "done": "Done", + "failedToLoadSessions": "Failed to load sessions.", + "failedToLoadTokens": "Failed to load tokens.", + "ipAddress": "IP", + "lastSeen": "Last seen", + "lastUsed": "Last used", + "loadingSessions": "Loading sessions...", + "loadingTokens": "Loading tokens...", + "name": "Name", + "never": "Never", + "newPassword": "New password", + "newToken": "New token", + "noTokensYet": "No tokens yet.", + "password": "Password", + "passwordUpdated": "Password updated.", + "revoke": "Revoke", + "saving": "Saving...", + "securityPageDescription": "Change your password and review devices signed in to your account.", + "securityPageTitle": "Profile & password", + "signOut": "Sign out", + "thisDevice": "This device", + "tokenCreated": "Token created", + "tokenShownOnlyOnce": "This is shown only once — copy it now and store it securely.", + "tokensPageDescription": "Personal access tokens for scripts and CLIs acting as you.", + "tokensPageTitle": "API tokens" +} diff --git a/apps/web/src/locales/en/executors.json b/apps/web/src/locales/en/executors.json index a839db8e71..ab96d1ca39 100644 --- a/apps/web/src/locales/en/executors.json +++ b/apps/web/src/locales/en/executors.json @@ -1,8 +1,11 @@ { "action": "Action", "add": "Add", + "addASpritesApiKeyBeforeCreating": "Add a Sprites API key before creating the profile.", + "addDockerfileContentBeforeCreating": "Add Dockerfile content before creating the profile.", "addRule": "Add Rule", "advanced": "Advanced", + "agent": "Agent", "allow": "Allow", "allowlistGithubPlaywright": "Allowlist GitHub + Playwright", "apiKey": "API Key", @@ -14,6 +17,7 @@ "buildFailedWithMessage": "Build failed: {{message}}", "buildFailedWithStatus": "Build failed ({{status}}): {{text}}", "buildImage": "Build Image", + "buildThisDockerImageBeforeCreating": "Build this Docker image before creating the profile.", "building": "Building...", "checking": "Checking...", "chooseAnExecutorTypeToRun": "Choose an executor type to run environments on your infrastructure.", @@ -24,21 +28,26 @@ "connected": "Connected", "connectedActiveSprites_one": "Connected. {{count}} active sprite.", "connectedActiveSprites_other": "Connected. {{count}} active sprites.", + "connection": "Connection", + "connectionSettings": "Connection Settings", "connectionTestFailed": "Connection test failed", "connectionTestPassed": "Connection test passed", "connectsToARemoteDockerHost": "Connects to a remote Docker host. The repository will be cloned inside the container.", "copyAuthFiles": "Copy auth files", "createANewProfileForThis": "Create a new profile for this executor. You can configure scripts and environment variables on the profile page.", "createExecutor": "Create Executor", + "createNewProfile": "Create New Profile", "createNewSecret": "Create new secret...", "createProfile": "Create Profile", "creating": "Creating...", + "creatingProfile": "Creating profile...", "defaultNoTls": "Default (no TLS)", "defineNetworkAccessRulesAppliedWhen": "Define network access rules applied when a sprite is created for this profile.", "defineTheDockerImageBuildAnd": "Define the Docker image. Build and test it here.", "delete": "Delete", "deleteExecutor": "Delete Executor", "deleteProfile": "Delete Profile", + "deleteProfileConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", "deleting": "Deleting...", "deny": "Deny", "descriptionCustom": "Custom executor.", @@ -46,6 +55,8 @@ "descriptionLocalPc": "Runs agents directly in the repository folder.", "descriptionRemoteDocker": "Connects to a remote Docker host.", "descriptionSprites": "Runs agents in Sprites.dev cloud sandboxes.", + "descriptionSsh": "Runs agents on a trusted Linux amd64 or macOS host over SSH.", + "descriptionSshCreate": "Connects to a remote host over SSH and runs agentctl there.", "descriptionWorktree": "Creates git worktrees for isolated agent sessions.", "differentConfigurationsForThisExecutorEach": "Different configurations for this executor. Each profile can have its own prepare script, environment variables, and settings.", "disabled": "Disabled", @@ -59,6 +70,8 @@ "eGMyApiToken": "e.g. {{example}}", "eGProductionDevelopment": "e.g. {{example}}", "enabled": "Enabled", + "enterAProfileName": "Enter a profile name.", + "enterAnImageTagBeforeCreating": "Enter an image tag before creating the profile.", "envVarsConfiguredCount_one": "{{count}} configured", "envVarsConfiguredCount_other": "{{count}} configured", "environmentVariables": "Environment Variables", @@ -69,6 +82,7 @@ "failedToConnectToBackend": "Failed to connect to backend", "failedToCreateProfile": "Failed to create profile", "failedToCreateSecret": "Failed to create secret", + "failedToLoadExecutor": "Failed to load executor", "failedToSaveProfile": "Failed to save profile", "filesSelected": "Files Selected", "gitIdentity": "Git Identity", @@ -78,6 +92,13 @@ "gitUserEmail": "Git User Email", "gitUserName": "Git User Name", "goToExecutors": "Go to Executors", + "host": "Host", + "hubDescription": "Executor profiles define how and where agents run. Each profile configures an execution environment with scripts, environment variables, and MCP policies.", + "hubDescriptionDocker": "Run Docker containers on this machine.", + "hubDescriptionLocal": "Run agents directly in the repository folder.", + "hubDescriptionSprites": "Run agents in Sprites.dev cloud sandboxes.", + "hubDescriptionSsh": "Connect to a remote host over SSH and run agentctl there.", + "hubDescriptionWorktree": "Create git worktrees for isolated agent sessions.", "image": "Image", "imageTag": "Image Tag", "include": "Include", @@ -88,6 +109,7 @@ "key": "Key", "loading": "Loading...", "loadingEditor": "Loading editor...", + "loadingExecutor": "Loading executor...", "localDocker": "Local Docker", "localDockerExecutor": "Local Docker Executor", "localGitIdentityNotDetected": "Local git user.name/user.email not detected on this machine", @@ -100,6 +122,8 @@ "name": "Name", "networkPolicies": "Network Policies", "newProfile": "New Profile", + "newSshExecutor": "New SSH Executor", + "newTypeProfile": "New {{type}} Profile", "noDockerContainers": "No Docker containers.", "noEnvironmentVariablesConfigured": "No environment variables configured.", "noEnvironmentVariablesConfiguredAddOne": "No environment variables configured. Add one below.", @@ -108,6 +132,7 @@ "noResponseBody": "No response body", "noTransferableCredentialsFound": "No transferable credentials found.", "none": "None", + "notAnSshExecutor": "Executor {{id}} is not an SSH executor", "notConfigured": "Not Configured", "onlyHttpSse": "Only HTTP/SSE", "onlyStdio": "Only stdio", @@ -117,6 +142,7 @@ "pasteYourSecretValue": "Paste your secret value...", "pathToTlsCertificatesForThe": "Path to TLS certificates for the Docker host.", "personalAccessTokenForCloningRepositories": "Personal access token for cloning repositories inside the container. Auto-detected from host environment if not set.", + "port": "Port", "prepareScript": "Prepare Script", "prepareScriptBadge": "Prepare script", "prepareScriptDescriptionLocal": "Runs on the host machine before the agent starts.", @@ -129,6 +155,7 @@ "profiles": "Profiles", "provideSecret": "Provide secret", "quickPresets": "Quick presets", + "refresh": "Refresh", "relatedDockerContainersRemoved_one": "{{count}} related Docker container will also be removed.", "relatedDockerContainersRemoved_other": "{{count}} related Docker containers will also be removed.", "remoteAuthMethod": "Remote auth method", @@ -149,8 +176,63 @@ "selectOrCreateASecret": "Select or create a secret...", "selectSecret": "Select secret...", "selectTheSecretContainingYourSprites": "Select the secret containing your Sprites.dev API token.", + "session": "Session", "setACustomNameAndEmail": "Set a custom name and email for remote git commits.", "setEnvVarViaStoredSecret": "Set <1>{{envVar}} via a stored secret", + "spritesApiKeyIsRequired": "Sprites API key is required.", + "spritesTokenIsRequired": "Sprites token is required.", + "sshActiveSessions": "Active sessions", + "sshActiveSessionsDescription": "Sessions currently running on this SSH host. Refreshes every 90 seconds.", + "sshAvailableAgents": "Available agents on this host", + "sshAvailableAgentsDescription": "Probes the remote {{pathVar}} for each enabled agent under the chosen login shell. Copy the install hint and run it on the remote when an agent is missing.", + "sshBinary": "Binary", + "sshConfirmRunningSessions_one": "This executor has {{count}} running session. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?", + "sshConfirmRunningSessions_other": "This executor has {{count}} running sessions. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?", + "sshConnectionDescription": "Run an agent on Linux amd64 or macOS hosts you can reach over SSH.", + "sshConnectionTestFailed": "Connection test failed", + "sshConnectionTestPassed": "Connection test passed", + "sshCopyInstallHint": "Copy install hint", + "sshCreateDescription": "Connect to a remote Linux amd64 or macOS host and run agentctl there.", + "sshExecutorPageDescription": "Edit the connection settings or re-trust the host. Existing sessions keep their snapshot of the previous config.", + "sshFailedToLoadSessions": "Failed to load sessions", + "sshFailedToProbeAgents": "Failed to probe agents", + "sshFailedToReachBackend": "Failed to reach backend", + "sshFailedToSaveExecutor": "Failed to save executor", + "sshFailedToSaveShell": "Failed to save shell", + "sshFingerprintChanged": "Warning: this fingerprint differs from the one currently pinned (<1>{{fingerprint}}). Trusting it overwrites the pinned key. If you didn’t expect a host re-key, stop here.", + "sshFingerprintObserved": "Host fingerprint observed:", + "sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config.", + "sshHostAliasLabel": "Host alias from {{path}} (optional)", + "sshIdentityFileHint": "Passphrase-protected keys must be loaded into ssh-agent first.", + "sshIdentityFilePath": "Identity file path", + "sshIdentitySource": "Identity source", + "sshIdentitySourceFile": "Identity file (private key path)", + "sshInstallHint": "Install hint", + "sshInstallHintCopied": "Install hint copied", + "sshInstalled": "Installed", + "sshLocalForward": "Local fwd", + "sshLoginShell": "Login shell", + "sshMissing": "Missing", + "sshNamePlaceholder": "My VPS", + "sshNoActiveSessions": "No active sessions.", + "sshNoEnabledAgents": "No enabled agents to probe.", + "sshNoHintAvailable": "No hint available", + "sshPinnedFingerprint": "Pinned fingerprint:", + "sshProbeAgents": "Probe agents", + "sshProbeError": "Probe error", + "sshProbeHint": "Click \"{{button}}\" to check which agents are installed on the remote.", + "sshProxyJumpHint": "Single bastion hop. Chained jumps are not supported.", + "sshProxyJumpLabel": "ProxyJump (optional)", + "sshRemotePort": "Remote port", + "sshReprobe": "Re-probe", + "sshShellNotDetected": "(not detected)", + "sshTestAndTrustToSave": "Test the connection and trust its fingerprint to save.", + "sshTestConnection": "Test connection", + "sshTestResultStale": "You changed a connection field after this test ran. Run Test Connection again before trusting and saving.", + "sshTrustThisHost": "<0>Trust this host. I’ve verified the fingerprint above matches my server. Future connections that report a different fingerprint will be refused.", + "sshTrusted": "Trusted", + "sshUnverified": "Unverified", + "status": "Status", "stop": "Stop", "success": "Success", "task": "Task", @@ -160,12 +242,17 @@ "tlsCertificatePath": "TLS certificate path", "tlsVerify": "TLS verify", "tokenConfiguredButUnableToConnect": "Token configured but unable to connect.", + "typeLocal": "Local", "typeTokenToConfirmDeletion": "Type \"{{token}}\" to confirm deletion. This action cannot be undone.", + "typeWorktree": "Worktree", "unknownError": "Unknown error", + "unknownExecutorType": "Unknown executor type", "untrackedTask": "Untracked", + "uptime": "Uptime", "useDefaults": "Use defaults", "useLocalGitConfig": "Use local git config", "usedByRemoteExecutorsForCommit": "Used by remote executors for commit author configuration.", + "user": "User", "usesTheLocalDockerDaemonOn": "Uses the local Docker daemon on this machine.", "value": "Value", "verifyingConnection": "Verifying connection..." diff --git a/apps/web/src/locales/en/plugins.json b/apps/web/src/locales/en/plugins.json new file mode 100644 index 0000000000..bd4d4588d3 --- /dev/null +++ b/apps/web/src/locales/en/plugins.json @@ -0,0 +1,116 @@ +{ + "add": "Add", + "allCategories": "All categories", + "autoUpdate": "Auto-update", + "autoUpdateDescription": "Check the marketplace periodically and install newer versions of active plugins. Applies to every plugin unless overridden per plugin below.", + "autoUpdateFor": "Auto-update for {{name}}", + "autoUpdateTitle": "Automatically update plugins", + "byAuthor": "by {{author}}", + "cancel": "Cancel", + "category": "Category", + "confirmUninstall": "Confirm uninstall", + "disable": "Disable", + "dropzoneHint": "Drag and drop a {{extension}} package here, or click to browse", + "enable": "Enable", + "failedToAddSource": "Failed to add source", + "failedToDisable": "Failed to disable {{name}}", + "failedToEnable": "Failed to enable {{name}}", + "failedToInstallPlugin": "Failed to install plugin", + "failedToLoadMarketplace": "Failed to load marketplace", + "failedToLoadPlugins": "Failed to load plugins", + "failedToLoadSettings": "Failed to load plugin settings", + "failedToRemoveSource": "Failed to remove source", + "failedToSaveSettings": "Failed to save plugin settings", + "failedToSetAutoUpdateFor": "Failed to update auto-update for {{name}}", + "failedToSyncPlugins": "Failed to sync plugins", + "failedToUninstall": "Failed to uninstall {{name}}", + "failedToUpdateAutoUpdate": "Failed to update auto-update setting", + "failedToUpdateSource": "Failed to update source", + "fromUrl": "From URL", + "hideRaw": "Hide raw", + "install": "Install", + "installPlugin": "Install plugin", + "installPluginDescription": "Install a plugin package from a URL, or upload a {{extension}} file directly.", + "installed": "Installed", + "installedPlugins": "Installed plugins", + "installing": "Installing…", + "loadingMarketplace": "Loading marketplace…", + "loadingPlugins": "Loading plugins...", + "loadingSelectedUtilityAgent": "Loading selected utility agent...", + "loadingSettings": "Loading settings...", + "loadingUtilityAgents": "Loading utility agents...", + "manifest": "Manifest", + "manifestApiVersion": "API version", + "manifestAuthor": "Author", + "manifestCapabilities": "Capabilities", + "manifestInstalled": "Installed", + "manifestSigned": "Signed", + "manifestVersion": "Version", + "manifestWebhooks": "Webhooks", + "marketplaceSources": "Marketplace sources", + "marketplaceSourcesDescription": "Add a team or corporate registry to browse its plugins alongside the official catalog. Each source serves an {{document}} document.", + "no": "no", + "noDeclaredSettings": "This plugin does not declare any settings (no <1>config_schema in its manifest).", + "noInstalledPluginWithId": "No installed plugin with id <1>{{id}}.", + "noPluginsAvailable": "No plugins available yet. Check back soon, or add a marketplace source with your own.", + "noPluginsMatchSearch": "No plugins match your search. Try clearing the filters.", + "noPluginsYet": "No plugins yet. Install one from the Browse tab, or upload a package.", + "notSet": "Not set", + "override": "override", + "packageUrl": "Package URL", + "pluginInstalled": "{{name}} installed", + "pluginPageFailedToLoad": "This plugin page failed to load.", + "pluginsAvailable_one": "{{count}} plugin available", + "pluginsAvailable_other": "{{count}} plugins available", + "refresh": "Refresh", + "removeSource": "Remove {{name}}", + "repo": "Repo", + "requiredFields": "Required: {{fields}}", + "reset": "Reset", + "resetAutoUpdateFor": "Reset auto-update for {{name}} to the default", + "savingRestartsPlugin": "Saving restarts the plugin so the new settings take effect.", + "searchPlugins": "Search plugins…", + "selectAUtilityAgent": "Select a utility agent...", + "selectPlaceholder": "Select...", + "selectedUtilityAgentUnavailable": "Selected utility agent unavailable", + "settings": "Settings", + "settingsDescription": "Browse the marketplace, then install, enable, disable, and uninstall kandev plugins.", + "settingsSaved": "Plugin settings saved", + "settingsSavedReloadFailed": "Settings saved, but reloading them failed — refresh to confirm.", + "sortMostStars": "Most stars", + "sortName": "Name", + "sortRecentlyUpdated": "Recently updated", + "sourceNamePlaceholder": "Source name (e.g. Acme Internal)", + "sourceOfficial": "Official", + "sourceUnreachable": "Unreachable", + "sourceUnreachableDetail": "<0>{{name}} is unreachable{{detail}} — its plugins are hidden.", + "sources": "Sources", + "statusActive": "Active", + "statusDisabled": "Disabled", + "statusError": "Error", + "statusRegistered": "Registered", + "statusUninstalled": "Uninstalled", + "sync": "Sync", + "syncInstalled_one": "{{count}} installed", + "syncInstalled_other": "{{count}} installed", + "syncMissing_one": "{{count}} missing", + "syncMissing_other": "{{count}} missing", + "syncSideloaded_one": "{{count}} sideloaded", + "syncSideloaded_other": "{{count}} sideloaded", + "syncSummary": "Sync: {{parts}}", + "syncUpToDate": "Everything up to date", + "tabBrowse": "Browse", + "tabInstalled": "Installed", + "thisPlugin": "this plugin", + "uninstall": "Uninstall", + "uninstallConfirm": "This will permanently remove <2>{{name}} and revoke its API key. This action cannot be undone.", + "uninstallPlugin": "Uninstall plugin", + "unsigned": "unsigned", + "update": "Update", + "updateToVersion": "Update to v{{version}}", + "updating": "Updating…", + "uploadFile": "Upload file", + "utilityAgentsUnavailable": "Utility agents unavailable", + "viewRaw": "View raw", + "yes": "yes" +} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 98809b10f0..174fe925da 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -291,6 +291,7 @@ "searchEditors": "Search editors...", "secrets": "Secrets", "secretValue": "Secret value", + "security": "Security", "selectADefaultEditor": "Select a default editor", "selectAShell": "Select a shell", "sendTestNotification": "Send test notification", @@ -355,6 +356,7 @@ "thisProfileWillBeRemovedWhen": "This profile will be removed when you save.", "thisSecret": "this secret", "thisWillPermanentlyRemoveSecret": "This will permanently remove <2>{{name}}. This action cannot be undone.", + "tokens": "Tokens", "transcriptNavigation": "Transcript Navigation", "tree": "Tree", "unableToConnect": "Unable to connect.", diff --git a/apps/web/src/locales/pseudo/account.json b/apps/web/src/locales/pseudo/account.json new file mode 100644 index 0000000000..16a77206ae --- /dev/null +++ b/apps/web/src/locales/pseudo/account.json @@ -0,0 +1,42 @@ +{ + "actions": "Àćţĩōńś", + "activeSessions": "Àćţĩvē śēśśĩōńś", + "apiTokens": "ÀƤĨ ţōķēńś", + "apiTokensBlurb": "Ƥēŕśōńàĺ àććēśś ţōķēńś àũţĥēńţĩćàţē śćŕĩƥţś àńď ĆĹĨś àś ŷōũ. Ŕēvōķē à ţōķēń ĩḿḿēďĩàţēĺŷ ĩƒ ĩţ ḿàŷ ĥàvē ĺēàķēď.", + "cancel": "Ćàńćēĺ", + "changePassword": "Ćĥàńĝē ƥàśśŵōŕď", + "couldNotChangePassword": "Ćōũĺď ńōţ ćĥàńĝē ƥàśśŵōŕď.", + "couldNotCreateToken": "Ćōũĺď ńōţ ćŕēàţē ţōķēń.", + "createApiToken": "Ćŕēàţē ÀƤĨ ţōķēń", + "createApiTokenDescription": "Ĝŕàńţś ţĥē śàḿē àććēśś àś ŷōũŕ àććōũńţ ţō àńŷ śćŕĩƥţ ōŕ ĆĹĨ ţĥàţ ũśēś ĩţ. Ĝĩvē ĩţ à ńàḿē śō ŷōũ ćàń ŕēćōĝńĩźē ĩţ ĺàţēŕ.", + "createToken": "Ćŕēàţē ţōķēń", + "created": "Ćŕēàţēď", + "creating": "Ćŕēàţĩńĝ...", + "currentPassword": "Ćũŕŕēńţ ƥàśśŵōŕď", + "device": "Ďēvĩćē", + "done": "Ďōńē", + "failedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś.", + "failedToLoadTokens": "Ƒàĩĺēď ţō ĺōàď ţōķēńś.", + "ipAddress": "ĨƤ", + "lastSeen": "Ĺàśţ śēēń", + "lastUsed": "Ĺàśţ ũśēď", + "loadingSessions": "Ĺōàďĩńĝ śēśśĩōńś...", + "loadingTokens": "Ĺōàďĩńĝ ţōķēńś...", + "name": "Ńàḿē", + "never": "Ńēvēŕ", + "newPassword": "Ńēŵ ƥàśśŵōŕď", + "newToken": "Ńēŵ ţōķēń", + "noTokensYet": "Ńō ţōķēńś ŷēţ.", + "password": "Ƥàśśŵōŕď", + "passwordUpdated": "Ƥàśśŵōŕď ũƥďàţēď.", + "revoke": "Ŕēvōķē", + "saving": "Śàvĩńĝ...", + "securityPageDescription": "Ćĥàńĝē ŷōũŕ ƥàśśŵōŕď àńď ŕēvĩēŵ ďēvĩćēś śĩĝńēď ĩń ţō ŷōũŕ àććōũńţ.", + "securityPageTitle": "Ƥŕōƒĩĺē & ƥàśśŵōŕď", + "signOut": "Śĩĝń ōũţ", + "thisDevice": "Ţĥĩś ďēvĩćē", + "tokenCreated": "Ţōķēń ćŕēàţēď", + "tokenShownOnlyOnce": "Ţĥĩś ĩś śĥōŵń ōńĺŷ ōńćē — ćōƥŷ ĩţ ńōŵ àńď śţōŕē ĩţ śēćũŕēĺŷ.", + "tokensPageDescription": "Ƥēŕśōńàĺ àććēśś ţōķēńś ƒōŕ śćŕĩƥţś àńď ĆĹĨś àćţĩńĝ àś ŷōũ.", + "tokensPageTitle": "ÀƤĨ ţōķēńś" +} diff --git a/apps/web/src/locales/pseudo/executors.json b/apps/web/src/locales/pseudo/executors.json index 127f7df2e9..c55a3ecfef 100644 --- a/apps/web/src/locales/pseudo/executors.json +++ b/apps/web/src/locales/pseudo/executors.json @@ -1,8 +1,11 @@ { "action": "Àćţĩōń", "add": "Àďď", + "addASpritesApiKeyBeforeCreating": "Àďď à Śƥŕĩţēś ÀƤĨ ķēŷ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.", + "addDockerfileContentBeforeCreating": "Àďď Ďōćķēŕƒĩĺē ćōńţēńţ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.", "addRule": "Àďď Ŕũĺē", "advanced": "Àďvàńćēď", + "agent": "Àĝēńţ", "allow": "Àĺĺōŵ", "allowlistGithubPlaywright": "Àĺĺōŵĺĩśţ ĜĩţĤũƀ + Ƥĺàŷŵŕĩĝĥţ", "apiKey": "ÀƤĨ Ķēŷ", @@ -14,6 +17,7 @@ "buildFailedWithMessage": "Ɓũĩĺď ƒàĩĺēď: {{message}}", "buildFailedWithStatus": "Ɓũĩĺď ƒàĩĺēď ({{status}}): {{text}}", "buildImage": "Ɓũĩĺď Ĩḿàĝē", + "buildThisDockerImageBeforeCreating": "Ɓũĩĺď ţĥĩś Ďōćķēŕ ĩḿàĝē ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.", "building": "Ɓũĩĺďĩńĝ...", "checking": "Ćĥēćķĩńĝ...", "chooseAnExecutorTypeToRun": "Ćĥōōśē àń ēxēćũţōŕ ţŷƥē ţō ŕũń ēńvĩŕōńḿēńţś ōń ŷōũŕ ĩńƒŕàśţŕũćţũŕē.", @@ -24,21 +28,26 @@ "connected": "Ćōńńēćţēď", "connectedActiveSprites_one": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţē.", "connectedActiveSprites_other": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţēś.", + "connection": "Ćōńńēćţĩōń", + "connectionSettings": "Ćōńńēćţĩōń Śēţţĩńĝś", "connectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď", "connectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď", "connectsToARemoteDockerHost": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ. Ţĥē ŕēƥōśĩţōŕŷ ŵĩĺĺ ƀē ćĺōńēď ĩńśĩďē ţĥē ćōńţàĩńēŕ.", "copyAuthFiles": "Ćōƥŷ àũţĥ ƒĩĺēś", "createANewProfileForThis": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś ēxēćũţōŕ. Ŷōũ ćàń ćōńƒĩĝũŕē śćŕĩƥţś àńď ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ōń ţĥē ƥŕōƒĩĺē ƥàĝē.", "createExecutor": "Ćŕēàţē Ēxēćũţōŕ", + "createNewProfile": "Ćŕēàţē Ńēŵ Ƥŕōƒĩĺē", "createNewSecret": "Ćŕēàţē ńēŵ śēćŕēţ...", "createProfile": "Ćŕēàţē Ƥŕōƒĩĺē", "creating": "Ćŕēàţĩńĝ...", + "creatingProfile": "Ćŕēàţĩńĝ ƥŕōƒĩĺē...", "defaultNoTls": "Ďēƒàũĺţ (ńō ŢĹŚ)", "defineNetworkAccessRulesAppliedWhen": "Ďēƒĩńē ńēţŵōŕķ àććēśś ŕũĺēś àƥƥĺĩēď ŵĥēń à śƥŕĩţē ĩś ćŕēàţēď ƒōŕ ţĥĩś ƥŕōƒĩĺē.", "defineTheDockerImageBuildAnd": "Ďēƒĩńē ţĥē Ďōćķēŕ ĩḿàĝē. Ɓũĩĺď àńď ţēśţ ĩţ ĥēŕē.", "delete": "Ďēĺēţē", "deleteExecutor": "Ďēĺēţē Ēxēćũţōŕ", "deleteProfile": "Ďēĺēţē Ƥŕōƒĩĺē", + "deleteProfileConfirm": "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē \"{{name}}\"? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", "deleting": "Ďēĺēţĩńĝ...", "deny": "Ďēńŷ", "descriptionCustom": "Ćũśţōḿ ēxēćũţōŕ.", @@ -46,6 +55,8 @@ "descriptionLocalPc": "Ŕũńś àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.", "descriptionRemoteDocker": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ.", "descriptionSprites": "Ŕũńś àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.", + "descriptionSsh": "Ŕũńś àĝēńţś ōń à ţŕũśţēď Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ ōvēŕ ŚŚĤ.", + "descriptionSshCreate": "Ćōńńēćţś ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũńś àĝēńţćţĺ ţĥēŕē.", "descriptionWorktree": "Ćŕēàţēś ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.", "differentConfigurationsForThisExecutorEach": "Ďĩƒƒēŕēńţ ćōńƒĩĝũŕàţĩōńś ƒōŕ ţĥĩś ēxēćũţōŕ. Ēàćĥ ƥŕōƒĩĺē ćàń ĥàvē ĩţś ōŵń ƥŕēƥàŕē śćŕĩƥţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď śēţţĩńĝś.", "disabled": "Ďĩśàƀĺēď", @@ -59,6 +70,8 @@ "eGMyApiToken": "ē.ĝ. {{example}}", "eGProductionDevelopment": "ē.ĝ. {{example}}", "enabled": "Ēńàƀĺēď", + "enterAProfileName": "Ēńţēŕ à ƥŕōƒĩĺē ńàḿē.", + "enterAnImageTagBeforeCreating": "Ēńţēŕ àń ĩḿàĝē ţàĝ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.", "envVarsConfiguredCount_one": "{{count}} ćōńƒĩĝũŕēď", "envVarsConfiguredCount_other": "{{count}} ćōńƒĩĝũŕēď", "environmentVariables": "Ēńvĩŕōńḿēńţ Vàŕĩàƀĺēś", @@ -69,6 +82,7 @@ "failedToConnectToBackend": "Ƒàĩĺēď ţō ćōńńēćţ ţō ƀàćķēńď", "failedToCreateProfile": "Ƒàĩĺēď ţō ćŕēàţē ƥŕōƒĩĺē", "failedToCreateSecret": "Ƒàĩĺēď ţō ćŕēàţē śēćŕēţ", + "failedToLoadExecutor": "Ƒàĩĺēď ţō ĺōàď ēxēćũţōŕ", "failedToSaveProfile": "Ƒàĩĺēď ţō śàvē ƥŕōƒĩĺē", "filesSelected": "Ƒĩĺēś Śēĺēćţēď", "gitIdentity": "Ĝĩţ Ĩďēńţĩţŷ", @@ -78,6 +92,13 @@ "gitUserEmail": "Ĝĩţ Ũśēŕ Ēḿàĩĺ", "gitUserName": "Ĝĩţ Ũśēŕ Ńàḿē", "goToExecutors": "Ĝō ţō Ēxēćũţōŕś", + "host": "Ĥōśţ", + "hubDescription": "Ēxēćũţōŕ ƥŕōƒĩĺēś ďēƒĩńē ĥōŵ àńď ŵĥēŕē àĝēńţś ŕũń. Ēàćĥ ƥŕōƒĩĺē ćōńƒĩĝũŕēś àń ēxēćũţĩōń ēńvĩŕōńḿēńţ ŵĩţĥ śćŕĩƥţś, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď ḾĆƤ ƥōĺĩćĩēś.", + "hubDescriptionDocker": "Ŕũń Ďōćķēŕ ćōńţàĩńēŕś ōń ţĥĩś ḿàćĥĩńē.", + "hubDescriptionLocal": "Ŕũń àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.", + "hubDescriptionSprites": "Ŕũń àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.", + "hubDescriptionSsh": "Ćōńńēćţ ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũń àĝēńţćţĺ ţĥēŕē.", + "hubDescriptionWorktree": "Ćŕēàţē ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.", "image": "Ĩḿàĝē", "imageTag": "Ĩḿàĝē Ţàĝ", "include": "Ĩńćĺũďē", @@ -88,6 +109,7 @@ "key": "Ķēŷ", "loading": "Ĺōàďĩńĝ...", "loadingEditor": "Ĺōàďĩńĝ ēďĩţōŕ...", + "loadingExecutor": "Ĺōàďĩńĝ ēxēćũţōŕ...", "localDocker": "Ĺōćàĺ Ďōćķēŕ", "localDockerExecutor": "Ĺōćàĺ Ďōćķēŕ Ēxēćũţōŕ", "localGitIdentityNotDetected": "Ĺōćàĺ ĝĩţ ũśēŕ.ńàḿē/ũśēŕ.ēḿàĩĺ ńōţ ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē", @@ -100,6 +122,8 @@ "name": "Ńàḿē", "networkPolicies": "Ńēţŵōŕķ Ƥōĺĩćĩēś", "newProfile": "Ńēŵ Ƥŕōƒĩĺē", + "newSshExecutor": "Ńēŵ ŚŚĤ Ēxēćũţōŕ", + "newTypeProfile": "Ńēŵ {{type}} Ƥŕōƒĩĺē", "noDockerContainers": "Ńō Ďōćķēŕ ćōńţàĩńēŕś.", "noEnvironmentVariablesConfigured": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď.", "noEnvironmentVariablesConfiguredAddOne": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.", @@ -108,6 +132,7 @@ "noResponseBody": "Ńō ŕēśƥōńśē ƀōďŷ", "noTransferableCredentialsFound": "Ńō ţŕàńśƒēŕàƀĺē ćŕēďēńţĩàĺś ƒōũńď.", "none": "Ńōńē", + "notAnSshExecutor": "Ēxēćũţōŕ {{id}} ĩś ńōţ àń ŚŚĤ ēxēćũţōŕ", "notConfigured": "Ńōţ Ćōńƒĩĝũŕēď", "onlyHttpSse": "Ōńĺŷ ĤŢŢƤ/ŚŚĒ", "onlyStdio": "Ōńĺŷ śţďĩō", @@ -117,6 +142,7 @@ "pasteYourSecretValue": "Ƥàśţē ŷōũŕ śēćŕēţ vàĺũē...", "pathToTlsCertificatesForThe": "Ƥàţĥ ţō ŢĹŚ ćēŕţĩƒĩćàţēś ƒōŕ ţĥē Ďōćķēŕ ĥōśţ.", "personalAccessTokenForCloningRepositories": "Ƥēŕśōńàĺ àććēśś ţōķēń ƒōŕ ćĺōńĩńĝ ŕēƥōśĩţōŕĩēś ĩńśĩďē ţĥē ćōńţàĩńēŕ. Àũţō-ďēţēćţēď ƒŕōḿ ĥōśţ ēńvĩŕōńḿēńţ ĩƒ ńōţ śēţ.", + "port": "Ƥōŕţ", "prepareScript": "Ƥŕēƥàŕē Śćŕĩƥţ", "prepareScriptBadge": "Ƥŕēƥàŕē śćŕĩƥţ", "prepareScriptDescriptionLocal": "Ŕũńś ōń ţĥē ĥōśţ ḿàćĥĩńē ƀēƒōŕē ţĥē àĝēńţ śţàŕţś.", @@ -129,6 +155,7 @@ "profiles": "Ƥŕōƒĩĺēś", "provideSecret": "Ƥŕōvĩďē śēćŕēţ", "quickPresets": "Qũĩćķ ƥŕēśēţś", + "refresh": "Ŕēƒŕēśĥ", "relatedDockerContainersRemoved_one": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.", "relatedDockerContainersRemoved_other": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕś ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.", "remoteAuthMethod": "Ŕēḿōţē àũţĥ ḿēţĥōď", @@ -149,8 +176,63 @@ "selectOrCreateASecret": "Śēĺēćţ ōŕ ćŕēàţē à śēćŕēţ...", "selectSecret": "Śēĺēćţ śēćŕēţ...", "selectTheSecretContainingYourSprites": "Śēĺēćţ ţĥē śēćŕēţ ćōńţàĩńĩńĝ ŷōũŕ Śƥŕĩţēś.ďēv ÀƤĨ ţōķēń.", + "session": "Śēśśĩōń", "setACustomNameAndEmail": "Śēţ à ćũśţōḿ ńàḿē àńď ēḿàĩĺ ƒōŕ ŕēḿōţē ĝĩţ ćōḿḿĩţś.", "setEnvVarViaStoredSecret": "Śēţ <1>{{envVar}} vĩà à śţōŕēď śēćŕēţ", + "spritesApiKeyIsRequired": "Śƥŕĩţēś ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.", + "spritesTokenIsRequired": "Śƥŕĩţēś ţōķēń ĩś ŕēqũĩŕēď.", + "sshActiveSessions": "Àćţĩvē śēśśĩōńś", + "sshActiveSessionsDescription": "Śēśśĩōńś ćũŕŕēńţĺŷ ŕũńńĩńĝ ōń ţĥĩś ŚŚĤ ĥōśţ. Ŕēƒŕēśĥēś ēvēŕŷ 90 śēćōńďś.", + "sshAvailableAgents": "Àvàĩĺàƀĺē àĝēńţś ōń ţĥĩś ĥōśţ", + "sshAvailableAgentsDescription": "Ƥŕōƀēś ţĥē ŕēḿōţē {{pathVar}} ƒōŕ ēàćĥ ēńàƀĺēď àĝēńţ ũńďēŕ ţĥē ćĥōśēń ĺōĝĩń śĥēĺĺ. Ćōƥŷ ţĥē ĩńśţàĺĺ ĥĩńţ àńď ŕũń ĩţ ōń ţĥē ŕēḿōţē ŵĥēń àń àĝēńţ ĩś ḿĩśśĩńĝ.", + "sshBinary": "Ɓĩńàŕŷ", + "sshConfirmRunningSessions_one": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōń. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?", + "sshConfirmRunningSessions_other": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōńś. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?", + "sshConnectionDescription": "Ŕũń àń àĝēńţ ōń Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţś ŷōũ ćàń ŕēàćĥ ōvēŕ ŚŚĤ.", + "sshConnectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď", + "sshConnectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď", + "sshCopyInstallHint": "Ćōƥŷ ĩńśţàĺĺ ĥĩńţ", + "sshCreateDescription": "Ćōńńēćţ ţō à ŕēḿōţē Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ àńď ŕũń àĝēńţćţĺ ţĥēŕē.", + "sshExecutorPageDescription": "Ēďĩţ ţĥē ćōńńēćţĩōń śēţţĩńĝś ōŕ ŕē-ţŕũśţ ţĥē ĥōśţ. Ēxĩśţĩńĝ śēśśĩōńś ķēēƥ ţĥēĩŕ śńàƥśĥōţ ōƒ ţĥē ƥŕēvĩōũś ćōńƒĩĝ.", + "sshFailedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś", + "sshFailedToProbeAgents": "Ƒàĩĺēď ţō ƥŕōƀē àĝēńţś", + "sshFailedToReachBackend": "Ƒàĩĺēď ţō ŕēàćĥ ƀàćķēńď", + "sshFailedToSaveExecutor": "Ƒàĩĺēď ţō śàvē ēxēćũţōŕ", + "sshFailedToSaveShell": "Ƒàĩĺēď ţō śàvē śĥēĺĺ", + "sshFingerprintChanged": "Ŵàŕńĩńĝ: ţĥĩś ƒĩńĝēŕƥŕĩńţ ďĩƒƒēŕś ƒŕōḿ ţĥē ōńē ćũŕŕēńţĺŷ ƥĩńńēď (<1>{{fingerprint}}). Ţŕũśţĩńĝ ĩţ ōvēŕŵŕĩţēś ţĥē ƥĩńńēď ķēŷ. Ĩƒ ŷōũ ďĩďń’ţ ēxƥēćţ à ĥōśţ ŕē-ķēŷ, śţōƥ ĥēŕē.", + "sshFingerprintObserved": "Ĥōśţ ƒĩńĝēŕƥŕĩńţ ōƀśēŕvēď:", + "sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.", + "sshHostAliasLabel": "Ĥōśţ àĺĩàś ƒŕōḿ {{path}} (ōƥţĩōńàĺ)", + "sshIdentityFileHint": "Ƥàśśƥĥŕàśē-ƥŕōţēćţēď ķēŷś ḿũśţ ƀē ĺōàďēď ĩńţō śśĥ-àĝēńţ ƒĩŕśţ.", + "sshIdentityFilePath": "Ĩďēńţĩţŷ ƒĩĺē ƥàţĥ", + "sshIdentitySource": "Ĩďēńţĩţŷ śōũŕćē", + "sshIdentitySourceFile": "Ĩďēńţĩţŷ ƒĩĺē (ƥŕĩvàţē ķēŷ ƥàţĥ)", + "sshInstallHint": "Ĩńśţàĺĺ ĥĩńţ", + "sshInstallHintCopied": "Ĩńśţàĺĺ ĥĩńţ ćōƥĩēď", + "sshInstalled": "Ĩńśţàĺĺēď", + "sshLocalForward": "Ĺōćàĺ ƒŵď", + "sshLoginShell": "Ĺōĝĩń śĥēĺĺ", + "sshMissing": "Ḿĩśśĩńĝ", + "sshNamePlaceholder": "Ḿŷ VƤŚ", + "sshNoActiveSessions": "Ńō àćţĩvē śēśśĩōńś.", + "sshNoEnabledAgents": "Ńō ēńàƀĺēď àĝēńţś ţō ƥŕōƀē.", + "sshNoHintAvailable": "Ńō ĥĩńţ àvàĩĺàƀĺē", + "sshPinnedFingerprint": "Ƥĩńńēď ƒĩńĝēŕƥŕĩńţ:", + "sshProbeAgents": "Ƥŕōƀē àĝēńţś", + "sshProbeError": "Ƥŕōƀē ēŕŕōŕ", + "sshProbeHint": "Ćĺĩćķ \"{{button}}\" ţō ćĥēćķ ŵĥĩćĥ àĝēńţś àŕē ĩńśţàĺĺēď ōń ţĥē ŕēḿōţē.", + "sshProxyJumpHint": "Śĩńĝĺē ƀàśţĩōń ĥōƥ. Ćĥàĩńēď ĵũḿƥś àŕē ńōţ śũƥƥōŕţēď.", + "sshProxyJumpLabel": "ƤŕōxŷĴũḿƥ (ōƥţĩōńàĺ)", + "sshRemotePort": "Ŕēḿōţē ƥōŕţ", + "sshReprobe": "Ŕē-ƥŕōƀē", + "sshShellNotDetected": "(ńōţ ďēţēćţēď)", + "sshTestAndTrustToSave": "Ţēśţ ţĥē ćōńńēćţĩōń àńď ţŕũśţ ĩţś ƒĩńĝēŕƥŕĩńţ ţō śàvē.", + "sshTestConnection": "Ţēśţ ćōńńēćţĩōń", + "sshTestResultStale": "Ŷōũ ćĥàńĝēď à ćōńńēćţĩōń ƒĩēĺď àƒţēŕ ţĥĩś ţēśţ ŕàń. Ŕũń Ţēśţ Ćōńńēćţĩōń àĝàĩń ƀēƒōŕē ţŕũśţĩńĝ àńď śàvĩńĝ.", + "sshTrustThisHost": "<0>Ţŕũśţ ţĥĩś ĥōśţ. Ĩ’vē vēŕĩƒĩēď ţĥē ƒĩńĝēŕƥŕĩńţ àƀōvē ḿàţćĥēś ḿŷ śēŕvēŕ. Ƒũţũŕē ćōńńēćţĩōńś ţĥàţ ŕēƥōŕţ à ďĩƒƒēŕēńţ ƒĩńĝēŕƥŕĩńţ ŵĩĺĺ ƀē ŕēƒũśēď.", + "sshTrusted": "Ţŕũśţēď", + "sshUnverified": "Ũńvēŕĩƒĩēď", + "status": "Śţàţũś", "stop": "Śţōƥ", "success": "Śũććēśś", "task": "Ţàśķ", @@ -160,12 +242,17 @@ "tlsCertificatePath": "ŢĹŚ ćēŕţĩƒĩćàţē ƥàţĥ", "tlsVerify": "ŢĹŚ vēŕĩƒŷ", "tokenConfiguredButUnableToConnect": "Ţōķēń ćōńƒĩĝũŕēď ƀũţ ũńàƀĺē ţō ćōńńēćţ.", + "typeLocal": "Ĺōćàĺ", "typeTokenToConfirmDeletion": "Ţŷƥē \"{{token}}\" ţō ćōńƒĩŕḿ ďēĺēţĩōń. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", + "typeWorktree": "Ŵōŕķţŕēē", "unknownError": "Ũńķńōŵń ēŕŕōŕ", + "unknownExecutorType": "Ũńķńōŵń ēxēćũţōŕ ţŷƥē", "untrackedTask": "Ũńţŕàćķēď", + "uptime": "Ũƥţĩḿē", "useDefaults": "Ũśē ďēƒàũĺţś", "useLocalGitConfig": "Ũśē ĺōćàĺ ĝĩţ ćōńƒĩĝ", "usedByRemoteExecutorsForCommit": "Ũśēď ƀŷ ŕēḿōţē ēxēćũţōŕś ƒōŕ ćōḿḿĩţ àũţĥōŕ ćōńƒĩĝũŕàţĩōń.", + "user": "Ũśēŕ", "usesTheLocalDockerDaemonOn": "Ũśēś ţĥē ĺōćàĺ Ďōćķēŕ ďàēḿōń ōń ţĥĩś ḿàćĥĩńē.", "value": "Vàĺũē", "verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń..." diff --git a/apps/web/src/locales/pseudo/plugins.json b/apps/web/src/locales/pseudo/plugins.json new file mode 100644 index 0000000000..1071670951 --- /dev/null +++ b/apps/web/src/locales/pseudo/plugins.json @@ -0,0 +1,116 @@ +{ + "add": "Àďď", + "allCategories": "Àĺĺ ćàţēĝōŕĩēś", + "autoUpdate": "Àũţō-ũƥďàţē", + "autoUpdateDescription": "Ćĥēćķ ţĥē ḿàŕķēţƥĺàćē ƥēŕĩōďĩćàĺĺŷ àńď ĩńśţàĺĺ ńēŵēŕ vēŕśĩōńś ōƒ àćţĩvē ƥĺũĝĩńś. Àƥƥĺĩēś ţō ēvēŕŷ ƥĺũĝĩń ũńĺēśś ōvēŕŕĩďďēń ƥēŕ ƥĺũĝĩń ƀēĺōŵ.", + "autoUpdateFor": "Àũţō-ũƥďàţē ƒōŕ {{name}}", + "autoUpdateTitle": "Àũţōḿàţĩćàĺĺŷ ũƥďàţē ƥĺũĝĩńś", + "byAuthor": "ƀŷ {{author}}", + "cancel": "Ćàńćēĺ", + "category": "Ćàţēĝōŕŷ", + "confirmUninstall": "Ćōńƒĩŕḿ ũńĩńśţàĺĺ", + "disable": "Ďĩśàƀĺē", + "dropzoneHint": "Ďŕàĝ àńď ďŕōƥ à {{extension}} ƥàćķàĝē ĥēŕē, ōŕ ćĺĩćķ ţō ƀŕōŵśē", + "enable": "Ēńàƀĺē", + "failedToAddSource": "Ƒàĩĺēď ţō àďď śōũŕćē", + "failedToDisable": "Ƒàĩĺēď ţō ďĩśàƀĺē {{name}}", + "failedToEnable": "Ƒàĩĺēď ţō ēńàƀĺē {{name}}", + "failedToInstallPlugin": "Ƒàĩĺēď ţō ĩńśţàĺĺ ƥĺũĝĩń", + "failedToLoadMarketplace": "Ƒàĩĺēď ţō ĺōàď ḿàŕķēţƥĺàćē", + "failedToLoadPlugins": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩńś", + "failedToLoadSettings": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩń śēţţĩńĝś", + "failedToRemoveSource": "Ƒàĩĺēď ţō ŕēḿōvē śōũŕćē", + "failedToSaveSettings": "Ƒàĩĺēď ţō śàvē ƥĺũĝĩń śēţţĩńĝś", + "failedToSetAutoUpdateFor": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē ƒōŕ {{name}}", + "failedToSyncPlugins": "Ƒàĩĺēď ţō śŷńć ƥĺũĝĩńś", + "failedToUninstall": "Ƒàĩĺēď ţō ũńĩńśţàĺĺ {{name}}", + "failedToUpdateAutoUpdate": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē śēţţĩńĝ", + "failedToUpdateSource": "Ƒàĩĺēď ţō ũƥďàţē śōũŕćē", + "fromUrl": "Ƒŕōḿ ŨŔĹ", + "hideRaw": "Ĥĩďē ŕàŵ", + "install": "Ĩńśţàĺĺ", + "installPlugin": "Ĩńśţàĺĺ ƥĺũĝĩń", + "installPluginDescription": "Ĩńśţàĺĺ à ƥĺũĝĩń ƥàćķàĝē ƒŕōḿ à ŨŔĹ, ōŕ ũƥĺōàď à {{extension}} ƒĩĺē ďĩŕēćţĺŷ.", + "installed": "Ĩńśţàĺĺēď", + "installedPlugins": "Ĩńśţàĺĺēď ƥĺũĝĩńś", + "installing": "Ĩńśţàĺĺĩńĝ…", + "loadingMarketplace": "Ĺōàďĩńĝ ḿàŕķēţƥĺàćē…", + "loadingPlugins": "Ĺōàďĩńĝ ƥĺũĝĩńś...", + "loadingSelectedUtilityAgent": "Ĺōàďĩńĝ śēĺēćţēď ũţĩĺĩţŷ àĝēńţ...", + "loadingSettings": "Ĺōàďĩńĝ śēţţĩńĝś...", + "loadingUtilityAgents": "Ĺōàďĩńĝ ũţĩĺĩţŷ àĝēńţś...", + "manifest": "Ḿàńĩƒēśţ", + "manifestApiVersion": "ÀƤĨ vēŕśĩōń", + "manifestAuthor": "Àũţĥōŕ", + "manifestCapabilities": "Ćàƥàƀĩĺĩţĩēś", + "manifestInstalled": "Ĩńśţàĺĺēď", + "manifestSigned": "Śĩĝńēď", + "manifestVersion": "Vēŕśĩōń", + "manifestWebhooks": "Ŵēƀĥōōķś", + "marketplaceSources": "Ḿàŕķēţƥĺàćē śōũŕćēś", + "marketplaceSourcesDescription": "Àďď à ţēàḿ ōŕ ćōŕƥōŕàţē ŕēĝĩśţŕŷ ţō ƀŕōŵśē ĩţś ƥĺũĝĩńś àĺōńĝśĩďē ţĥē ōƒƒĩćĩàĺ ćàţàĺōĝ. Ēàćĥ śōũŕćē śēŕvēś àń {{document}} ďōćũḿēńţ.", + "no": "ńō", + "noDeclaredSettings": "Ţĥĩś ƥĺũĝĩń ďōēś ńōţ ďēćĺàŕē àńŷ śēţţĩńĝś (ńō <1>ćōńƒĩĝ_śćĥēḿà ĩń ĩţś ḿàńĩƒēśţ).", + "noInstalledPluginWithId": "Ńō ĩńśţàĺĺēď ƥĺũĝĩń ŵĩţĥ ĩď <1>{{id}}.", + "noPluginsAvailable": "Ńō ƥĺũĝĩńś àvàĩĺàƀĺē ŷēţ. Ćĥēćķ ƀàćķ śōōń, ōŕ àďď à ḿàŕķēţƥĺàćē śōũŕćē ŵĩţĥ ŷōũŕ ōŵń.", + "noPluginsMatchSearch": "Ńō ƥĺũĝĩńś ḿàţćĥ ŷōũŕ śēàŕćĥ. Ţŕŷ ćĺēàŕĩńĝ ţĥē ƒĩĺţēŕś.", + "noPluginsYet": "Ńō ƥĺũĝĩńś ŷēţ. Ĩńśţàĺĺ ōńē ƒŕōḿ ţĥē Ɓŕōŵśē ţàƀ, ōŕ ũƥĺōàď à ƥàćķàĝē.", + "notSet": "Ńōţ śēţ", + "override": "ōvēŕŕĩďē", + "packageUrl": "Ƥàćķàĝē ŨŔĹ", + "pluginInstalled": "{{name}} ĩńśţàĺĺēď", + "pluginPageFailedToLoad": "Ţĥĩś ƥĺũĝĩń ƥàĝē ƒàĩĺēď ţō ĺōàď.", + "pluginsAvailable_one": "{{count}} ƥĺũĝĩń àvàĩĺàƀĺē", + "pluginsAvailable_other": "{{count}} ƥĺũĝĩńś àvàĩĺàƀĺē", + "refresh": "Ŕēƒŕēśĥ", + "removeSource": "Ŕēḿōvē {{name}}", + "repo": "Ŕēƥō", + "requiredFields": "Ŕēqũĩŕēď: {{fields}}", + "reset": "Ŕēśēţ", + "resetAutoUpdateFor": "Ŕēśēţ àũţō-ũƥďàţē ƒōŕ {{name}} ţō ţĥē ďēƒàũĺţ", + "savingRestartsPlugin": "Śàvĩńĝ ŕēśţàŕţś ţĥē ƥĺũĝĩń śō ţĥē ńēŵ śēţţĩńĝś ţàķē ēƒƒēćţ.", + "searchPlugins": "Śēàŕćĥ ƥĺũĝĩńś…", + "selectAUtilityAgent": "Śēĺēćţ à ũţĩĺĩţŷ àĝēńţ...", + "selectPlaceholder": "Śēĺēćţ...", + "selectedUtilityAgentUnavailable": "Śēĺēćţēď ũţĩĺĩţŷ àĝēńţ ũńàvàĩĺàƀĺē", + "settings": "Śēţţĩńĝś", + "settingsDescription": "Ɓŕōŵśē ţĥē ḿàŕķēţƥĺàćē, ţĥēń ĩńśţàĺĺ, ēńàƀĺē, ďĩśàƀĺē, àńď ũńĩńśţàĺĺ ķàńďēv ƥĺũĝĩńś.", + "settingsSaved": "Ƥĺũĝĩń śēţţĩńĝś śàvēď", + "settingsSavedReloadFailed": "Śēţţĩńĝś śàvēď, ƀũţ ŕēĺōàďĩńĝ ţĥēḿ ƒàĩĺēď — ŕēƒŕēśĥ ţō ćōńƒĩŕḿ.", + "sortMostStars": "Ḿōśţ śţàŕś", + "sortName": "Ńàḿē", + "sortRecentlyUpdated": "Ŕēćēńţĺŷ ũƥďàţēď", + "sourceNamePlaceholder": "Śōũŕćē ńàḿē (ē.ĝ. Àćḿē Ĩńţēŕńàĺ)", + "sourceOfficial": "Ōƒƒĩćĩàĺ", + "sourceUnreachable": "Ũńŕēàćĥàƀĺē", + "sourceUnreachableDetail": "<0>{{name}} ĩś ũńŕēàćĥàƀĺē{{detail}} — ĩţś ƥĺũĝĩńś àŕē ĥĩďďēń.", + "sources": "Śōũŕćēś", + "statusActive": "Àćţĩvē", + "statusDisabled": "Ďĩśàƀĺēď", + "statusError": "Ēŕŕōŕ", + "statusRegistered": "Ŕēĝĩśţēŕēď", + "statusUninstalled": "Ũńĩńśţàĺĺēď", + "sync": "Śŷńć", + "syncInstalled_one": "{{count}} ĩńśţàĺĺēď", + "syncInstalled_other": "{{count}} ĩńśţàĺĺēď", + "syncMissing_one": "{{count}} ḿĩśśĩńĝ", + "syncMissing_other": "{{count}} ḿĩśśĩńĝ", + "syncSideloaded_one": "{{count}} śĩďēĺōàďēď", + "syncSideloaded_other": "{{count}} śĩďēĺōàďēď", + "syncSummary": "Śŷńć: {{parts}}", + "syncUpToDate": "Ēvēŕŷţĥĩńĝ ũƥ ţō ďàţē", + "tabBrowse": "Ɓŕōŵśē", + "tabInstalled": "Ĩńśţàĺĺēď", + "thisPlugin": "ţĥĩś ƥĺũĝĩń", + "uninstall": "Ũńĩńśţàĺĺ", + "uninstallConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}} àńď ŕēvōķē ĩţś ÀƤĨ ķēŷ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", + "uninstallPlugin": "Ũńĩńśţàĺĺ ƥĺũĝĩń", + "unsigned": "ũńśĩĝńēď", + "update": "Ũƥďàţē", + "updateToVersion": "Ũƥďàţē ţō v{{version}}", + "updating": "Ũƥďàţĩńĝ…", + "uploadFile": "Ũƥĺōàď ƒĩĺē", + "utilityAgentsUnavailable": "Ũţĩĺĩţŷ àĝēńţś ũńàvàĩĺàƀĺē", + "viewRaw": "Vĩēŵ ŕàŵ", + "yes": "ŷēś" +} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index ed18d27a5a..1d124398b3 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -291,6 +291,7 @@ "searchEditors": "Śēàŕćĥ ēďĩţōŕś...", "secrets": "Śēćŕēţś", "secretValue": "Śēćŕēţ vàĺũē", + "security": "Śēćũŕĩţŷ", "selectADefaultEditor": "Śēĺēćţ à ďēƒàũĺţ ēďĩţōŕ", "selectAShell": "Śēĺēćţ à śĥēĺĺ", "sendTestNotification": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń", @@ -355,6 +356,7 @@ "thisProfileWillBeRemovedWhen": "Ţĥĩś ƥŕōƒĩĺē ŵĩĺĺ ƀē ŕēḿōvēď ŵĥēń ŷōũ śàvē.", "thisSecret": "ţĥĩś śēćŕēţ", "thisWillPermanentlyRemoveSecret": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", + "tokens": "Ţōķēńś", "transcriptNavigation": "Ţŕàńśćŕĩƥţ Ńàvĩĝàţĩōń", "tree": "Ţŕēē", "unableToConnect": "Ũńàƀĺē ţō ćōńńēćţ.", diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index b86deae427..76f8edb990 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import AgentsSettingsPage from "@/app/settings/agents/page"; import AgentSetupPage from "@/app/settings/agents/[agentId]/page"; @@ -176,22 +176,8 @@ const SETTINGS_ROUTES: Record = { ), - "/settings/account/security": () => ( - - - - ), - "/settings/account/tokens": () => ( - - - - ), + "/settings/account/security": () => , + "/settings/account/tokens": () => , "/settings/system/about": () => ( @@ -418,6 +404,32 @@ function renderIntegrationSettingsRoute(section: string | null, workspaceId?: st } } +// Components rather than inline JSX so `t()` resolves at render — a t() call +// inside SETTINGS_ROUTES would run at module load and freeze at the boot locale. +function AccountSecurityRoute() { + const { t } = useTranslation(); + return ( + + + + ); +} + +function AccountTokensRoute() { + const { t } = useTranslation(); + return ( + + + + ); +} + function renderUpdatesRoute() { return ; }