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:
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user