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
+56 -44
View File
@@ -28,6 +28,8 @@ import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { sessionEvents } from '@/lib/sessionEvents';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
@@ -52,9 +54,10 @@ type FileEntry = GitStatus['files'][number] & {
type DiffData = { original: string; modified: string; isBinary?: boolean };
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="typography-meta text-muted-foreground">Content of this file cannot be viewed.</div>
<div className="typography-meta text-muted-foreground">{t('diffView.binary.unavailable')}</div>
</div>
);
});
@@ -64,34 +67,34 @@ type DiffTabViewMode = 'single' | 'stacked';
type ChangeDescriptor = {
code: string;
color: string;
description: string;
descriptionKey: I18nKey;
};
const CHANGE_DESCRIPTORS: Record<string, ChangeDescriptor> = {
'?': { code: '?', color: 'var(--status-info)', description: 'Untracked file' },
A: { code: 'A', color: 'var(--status-success)', description: 'New file' },
D: { code: 'D', color: 'var(--status-error)', description: 'Deleted file' },
R: { code: 'R', color: 'var(--status-info)', description: 'Renamed file' },
C: { code: 'C', color: 'var(--status-info)', description: 'Copied file' },
M: { code: 'M', color: 'var(--status-warning)', description: 'Modified file' },
'?': { code: '?', color: 'var(--status-info)', descriptionKey: 'diffView.change.untracked' },
A: { code: 'A', color: 'var(--status-success)', descriptionKey: 'diffView.change.new' },
D: { code: 'D', color: 'var(--status-error)', descriptionKey: 'diffView.change.deleted' },
R: { code: 'R', color: 'var(--status-info)', descriptionKey: 'diffView.change.renamed' },
C: { code: 'C', color: 'var(--status-info)', descriptionKey: 'diffView.change.copied' },
M: { code: 'M', color: 'var(--status-warning)', descriptionKey: 'diffView.change.modified' },
};
const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M;
const DIFF_VIEW_MODE_OPTIONS: Array<{
value: DiffTabViewMode;
label: string;
description: string;
labelKey: I18nKey;
descriptionKey: I18nKey;
}> = [
{
value: 'single',
label: 'Single file',
description: 'Show one file at a time',
labelKey: 'diffView.mode.single.label',
descriptionKey: 'diffView.mode.single.description',
},
{
value: 'stacked',
label: 'All files',
description: 'Stack all modified files together',
labelKey: 'diffView.mode.stacked.label',
descriptionKey: 'diffView.mode.stacked.description',
},
];
@@ -205,6 +208,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
mode,
onModeChange,
}) => {
const { t } = useI18n();
const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path;
const lastSlash = path.lastIndexOf('/');
@@ -226,7 +230,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions)}
</div>
) : (
<span className="text-muted-foreground">Select file</span>
<span className="text-muted-foreground">{t('diffView.selector.selectFile')}</span>
)}
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
@@ -235,7 +239,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
{showModeSelector && mode && onModeChange ? (
<>
<DropdownMenuLabel className="typography-meta text-muted-foreground">
View mode
{t('diffView.selector.viewMode')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={mode}
@@ -248,7 +252,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
className="items-center"
>
<span className="typography-meta text-foreground">
{option.label}
{t(option.labelKey)}
</span>
</DropdownMenuRadioItem>
))}
@@ -282,6 +286,7 @@ interface DiffViewModeSelectorProps {
}
const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onModeChange }) => {
const { t } = useI18n();
const currentOption =
DIFF_VIEW_MODE_OPTIONS.find((option) => option.value === mode) ?? DIFF_VIEW_MODE_OPTIONS[0];
@@ -290,7 +295,7 @@ const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onMo
<DropdownMenuTrigger asChild>
<button className="flex h-7 items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-interactive-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 truncate typography-meta">
{currentOption.label}
{t(currentOption.labelKey)}
</span>
<RiArrowDownSLine className="size-4 opacity-50" />
</button>
@@ -303,7 +308,7 @@ const DiffViewModeSelector = React.memo<DiffViewModeSelectorProps>(({ mode, onMo
{DIFF_VIEW_MODE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span className="typography-meta text-foreground">
{option.label}
{t(option.labelKey)}
</span>
</DropdownMenuRadioItem>
))}
@@ -324,6 +329,7 @@ const FileList = React.memo<FileListProps>(({
selectedFile,
onSelectFile,
}) => {
const { t } = useI18n();
if (changedFiles.length === 0) return null;
return (
@@ -349,8 +355,8 @@ const FileList = React.memo<FileListProps>(({
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
title={t(descriptor.descriptionKey)}
aria-label={t(descriptor.descriptionKey)}
>
{descriptor.code}
</span>
@@ -385,6 +391,7 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
isVisible,
renderSideBySide,
}) => {
const { t } = useI18n();
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
@@ -406,10 +413,10 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<span className="typography-meta text-muted-foreground font-medium">{t('diffView.image.original')}</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
alt={t('diffView.image.originalAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -418,11 +425,11 @@ const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
alt={t('diffView.image.modifiedAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -444,6 +451,7 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
diff,
renderSideBySide,
}) => {
const { t } = useI18n();
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
@@ -460,10 +468,10 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<span className="typography-meta text-muted-foreground font-medium">{t('diffView.image.original')}</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
alt={t('diffView.image.originalAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -472,11 +480,11 @@ const InlineImageDiffViewer = React.memo<InlineImageDiffViewerProps>(({
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
{hasOriginal ? t('diffView.image.modified') : t('diffView.image.new')}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
alt={t('diffView.image.modifiedAlt', { path: filePath })}
className={renderSideBySide ? "max-w-full max-h-[70vh] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
@@ -632,6 +640,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
isOpeningInEditor = false,
onOpenInEditor,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
React.useCallback((state) => {
@@ -787,8 +796,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
<span
className="typography-micro font-semibold leading-none w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
title={t(descriptor.descriptionKey)}
aria-label={t(descriptor.descriptionKey)}
>
{descriptor.code}
</span>
@@ -839,7 +848,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
title="Open this file in editor at change"
title={t('diffView.actions.openFileInEditorAtChange')}
onClick={(event) => {
event.stopPropagation();
onOpenInEditor(file.path, diffData);
@@ -936,6 +945,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
@@ -1559,7 +1569,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
{showFileSidebar && (
<section className="hidden lg:flex w-72 flex-col rounded-xl border border-border/60 bg-background/70 overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/40">
<span className="typography-ui-header font-semibold text-foreground">Files</span>
<span className="typography-ui-header font-semibold text-foreground">{t('diffView.section.files')}</span>
<span className="typography-meta text-muted-foreground">{changedFiles.length}</span>
</div>
<FileList
@@ -1612,7 +1622,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (!effectiveDirectory) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a session directory to view diffs
{t('diffView.state.selectSessionDirectory')}
</div>
);
}
@@ -1621,7 +1631,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading repository status…
{t('diffView.state.loadingRepositoryStatus')}
</div>
);
}
@@ -1629,7 +1639,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (isGitRepo === false) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Not a git repository. Use the Git tab to initialize or change directories.
{t('diffView.state.notGitRepository')}
</div>
);
}
@@ -1637,7 +1647,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (changedFiles.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Working tree clean — no changes to display
{t('diffView.state.cleanWorkingTree')}
</div>
);
}
@@ -1654,7 +1664,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
{diffLoadError ? (
<div className="flex flex-col items-center gap-2">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
{t('diffView.state.failedToLoadDiff')}
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
@@ -1667,13 +1677,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
setDiffRetryNonce((n) => n + 1);
}}
>
Retry
{t('diffView.actions.retry')}
</button>
</div>
) : (
<>
<RiLoader4Line size={16} className="animate-spin" />
Loading diff…
{t('diffView.state.loadingDiff')}
</>
)}
</div>
@@ -1690,8 +1700,10 @@ export const DiffView: React.FC<DiffViewProps> = ({
<RiGitCommitLine size={16} />
<span className="typography-ui-label font-semibold text-foreground">
{isLoadingStatus && !status
? 'Loading changes…'
: `${changedFiles.length} ${changedFiles.length === 1 ? 'file' : 'files'} changed`}
? t('diffView.state.loadingChanges')
: (changedFiles.length === 1
? t('diffView.summary.changedFilesSingle', { count: changedFiles.length })
: t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
</span>
</div>
)}
@@ -1720,7 +1732,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
'h-5 w-5 p-0 transition-opacity',
diffWrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={diffWrapLines ? 'Disable line wrap' : 'Enable line wrap'}
title={diffWrapLines ? t('diffView.actions.disableLineWrap') : t('diffView.actions.enableLineWrap')}
>
<RiTextWrap className="size-4" />
</Button>
@@ -1734,7 +1746,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
void openSelectedFileInEditorAtChange();
}}
disabled={isOpeningSelectedInEditor}
title="Open this file at first changed line"
title={t('diffView.actions.openFileAtFirstChangedLine')}
>
{isOpeningSelectedInEditor ? (
<RiLoader4Line className="size-3.5 animate-spin" />