Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -44,6 +44,7 @@ import {
PROJECT_ACTION_ICONS,
PROJECT_ACTIONS_UPDATED_EVENT,
} from '@/lib/projectActions';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type EditableProjectAction = OpenChamberProjectAction;
@@ -67,6 +68,7 @@ interface ProjectActionsSectionProps {
}
export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ projectRef }) => {
const { t } = useI18n();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
@@ -126,10 +128,10 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
return entry.name.trim().length === 0 || entry.command.trim().length === 0;
});
if (hasIncomplete) {
return 'Fill action name and command before saving.';
return t('settings.projects.actions.validation.fillNameAndCommand');
}
return null;
}, [actions]);
}, [actions, t]);
const hasChanges = React.useMemo(() => {
if (initialSnapshot === null) {
@@ -172,7 +174,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
primaryActionId: null,
});
if (!ok) {
toast.error('Failed to save actions');
toast.error(t('settings.projects.actions.toast.saveFailed'));
return;
}
setInitialSnapshot(JSON.stringify({ actions }));
@@ -181,13 +183,13 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
detail: { projectId: projectRef.id },
}));
}
toast.success('Project actions saved');
toast.success(t('settings.projects.actions.toast.saved'));
} catch {
toast.error('Failed to save actions');
toast.error(t('settings.projects.actions.toast.saveFailed'));
} finally {
setIsSaving(false);
}
}, [actions, projectRef, validationError]);
}, [actions, projectRef, t, validationError]);
const canSave = !isSaving && !isLoading && hasChanges && !validationError;
@@ -195,21 +197,21 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<div className="mb-8">
<div className="mb-1 flex items-start justify-between gap-2">
<div>
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.projects.actions.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.description')}</p>
</div>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
<RiAddLine className="h-3.5 w-3.5" />
Add action
{t('settings.projects.actions.actions.add')}
</Button>
</div>
<section className="pb-2 pt-0 space-y-2">
{isLoading ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.loading')}</p>
) : actions.length === 0 ? (
<div className="py-2">
<p className="typography-meta text-muted-foreground">No actions configured yet.</p>
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.empty')}</p>
</div>
) : (
<div className="space-y-0 max-w-[30rem]">
@@ -217,7 +219,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const isOpen = expandedActions[action.id] ?? false;
const title = action.name.trim() || 'Untitled action';
const title = action.name.trim() || t('settings.projects.actions.state.untitled');
return (
<Collapsible
@@ -267,7 +269,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<button
type="button"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
aria-label="Select icon"
aria-label={t('settings.projects.actions.field.selectIconAria')}
>
<SelectedIcon className="h-4 w-4" />
</button>
@@ -286,7 +288,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
)}
aria-label={`Icon ${entry.label}`}
aria-label={t('settings.projects.actions.field.iconAria', { icon: entry.label })}
>
<Icon className="h-4 w-4" />
</button>
@@ -299,24 +301,24 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<Input
value={action.name}
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
placeholder="Action name"
placeholder={t('settings.projects.actions.field.actionNamePlaceholder')}
className="h-7 max-w-[14rem]"
/>
</div>
<div className="py-1">
<p className="typography-meta mb-0.5 text-muted-foreground">Command</p>
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.command')}</p>
<Textarea
value={action.command}
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
placeholder="e.g. bun run lint"
placeholder={t('settings.projects.actions.field.commandPlaceholder')}
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
/>
</div>
<div className="py-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="typography-ui-label text-foreground">Auto-open URL</span>
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
<div
className="group flex cursor-pointer items-center gap-2"
role="button"
@@ -342,9 +344,9 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
...current,
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
}))}
ariaLabel={`Auto-open URL for ${title}`}
ariaLabel={t('settings.projects.actions.field.autoOpenUrlForAria', { title })}
/>
<span className="typography-ui-label font-normal text-foreground/80">Open URL from output or custom URL below</span>
<span className="typography-ui-label font-normal text-foreground/80">{t('settings.projects.actions.field.autoOpenUrlDescription')}</span>
</div>
</div>
@@ -357,7 +359,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
...current,
openUrl: event.target.value,
}))}
placeholder="Override URL (optional)"
placeholder={t('settings.projects.actions.field.overrideUrlPlaceholder')}
className="h-7 w-full max-w-[24rem]"
/>
<Tooltip delayDuration={1000}>
@@ -365,14 +367,14 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
If this field is filled, custom URL is used. If empty, app opens best URL from output.
{t('settings.projects.actions.field.overrideUrlTooltip')}
</TooltipContent>
</Tooltip>
</div>
{isDesktopShellApp ? (
<div className="mt-2">
<p className="typography-meta mb-0.5 text-muted-foreground">Desktop SSH forward</p>
<p className="typography-meta mb-0.5 text-muted-foreground">{t('settings.projects.actions.field.desktopSshForward')}</p>
{desktopForwardOptions.length > 0 ? (
<Select
value={
@@ -388,17 +390,17 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
}}
>
<SelectTrigger className="h-7 w-full max-w-[30rem]">
<SelectValue placeholder="Use output/manual URL" />
<SelectValue placeholder={t('settings.projects.actions.field.useOutputManualUrl')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Use output/manual URL</SelectItem>
<SelectItem value="__none__">{t('settings.projects.actions.field.useOutputManualUrl')}</SelectItem>
{desktopForwardOptions.map((entry) => (
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="typography-meta text-muted-foreground">No enabled local SSH forwards available.</p>
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.state.noDesktopSshForwards')}</p>
)}
</div>
) : null}
@@ -425,7 +427,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
onClick={handleSave}
disabled={!canSave}
>
{isSaving ? 'Saving...' : 'Save Actions'}
{isSaving ? t('settings.common.actions.saving') : t('settings.projects.actions.actions.save')}
</Button>
</div>
</section>
@@ -11,8 +11,10 @@ import { RiCloseLine } from '@remixicon/react';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
export const ProjectsPage: React.FC = () => {
const { t } = useI18n();
const projects = useProjectsStore((state) => state.projects);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
@@ -108,10 +110,10 @@ export const ProjectsPage: React.FC = () => {
const uploadResult = await uploadProjectIcon(selectedProject.id, pendingUploadIconFile);
setIsUploadingIcon(false);
if (!uploadResult.ok) {
toast.error(uploadResult.error || 'Failed to upload project icon');
toast.error(uploadResult.error || t('settings.projects.page.toast.uploadIconFailed'));
return;
}
toast.success('Project icon updated');
toast.success(t('settings.projects.page.toast.iconUpdated'));
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
}
@@ -123,10 +125,10 @@ export const ProjectsPage: React.FC = () => {
const removeResult = await removeProjectIcon(selectedProject.id);
setIsRemovingCustomIcon(false);
if (!removeResult.ok) {
toast.error(removeResult.error || 'Failed to remove project icon');
toast.error(removeResult.error || t('settings.projects.page.toast.removeIconFailed'));
return;
}
toast.success('Project icon removed');
toast.success(t('settings.projects.page.toast.iconRemoved'));
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
@@ -220,25 +222,25 @@ export const ProjectsPage: React.FC = () => {
void discoverProjectIcon(selectedProject.id)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
toast.error(result.error || t('settings.projects.page.toast.discoverIconFailed'));
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
toast.success(t('settings.projects.page.toast.customIconAlreadySet'));
return;
}
toast.success('Project icon discovered');
toast.success(t('settings.projects.page.toast.iconDiscovered'));
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]);
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject, t]);
if (!selectedProject) {
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
<p className="typography-meta text-muted-foreground">No projects available.</p>
<p className="typography-meta text-muted-foreground">{t('settings.projects.page.empty.noProjects')}</p>
</div>
</ScrollableOverlay>
);
@@ -251,7 +253,7 @@ export const ProjectsPage: React.FC = () => {
<div className="mb-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{selectedProject.label ?? 'Project Settings'}
{selectedProject.label ?? t('settings.projects.page.title.default')}
</h2>
<p className="typography-meta text-muted-foreground truncate" title={selectedProject.path}>
{selectedProject.path}
@@ -266,13 +268,13 @@ export const ProjectsPage: React.FC = () => {
{/* Name */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Name</span>
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectName')}</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
className="h-7 min-w-0 w-full sm:max-w-[19rem]"
/>
</div>
@@ -281,7 +283,7 @@ export const ProjectsPage: React.FC = () => {
{/* Color */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Accent Color</span>
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.accentColor')}</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<button
@@ -293,7 +295,7 @@ export const ProjectsPage: React.FC = () => {
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
title={t('settings.projects.page.field.none')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
@@ -318,7 +320,7 @@ export const ProjectsPage: React.FC = () => {
{/* Icon */}
<div className="py-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Icon</span>
<span className="typography-ui-label text-foreground">{t('settings.projects.page.field.projectIcon')}</span>
</div>
<input
ref={fileInputRef}
@@ -341,7 +343,7 @@ export const ProjectsPage: React.FC = () => {
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]'
)}
title="None"
title={t('settings.projects.page.field.none')}
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</button>
@@ -367,7 +369,7 @@ export const ProjectsPage: React.FC = () => {
</div>
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="mt-2 flex items-center gap-2">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
@@ -391,7 +393,7 @@ export const ProjectsPage: React.FC = () => {
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
aria-label={t('settings.projects.page.field.projectIconBackgroundAria')}
/>
<Input
value={iconBackground ?? ''}
@@ -405,8 +407,8 @@ export const ProjectsPage: React.FC = () => {
variant="outline"
onClick={() => setIconBackground(null)}
className="h-7 w-7 p-0"
aria-label="Clear icon background"
title="Clear background"
aria-label={t('settings.projects.page.field.clearIconBackgroundAria')}
title={t('settings.projects.page.field.clearBackground')}
disabled={!iconBackground}
>
<RiCloseLine className="h-3.5 w-3.5" />
@@ -422,7 +424,7 @@ export const ProjectsPage: React.FC = () => {
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
{isUploadingIcon ? t('settings.projects.page.actions.uploading') : t('settings.projects.page.actions.uploadIcon')}
</Button>
<Button
size="xs"
@@ -431,7 +433,7 @@ export const ProjectsPage: React.FC = () => {
onClick={() => void handleDiscoverIcon()}
disabled={isDiscoveringIcon}
>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
{isDiscoveringIcon ? t('settings.projects.page.actions.discovering') : t('settings.projects.page.actions.discoverFavicon')}
</Button>
</>
)}
@@ -443,7 +445,7 @@ export const ProjectsPage: React.FC = () => {
onClick={() => void handleRemoveImageIcon()}
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
{isRemovingCustomIcon ? t('settings.projects.page.actions.removing') : t('settings.projects.page.actions.removeProjectIcon')}
</Button>
)}
{pendingRemoveImageIcon && (
@@ -454,7 +456,7 @@ export const ProjectsPage: React.FC = () => {
onClick={() => setPendingRemoveImageIcon(false)}
disabled={isRemovingCustomIcon}
>
Undo Remove
{t('settings.projects.page.actions.undoRemove')}
</Button>
)}
</div>
@@ -469,7 +471,7 @@ export const ProjectsPage: React.FC = () => {
size="xs"
className="!font-normal"
>
Save Changes
{t('settings.common.actions.saveChanges')}
</Button>
</div>
</div>
@@ -485,7 +487,7 @@ export const ProjectsPage: React.FC = () => {
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">
Worktree
{t('settings.projects.page.section.worktree')}
</h3>
</div>
<section className="px-2 pb-2 pt-0">
@@ -11,8 +11,10 @@ import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirec
import { sessionEvents } from '@/lib/sessionEvents';
import { toast } from '@/components/ui';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
const { t } = useI18n();
const projects = useProjectsStore((state) => state.projects);
const addProject = useProjectsStore((state) => state.addProject);
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
@@ -34,23 +36,23 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
toast.error(t('sessions.sidebar.directory.errorAddProjectTitle'), {
description: t('sessions.sidebar.directory.errorAddProjectDescription'),
});
return;
}
setSelectedId(added.id);
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'), {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
toast.error(t('sessions.sidebar.directory.errorSelectDirectoryTitle'));
});
}, [addProject, setSelectedId, tauriIpcAvailable]);
}, [addProject, setSelectedId, tauriIpcAvailable, t]);
React.useEffect(() => {
if (projects.length === 0) {
@@ -70,9 +72,9 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
variant="background"
header={
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.page.projects.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
<span className="typography-meta text-muted-foreground">{t('settings.projects.sidebar.total', { count: projects.length })}</span>
{!isVSCode && (
<Button
type="button"
@@ -80,7 +82,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleAddProject}
aria-label="Add project"
aria-label={t('settings.projects.sidebar.actions.addProject')}
>
<RiAddLine className="size-4" />
</Button>