Internationalization: 7 languages with live switching

Custom signal-based runtime (no i18n dependency): shared src/shared/i18n
dictionaries, ui I18nService.t() reactive by construction so language
switches live, and a backend i18n.ts for native menus + thrown errors.
Every user-facing string keyed across templates, toasts, dialogs, file
kinds, columns, macOS menu/context menus, and errors. Language picker in
Options (auto-detects OS locale, falls back to English); switching
relabels the webview and native chrome instantly. English/Spanish/French/
German fully drafted; Chinese/Japanese/Basque drafted, flagged for native
review. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:52:54 +02:00
co-authored by Claude Fable 5
parent d8cfc54049
commit a0f56822cd
43 changed files with 2755 additions and 322 deletions
+14 -10
View File
@@ -1,8 +1,9 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type ChmodRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
const ROLES = ['Owner', 'Group', 'Others'] as const;
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
@@ -14,22 +15,22 @@ const PERMS = ['r', 'w', 'x'] as const;
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">Permissions</h2>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">Read</th>
<th class="pb-1 font-normal">Write</th>
<th class="pb-1 font-normal">Execute</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ role }}</td>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
@@ -46,7 +47,7 @@ const PERMS = ['r', 'w', 'x'] as const;
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">Octal</label>
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
@@ -61,13 +62,13 @@ const PERMS = ['r', 'w', 'x'] as const;
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
{{ i18n.t('common.apply') }}
</button>
</div>
</div>
@@ -75,6 +76,7 @@ const PERMS = ['r', 'w', 'x'] as const;
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
@@ -90,7 +92,9 @@ export class ChmodDialog {
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1 ? targets[0] : `${targets.length} items`;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
+3 -1
View File
@@ -1,4 +1,5 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type ConfirmRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
@@ -18,7 +19,7 @@ import { FocusService } from '../focus.service';
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
@@ -37,6 +38,7 @@ import { FocusService } from '../focus.service';
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
+17 -19
View File
@@ -8,8 +8,8 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
import { formatDate, formatSize } from '../format';
import { FocusService } from '../focus.service';
@Component({
@@ -25,23 +25,22 @@ import { FocusService } from '../focus.service';
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? 'Merge folder' : 'Replace' }}
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{{ request().index }} of {{ request().total }}
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
The destination already has a folder with this name. Merging may replace files inside
it.
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
The destination already has a file with this name.
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
@@ -51,11 +50,11 @@ import { FocusService } from '../focus.service';
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ side.label }}
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
newer
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
@@ -66,11 +65,11 @@ import { FocusService } from '../focus.service';
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : formatSize(side.entry.size) }}
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
{{ formatDate(side.entry.mtime) }}
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
@@ -82,7 +81,7 @@ import { FocusService } from '../focus.service';
(click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
@@ -90,13 +89,13 @@ import { FocusService } from '../focus.service';
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Skip all
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? 'Merge all' : 'Replace all' }}
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
@@ -104,14 +103,14 @@ import { FocusService } from '../focus.service';
(click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Skip
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? 'Merge' : 'Replace' }}
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
</div>
</div>
@@ -119,10 +118,9 @@ import { FocusService } from '../focus.service';
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
readonly formatSize = formatSize;
readonly formatDate = formatDate;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
@@ -137,8 +135,8 @@ export class OverwriteDialog {
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
+3 -1
View File
@@ -7,6 +7,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '../i18n.service';
import { DialogsService, type PromptRequest } from '../dialogs.service';
import { FocusService } from '../focus.service';
@@ -34,7 +35,7 @@ import { FocusService } from '../focus.service';
(click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
@@ -49,6 +50,7 @@ import { FocusService } from '../focus.service';
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');