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:
+17
-1
@@ -154,7 +154,14 @@ would float over editor windows).
|
||||
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
||||
routes `close-tab` to the focused editor window first.
|
||||
routes `close-tab` to the focused editor window first. Labels go through
|
||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||
the app menu). `context-menu.ts` labels resolve through `t()` at show-time.
|
||||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||
treatment) and no menu (clicks arrive directly).
|
||||
@@ -202,6 +209,15 @@ would float over editor windows).
|
||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
||||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||||
`connectionsChanged` push.
|
||||
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||||
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||||
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||||
binding that calls it re-renders live on a language switch (same call-stack
|
||||
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||||
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||||
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||
|
||||
@@ -85,6 +85,25 @@ touching the adjacent code.
|
||||
don't shrink the 400 ms debounce.
|
||||
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||
against a slow connect resolving into a panel the user already reused.
|
||||
|
||||
## i18n
|
||||
|
||||
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
|
||||
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
|
||||
Bun builds and gives full typing. `en.ts` is the source of truth; other
|
||||
locales are partial and fall back to English then the raw key.
|
||||
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
|
||||
makes live language switching work with no pipe. If you cache a translated
|
||||
string in a plain field it WON'T update on switch; call `t()` in the template
|
||||
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
|
||||
- Dictionary values are TS string literals: **French/other apostrophes break
|
||||
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
|
||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||
zh/ja supply only `other`.
|
||||
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts`
|
||||
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale
|
||||
changes (wired in index.ts `settingsChanged`), or it keeps the old language.
|
||||
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||
silent variant (poll/watch) runs while the user can navigate — without the
|
||||
|
||||
+27
-33
@@ -1,25 +1,21 @@
|
||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||
import type { ColumnKey } from "../shared/types";
|
||||
import { t } from "./i18n";
|
||||
|
||||
/** Optional list columns, in display order (Name is fixed). */
|
||||
const COLUMNS: { key: ColumnKey; label: string }[] = [
|
||||
{ key: "size", label: "Size" },
|
||||
{ key: "mtime", label: "Modified" },
|
||||
{ key: "kind", label: "Kind" },
|
||||
{ key: "rights", label: "Rights" },
|
||||
{ key: "owner", label: "Owner" },
|
||||
{ key: "group", label: "Group" },
|
||||
];
|
||||
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
|
||||
|
||||
// PORTABILITY: shell-integration wording per OS
|
||||
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const REVEAL_LABEL =
|
||||
const revealLabel = (): string =>
|
||||
t(
|
||||
process.platform === "darwin"
|
||||
? "Reveal in Finder"
|
||||
? "ctx.revealFinder"
|
||||
: IS_WIN
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager";
|
||||
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
|
||||
? "ctx.revealExplorer"
|
||||
: "ctx.revealFileManager",
|
||||
);
|
||||
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
@@ -75,8 +71,8 @@ export class PanelContextMenu {
|
||||
/** Column picker for the list headers: checkable toggles per column. */
|
||||
showColumns(visible: ColumnKey[]): void {
|
||||
ContextMenu.showContextMenu(
|
||||
COLUMNS.map(({ key, label }) => ({
|
||||
label,
|
||||
COLUMN_KEYS.map((key) => ({
|
||||
label: t(`columns.${key}`),
|
||||
action: `col:${key}`,
|
||||
checked: visible.includes(key),
|
||||
})),
|
||||
@@ -88,47 +84,45 @@ export class PanelContextMenu {
|
||||
|
||||
if (!ctx.hasEntry || ctx.name === "..") {
|
||||
// empty space (or the parent row): folder-level actions
|
||||
items.push({ label: "New Folder", action: "ctx-new-folder" });
|
||||
items.push({ label: "New File", action: "ctx-new-file" });
|
||||
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
|
||||
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Refresh", action: "ctx-refresh" });
|
||||
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (ctx.type === "dir") {
|
||||
items.push({ label: "Open", action: "ctx-open" });
|
||||
items.push({ label: t("ctx.open"), action: "ctx-open" });
|
||||
} else {
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
|
||||
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
|
||||
}
|
||||
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
|
||||
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
|
||||
}
|
||||
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
||||
items.push({ label: "Rename", action: "ctx-rename" });
|
||||
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
|
||||
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
|
||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||
if (!(ctx.isLocal && IS_WIN)) {
|
||||
items.push({ label: "Permissions", action: "ctx-chmod" });
|
||||
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
||||
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
items.push({ label: revealLabel(), action: "ctx-reveal" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({
|
||||
label: TRASH_LABEL,
|
||||
label: trashLabel(),
|
||||
action: "ctx-trash",
|
||||
...(process.platform === "darwin"
|
||||
? { tooltip: "Finder's Restore is not available for items trashed from here" }
|
||||
: {}),
|
||||
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
|
||||
});
|
||||
}
|
||||
items.push({ label: "Delete", action: "ctx-delete" });
|
||||
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "../i18n";
|
||||
import { LocalProvider } from "./local";
|
||||
import type { FileSystemProvider } from "./provider";
|
||||
|
||||
@@ -15,7 +16,7 @@ export class SessionRegistry {
|
||||
|
||||
get(sessionId: string): FileSystemProvider {
|
||||
const provider = this.sessions.get(sessionId);
|
||||
if (!provider) throw new Error(`Session "${sessionId}" is not connected`);
|
||||
if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId }));
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export class SessionRegistry {
|
||||
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
if (sessionId === LOCAL_SESSION_ID)
|
||||
throw new Error("The local session cannot be disconnected");
|
||||
throw new Error(t("errors.localCannotDisconnect"));
|
||||
const provider = this.sessions.get(sessionId);
|
||||
if (!provider) return;
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||
import { t } from "../i18n";
|
||||
import { expandTilde } from "../store/ssh-config";
|
||||
import type { FileSystemProvider } from "./provider";
|
||||
|
||||
@@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider {
|
||||
connectConfig.password = config.auth.password;
|
||||
break;
|
||||
case "agent":
|
||||
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
|
||||
if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
|
||||
connectConfig.agent = agentSock;
|
||||
break;
|
||||
case "key": {
|
||||
@@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider {
|
||||
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||
} catch {
|
||||
if (!agentSock)
|
||||
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
|
||||
throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
|
||||
}
|
||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||
else if (agentSock) connectConfig.agent = agentSock;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DICTS, type Messages, resolveLocale } from "../shared/i18n";
|
||||
import type { AppSettings, SupportedLocale } from "../shared/types";
|
||||
|
||||
// Backend-side translator for the native menus and thrown error messages
|
||||
// (the Angular I18nService can't reach OS chrome). The locale is cached and
|
||||
// refreshed from settings (see index.ts wiring); no signals here.
|
||||
let locale: SupportedLocale = "en";
|
||||
|
||||
/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */
|
||||
function osLocale(): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocale(setting: AppSettings["locale"]): void {
|
||||
locale = resolveLocale(setting, osLocale());
|
||||
}
|
||||
|
||||
function lookup(dict: Messages, key: string): string | undefined {
|
||||
let node: string | Messages | undefined = dict;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object") return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return typeof node === "string" ? node : undefined;
|
||||
}
|
||||
|
||||
/** Translate a key with {name} interpolation; falls back to English then the key. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key;
|
||||
if (!params) return value;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { EditorWindows } from "./editor-windows";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
import { LocalDirWatcher } from "./fs/watcher";
|
||||
import { ManagerWindow } from "./manager-window";
|
||||
import { setLocale as setBackendLocale } from "./i18n";
|
||||
import { applyApplicationMenu } from "./menu";
|
||||
import { createAppRPC } from "./rpc-handlers";
|
||||
import { ConnectionStore } from "./store/connections";
|
||||
@@ -64,6 +65,7 @@ const url = await getMainViewUrl();
|
||||
// window's preload so the first paint is already themed (no flash). Kept
|
||||
// fresh via the saveSettings flow below.
|
||||
let currentSettings = await settings.load();
|
||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
@@ -115,7 +117,12 @@ const manager = new ManagerWindow({
|
||||
|
||||
events.openEditor = (ctx) => editors.open(ctx);
|
||||
events.settingsChanged = (s) => {
|
||||
const localeChanged = s.locale !== currentSettings.locale;
|
||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||
if (localeChanged) {
|
||||
setBackendLocale(s.locale);
|
||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
||||
}
|
||||
editors.broadcastSettings(s);
|
||||
manager.broadcastSettings(s);
|
||||
};
|
||||
|
||||
+15
-14
@@ -1,6 +1,7 @@
|
||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { SavedConnection } from "../shared/types";
|
||||
import { t } from "./i18n";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
|
||||
/**
|
||||
@@ -27,37 +28,37 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
...looseItems,
|
||||
...stackMenus,
|
||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||
{ label: "Connection Manager…", action: "manage-connections" },
|
||||
{ label: t("menu.connectionManager"), action: "manage-connections" },
|
||||
];
|
||||
|
||||
ApplicationMenu.setApplicationMenu([
|
||||
{
|
||||
submenu: [
|
||||
{ label: `About ${BRANDING.appName}`, action: "about" },
|
||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "showAll" },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit", role: "quit" },
|
||||
{ label: t("menu.quit"), role: "quit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File",
|
||||
label: t("menu.file"),
|
||||
submenu: [
|
||||
{ label: "New Tab", action: "new-tab", accelerator: "t" },
|
||||
{ label: "Close Tab", action: "close-tab", accelerator: "w" },
|
||||
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
|
||||
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
|
||||
{ type: "separator" },
|
||||
{ label: "New Folder", action: "new-folder" },
|
||||
{ label: "New File", action: "new-file" },
|
||||
{ label: t("menu.newFolder"), action: "new-folder" },
|
||||
{ label: t("menu.newFile"), action: "new-file" },
|
||||
{ type: "separator" },
|
||||
{ label: "Edit File", action: "edit-file", accelerator: "e" },
|
||||
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
|
||||
{ type: "separator" },
|
||||
{ label: "Connect", submenu: connectSubmenu },
|
||||
{ label: t("menu.connect"), submenu: connectSubmenu },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
label: t("menu.edit"),
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
@@ -71,15 +72,15 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
label: t("menu.view"),
|
||||
submenu: [
|
||||
{ label: "Refresh", action: "refresh-active", accelerator: "r" },
|
||||
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleFullScreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
label: t("menu.window"),
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BRANDING } from "../../shared/branding";
|
||||
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
||||
import type { FileSystemProvider } from "../fs/provider";
|
||||
import type { SessionRegistry } from "../fs/registry";
|
||||
import { t } from "../i18n";
|
||||
import { walkTree } from "./walk";
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
@@ -59,7 +60,7 @@ async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
|
||||
try {
|
||||
const st = await fs.stat(path);
|
||||
if (st.type === "dir") return;
|
||||
throw new Error(`"${path}" exists and is not a directory`);
|
||||
throw new Error(t("errors.notADirectory", { path }));
|
||||
} catch {
|
||||
await fs.mkdir(path);
|
||||
}
|
||||
@@ -235,7 +236,7 @@ export class TransferEngine {
|
||||
if (sameFs) {
|
||||
for (const file of files) {
|
||||
if (file.srcPath === file.dstPath) {
|
||||
throw new Error(`"${file.name}": source and destination are the same file`);
|
||||
throw new Error(t("errors.sameFileTransfer", { name: file.name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,10 +344,10 @@ export class TransferEngine {
|
||||
|
||||
if (samePath) {
|
||||
throw new Error(
|
||||
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file",
|
||||
isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"),
|
||||
);
|
||||
}
|
||||
throw new Error("Cannot copy a folder into itself");
|
||||
throw new Error(t("errors.folderIntoItself"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Translations
|
||||
|
||||
`en.ts` is the **source of truth**. Every other locale mirrors its key shape;
|
||||
any missing key falls back to English at lookup time (see `index.ts` +
|
||||
`I18nService`/backend `i18n.ts`), so partial translations are safe.
|
||||
|
||||
## Adding / editing strings
|
||||
|
||||
1. Add the key to `en.ts` (nested namespace, `{param}` interpolation). For
|
||||
plurals, use a `{ one, other }` map instead of a string — the runtime picks
|
||||
the CLDR category via `Intl.PluralRules` (languages without plural, e.g. zh
|
||||
and ja, only need `other`).
|
||||
2. Reference it as `i18n.t('ns.key', { param })` in the UI (or backend `t(...)`
|
||||
for native menus / thrown errors).
|
||||
3. Add the translation to the other locale files as convenient — until then
|
||||
English shows through.
|
||||
|
||||
The format is kept **Transloco-compatible** (nested namespaces, `{param}`) so
|
||||
swapping in a real i18n library later is a drop-in of these dictionaries.
|
||||
|
||||
## Review status
|
||||
|
||||
| Locale | File | Status |
|
||||
|---|---|---|
|
||||
| English | `en.ts` | Source |
|
||||
| Spanish | `es.ts` | Full draft |
|
||||
| French | `fr.ts` | Full draft |
|
||||
| German | `de.ts` | Full draft |
|
||||
| Chinese (Simplified) | `zh.ts` | **AI draft — needs native review** |
|
||||
| Japanese | `ja.ts` | **AI draft — needs native review** |
|
||||
| Basque | `eu.ts` | **AI draft — needs native review (highest priority; machine/AI Basque is unreliable)** |
|
||||
|
||||
Native-speaker review is especially important for Basque and the CJK locales:
|
||||
grammatical agreement, counters/measure words, and UI-context word choice are
|
||||
easy to get subtly wrong.
|
||||
@@ -0,0 +1,299 @@
|
||||
// German translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const de = {
|
||||
common: {
|
||||
cancel: "Abbrechen",
|
||||
save: "Speichern",
|
||||
close: "Schließen",
|
||||
delete: "Löschen",
|
||||
rename: "Umbenennen",
|
||||
create: "Erstellen",
|
||||
apply: "Anwenden",
|
||||
choose: "Wählen…",
|
||||
connect: "Verbinden",
|
||||
local: "Lokal",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Nicht mit der App verbunden",
|
||||
notConnectedBody: "Diese Seite läuft in einem normalen Browser. Starte die Desktop-App, um sie zu verwenden.",
|
||||
managerOpen: "Der Verbindungsmanager ist geöffnet.",
|
||||
showConnections: "Verbindungen anzeigen",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Verbindungen",
|
||||
manageConnections: "Verbindungen verwalten",
|
||||
newTab: "Neuer Tab ({mod}T)",
|
||||
closeTab: "Tab schließen ({mod}W)",
|
||||
options: "Optionen",
|
||||
},
|
||||
options: {
|
||||
title: "Optionen",
|
||||
language: "Sprache",
|
||||
system: "System",
|
||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||
accent: { label: "Akzent" },
|
||||
accentNames: {
|
||||
graphite: "Graphit",
|
||||
blue: "Blau",
|
||||
indigo: "Indigo",
|
||||
purple: "Lila",
|
||||
rose: "Rosa",
|
||||
orange: "Orange",
|
||||
green: "Grün",
|
||||
teal: "Petrol",
|
||||
},
|
||||
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
|
||||
interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" },
|
||||
speedUnit: { label: "Geschwindigkeitseinheit" },
|
||||
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
|
||||
atOnce: "Gleichzeitig",
|
||||
editor: { label: "Editor", builtin: "Integriert", custom: "Benutzerdefiniert" },
|
||||
systemDefault: "Systemstandard",
|
||||
resetDefault: "Auf Standard zurücksetzen",
|
||||
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Ordner",
|
||||
symlink: "Symbolischer Link",
|
||||
file: "Datei",
|
||||
image: "Bild",
|
||||
audio: "Audio",
|
||||
video: "Video",
|
||||
archive: "Archiv",
|
||||
code: "Code",
|
||||
document: "Dokument",
|
||||
},
|
||||
columns: {
|
||||
name: "Name",
|
||||
size: "Größe",
|
||||
mtime: "Geändert",
|
||||
kind: "Typ",
|
||||
rights: "Rechte",
|
||||
owner: "Eigentümer",
|
||||
group: "Gruppe",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||
copy: "Kopieren",
|
||||
copyToRight: "In rechtes Panel kopieren (F5)",
|
||||
copyToLeft: "In linkes Panel kopieren (F5)",
|
||||
edit: "Bearbeiten",
|
||||
editFile: "Datei bearbeiten (F4)",
|
||||
rename: "Umbenennen (F2)",
|
||||
newFolder: "Neuer Ordner (F7)",
|
||||
newFile: "Neue Datei",
|
||||
permissions: "Berechtigungen",
|
||||
delete: "Löschen",
|
||||
deleteHint: "Löschen (F8 / Entf)",
|
||||
parentDir: "Übergeordneter Ordner (Rücktaste)",
|
||||
refresh: "Aktualisieren",
|
||||
clearFilter: "Filter löschen (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Verbinde mit {name}…",
|
||||
emptyDir: "Leerer Ordner",
|
||||
noMatches: "Keine Treffer",
|
||||
matches: { one: "{count} Treffer · Esc", other: "{count} Treffer · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Verbindungen",
|
||||
importWinscp: "Aus WinSCP importieren…",
|
||||
importWinscpHint: "Sitzungen aus einem WinSCP.ini-Export importieren",
|
||||
newConnection: "Verbindung",
|
||||
newConnectionHint: "Neue Verbindung",
|
||||
newStack: "Gruppe",
|
||||
newStackHint: "Neue Gruppe",
|
||||
expand: "Aufklappen",
|
||||
collapse: "Zuklappen",
|
||||
renameStack: "Gruppe umbenennen",
|
||||
deleteStack: "Gruppe löschen",
|
||||
openStack: "Gruppe in Tabs öffnen",
|
||||
empty: "leer",
|
||||
connectionsHeader: "Verbindungen",
|
||||
noneTitle: "Noch keine gespeicherten Verbindungen.",
|
||||
noneBody: "Erstelle eine oder importiere deine WinSCP-Sitzungen.",
|
||||
name: "Name",
|
||||
namePlaceholder: "mein Server",
|
||||
host: "Host",
|
||||
port: "Port",
|
||||
stack: "Gruppe",
|
||||
stackNone: "(keine)",
|
||||
username: "Benutzername",
|
||||
remotePath: "Entfernter Pfad (optional)",
|
||||
remotePathPlaceholder: "Home-Verzeichnis",
|
||||
auth: "Authentifizierung",
|
||||
authPassword: "Passwort",
|
||||
authKey: "Privater Schlüssel",
|
||||
password: "Passwort",
|
||||
privateKeyFile: "Datei mit privatem Schlüssel",
|
||||
passphrase: "Passphrase (optional)",
|
||||
localDir: "Lokales Verzeichnis (optional)",
|
||||
localDirPlaceholder: "~/projekte/mein-server",
|
||||
localDirHint: "Das andere Panel öffnet sich hier, wenn es auf Lokal steht und nicht navigiert wurde.",
|
||||
more: "Mehr…",
|
||||
less: "Weniger…",
|
||||
selectPrompt: "Wähle eine Verbindung oder erstelle eine neue.",
|
||||
newStackTitle: "Neue Gruppe",
|
||||
newStackPrompt: "Gruppenname",
|
||||
renameStackTitle: "Gruppe umbenennen",
|
||||
deleteStackTitle: "Gruppe löschen",
|
||||
deleteStackBody: 'Gruppe „{name}" löschen? Ihre Verbindungen bleiben, ohne Gruppe.',
|
||||
deleteConnectionTitle: "Verbindung löschen",
|
||||
deleteConnectionBody: '„{name}" löschen?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||
wrap: "Umbruch",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
|
||||
findHint: "Suchen ({mod}F)",
|
||||
findPlaceholder: "In Datei suchen",
|
||||
prevMatch: "Vorheriger Treffer (⇧↵)",
|
||||
nextMatch: "Nächster Treffer (↵)",
|
||||
closeHint: "Schließen (Esc)",
|
||||
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||
cantOpen: "{name} kann nicht geöffnet werden",
|
||||
closeWindow: "Fenster schließen",
|
||||
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
|
||||
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||
close: "Schließen",
|
||||
save: "Speichern",
|
||||
saveChangesTitle: 'Änderungen an „{name}" speichern?',
|
||||
saveChangesBody: "Deine Änderungen gehen verloren, wenn du sie nicht speicherst.",
|
||||
dontSave: "Nicht speichern",
|
||||
countLabel: "{current} von {total}",
|
||||
noMatches: "Keine Treffer",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} aktiv · {finished} abgeschlossen",
|
||||
clearFinished: "Abgeschlossene löschen",
|
||||
cancel: "Abbrechen",
|
||||
dismiss: "Verwerfen",
|
||||
showDetails: "Übertragungsdetails anzeigen",
|
||||
copying: "Kopiere {name} · {percent}%{speed}",
|
||||
queued: "{count} in Warteschlange",
|
||||
failed: { one: "{count} Übertragung fehlgeschlagen", other: "{count} Übertragungen fehlgeschlagen" },
|
||||
finished: "Übertragungen abgeschlossen",
|
||||
ready: "Bereit",
|
||||
items: "{count} Elemente",
|
||||
selected: "{count} ausgewählt",
|
||||
queuedState: "In Warteschlange",
|
||||
scanning: "Analysiere…",
|
||||
failedState: "Fehlgeschlagen",
|
||||
cancelled: "Abgebrochen",
|
||||
completedFiles: { one: "{count} Datei · {size}", other: "{count} Dateien · {size}" },
|
||||
left: "{eta} übrig",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Berechtigungen",
|
||||
chmodItems: "{count} Elemente",
|
||||
read: "Lesen",
|
||||
write: "Schreiben",
|
||||
execute: "Ausführen",
|
||||
roleOwner: "Eigentümer",
|
||||
roleGroup: "Gruppe",
|
||||
roleOthers: "Andere",
|
||||
octal: "Oktal",
|
||||
mergeFolder: "Ordner zusammenführen",
|
||||
replace: "Ersetzen",
|
||||
merge: "Zusammenführen",
|
||||
mergeAll: "Alle zusammenführen",
|
||||
replaceAll: "Alle ersetzen",
|
||||
skip: "Überspringen",
|
||||
skipAll: "Alle überspringen",
|
||||
ofTotal: "{index} von {total}",
|
||||
overwriteDirBody: "Am Ziel existiert bereits ein Ordner mit diesem Namen. Zusammenführen kann Dateien darin ersetzen.",
|
||||
overwriteFileBody: "Am Ziel existiert bereits eine Datei mit diesem Namen.",
|
||||
source: "Quelle",
|
||||
destination: "Ziel",
|
||||
newer: "neuer",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||
sameFolder: "Beide Panels zeigen denselben Ordner",
|
||||
sameFile: '„{name}" übersprungen — es ist dieselbe Datei',
|
||||
typeMismatch: '„{name}" übersprungen — ein anderer Elementtyp ist im Weg',
|
||||
transferFailed: 'Übertragung „{name}" fehlgeschlagen: {error}',
|
||||
unknownError: "unbekannter Fehler",
|
||||
saved: "{name} gespeichert",
|
||||
disconnectedReason: "{title}: getrennt ({reason})",
|
||||
imported: { one: "{count} Verbindung importiert", other: "{count} Verbindungen importiert" },
|
||||
importedIntoStacks: { one: " in {count} neue Gruppe", other: " in {count} neue Gruppen" },
|
||||
importedSkipped: " ({skipped} übersprungen)",
|
||||
leftoverTitle: "Übrig gebliebene bearbeitete Dateien",
|
||||
leftoverMore: ", +{count} weitere",
|
||||
leftoverBody: {
|
||||
one: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Datei ({names}). Löschen?",
|
||||
other: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Dateien ({names}). Löschen?",
|
||||
},
|
||||
leftoverClear: "Löschen",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Löschen",
|
||||
deleteBody: "{what} löschen? Ordner werden rekursiv gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
deleteConfirm: "Löschen",
|
||||
renameTitle: '„{name}" umbenennen',
|
||||
renameConfirm: "Umbenennen",
|
||||
newFolderTitle: "Neuer Ordner",
|
||||
newFolderPlaceholder: "Ordnername",
|
||||
newFileTitle: "Neue Datei",
|
||||
newFilePlaceholder: "Dateiname",
|
||||
createConfirm: "Erstellen",
|
||||
itemsCount: "{count} Elemente",
|
||||
},
|
||||
menu: {
|
||||
about: "Über {app}",
|
||||
quit: "Beenden",
|
||||
file: "Datei",
|
||||
newTab: "Neuer Tab",
|
||||
closeTab: "Tab schließen",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
editFile: "Datei bearbeiten",
|
||||
connect: "Verbinden",
|
||||
connectionManager: "Verbindungsmanager…",
|
||||
edit: "Bearbeiten",
|
||||
view: "Ansicht",
|
||||
refresh: "Aktualisieren",
|
||||
window: "Fenster",
|
||||
},
|
||||
ctx: {
|
||||
size: "Größe",
|
||||
modified: "Geändert",
|
||||
kind: "Typ",
|
||||
rights: "Rechte",
|
||||
owner: "Eigentümer",
|
||||
group: "Gruppe",
|
||||
revealFinder: "Im Finder anzeigen",
|
||||
revealExplorer: "Im Explorer anzeigen",
|
||||
revealFileManager: "Im Dateimanager anzeigen",
|
||||
moveToTrash: "In den Papierkorb",
|
||||
moveToRecycleBin: "In den Papierkorb",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
refresh: "Aktualisieren",
|
||||
open: "Öffnen",
|
||||
openDefault: "Mit Standard-App öffnen",
|
||||
edit: "Bearbeiten",
|
||||
copyToOther: "In anderes Panel kopieren",
|
||||
rename: "Umbenennen",
|
||||
permissions: "Berechtigungen",
|
||||
copyPath: "Pfad kopieren",
|
||||
delete: "Löschen",
|
||||
columns: "Spalten",
|
||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||
sessionNotConnected: 'Sitzung „{id}" ist nicht verbunden',
|
||||
localCannotDisconnect: "Die lokale Sitzung kann nicht getrennt werden",
|
||||
notADirectory: '„{path}" existiert und ist kein Ordner',
|
||||
sameFileTransfer: '„{name}": Quelle und Ziel sind dieselbe Datei',
|
||||
sameFileNoName: "Quelle und Ziel sind dieselbe Datei",
|
||||
folderOntoItself: "Ein Ordner kann nicht auf sich selbst kopiert werden",
|
||||
folderIntoItself: "Ein Ordner kann nicht in sich selbst kopiert werden",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,303 @@
|
||||
// English is the source of truth. Other locales mirror this shape; missing keys
|
||||
// fall back to English at lookup time. Placeholders use {name} interpolation.
|
||||
// Keep Transloco-compatible (nested namespaces, {param}) so a later migration
|
||||
// to a real i18n library is a drop-in of these dictionaries.
|
||||
|
||||
export const en = {
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
close: "Close",
|
||||
delete: "Delete",
|
||||
rename: "Rename",
|
||||
create: "Create",
|
||||
apply: "Apply",
|
||||
choose: "Choose…",
|
||||
connect: "Connect",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Not connected to the app",
|
||||
notConnectedBody: "This page is running in a plain browser. Launch the desktop app to use it.",
|
||||
managerOpen: "The connection manager is open.",
|
||||
showConnections: "Show Connections",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Connections",
|
||||
manageConnections: "Manage connections",
|
||||
newTab: "New tab ({mod}T)",
|
||||
closeTab: "Close tab ({mod}W)",
|
||||
options: "Options",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
language: "Language",
|
||||
system: "System",
|
||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||
accent: { label: "Accent" },
|
||||
accentNames: {
|
||||
graphite: "Graphite",
|
||||
blue: "Blue",
|
||||
indigo: "Indigo",
|
||||
purple: "Purple",
|
||||
rose: "Rose",
|
||||
orange: "Orange",
|
||||
green: "Green",
|
||||
teal: "Teal",
|
||||
},
|
||||
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
|
||||
interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" },
|
||||
speedUnit: { label: "Speed unit" },
|
||||
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
|
||||
atOnce: "At once",
|
||||
editor: { label: "Editor", builtin: "Built-in", custom: "Custom" },
|
||||
systemDefault: "System default",
|
||||
resetDefault: "Reset to system default",
|
||||
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Folder",
|
||||
symlink: "Symlink",
|
||||
file: "File",
|
||||
image: "Image",
|
||||
audio: "Audio",
|
||||
video: "Video",
|
||||
archive: "Archive",
|
||||
code: "Code",
|
||||
document: "Document",
|
||||
},
|
||||
columns: {
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
mtime: "Modified",
|
||||
kind: "Kind",
|
||||
rights: "Rights",
|
||||
owner: "Owner",
|
||||
group: "Group",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Choose what this panel shows",
|
||||
copy: "Copy",
|
||||
copyToRight: "Copy to right panel (F5)",
|
||||
copyToLeft: "Copy to left panel (F5)",
|
||||
edit: "Edit",
|
||||
editFile: "Edit file (F4)",
|
||||
rename: "Rename (F2)",
|
||||
newFolder: "New folder (F7)",
|
||||
newFile: "New file",
|
||||
permissions: "Permissions",
|
||||
delete: "Delete",
|
||||
deleteHint: "Delete (F8 / Del)",
|
||||
parentDir: "Parent directory (Backspace)",
|
||||
refresh: "Refresh",
|
||||
clearFilter: "Clear filter (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Connecting to {name}…",
|
||||
emptyDir: "Empty directory",
|
||||
noMatches: "No matches",
|
||||
matches: { one: "{count} match · Esc", other: "{count} matches · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Connections",
|
||||
importWinscp: "Import from WinSCP…",
|
||||
importWinscpHint: "Import sessions from a WinSCP.ini export",
|
||||
newConnection: "Connection",
|
||||
newConnectionHint: "New connection",
|
||||
newStack: "Stack",
|
||||
newStackHint: "New stack",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
renameStack: "Rename stack",
|
||||
deleteStack: "Delete stack",
|
||||
openStack: "Open stack in tabs",
|
||||
empty: "empty",
|
||||
connectionsHeader: "Connections",
|
||||
noneTitle: "No saved connections yet.",
|
||||
noneBody: "Create one, or import your WinSCP sessions.",
|
||||
name: "Name",
|
||||
namePlaceholder: "my server",
|
||||
host: "Host",
|
||||
port: "Port",
|
||||
stack: "Stack",
|
||||
stackNone: "(none)",
|
||||
username: "Username",
|
||||
remotePath: "Remote path (optional)",
|
||||
remotePathPlaceholder: "home directory",
|
||||
auth: "Auth",
|
||||
authPassword: "Password",
|
||||
authKey: "Private key",
|
||||
password: "Password",
|
||||
privateKeyFile: "Private key file",
|
||||
passphrase: "Passphrase (optional)",
|
||||
localDir: "Local directory (optional)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "The other panel opens here when it's on Local and hasn't been navigated.",
|
||||
more: "More…",
|
||||
less: "Less…",
|
||||
selectPrompt: "Select a connection or create a new one.",
|
||||
newStackTitle: "New stack",
|
||||
newStackPrompt: "stack name",
|
||||
renameStackTitle: "Rename stack",
|
||||
deleteStackTitle: "Delete stack",
|
||||
deleteStackBody: 'Delete stack "{name}"? Its connections stay, ungrouped.',
|
||||
deleteConnectionTitle: "Delete connection",
|
||||
deleteConnectionBody: 'Delete "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "No file to edit",
|
||||
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||
wrap: "Wrap",
|
||||
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
|
||||
findHint: "Find ({mod}F)",
|
||||
findPlaceholder: "Find in file",
|
||||
prevMatch: "Previous match (⇧↵)",
|
||||
nextMatch: "Next match (↵)",
|
||||
closeHint: "Close (Esc)",
|
||||
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||
cantOpen: "Can't open {name}",
|
||||
closeWindow: "Close Window",
|
||||
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
|
||||
lines: { one: "{count} line", other: "{count} lines" },
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
saveChangesTitle: 'Save changes to "{name}"?',
|
||||
saveChangesBody: "Your changes will be lost if you don't save them.",
|
||||
dontSave: "Don't Save",
|
||||
countLabel: "{current} of {total}",
|
||||
noMatches: "No matches",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} active · {finished} finished",
|
||||
clearFinished: "Clear finished",
|
||||
cancel: "Cancel",
|
||||
dismiss: "Dismiss",
|
||||
showDetails: "Show transfer details",
|
||||
copying: "Copying {name} · {percent}%{speed}",
|
||||
queued: "{count} queued",
|
||||
failed: { one: "{count} transfer failed", other: "{count} transfers failed" },
|
||||
finished: "Transfers finished",
|
||||
ready: "Ready",
|
||||
items: "{count} items",
|
||||
selected: "{count} selected",
|
||||
queuedState: "Queued",
|
||||
scanning: "Scanning…",
|
||||
failedState: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
completedFiles: { one: "{count} file · {size}", other: "{count} files · {size}" },
|
||||
left: "{eta} left",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Permissions",
|
||||
chmodItems: "{count} items",
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
execute: "Execute",
|
||||
roleOwner: "Owner",
|
||||
roleGroup: "Group",
|
||||
roleOthers: "Others",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Merge folder",
|
||||
replace: "Replace",
|
||||
merge: "Merge",
|
||||
mergeAll: "Merge all",
|
||||
replaceAll: "Replace all",
|
||||
skip: "Skip",
|
||||
skipAll: "Skip all",
|
||||
ofTotal: "{index} of {total}",
|
||||
overwriteDirBody: "The destination already has a folder with this name. Merging may replace files inside it.",
|
||||
overwriteFileBody: "The destination already has a file with this name.",
|
||||
source: "Source",
|
||||
destination: "Destination",
|
||||
newer: "newer",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Moved "{name}" to Trash',
|
||||
editingExternal: "Editing {name} — saves upload automatically",
|
||||
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||
sameFolder: "Both panels show the same folder",
|
||||
sameFile: 'Skipped "{name}" — it is the same file',
|
||||
typeMismatch: 'Skipped "{name}" — a different kind of item is in the way',
|
||||
transferFailed: 'Transfer "{name}" failed: {error}',
|
||||
unknownError: "unknown error",
|
||||
saved: "Saved {name}",
|
||||
disconnectedReason: "{title}: disconnected ({reason})",
|
||||
imported: { one: "Imported {count} connection", other: "Imported {count} connections" },
|
||||
importedIntoStacks: { one: " into {count} new stack", other: " into {count} new stacks" },
|
||||
importedSkipped: " ({skipped} skipped)",
|
||||
leftoverTitle: "Leftover edited files",
|
||||
leftoverBody: {
|
||||
one: "A previous session left {count} temporary edited file behind ({names}). Clear them?",
|
||||
other: "A previous session left {count} temporary edited files behind ({names}). Clear them?",
|
||||
},
|
||||
leftoverMore: ", +{count} more",
|
||||
leftoverClear: "Clear",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Delete",
|
||||
deleteBody: "Delete {what}? Folders are deleted recursively. This cannot be undone.",
|
||||
deleteConfirm: "Delete",
|
||||
renameTitle: 'Rename "{name}"',
|
||||
renameConfirm: "Rename",
|
||||
newFolderTitle: "New folder",
|
||||
newFolderPlaceholder: "folder name",
|
||||
newFileTitle: "New file",
|
||||
newFilePlaceholder: "file name",
|
||||
createConfirm: "Create",
|
||||
itemsCount: "{count} items",
|
||||
},
|
||||
menu: {
|
||||
about: "About {app}",
|
||||
quit: "Quit",
|
||||
file: "File",
|
||||
newTab: "New Tab",
|
||||
closeTab: "Close Tab",
|
||||
newFolder: "New Folder",
|
||||
newFile: "New File",
|
||||
editFile: "Edit File",
|
||||
connect: "Connect",
|
||||
connectionManager: "Connection Manager…",
|
||||
edit: "Edit",
|
||||
view: "View",
|
||||
refresh: "Refresh",
|
||||
window: "Window",
|
||||
},
|
||||
ctx: {
|
||||
size: "Size",
|
||||
modified: "Modified",
|
||||
kind: "Kind",
|
||||
rights: "Rights",
|
||||
owner: "Owner",
|
||||
group: "Group",
|
||||
revealFinder: "Reveal in Finder",
|
||||
revealExplorer: "Show in Explorer",
|
||||
revealFileManager: "Show in File Manager",
|
||||
moveToTrash: "Move to Trash",
|
||||
moveToRecycleBin: "Move to Recycle Bin",
|
||||
newFolder: "New Folder",
|
||||
newFile: "New File",
|
||||
refresh: "Refresh",
|
||||
open: "Open",
|
||||
openDefault: "Open with the default application",
|
||||
edit: "Edit",
|
||||
copyToOther: "Copy to Other Panel",
|
||||
rename: "Rename",
|
||||
permissions: "Permissions",
|
||||
copyPath: "Copy Path",
|
||||
delete: "Delete",
|
||||
columns: "Columns",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||
identityFileNotFound: "identity file not found: {path}",
|
||||
sessionNotConnected: 'Session "{id}" is not connected',
|
||||
localCannotDisconnect: "The local session cannot be disconnected",
|
||||
notADirectory: '"{path}" exists and is not a directory',
|
||||
sameFileTransfer: '"{name}": source and destination are the same file',
|
||||
sameFileNoName: "Source and destination are the same file",
|
||||
folderOntoItself: "Cannot copy a folder onto itself",
|
||||
folderIntoItself: "Cannot copy a folder into itself",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,299 @@
|
||||
// Spanish translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const es = {
|
||||
common: {
|
||||
cancel: "Cancelar",
|
||||
save: "Guardar",
|
||||
close: "Cerrar",
|
||||
delete: "Eliminar",
|
||||
rename: "Renombrar",
|
||||
create: "Crear",
|
||||
apply: "Aplicar",
|
||||
choose: "Elegir…",
|
||||
connect: "Conectar",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "No conectado a la aplicación",
|
||||
notConnectedBody: "Esta página se ejecuta en un navegador normal. Abre la aplicación de escritorio para usarla.",
|
||||
managerOpen: "El gestor de conexiones está abierto.",
|
||||
showConnections: "Mostrar conexiones",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Conexiones",
|
||||
manageConnections: "Gestionar conexiones",
|
||||
newTab: "Nueva pestaña ({mod}T)",
|
||||
closeTab: "Cerrar pestaña ({mod}W)",
|
||||
options: "Opciones",
|
||||
},
|
||||
options: {
|
||||
title: "Opciones",
|
||||
language: "Idioma",
|
||||
system: "Sistema",
|
||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||
accent: { label: "Acento" },
|
||||
accentNames: {
|
||||
graphite: "Grafito",
|
||||
blue: "Azul",
|
||||
indigo: "Índigo",
|
||||
purple: "Morado",
|
||||
rose: "Rosa",
|
||||
orange: "Naranja",
|
||||
green: "Verde",
|
||||
teal: "Turquesa",
|
||||
},
|
||||
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
|
||||
interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" },
|
||||
speedUnit: { label: "Unidad de velocidad" },
|
||||
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
|
||||
atOnce: "A la vez",
|
||||
editor: { label: "Editor", builtin: "Integrado", custom: "Personalizado" },
|
||||
systemDefault: "Predeterminado del sistema",
|
||||
resetDefault: "Restablecer al predeterminado",
|
||||
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Carpeta",
|
||||
symlink: "Enlace simbólico",
|
||||
file: "Archivo",
|
||||
image: "Imagen",
|
||||
audio: "Audio",
|
||||
video: "Vídeo",
|
||||
archive: "Archivo comprimido",
|
||||
code: "Código",
|
||||
document: "Documento",
|
||||
},
|
||||
columns: {
|
||||
name: "Nombre",
|
||||
size: "Tamaño",
|
||||
mtime: "Modificado",
|
||||
kind: "Tipo",
|
||||
rights: "Permisos",
|
||||
owner: "Propietario",
|
||||
group: "Grupo",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Elige qué muestra este panel",
|
||||
copy: "Copiar",
|
||||
copyToRight: "Copiar al panel derecho (F5)",
|
||||
copyToLeft: "Copiar al panel izquierdo (F5)",
|
||||
edit: "Editar",
|
||||
editFile: "Editar archivo (F4)",
|
||||
rename: "Renombrar (F2)",
|
||||
newFolder: "Nueva carpeta (F7)",
|
||||
newFile: "Nuevo archivo",
|
||||
permissions: "Permisos",
|
||||
delete: "Eliminar",
|
||||
deleteHint: "Eliminar (F8 / Supr)",
|
||||
parentDir: "Carpeta superior (Retroceso)",
|
||||
refresh: "Actualizar",
|
||||
clearFilter: "Borrar filtro (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Conectando con {name}…",
|
||||
emptyDir: "Carpeta vacía",
|
||||
noMatches: "Sin coincidencias",
|
||||
matches: { one: "{count} coincidencia · Esc", other: "{count} coincidencias · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Conexiones",
|
||||
importWinscp: "Importar de WinSCP…",
|
||||
importWinscpHint: "Importar sesiones de una exportación WinSCP.ini",
|
||||
newConnection: "Conexión",
|
||||
newConnectionHint: "Nueva conexión",
|
||||
newStack: "Grupo",
|
||||
newStackHint: "Nuevo grupo",
|
||||
expand: "Expandir",
|
||||
collapse: "Contraer",
|
||||
renameStack: "Renombrar grupo",
|
||||
deleteStack: "Eliminar grupo",
|
||||
openStack: "Abrir grupo en pestañas",
|
||||
empty: "vacío",
|
||||
connectionsHeader: "Conexiones",
|
||||
noneTitle: "Aún no hay conexiones guardadas.",
|
||||
noneBody: "Crea una o importa tus sesiones de WinSCP.",
|
||||
name: "Nombre",
|
||||
namePlaceholder: "mi servidor",
|
||||
host: "Servidor",
|
||||
port: "Puerto",
|
||||
stack: "Grupo",
|
||||
stackNone: "(ninguno)",
|
||||
username: "Usuario",
|
||||
remotePath: "Ruta remota (opcional)",
|
||||
remotePathPlaceholder: "carpeta de inicio",
|
||||
auth: "Autenticación",
|
||||
authPassword: "Contraseña",
|
||||
authKey: "Clave privada",
|
||||
password: "Contraseña",
|
||||
privateKeyFile: "Archivo de clave privada",
|
||||
passphrase: "Frase de contraseña (opcional)",
|
||||
localDir: "Carpeta local (opcional)",
|
||||
localDirPlaceholder: "~/proyectos/mi-servidor",
|
||||
localDirHint: "El otro panel se abre aquí cuando está en Local y no se ha navegado.",
|
||||
more: "Más…",
|
||||
less: "Menos…",
|
||||
selectPrompt: "Selecciona una conexión o crea una nueva.",
|
||||
newStackTitle: "Nuevo grupo",
|
||||
newStackPrompt: "nombre del grupo",
|
||||
renameStackTitle: "Renombrar grupo",
|
||||
deleteStackTitle: "Eliminar grupo",
|
||||
deleteStackBody: '¿Eliminar el grupo "{name}"? Sus conexiones se mantienen, sin agrupar.',
|
||||
deleteConnectionTitle: "Eliminar conexión",
|
||||
deleteConnectionBody: '¿Eliminar "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Ningún archivo que editar",
|
||||
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||
wrap: "Ajustar",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
|
||||
findHint: "Buscar ({mod}F)",
|
||||
findPlaceholder: "Buscar en el archivo",
|
||||
prevMatch: "Coincidencia anterior (⇧↵)",
|
||||
nextMatch: "Coincidencia siguiente (↵)",
|
||||
closeHint: "Cerrar (Esc)",
|
||||
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||
cantOpen: "No se puede abrir {name}",
|
||||
closeWindow: "Cerrar ventana",
|
||||
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
|
||||
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||
close: "Cerrar",
|
||||
save: "Guardar",
|
||||
saveChangesTitle: '¿Guardar los cambios en "{name}"?',
|
||||
saveChangesBody: "Perderás tus cambios si no los guardas.",
|
||||
dontSave: "No guardar",
|
||||
countLabel: "{current} de {total}",
|
||||
noMatches: "Sin coincidencias",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} activas · {finished} completadas",
|
||||
clearFinished: "Limpiar completadas",
|
||||
cancel: "Cancelar",
|
||||
dismiss: "Descartar",
|
||||
showDetails: "Ver detalles de transferencias",
|
||||
copying: "Copiando {name} · {percent}%{speed}",
|
||||
queued: "{count} en cola",
|
||||
failed: { one: "{count} transferencia fallida", other: "{count} transferencias fallidas" },
|
||||
finished: "Transferencias completadas",
|
||||
ready: "Listo",
|
||||
items: "{count} elementos",
|
||||
selected: "{count} seleccionados",
|
||||
queuedState: "En cola",
|
||||
scanning: "Analizando…",
|
||||
failedState: "Fallida",
|
||||
cancelled: "Cancelada",
|
||||
completedFiles: { one: "{count} archivo · {size}", other: "{count} archivos · {size}" },
|
||||
left: "{eta} restante",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Permisos",
|
||||
chmodItems: "{count} elementos",
|
||||
read: "Lectura",
|
||||
write: "Escritura",
|
||||
execute: "Ejecución",
|
||||
roleOwner: "Propietario",
|
||||
roleGroup: "Grupo",
|
||||
roleOthers: "Otros",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Combinar carpeta",
|
||||
replace: "Reemplazar",
|
||||
merge: "Combinar",
|
||||
mergeAll: "Combinar todo",
|
||||
replaceAll: "Reemplazar todo",
|
||||
skip: "Omitir",
|
||||
skipAll: "Omitir todo",
|
||||
ofTotal: "{index} de {total}",
|
||||
overwriteDirBody: "El destino ya tiene una carpeta con este nombre. Combinar puede reemplazar archivos dentro.",
|
||||
overwriteFileBody: "El destino ya tiene un archivo con este nombre.",
|
||||
source: "Origen",
|
||||
destination: "Destino",
|
||||
newer: "más reciente",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||
sameFolder: "Ambos paneles muestran la misma carpeta",
|
||||
sameFile: 'Se omitió "{name}" — es el mismo archivo',
|
||||
typeMismatch: 'Se omitió "{name}" — hay otro tipo de elemento en el camino',
|
||||
transferFailed: 'La transferencia "{name}" falló: {error}',
|
||||
unknownError: "error desconocido",
|
||||
saved: "Se guardó {name}",
|
||||
disconnectedReason: "{title}: desconectado ({reason})",
|
||||
imported: { one: "Se importó {count} conexión", other: "Se importaron {count} conexiones" },
|
||||
importedIntoStacks: { one: " en {count} grupo nuevo", other: " en {count} grupos nuevos" },
|
||||
importedSkipped: " ({skipped} omitidas)",
|
||||
leftoverTitle: "Archivos editados sobrantes",
|
||||
leftoverMore: ", +{count} más",
|
||||
leftoverBody: {
|
||||
one: "Una sesión anterior dejó {count} archivo editado temporal ({names}). ¿Eliminarlo?",
|
||||
other: "Una sesión anterior dejó {count} archivos editados temporales ({names}). ¿Eliminarlos?",
|
||||
},
|
||||
leftoverClear: "Eliminar",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Eliminar",
|
||||
deleteBody: "¿Eliminar {what}? Las carpetas se eliminan de forma recursiva. Esto no se puede deshacer.",
|
||||
deleteConfirm: "Eliminar",
|
||||
renameTitle: 'Renombrar "{name}"',
|
||||
renameConfirm: "Renombrar",
|
||||
newFolderTitle: "Nueva carpeta",
|
||||
newFolderPlaceholder: "nombre de la carpeta",
|
||||
newFileTitle: "Nuevo archivo",
|
||||
newFilePlaceholder: "nombre del archivo",
|
||||
createConfirm: "Crear",
|
||||
itemsCount: "{count} elementos",
|
||||
},
|
||||
menu: {
|
||||
about: "Acerca de {app}",
|
||||
quit: "Salir",
|
||||
file: "Archivo",
|
||||
newTab: "Nueva pestaña",
|
||||
closeTab: "Cerrar pestaña",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
editFile: "Editar archivo",
|
||||
connect: "Conectar",
|
||||
connectionManager: "Gestor de conexiones…",
|
||||
edit: "Edición",
|
||||
view: "Ver",
|
||||
refresh: "Actualizar",
|
||||
window: "Ventana",
|
||||
},
|
||||
ctx: {
|
||||
size: "Tamaño",
|
||||
modified: "Modificado",
|
||||
kind: "Tipo",
|
||||
rights: "Permisos",
|
||||
owner: "Propietario",
|
||||
group: "Grupo",
|
||||
revealFinder: "Mostrar en Finder",
|
||||
revealExplorer: "Mostrar en el Explorador",
|
||||
revealFileManager: "Mostrar en el gestor de archivos",
|
||||
moveToTrash: "Mover a la papelera",
|
||||
moveToRecycleBin: "Mover a la papelera de reciclaje",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
refresh: "Actualizar",
|
||||
open: "Abrir",
|
||||
openDefault: "Abrir con la aplicación predeterminada",
|
||||
edit: "Editar",
|
||||
copyToOther: "Copiar al otro panel",
|
||||
rename: "Renombrar",
|
||||
permissions: "Permisos",
|
||||
copyPath: "Copiar ruta",
|
||||
delete: "Eliminar",
|
||||
columns: "Columnas",
|
||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||
sessionNotConnected: 'La sesión "{id}" no está conectada',
|
||||
localCannotDisconnect: "La sesión local no se puede desconectar",
|
||||
notADirectory: '"{path}" existe y no es una carpeta',
|
||||
sameFileTransfer: '"{name}": el origen y el destino son el mismo archivo',
|
||||
sameFileNoName: "El origen y el destino son el mismo archivo",
|
||||
folderOntoItself: "No se puede copiar una carpeta sobre sí misma",
|
||||
folderIntoItself: "No se puede copiar una carpeta dentro de sí misma",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,301 @@
|
||||
// Basque (Euskara) translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — REQUIRES native-speaker review (Basque is error-prone
|
||||
// for machine/AI translation; treat every string as provisional).
|
||||
export const eu = {
|
||||
common: {
|
||||
cancel: "Utzi",
|
||||
save: "Gorde",
|
||||
close: "Itxi",
|
||||
delete: "Ezabatu",
|
||||
rename: "Berrizendatu",
|
||||
create: "Sortu",
|
||||
apply: "Aplikatu",
|
||||
choose: "Aukeratu…",
|
||||
connect: "Konektatu",
|
||||
local: "Lokala",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Ez dago aplikaziora konektatuta",
|
||||
notConnectedBody: "Orri hau nabigatzaile arrunt batean exekutatzen ari da. Ireki mahaigaineko aplikazioa erabiltzeko.",
|
||||
managerOpen: "Konexio-kudeatzailea irekita dago.",
|
||||
showConnections: "Erakutsi konexioak",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Konexioak",
|
||||
manageConnections: "Kudeatu konexioak",
|
||||
newTab: "Fitxa berria ({mod}T)",
|
||||
closeTab: "Itxi fitxa ({mod}W)",
|
||||
options: "Aukerak",
|
||||
},
|
||||
options: {
|
||||
title: "Aukerak",
|
||||
language: "Hizkuntza",
|
||||
system: "Sistema",
|
||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||
accent: { label: "Azentua" },
|
||||
accentNames: {
|
||||
graphite: "Grafitoa",
|
||||
blue: "Urdina",
|
||||
indigo: "Indigoa",
|
||||
purple: "Morea",
|
||||
rose: "Arrosa",
|
||||
orange: "Laranja",
|
||||
green: "Berdea",
|
||||
teal: "Turkesa",
|
||||
},
|
||||
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
|
||||
interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" },
|
||||
speedUnit: { label: "Abiadura-unitatea" },
|
||||
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
|
||||
atOnce: "Aldi berean",
|
||||
editor: { label: "Editorea", builtin: "Barnekoa", custom: "Pertsonalizatua" },
|
||||
systemDefault: "Sistemaren lehenetsia",
|
||||
resetDefault: "Berrezarri lehenetsira",
|
||||
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Karpeta",
|
||||
symlink: "Esteka sinbolikoa",
|
||||
file: "Fitxategia",
|
||||
image: "Irudia",
|
||||
audio: "Audioa",
|
||||
video: "Bideoa",
|
||||
archive: "Artxiboa",
|
||||
code: "Kodea",
|
||||
document: "Dokumentua",
|
||||
},
|
||||
columns: {
|
||||
name: "Izena",
|
||||
size: "Tamaina",
|
||||
mtime: "Aldatze-data",
|
||||
kind: "Mota",
|
||||
rights: "Baimenak",
|
||||
owner: "Jabea",
|
||||
group: "Taldea",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||
copy: "Kopiatu",
|
||||
copyToRight: "Kopiatu eskuineko panelera (F5)",
|
||||
copyToLeft: "Kopiatu ezkerreko panelera (F5)",
|
||||
edit: "Editatu",
|
||||
editFile: "Editatu fitxategia (F4)",
|
||||
rename: "Berrizendatu (F2)",
|
||||
newFolder: "Karpeta berria (F7)",
|
||||
newFile: "Fitxategi berria",
|
||||
permissions: "Baimenak",
|
||||
delete: "Ezabatu",
|
||||
deleteHint: "Ezabatu (F8 / Del)",
|
||||
parentDir: "Karpeta gurasoa (Atzera-tekla)",
|
||||
refresh: "Freskatu",
|
||||
clearFilter: "Garbitu iragazkia (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "{name}(e)ra konektatzen…",
|
||||
emptyDir: "Karpeta hutsa",
|
||||
noMatches: "Bat-etortzerik ez",
|
||||
matches: { one: "bat-etortze {count} · Esc", other: "{count} bat-etortze · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Konexioak",
|
||||
importWinscp: "Inportatu WinSCPtik…",
|
||||
importWinscpHint: "Inportatu saioak WinSCP.ini esportazio batetik",
|
||||
newConnection: "Konexioa",
|
||||
newConnectionHint: "Konexio berria",
|
||||
newStack: "Taldea",
|
||||
newStackHint: "Talde berria",
|
||||
expand: "Zabaldu",
|
||||
collapse: "Tolestu",
|
||||
renameStack: "Berrizendatu taldea",
|
||||
deleteStack: "Ezabatu taldea",
|
||||
openStack: "Ireki taldea fitxetan",
|
||||
empty: "hutsik",
|
||||
connectionsHeader: "Konexioak",
|
||||
noneTitle: "Oraindik ez dago gordetako konexiorik.",
|
||||
noneBody: "Sortu bat edo inportatu zure WinSCP saioak.",
|
||||
name: "Izena",
|
||||
namePlaceholder: "nire zerbitzaria",
|
||||
host: "Ostalaria",
|
||||
port: "Ataka",
|
||||
stack: "Taldea",
|
||||
stackNone: "(bat ere ez)",
|
||||
username: "Erabiltzailea",
|
||||
remotePath: "Urruneko bidea (aukerakoa)",
|
||||
remotePathPlaceholder: "hasierako karpeta",
|
||||
auth: "Autentifikazioa",
|
||||
authPassword: "Pasahitza",
|
||||
authKey: "Gako pribatua",
|
||||
password: "Pasahitza",
|
||||
privateKeyFile: "Gako pribatuaren fitxategia",
|
||||
passphrase: "Pasaesaldia (aukerakoa)",
|
||||
localDir: "Karpeta lokala (aukerakoa)",
|
||||
localDirPlaceholder: "~/proiektuak/nire-zerbitzaria",
|
||||
localDirHint: "Beste panela hemen irekiko da Lokalean dagoenean eta nabigatu ez denean.",
|
||||
more: "Gehiago…",
|
||||
less: "Gutxiago…",
|
||||
selectPrompt: "Hautatu konexio bat edo sortu berri bat.",
|
||||
newStackTitle: "Talde berria",
|
||||
newStackPrompt: "taldearen izena",
|
||||
renameStackTitle: "Berrizendatu taldea",
|
||||
deleteStackTitle: "Ezabatu taldea",
|
||||
deleteStackBody: 'Ezabatu "{name}" taldea? Bere konexioak mantendu egingo dira, taldekatu gabe.',
|
||||
deleteConnectionTitle: "Ezabatu konexioa",
|
||||
deleteConnectionBody: 'Ezabatu "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||
wrap: "Doitu",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
|
||||
findHint: "Bilatu ({mod}F)",
|
||||
findPlaceholder: "Bilatu fitxategian",
|
||||
prevMatch: "Aurreko bat-etortzea (⇧↵)",
|
||||
nextMatch: "Hurrengo bat-etortzea (↵)",
|
||||
closeHint: "Itxi (Esc)",
|
||||
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||
cantOpen: "Ezin da {name} ireki",
|
||||
closeWindow: "Itxi leihoa",
|
||||
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
|
||||
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||
close: "Itxi",
|
||||
save: "Gorde",
|
||||
saveChangesTitle: 'Gorde "{name}"(r)en aldaketak?',
|
||||
saveChangesBody: "Zure aldaketak galduko dira gordetzen ez badituzu.",
|
||||
dontSave: "Ez gorde",
|
||||
countLabel: "{total}(e)tik {current}",
|
||||
noMatches: "Bat-etortzerik ez",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} aktibo · {finished} amaituta",
|
||||
clearFinished: "Garbitu amaituak",
|
||||
cancel: "Utzi",
|
||||
dismiss: "Baztertu",
|
||||
showDetails: "Erakutsi transferentzien xehetasunak",
|
||||
copying: "{name} kopiatzen · %{percent}{speed}",
|
||||
queued: "{count} ilaran",
|
||||
failed: { one: "transferentzia {count}(e)k huts egin du", other: "{count} transferentziak huts egin dute" },
|
||||
finished: "Transferentziak amaituta",
|
||||
ready: "Prest",
|
||||
items: "{count} elementu",
|
||||
selected: "{count} hautatuta",
|
||||
queuedState: "Ilaran",
|
||||
scanning: "Aztertzen…",
|
||||
failedState: "Huts egin du",
|
||||
cancelled: "Bertan behera utzita",
|
||||
completedFiles: { one: "fitxategi {count} · {size}", other: "{count} fitxategi · {size}" },
|
||||
left: "{eta} geratzen da",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Baimenak",
|
||||
chmodItems: "{count} elementu",
|
||||
read: "Irakurri",
|
||||
write: "Idatzi",
|
||||
execute: "Exekutatu",
|
||||
roleOwner: "Jabea",
|
||||
roleGroup: "Taldea",
|
||||
roleOthers: "Besteak",
|
||||
octal: "Zortzitarra",
|
||||
mergeFolder: "Batu karpeta",
|
||||
replace: "Ordeztu",
|
||||
merge: "Batu",
|
||||
mergeAll: "Batu guztiak",
|
||||
replaceAll: "Ordeztu guztiak",
|
||||
skip: "Saltatu",
|
||||
skipAll: "Saltatu guztiak",
|
||||
ofTotal: "{total}(e)tik {index}",
|
||||
overwriteDirBody: "Helmugan izen bereko karpeta bat dago jada. Batzeak barneko fitxategiak ordeztu ditzake.",
|
||||
overwriteFileBody: "Helmugan izen bereko fitxategi bat dago jada.",
|
||||
source: "Iturburua",
|
||||
destination: "Helmuga",
|
||||
newer: "berriagoa",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||
sameFolder: "Bi panelek karpeta bera erakusten dute",
|
||||
sameFile: '"{name}" saltatu da — fitxategi bera da',
|
||||
typeMismatch: '"{name}" saltatu da — beste mota bateko elementu bat dago bidean',
|
||||
transferFailed: '"{name}" transferentziak huts egin du: {error}',
|
||||
unknownError: "errore ezezaguna",
|
||||
saved: "{name} gorde da",
|
||||
disconnectedReason: "{title}: deskonektatuta ({reason})",
|
||||
imported: { one: "konexio {count} inportatu da", other: "{count} konexio inportatu dira" },
|
||||
importedIntoStacks: { one: " talde berri {count}ean", other: " {count} talde berritan" },
|
||||
importedSkipped: " ({skipped} saltatuta)",
|
||||
leftoverTitle: "Geratutako editatutako fitxategiak",
|
||||
leftoverMore: ", +{count} gehiago",
|
||||
leftoverBody: {
|
||||
one: "Aurreko saio batek editatutako aldi baterako fitxategi {count} utzi zuen ({names}). Garbitu?",
|
||||
other: "Aurreko saio batek editatutako aldi baterako {count} fitxategi utzi zituen ({names}). Garbitu?",
|
||||
},
|
||||
leftoverClear: "Garbitu",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Ezabatu",
|
||||
deleteBody: "Ezabatu {what}? Karpetak errekurtsiboki ezabatzen dira. Ezin da desegin.",
|
||||
deleteConfirm: "Ezabatu",
|
||||
renameTitle: 'Berrizendatu "{name}"',
|
||||
renameConfirm: "Berrizendatu",
|
||||
newFolderTitle: "Karpeta berria",
|
||||
newFolderPlaceholder: "karpetaren izena",
|
||||
newFileTitle: "Fitxategi berria",
|
||||
newFilePlaceholder: "fitxategiaren izena",
|
||||
createConfirm: "Sortu",
|
||||
itemsCount: "{count} elementu",
|
||||
},
|
||||
menu: {
|
||||
about: "{app} (r)i buruz",
|
||||
quit: "Irten",
|
||||
file: "Fitxategia",
|
||||
newTab: "Fitxa berria",
|
||||
closeTab: "Itxi fitxa",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
editFile: "Editatu fitxategia",
|
||||
connect: "Konektatu",
|
||||
connectionManager: "Konexio-kudeatzailea…",
|
||||
edit: "Editatu",
|
||||
view: "Ikusi",
|
||||
refresh: "Freskatu",
|
||||
window: "Leihoa",
|
||||
},
|
||||
ctx: {
|
||||
size: "Tamaina",
|
||||
modified: "Aldatze-data",
|
||||
kind: "Mota",
|
||||
rights: "Baimenak",
|
||||
owner: "Jabea",
|
||||
group: "Taldea",
|
||||
revealFinder: "Erakutsi Finder-en",
|
||||
revealExplorer: "Erakutsi Explorer-en",
|
||||
revealFileManager: "Erakutsi fitxategi-kudeatzailean",
|
||||
moveToTrash: "Eraman zakarrontzira",
|
||||
moveToRecycleBin: "Eraman birziklatze-ontzira",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
refresh: "Freskatu",
|
||||
open: "Ireki",
|
||||
openDefault: "Ireki lehenetsitako aplikazioarekin",
|
||||
edit: "Editatu",
|
||||
copyToOther: "Kopiatu beste panelera",
|
||||
rename: "Berrizendatu",
|
||||
permissions: "Baimenak",
|
||||
copyPath: "Kopiatu bidea",
|
||||
delete: "Ezabatu",
|
||||
columns: "Zutabeak",
|
||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||
sessionNotConnected: '"{id}" saioa ez dago konektatuta',
|
||||
localCannotDisconnect: "Saio lokala ezin da deskonektatu",
|
||||
notADirectory: '"{path}" badago eta ez da karpeta bat',
|
||||
sameFileTransfer: '"{name}": iturburua eta helmuga fitxategi bera dira',
|
||||
sameFileNoName: "Iturburua eta helmuga fitxategi bera dira",
|
||||
folderOntoItself: "Ezin da karpeta bat bere gainean kopiatu",
|
||||
folderIntoItself: "Ezin da karpeta bat bere barnean kopiatu",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,299 @@
|
||||
// French translations. Missing keys fall back to English (see en.ts / index.ts).
|
||||
export const fr = {
|
||||
common: {
|
||||
cancel: "Annuler",
|
||||
save: "Enregistrer",
|
||||
close: "Fermer",
|
||||
delete: "Supprimer",
|
||||
rename: "Renommer",
|
||||
create: "Créer",
|
||||
apply: "Appliquer",
|
||||
choose: "Choisir…",
|
||||
connect: "Connecter",
|
||||
local: "Local",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "Non connecté à l'application",
|
||||
notConnectedBody: "Cette page s'exécute dans un navigateur classique. Lancez l'application de bureau pour l'utiliser.",
|
||||
managerOpen: "Le gestionnaire de connexions est ouvert.",
|
||||
showConnections: "Afficher les connexions",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "Connexions",
|
||||
manageConnections: "Gérer les connexions",
|
||||
newTab: "Nouvel onglet ({mod}T)",
|
||||
closeTab: "Fermer l'onglet ({mod}W)",
|
||||
options: "Options",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
language: "Langue",
|
||||
system: "Système",
|
||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||
accent: { label: "Accent" },
|
||||
accentNames: {
|
||||
graphite: "Graphite",
|
||||
blue: "Bleu",
|
||||
indigo: "Indigo",
|
||||
purple: "Violet",
|
||||
rose: "Rose",
|
||||
orange: "Orange",
|
||||
green: "Vert",
|
||||
teal: "Sarcelle",
|
||||
},
|
||||
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
|
||||
interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" },
|
||||
speedUnit: { label: "Unité de vitesse" },
|
||||
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
|
||||
atOnce: "Simultanés",
|
||||
editor: { label: "Éditeur", builtin: "Intégré", custom: "Personnalisé" },
|
||||
systemDefault: "Valeur par défaut du système",
|
||||
resetDefault: "Réinitialiser par défaut",
|
||||
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Dossier",
|
||||
symlink: "Lien symbolique",
|
||||
file: "Fichier",
|
||||
image: "Image",
|
||||
audio: "Audio",
|
||||
video: "Vidéo",
|
||||
archive: "Archive",
|
||||
code: "Code",
|
||||
document: "Document",
|
||||
},
|
||||
columns: {
|
||||
name: "Nom",
|
||||
size: "Taille",
|
||||
mtime: "Modifié",
|
||||
kind: "Type",
|
||||
rights: "Droits",
|
||||
owner: "Propriétaire",
|
||||
group: "Groupe",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||
copy: "Copier",
|
||||
copyToRight: "Copier vers le panneau droit (F5)",
|
||||
copyToLeft: "Copier vers le panneau gauche (F5)",
|
||||
edit: "Éditer",
|
||||
editFile: "Éditer le fichier (F4)",
|
||||
rename: "Renommer (F2)",
|
||||
newFolder: "Nouveau dossier (F7)",
|
||||
newFile: "Nouveau fichier",
|
||||
permissions: "Autorisations",
|
||||
delete: "Supprimer",
|
||||
deleteHint: "Supprimer (F8 / Suppr)",
|
||||
parentDir: "Dossier parent (Retour arrière)",
|
||||
refresh: "Actualiser",
|
||||
clearFilter: "Effacer le filtre (Échap)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "Connexion à {name}…",
|
||||
emptyDir: "Dossier vide",
|
||||
noMatches: "Aucun résultat",
|
||||
matches: { one: "{count} résultat · Échap", other: "{count} résultats · Échap" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "Connexions",
|
||||
importWinscp: "Importer depuis WinSCP…",
|
||||
importWinscpHint: "Importer des sessions depuis un export WinSCP.ini",
|
||||
newConnection: "Connexion",
|
||||
newConnectionHint: "Nouvelle connexion",
|
||||
newStack: "Groupe",
|
||||
newStackHint: "Nouveau groupe",
|
||||
expand: "Développer",
|
||||
collapse: "Réduire",
|
||||
renameStack: "Renommer le groupe",
|
||||
deleteStack: "Supprimer le groupe",
|
||||
openStack: "Ouvrir le groupe dans des onglets",
|
||||
empty: "vide",
|
||||
connectionsHeader: "Connexions",
|
||||
noneTitle: "Aucune connexion enregistrée.",
|
||||
noneBody: "Créez-en une ou importez vos sessions WinSCP.",
|
||||
name: "Nom",
|
||||
namePlaceholder: "mon serveur",
|
||||
host: "Hôte",
|
||||
port: "Port",
|
||||
stack: "Groupe",
|
||||
stackNone: "(aucun)",
|
||||
username: "Utilisateur",
|
||||
remotePath: "Chemin distant (facultatif)",
|
||||
remotePathPlaceholder: "dossier personnel",
|
||||
auth: "Authentification",
|
||||
authPassword: "Mot de passe",
|
||||
authKey: "Clé privée",
|
||||
password: "Mot de passe",
|
||||
privateKeyFile: "Fichier de clé privée",
|
||||
passphrase: "Phrase secrète (facultative)",
|
||||
localDir: "Dossier local (facultatif)",
|
||||
localDirPlaceholder: "~/projets/mon-serveur",
|
||||
localDirHint: "L'autre panneau s'ouvre ici lorsqu'il est sur Local et n'a pas été parcouru.",
|
||||
more: "Plus…",
|
||||
less: "Moins…",
|
||||
selectPrompt: "Sélectionnez une connexion ou créez-en une nouvelle.",
|
||||
newStackTitle: "Nouveau groupe",
|
||||
newStackPrompt: "nom du groupe",
|
||||
renameStackTitle: "Renommer le groupe",
|
||||
deleteStackTitle: "Supprimer le groupe",
|
||||
deleteStackBody: 'Supprimer le groupe « {name} » ? Ses connexions sont conservées, sans groupe.',
|
||||
deleteConnectionTitle: "Supprimer la connexion",
|
||||
deleteConnectionBody: 'Supprimer « {name} » ?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "Aucun fichier à éditer",
|
||||
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||
wrap: "Retour",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
|
||||
findHint: "Rechercher ({mod}F)",
|
||||
findPlaceholder: "Rechercher dans le fichier",
|
||||
prevMatch: "Résultat précédent (⇧↵)",
|
||||
nextMatch: "Résultat suivant (↵)",
|
||||
closeHint: "Fermer (Échap)",
|
||||
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||
cantOpen: "Impossible d'ouvrir {name}",
|
||||
closeWindow: "Fermer la fenêtre",
|
||||
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
|
||||
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||
close: "Fermer",
|
||||
save: "Enregistrer",
|
||||
saveChangesTitle: 'Enregistrer les modifications de « {name} » ?',
|
||||
saveChangesBody: "Vos modifications seront perdues si vous ne les enregistrez pas.",
|
||||
dontSave: "Ne pas enregistrer",
|
||||
countLabel: "{current} sur {total}",
|
||||
noMatches: "Aucun résultat",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} en cours · {finished} terminés",
|
||||
clearFinished: "Effacer les terminés",
|
||||
cancel: "Annuler",
|
||||
dismiss: "Ignorer",
|
||||
showDetails: "Afficher les détails des transferts",
|
||||
copying: "Copie de {name} · {percent}%{speed}",
|
||||
queued: "{count} en file",
|
||||
failed: { one: "{count} transfert échoué", other: "{count} transferts échoués" },
|
||||
finished: "Transferts terminés",
|
||||
ready: "Prêt",
|
||||
items: "{count} éléments",
|
||||
selected: "{count} sélectionnés",
|
||||
queuedState: "En file",
|
||||
scanning: "Analyse…",
|
||||
failedState: "Échec",
|
||||
cancelled: "Annulé",
|
||||
completedFiles: { one: "{count} fichier · {size}", other: "{count} fichiers · {size}" },
|
||||
left: "{eta} restant",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "Autorisations",
|
||||
chmodItems: "{count} éléments",
|
||||
read: "Lecture",
|
||||
write: "Écriture",
|
||||
execute: "Exécution",
|
||||
roleOwner: "Propriétaire",
|
||||
roleGroup: "Groupe",
|
||||
roleOthers: "Autres",
|
||||
octal: "Octal",
|
||||
mergeFolder: "Fusionner le dossier",
|
||||
replace: "Remplacer",
|
||||
merge: "Fusionner",
|
||||
mergeAll: "Tout fusionner",
|
||||
replaceAll: "Tout remplacer",
|
||||
skip: "Ignorer",
|
||||
skipAll: "Tout ignorer",
|
||||
ofTotal: "{index} sur {total}",
|
||||
overwriteDirBody: "La destination contient déjà un dossier de ce nom. La fusion peut remplacer des fichiers.",
|
||||
overwriteFileBody: "La destination contient déjà un fichier de ce nom.",
|
||||
source: "Source",
|
||||
destination: "Destination",
|
||||
newer: "plus récent",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '« {name} » mis à la corbeille',
|
||||
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||
sameFolder: "Les deux panneaux affichent le même dossier",
|
||||
sameFile: "« {name} » ignoré — c'est le même fichier",
|
||||
typeMismatch: "« {name} » ignoré — un autre type d'élément est déjà là",
|
||||
transferFailed: 'Le transfert « {name} » a échoué : {error}',
|
||||
unknownError: "erreur inconnue",
|
||||
saved: "{name} enregistré",
|
||||
disconnectedReason: "{title} : déconnecté ({reason})",
|
||||
imported: { one: "{count} connexion importée", other: "{count} connexions importées" },
|
||||
importedIntoStacks: { one: " dans {count} nouveau groupe", other: " dans {count} nouveaux groupes" },
|
||||
importedSkipped: " ({skipped} ignorées)",
|
||||
leftoverTitle: "Fichiers édités restants",
|
||||
leftoverMore: ", +{count} de plus",
|
||||
leftoverBody: {
|
||||
one: "Une session précédente a laissé {count} fichier édité temporaire ({names}). Les supprimer ?",
|
||||
other: "Une session précédente a laissé {count} fichiers édités temporaires ({names}). Les supprimer ?",
|
||||
},
|
||||
leftoverClear: "Supprimer",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "Supprimer",
|
||||
deleteBody: "Supprimer {what} ? Les dossiers sont supprimés récursivement. Action irréversible.",
|
||||
deleteConfirm: "Supprimer",
|
||||
renameTitle: 'Renommer « {name} »',
|
||||
renameConfirm: "Renommer",
|
||||
newFolderTitle: "Nouveau dossier",
|
||||
newFolderPlaceholder: "nom du dossier",
|
||||
newFileTitle: "Nouveau fichier",
|
||||
newFilePlaceholder: "nom du fichier",
|
||||
createConfirm: "Créer",
|
||||
itemsCount: "{count} éléments",
|
||||
},
|
||||
menu: {
|
||||
about: "À propos de {app}",
|
||||
quit: "Quitter",
|
||||
file: "Fichier",
|
||||
newTab: "Nouvel onglet",
|
||||
closeTab: "Fermer l'onglet",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
editFile: "Éditer le fichier",
|
||||
connect: "Connecter",
|
||||
connectionManager: "Gestionnaire de connexions…",
|
||||
edit: "Édition",
|
||||
view: "Affichage",
|
||||
refresh: "Actualiser",
|
||||
window: "Fenêtre",
|
||||
},
|
||||
ctx: {
|
||||
size: "Taille",
|
||||
modified: "Modifié",
|
||||
kind: "Type",
|
||||
rights: "Droits",
|
||||
owner: "Propriétaire",
|
||||
group: "Groupe",
|
||||
revealFinder: "Afficher dans le Finder",
|
||||
revealExplorer: "Afficher dans l'Explorateur",
|
||||
revealFileManager: "Afficher dans le gestionnaire de fichiers",
|
||||
moveToTrash: "Mettre à la corbeille",
|
||||
moveToRecycleBin: "Mettre à la corbeille",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
refresh: "Actualiser",
|
||||
open: "Ouvrir",
|
||||
openDefault: "Ouvrir avec l'application par défaut",
|
||||
edit: "Éditer",
|
||||
copyToOther: "Copier vers l'autre panneau",
|
||||
rename: "Renommer",
|
||||
permissions: "Autorisations",
|
||||
copyPath: "Copier le chemin",
|
||||
delete: "Supprimer",
|
||||
columns: "Colonnes",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||
sessionNotConnected: "La session « {id} » n'est pas connectée",
|
||||
localCannotDisconnect: "La session locale ne peut pas être déconnectée",
|
||||
notADirectory: "« {path} » existe et n'est pas un dossier",
|
||||
sameFileTransfer: '« {name} » : la source et la destination sont le même fichier',
|
||||
sameFileNoName: "La source et la destination sont le même fichier",
|
||||
folderOntoItself: "Impossible de copier un dossier sur lui-même",
|
||||
folderIntoItself: "Impossible de copier un dossier dans lui-même",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SupportedLocale } from "../types";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { es } from "./es";
|
||||
import { eu } from "./eu";
|
||||
import { fr } from "./fr";
|
||||
import { ja } from "./ja";
|
||||
import { zh } from "./zh";
|
||||
|
||||
/** A translation tree: leaves are strings; a leaf may be a plural map. */
|
||||
export type Messages = { readonly [key: string]: string | Messages };
|
||||
|
||||
export const DICTS: Record<SupportedLocale, Messages> = { en, es, eu, fr, de, zh, ja };
|
||||
|
||||
/** Order shown in the language picker. */
|
||||
export const SUPPORTED: readonly SupportedLocale[] = ["en", "es", "eu", "fr", "de", "zh", "ja"];
|
||||
|
||||
/** Each language named in itself (endonyms) for the picker. */
|
||||
export const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
eu: "Euskara",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective locale: an explicit choice wins; "system" maps the OS
|
||||
* BCP-47 tag (e.g. "es-ES", "zh-Hans-CN") to a supported language, else English.
|
||||
*/
|
||||
export function resolveLocale(
|
||||
setting: "system" | SupportedLocale,
|
||||
osLocale: string | undefined,
|
||||
): SupportedLocale {
|
||||
if (setting !== "system") return setting;
|
||||
const tag = (osLocale ?? "en").toLowerCase();
|
||||
const primary = tag.split("-")[0];
|
||||
return (SUPPORTED as readonly string[]).includes(primary)
|
||||
? (primary as SupportedLocale)
|
||||
: "en";
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Japanese translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — needs native-speaker review.
|
||||
export const ja = {
|
||||
common: {
|
||||
cancel: "キャンセル",
|
||||
save: "保存",
|
||||
close: "閉じる",
|
||||
delete: "削除",
|
||||
rename: "名前を変更",
|
||||
create: "作成",
|
||||
apply: "適用",
|
||||
choose: "選択…",
|
||||
connect: "接続",
|
||||
local: "ローカル",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "アプリに接続されていません",
|
||||
notConnectedBody: "このページは通常のブラウザで動作しています。デスクトップアプリを起動して使用してください。",
|
||||
managerOpen: "接続マネージャーが開いています。",
|
||||
showConnections: "接続を表示",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "接続",
|
||||
manageConnections: "接続を管理",
|
||||
newTab: "新しいタブ ({mod}T)",
|
||||
closeTab: "タブを閉じる ({mod}W)",
|
||||
options: "オプション",
|
||||
},
|
||||
options: {
|
||||
title: "オプション",
|
||||
language: "言語",
|
||||
system: "システム",
|
||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||
accent: { label: "アクセント" },
|
||||
accentNames: {
|
||||
graphite: "グラファイト",
|
||||
blue: "ブルー",
|
||||
indigo: "インディゴ",
|
||||
purple: "パープル",
|
||||
rose: "ローズ",
|
||||
orange: "オレンジ",
|
||||
green: "グリーン",
|
||||
teal: "ティール",
|
||||
},
|
||||
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
|
||||
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" },
|
||||
speedUnit: { label: "速度の単位" },
|
||||
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
|
||||
atOnce: "同時数",
|
||||
editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" },
|
||||
systemDefault: "システムの既定",
|
||||
resetDefault: "既定に戻す",
|
||||
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "フォルダ",
|
||||
symlink: "シンボリックリンク",
|
||||
file: "ファイル",
|
||||
image: "画像",
|
||||
audio: "オーディオ",
|
||||
video: "動画",
|
||||
archive: "アーカイブ",
|
||||
code: "コード",
|
||||
document: "ドキュメント",
|
||||
},
|
||||
columns: {
|
||||
name: "名前",
|
||||
size: "サイズ",
|
||||
mtime: "更新日時",
|
||||
kind: "種類",
|
||||
rights: "権限",
|
||||
owner: "所有者",
|
||||
group: "グループ",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "このパネルに表示する内容を選択",
|
||||
copy: "コピー",
|
||||
copyToRight: "右のパネルへコピー (F5)",
|
||||
copyToLeft: "左のパネルへコピー (F5)",
|
||||
edit: "編集",
|
||||
editFile: "ファイルを編集 (F4)",
|
||||
rename: "名前を変更 (F2)",
|
||||
newFolder: "新規フォルダ (F7)",
|
||||
newFile: "新規ファイル",
|
||||
permissions: "アクセス権",
|
||||
delete: "削除",
|
||||
deleteHint: "削除 (F8 / Del)",
|
||||
parentDir: "親フォルダ (Backspace)",
|
||||
refresh: "更新",
|
||||
clearFilter: "フィルターをクリア (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "{name} に接続中…",
|
||||
emptyDir: "空のフォルダ",
|
||||
noMatches: "一致なし",
|
||||
matches: { other: "{count} 件一致 · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "接続",
|
||||
importWinscp: "WinSCP からインポート…",
|
||||
importWinscpHint: "WinSCP.ini エクスポートからセッションをインポート",
|
||||
newConnection: "接続",
|
||||
newConnectionHint: "新規接続",
|
||||
newStack: "グループ",
|
||||
newStackHint: "新規グループ",
|
||||
expand: "展開",
|
||||
collapse: "折りたたむ",
|
||||
renameStack: "グループ名を変更",
|
||||
deleteStack: "グループを削除",
|
||||
openStack: "グループをタブで開く",
|
||||
empty: "空",
|
||||
connectionsHeader: "接続",
|
||||
noneTitle: "保存された接続はまだありません。",
|
||||
noneBody: "作成するか、WinSCP セッションをインポートしてください。",
|
||||
name: "名前",
|
||||
namePlaceholder: "マイサーバー",
|
||||
host: "ホスト",
|
||||
port: "ポート",
|
||||
stack: "グループ",
|
||||
stackNone: "(なし)",
|
||||
username: "ユーザー名",
|
||||
remotePath: "リモートパス(任意)",
|
||||
remotePathPlaceholder: "ホームディレクトリ",
|
||||
auth: "認証",
|
||||
authPassword: "パスワード",
|
||||
authKey: "秘密鍵",
|
||||
password: "パスワード",
|
||||
privateKeyFile: "秘密鍵ファイル",
|
||||
passphrase: "パスフレーズ(任意)",
|
||||
localDir: "ローカルディレクトリ(任意)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "もう一方のパネルがローカルで未操作のとき、ここが開きます。",
|
||||
more: "詳細…",
|
||||
less: "閉じる…",
|
||||
selectPrompt: "接続を選択するか、新しく作成してください。",
|
||||
newStackTitle: "新規グループ",
|
||||
newStackPrompt: "グループ名",
|
||||
renameStackTitle: "グループ名を変更",
|
||||
deleteStackTitle: "グループを削除",
|
||||
deleteStackBody: 'グループ "{name}" を削除しますか?含まれる接続は残り、グループ解除されます。',
|
||||
deleteConnectionTitle: "接続を削除",
|
||||
deleteConnectionBody: '"{name}" を削除しますか?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "編集するファイルがありません",
|
||||
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||
wrap: "折り返し",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
|
||||
findHint: "検索 ({mod}F)",
|
||||
findPlaceholder: "ファイル内を検索",
|
||||
prevMatch: "前の一致 (⇧↵)",
|
||||
nextMatch: "次の一致 (↵)",
|
||||
closeHint: "閉じる (Esc)",
|
||||
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||
cantOpen: "{name} を開けません",
|
||||
closeWindow: "ウィンドウを閉じる",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
|
||||
lines: { other: "{count} 行" },
|
||||
close: "閉じる",
|
||||
save: "保存",
|
||||
saveChangesTitle: '"{name}" への変更を保存しますか?',
|
||||
saveChangesBody: "保存しないと変更は失われます。",
|
||||
dontSave: "保存しない",
|
||||
countLabel: "{total} 件中 {current} 件目",
|
||||
noMatches: "一致なし",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} 件実行中 · {finished} 件完了",
|
||||
clearFinished: "完了分をクリア",
|
||||
cancel: "キャンセル",
|
||||
dismiss: "閉じる",
|
||||
showDetails: "転送の詳細を表示",
|
||||
copying: "{name} をコピー中 · {percent}%{speed}",
|
||||
queued: "{count} 件待機中",
|
||||
failed: { other: "{count} 件の転送が失敗" },
|
||||
finished: "転送が完了しました",
|
||||
ready: "準備完了",
|
||||
items: "{count} 項目",
|
||||
selected: "{count} 件選択中",
|
||||
queuedState: "待機中",
|
||||
scanning: "スキャン中…",
|
||||
failedState: "失敗",
|
||||
cancelled: "キャンセル済み",
|
||||
completedFiles: { other: "{count} 個のファイル · {size}" },
|
||||
left: "残り {eta}",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "アクセス権",
|
||||
chmodItems: "{count} 項目",
|
||||
read: "読み取り",
|
||||
write: "書き込み",
|
||||
execute: "実行",
|
||||
roleOwner: "所有者",
|
||||
roleGroup: "グループ",
|
||||
roleOthers: "その他",
|
||||
octal: "8進数",
|
||||
mergeFolder: "フォルダを結合",
|
||||
replace: "置き換える",
|
||||
merge: "結合",
|
||||
mergeAll: "すべて結合",
|
||||
replaceAll: "すべて置き換える",
|
||||
skip: "スキップ",
|
||||
skipAll: "すべてスキップ",
|
||||
ofTotal: "{total} 件中 {index} 件目",
|
||||
overwriteDirBody: "コピー先に同名のフォルダが既にあります。結合すると中のファイルが置き換わる可能性があります。",
|
||||
overwriteFileBody: "コピー先に同名のファイルが既にあります。",
|
||||
source: "コピー元",
|
||||
destination: "コピー先",
|
||||
newer: "新しい",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||
sameFolder: "両方のパネルが同じフォルダを表示しています",
|
||||
sameFile: '"{name}" をスキップしました — 同じファイルです',
|
||||
typeMismatch: '"{name}" をスキップしました — 別の種類の項目があります',
|
||||
transferFailed: '転送 "{name}" が失敗しました: {error}',
|
||||
unknownError: "不明なエラー",
|
||||
saved: "{name} を保存しました",
|
||||
disconnectedReason: "{title}: 切断されました ({reason})",
|
||||
imported: { other: "{count} 件の接続をインポートしました" },
|
||||
importedIntoStacks: { other: "({count} 件の新規グループに)" },
|
||||
importedSkipped: "({skipped} 件スキップ)",
|
||||
leftoverTitle: "残った編集済みファイル",
|
||||
leftoverMore: "、他 {count} 件",
|
||||
leftoverBody: {
|
||||
other: "前回のセッションで {count} 件の一時編集ファイルが残っています({names})。クリアしますか?",
|
||||
},
|
||||
leftoverClear: "クリア",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "削除",
|
||||
deleteBody: "{what} を削除しますか?フォルダは再帰的に削除されます。この操作は取り消せません。",
|
||||
deleteConfirm: "削除",
|
||||
renameTitle: '"{name}" の名前を変更',
|
||||
renameConfirm: "名前を変更",
|
||||
newFolderTitle: "新規フォルダ",
|
||||
newFolderPlaceholder: "フォルダ名",
|
||||
newFileTitle: "新規ファイル",
|
||||
newFilePlaceholder: "ファイル名",
|
||||
createConfirm: "作成",
|
||||
itemsCount: "{count} 項目",
|
||||
},
|
||||
menu: {
|
||||
about: "{app} について",
|
||||
quit: "終了",
|
||||
file: "ファイル",
|
||||
newTab: "新しいタブ",
|
||||
closeTab: "タブを閉じる",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
editFile: "ファイルを編集",
|
||||
connect: "接続",
|
||||
connectionManager: "接続マネージャー…",
|
||||
edit: "編集",
|
||||
view: "表示",
|
||||
refresh: "更新",
|
||||
window: "ウィンドウ",
|
||||
},
|
||||
ctx: {
|
||||
size: "サイズ",
|
||||
modified: "更新日時",
|
||||
kind: "種類",
|
||||
rights: "権限",
|
||||
owner: "所有者",
|
||||
group: "グループ",
|
||||
revealFinder: "Finder で表示",
|
||||
revealExplorer: "エクスプローラーで表示",
|
||||
revealFileManager: "ファイルマネージャーで表示",
|
||||
moveToTrash: "ゴミ箱に移動",
|
||||
moveToRecycleBin: "ごみ箱に移動",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
refresh: "更新",
|
||||
open: "開く",
|
||||
openDefault: "既定のアプリで開く",
|
||||
edit: "編集",
|
||||
copyToOther: "もう一方のパネルへコピー",
|
||||
rename: "名前を変更",
|
||||
permissions: "アクセス権",
|
||||
copyPath: "パスをコピー",
|
||||
delete: "削除",
|
||||
columns: "列",
|
||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||
sessionNotConnected: 'セッション "{id}" は接続されていません',
|
||||
localCannotDisconnect: "ローカルセッションは切断できません",
|
||||
notADirectory: '"{path}" は存在し、フォルダではありません',
|
||||
sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです',
|
||||
sameFileNoName: "コピー元とコピー先が同じファイルです",
|
||||
folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません",
|
||||
folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,299 @@
|
||||
// Simplified Chinese translations. Fall back to English for missing keys.
|
||||
// TODO(i18n): AI draft — needs native-speaker review.
|
||||
export const zh = {
|
||||
common: {
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
close: "关闭",
|
||||
delete: "删除",
|
||||
rename: "重命名",
|
||||
create: "创建",
|
||||
apply: "应用",
|
||||
choose: "选择…",
|
||||
connect: "连接",
|
||||
local: "本地",
|
||||
},
|
||||
app: {
|
||||
notConnectedTitle: "未连接到应用",
|
||||
notConnectedBody: "此页面运行在普通浏览器中。请启动桌面应用来使用。",
|
||||
managerOpen: "连接管理器已打开。",
|
||||
showConnections: "显示连接",
|
||||
},
|
||||
tabBar: {
|
||||
connections: "连接",
|
||||
manageConnections: "管理连接",
|
||||
newTab: "新建标签页 ({mod}T)",
|
||||
closeTab: "关闭标签页 ({mod}W)",
|
||||
options: "选项",
|
||||
},
|
||||
options: {
|
||||
title: "选项",
|
||||
language: "语言",
|
||||
system: "系统",
|
||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||
accent: { label: "强调色" },
|
||||
accentNames: {
|
||||
graphite: "石墨",
|
||||
blue: "蓝色",
|
||||
indigo: "靛蓝",
|
||||
purple: "紫色",
|
||||
rose: "玫红",
|
||||
orange: "橙色",
|
||||
green: "绿色",
|
||||
teal: "青色",
|
||||
},
|
||||
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
|
||||
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" },
|
||||
speedUnit: { label: "速度单位" },
|
||||
transfers: { label: "传输", single: "单个", multi: "多个" },
|
||||
atOnce: "同时",
|
||||
editor: { label: "编辑器", builtin: "内置", custom: "自定义" },
|
||||
systemDefault: "系统默认",
|
||||
resetDefault: "恢复为系统默认",
|
||||
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "文件夹",
|
||||
symlink: "符号链接",
|
||||
file: "文件",
|
||||
image: "图像",
|
||||
audio: "音频",
|
||||
video: "视频",
|
||||
archive: "压缩包",
|
||||
code: "代码",
|
||||
document: "文档",
|
||||
},
|
||||
columns: {
|
||||
name: "名称",
|
||||
size: "大小",
|
||||
mtime: "修改时间",
|
||||
kind: "类型",
|
||||
rights: "权限",
|
||||
owner: "所有者",
|
||||
group: "组",
|
||||
},
|
||||
panel: {
|
||||
choosePanel: "选择此面板显示的内容",
|
||||
copy: "复制",
|
||||
copyToRight: "复制到右侧面板 (F5)",
|
||||
copyToLeft: "复制到左侧面板 (F5)",
|
||||
edit: "编辑",
|
||||
editFile: "编辑文件 (F4)",
|
||||
rename: "重命名 (F2)",
|
||||
newFolder: "新建文件夹 (F7)",
|
||||
newFile: "新建文件",
|
||||
permissions: "权限",
|
||||
delete: "删除",
|
||||
deleteHint: "删除 (F8 / Del)",
|
||||
parentDir: "上级目录 (退格)",
|
||||
refresh: "刷新",
|
||||
clearFilter: "清除筛选 (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
connectingTo: "正在连接 {name}…",
|
||||
emptyDir: "空目录",
|
||||
noMatches: "无匹配项",
|
||||
matches: { other: "{count} 个匹配 · Esc" },
|
||||
},
|
||||
connMgr: {
|
||||
title: "连接",
|
||||
importWinscp: "从 WinSCP 导入…",
|
||||
importWinscpHint: "从 WinSCP.ini 导出文件导入会话",
|
||||
newConnection: "连接",
|
||||
newConnectionHint: "新建连接",
|
||||
newStack: "分组",
|
||||
newStackHint: "新建分组",
|
||||
expand: "展开",
|
||||
collapse: "折叠",
|
||||
renameStack: "重命名分组",
|
||||
deleteStack: "删除分组",
|
||||
openStack: "在标签页中打开分组",
|
||||
empty: "空",
|
||||
connectionsHeader: "连接",
|
||||
noneTitle: "尚无已保存的连接。",
|
||||
noneBody: "创建一个,或导入你的 WinSCP 会话。",
|
||||
name: "名称",
|
||||
namePlaceholder: "我的服务器",
|
||||
host: "主机",
|
||||
port: "端口",
|
||||
stack: "分组",
|
||||
stackNone: "(无)",
|
||||
username: "用户名",
|
||||
remotePath: "远程路径(可选)",
|
||||
remotePathPlaceholder: "主目录",
|
||||
auth: "认证",
|
||||
authPassword: "密码",
|
||||
authKey: "私钥",
|
||||
password: "密码",
|
||||
privateKeyFile: "私钥文件",
|
||||
passphrase: "密钥口令(可选)",
|
||||
localDir: "本地目录(可选)",
|
||||
localDirPlaceholder: "~/projects/my-server",
|
||||
localDirHint: "当另一个面板处于本地且未浏览过时,将在此打开。",
|
||||
more: "更多…",
|
||||
less: "收起…",
|
||||
selectPrompt: "选择一个连接或新建一个。",
|
||||
newStackTitle: "新建分组",
|
||||
newStackPrompt: "分组名称",
|
||||
renameStackTitle: "重命名分组",
|
||||
deleteStackTitle: "删除分组",
|
||||
deleteStackBody: '删除分组 "{name}"?其中的连接将保留但不再分组。',
|
||||
deleteConnectionTitle: "删除连接",
|
||||
deleteConnectionBody: '删除 "{name}"?',
|
||||
},
|
||||
editor: {
|
||||
noFileTitle: "没有要编辑的文件",
|
||||
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||
wrap: "换行",
|
||||
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
|
||||
findHint: "查找 ({mod}F)",
|
||||
findPlaceholder: "在文件中查找",
|
||||
prevMatch: "上一个匹配 (⇧↵)",
|
||||
nextMatch: "下一个匹配 (↵)",
|
||||
closeHint: "关闭 (Esc)",
|
||||
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||
cantOpen: "无法打开 {name}",
|
||||
closeWindow: "关闭窗口",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
|
||||
lines: { other: "{count} 行" },
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
saveChangesTitle: '保存对 "{name}" 的更改?',
|
||||
saveChangesBody: "如果不保存,你的更改将丢失。",
|
||||
dontSave: "不保存",
|
||||
countLabel: "第 {current} / {total}",
|
||||
noMatches: "无匹配项",
|
||||
},
|
||||
transfer: {
|
||||
summary: "{active} 进行中 · {finished} 已完成",
|
||||
clearFinished: "清除已完成",
|
||||
cancel: "取消",
|
||||
dismiss: "忽略",
|
||||
showDetails: "显示传输详情",
|
||||
copying: "正在复制 {name} · {percent}%{speed}",
|
||||
queued: "{count} 个排队中",
|
||||
failed: { other: "{count} 个传输失败" },
|
||||
finished: "传输已完成",
|
||||
ready: "就绪",
|
||||
items: "{count} 项",
|
||||
selected: "已选 {count} 项",
|
||||
queuedState: "排队中",
|
||||
scanning: "扫描中…",
|
||||
failedState: "失败",
|
||||
cancelled: "已取消",
|
||||
completedFiles: { other: "{count} 个文件 · {size}" },
|
||||
left: "剩余 {eta}",
|
||||
},
|
||||
dialogs: {
|
||||
chmodTitle: "权限",
|
||||
chmodItems: "{count} 项",
|
||||
read: "读",
|
||||
write: "写",
|
||||
execute: "执行",
|
||||
roleOwner: "所有者",
|
||||
roleGroup: "组",
|
||||
roleOthers: "其他",
|
||||
octal: "八进制",
|
||||
mergeFolder: "合并文件夹",
|
||||
replace: "替换",
|
||||
merge: "合并",
|
||||
mergeAll: "全部合并",
|
||||
replaceAll: "全部替换",
|
||||
skip: "跳过",
|
||||
skipAll: "全部跳过",
|
||||
ofTotal: "第 {index} / {total}",
|
||||
overwriteDirBody: "目标位置已存在同名文件夹。合并可能会替换其中的文件。",
|
||||
overwriteFileBody: "目标位置已存在同名文件。",
|
||||
source: "源",
|
||||
destination: "目标",
|
||||
newer: "较新",
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||
sameFolder: "两个面板显示的是同一个文件夹",
|
||||
sameFile: '已跳过 "{name}" — 它是同一个文件',
|
||||
typeMismatch: '已跳过 "{name}" — 目标处是不同类型的项目',
|
||||
transferFailed: '传输 "{name}" 失败:{error}',
|
||||
unknownError: "未知错误",
|
||||
saved: "已保存 {name}",
|
||||
disconnectedReason: "{title}:已断开({reason})",
|
||||
imported: { other: "已导入 {count} 个连接" },
|
||||
importedIntoStacks: { other: ",归入 {count} 个新分组" },
|
||||
importedSkipped: "(跳过 {skipped} 个)",
|
||||
leftoverTitle: "残留的已编辑文件",
|
||||
leftoverMore: ",还有 {count} 个",
|
||||
leftoverBody: {
|
||||
other: "上一次会话遗留了 {count} 个临时编辑文件({names})。要清除吗?",
|
||||
},
|
||||
leftoverClear: "清除",
|
||||
},
|
||||
fileActions: {
|
||||
deleteTitle: "删除",
|
||||
deleteBody: "删除 {what}?文件夹将被递归删除。此操作无法撤销。",
|
||||
deleteConfirm: "删除",
|
||||
renameTitle: '重命名 "{name}"',
|
||||
renameConfirm: "重命名",
|
||||
newFolderTitle: "新建文件夹",
|
||||
newFolderPlaceholder: "文件夹名称",
|
||||
newFileTitle: "新建文件",
|
||||
newFilePlaceholder: "文件名称",
|
||||
createConfirm: "创建",
|
||||
itemsCount: "{count} 项",
|
||||
},
|
||||
menu: {
|
||||
about: "关于 {app}",
|
||||
quit: "退出",
|
||||
file: "文件",
|
||||
newTab: "新建标签页",
|
||||
closeTab: "关闭标签页",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
editFile: "编辑文件",
|
||||
connect: "连接",
|
||||
connectionManager: "连接管理器…",
|
||||
edit: "编辑",
|
||||
view: "查看",
|
||||
refresh: "刷新",
|
||||
window: "窗口",
|
||||
},
|
||||
ctx: {
|
||||
size: "大小",
|
||||
modified: "修改时间",
|
||||
kind: "类型",
|
||||
rights: "权限",
|
||||
owner: "所有者",
|
||||
group: "组",
|
||||
revealFinder: "在访达中显示",
|
||||
revealExplorer: "在资源管理器中显示",
|
||||
revealFileManager: "在文件管理器中显示",
|
||||
moveToTrash: "移到废纸篓",
|
||||
moveToRecycleBin: "移到回收站",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
refresh: "刷新",
|
||||
open: "打开",
|
||||
openDefault: "用默认应用打开",
|
||||
edit: "编辑",
|
||||
copyToOther: "复制到另一个面板",
|
||||
rename: "重命名",
|
||||
permissions: "权限",
|
||||
copyPath: "复制路径",
|
||||
delete: "删除",
|
||||
columns: "列",
|
||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||
identityFileNotFound: "找不到身份文件:{path}",
|
||||
sessionNotConnected: '会话 "{id}" 未连接',
|
||||
localCannotDisconnect: "无法断开本地会话",
|
||||
notADirectory: '"{path}" 已存在且不是文件夹',
|
||||
sameFileTransfer: '"{name}":源和目标是同一个文件',
|
||||
sameFileNoName: "源和目标是同一个文件",
|
||||
folderOntoItself: "无法将文件夹复制到其自身之上",
|
||||
folderIntoItself: "无法将文件夹复制到其自身内部",
|
||||
},
|
||||
} as const;
|
||||
@@ -62,6 +62,9 @@ export interface ConnectionsFile {
|
||||
connections: SavedConnection[];
|
||||
}
|
||||
|
||||
/** UI languages the app ships translations for. */
|
||||
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
||||
|
||||
export type AccentTone =
|
||||
| "graphite"
|
||||
| "blue"
|
||||
@@ -73,6 +76,8 @@ export type AccentTone =
|
||||
| "teal";
|
||||
|
||||
export interface AppSettings {
|
||||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||||
locale: "system" | SupportedLocale;
|
||||
theme: "system" | "light" | "dark";
|
||||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||||
darkTone: "dark" | "darker";
|
||||
@@ -102,6 +107,7 @@ export interface AppSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: "system",
|
||||
theme: "system",
|
||||
darkTone: "dark",
|
||||
accent: "blue",
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">Not connected to the app</p>
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
This page is running in a plain browser. Launch the desktop app to use it.
|
||||
{{ i18n.t('app.notConnectedBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,11 +29,11 @@
|
||||
>
|
||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">The connection manager is open.</p>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
||||
<button
|
||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Show Connections
|
||||
{{ i18n.t('app.showConnections') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-4
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from './connections.service';
|
||||
@@ -42,6 +43,7 @@ import { TransfersService } from './transfers.service';
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly rpc = inject(RpcService);
|
||||
readonly toast = inject(ToastService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
@@ -76,11 +78,12 @@ export class App {
|
||||
const res = await this.rpc.request.getEditLeftovers({});
|
||||
if (!res.ok || !res.names.length) return;
|
||||
const shown = res.names.slice(0, 5).join(', ');
|
||||
const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : '';
|
||||
const more =
|
||||
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
|
||||
this.dialogs.confirm.set({
|
||||
title: 'Leftover edited files',
|
||||
body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`,
|
||||
confirmLabel: 'Clear',
|
||||
title: this.i18n.t('toasts.leftoverTitle'),
|
||||
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
|
||||
confirmLabel: this.i18n.t('toasts.leftoverClear'),
|
||||
destructive: true,
|
||||
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
|
||||
});
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
[class.pl-3]="!isMacChrome"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="text-sm font-semibold">Connections</h1>
|
||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="importWinScp()"
|
||||
[disabled]="importing()"
|
||||
title="Import sessions from a WinSCP.ini export"
|
||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
@if (importing()) {
|
||||
@@ -23,7 +23,7 @@
|
||||
} @else {
|
||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
||||
}
|
||||
Import from WinSCP...
|
||||
{{ i18n.t('connMgr.importWinscp') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -33,17 +33,17 @@
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="newConnection()"
|
||||
title="New connection"
|
||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="newStack()"
|
||||
title="New stack"
|
||||
[title]="i18n.t('connMgr.newStackHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<button
|
||||
(click)="toggleStack(group.stack)"
|
||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
[title]="group.stack.collapsed ? 'Expand' : 'Collapse'"
|
||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
||||
@@ -85,14 +85,14 @@
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
(click)="renameStack(group.stack)"
|
||||
title="Rename stack"
|
||||
[title]="i18n.t('connMgr.renameStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteStack(group.stack)"
|
||||
title="Delete stack"
|
||||
[title]="i18n.t('connMgr.deleteStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||
@@ -100,7 +100,7 @@
|
||||
@if (group.connections.length) {
|
||||
<button
|
||||
(click)="openStack(group.stack.id)"
|
||||
title="Open stack in tabs"
|
||||
[title]="i18n.t('connMgr.openStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
||||
@@ -123,7 +123,7 @@
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@
|
||||
>
|
||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
|
||||
Connections
|
||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||
</div>
|
||||
}
|
||||
@for (connection of store.grouped().loose; track connection.id) {
|
||||
@@ -163,10 +163,10 @@
|
||||
|
||||
@if (!store.connections().length && !store.stacks().length) {
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
No saved connections yet.
|
||||
{{ i18n.t('connMgr.noneTitle') }}
|
||||
</p>
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Create one, or import your WinSCP sessions.
|
||||
{{ i18n.t('connMgr.noneBody') }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -177,18 +177,18 @@
|
||||
@if (draft(); as d) {
|
||||
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Name</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
[value]="d.name"
|
||||
(input)="patch('name', $any($event.target).value)"
|
||||
placeholder="my server"
|
||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Host</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
||||
<input
|
||||
[value]="d.host"
|
||||
(input)="patch('host', $any($event.target).value)"
|
||||
@@ -199,7 +199,7 @@
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Port</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
[value]="d.port"
|
||||
@@ -208,14 +208,14 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Stack</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
||||
<select
|
||||
(change)="patch('stackId', $any($event.target).value || null)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<!-- [selected] per option: binding [value] on the <select> runs
|
||||
before the options render and silently shows "(none)" -->
|
||||
<option value="" [selected]="!d.stackId">(none)</option>
|
||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
||||
@for (stack of store.stacks(); track stack.id) {
|
||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||
{{ stack.name }}
|
||||
@@ -226,7 +226,7 @@
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Username</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
||||
<input
|
||||
[value]="d.username"
|
||||
(input)="patch('username', $any($event.target).value)"
|
||||
@@ -235,18 +235,18 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Remote path (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
||||
<input
|
||||
[value]="d.initialPath"
|
||||
(input)="patch('initialPath', $any($event.target).value)"
|
||||
placeholder="home directory"
|
||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
||||
<span class="text-muted-foreground">Auth</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -255,7 +255,7 @@
|
||||
(change)="patch('authKind', 'password')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
Password
|
||||
{{ i18n.t('connMgr.authPassword') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
@@ -265,13 +265,13 @@
|
||||
(change)="patch('authKind', 'key')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
Private key
|
||||
{{ i18n.t('connMgr.authKey') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (d.authKind === 'password') {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Password</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.password"
|
||||
@@ -281,7 +281,7 @@
|
||||
</label>
|
||||
} @else {
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Private key file</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
[value]="d.privateKeyPath"
|
||||
@@ -295,12 +295,12 @@
|
||||
(click)="pickKeyFile()"
|
||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
||||
>
|
||||
Choose…
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Passphrase (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.passphrase"
|
||||
@@ -312,16 +312,16 @@
|
||||
|
||||
@if (moreOpen()) {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Local directory (optional)</span>
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
||||
<input
|
||||
[value]="d.localDir"
|
||||
(input)="patch('localDir', $any($event.target).value)"
|
||||
placeholder="~/projects/my-server"
|
||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span class="text-[10px] text-muted-foreground/80">
|
||||
The other panel opens here when it's on Local and hasn't been navigated.
|
||||
{{ i18n.t('connMgr.localDirHint') }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
@@ -332,13 +332,13 @@
|
||||
(click)="deleteCurrent()"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="moreOpen.set(!moreOpen())"
|
||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ moreOpen() ? 'Less…' : 'More…' }}
|
||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
@@ -346,7 +346,7 @@
|
||||
[disabled]="!valid()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndConnect()"
|
||||
@@ -356,7 +356,7 @@
|
||||
@if (busy()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
Connect
|
||||
{{ i18n.t('common.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -364,7 +364,7 @@
|
||||
<div class="text-center">
|
||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Select a connection or create a new one.
|
||||
{{ i18n.t('connMgr.selectPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import type { SavedConnection, Stack } from '@shared/types';
|
||||
import { ConnectionsService } from '../connections.service';
|
||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||
@@ -79,6 +80,7 @@ function fromDraft(d: Draft): SavedConnection {
|
||||
},
|
||||
})
|
||||
export class ConnectionManager {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly store = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
@@ -174,9 +176,9 @@ export class ConnectionManager {
|
||||
return;
|
||||
}
|
||||
this.dialogs.confirm.set({
|
||||
title: 'Delete connection',
|
||||
body: `Delete "${d.name || d.host}"?`,
|
||||
confirmLabel: 'Delete',
|
||||
title: this.i18n.t('connMgr.deleteConnectionTitle'),
|
||||
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
|
||||
confirmLabel: this.i18n.t('common.delete'),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await this.store.remove(d.id);
|
||||
@@ -212,27 +214,27 @@ export class ConnectionManager {
|
||||
|
||||
newStack(): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New stack',
|
||||
placeholder: 'stack name',
|
||||
confirmLabel: 'Create',
|
||||
title: this.i18n.t('connMgr.newStackTitle'),
|
||||
placeholder: this.i18n.t('connMgr.newStackPrompt'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
||||
});
|
||||
}
|
||||
|
||||
renameStack(stack: Stack): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'Rename stack',
|
||||
title: this.i18n.t('connMgr.renameStackTitle'),
|
||||
initial: stack.name,
|
||||
confirmLabel: 'Rename',
|
||||
confirmLabel: this.i18n.t('connMgr.renameStack'),
|
||||
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteStack(stack: Stack): void {
|
||||
this.dialogs.confirm.set({
|
||||
title: 'Delete stack',
|
||||
body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`,
|
||||
confirmLabel: 'Delete',
|
||||
title: this.i18n.t('connMgr.deleteStackTitle'),
|
||||
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
|
||||
confirmLabel: this.i18n.t('common.delete'),
|
||||
destructive: true,
|
||||
onConfirm: () => void this.store.removeStack(stack.id),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConnectionsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
@@ -86,11 +88,10 @@ export class ConnectionsService {
|
||||
return;
|
||||
}
|
||||
if (res.connections || res.stacks || res.skipped) {
|
||||
const skipped = res.skipped ? ` (${res.skipped} skipped)` : '';
|
||||
const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
|
||||
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
|
||||
this.toast.success(
|
||||
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` +
|
||||
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
|
||||
skipped,
|
||||
this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
|
||||
);
|
||||
}
|
||||
await this.reload();
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">No file to edit</p>
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
This window was opened without an editor context. Launch files from the app.
|
||||
{{ i18n.t('editor.noFileBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,17 +28,17 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
|
||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
[class.text-muted-foreground]="!wrap()"
|
||||
>
|
||||
Wrap
|
||||
{{ i18n.t('editor.wrap') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
[title]="'Find (' + modLabel + 'F)'"
|
||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||
@@ -53,7 +53,7 @@
|
||||
[value]="findQuery()"
|
||||
(input)="onFindInput($any($event.target).value)"
|
||||
(keydown)="onFindKeydown($event)"
|
||||
placeholder="Find in file"
|
||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
@@ -66,14 +66,14 @@
|
||||
</span>
|
||||
<button
|
||||
(click)="findStep(-1)"
|
||||
title="Previous match (⇧↵)"
|
||||
[title]="i18n.t('editor.prevMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="findStep(1)"
|
||||
title="Next match (↵)"
|
||||
[title]="i18n.t('editor.nextMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="closeFind()"
|
||||
title="Close (Esc)"
|
||||
[title]="i18n.t('editor.closeHint')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.8rem" />
|
||||
@@ -94,8 +94,7 @@
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
||||
Disconnected from {{ ctx.sessionTitle }} — saving will fail, but your text is preserved
|
||||
in this window.
|
||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -107,13 +106,13 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">Can't open {{ ctx.name }}</p>
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Close Window
|
||||
{{ i18n.t('editor.closeWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,17 +141,17 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||
<span class="text-[11px] text-muted-foreground">·</span>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="requestClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Close
|
||||
{{ i18n.t('editor.close') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="save()"
|
||||
@@ -162,7 +161,7 @@
|
||||
@if (saving()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
Save
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
@@ -175,30 +174,30 @@
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">Save changes to "{{ ctx.name }}"?</h2>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||
Your changes will be lost if you don't save them.
|
||||
{{ i18n.t('editor.saveChangesBody') }}
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||
>
|
||||
Don't Save
|
||||
{{ i18n.t('editor.dontSave') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="cancelClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
@@ -38,6 +39,7 @@ const DRAFT_DEBOUNCE_MS = 400;
|
||||
},
|
||||
})
|
||||
export class EditorWindow {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
@@ -273,7 +275,7 @@ export class EditorWindow {
|
||||
}
|
||||
this.savedContent.set(content);
|
||||
this.lastSentDirty = false; // bun record already cleaned by the handler
|
||||
this.toast.success(`Saved ${this.ctx!.name}`);
|
||||
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
|
||||
return true;
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type PanelState, type Side } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
||||
@@ -21,6 +22,7 @@ export interface MenuTarget {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileActionsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
@@ -44,7 +46,7 @@ export class FileActionsService {
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
else this.toast.info(`Moved "${entry.name}" to Trash`);
|
||||
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
||||
await panel.refresh();
|
||||
}
|
||||
|
||||
@@ -58,9 +60,9 @@ export class FileActionsService {
|
||||
|
||||
mkdir(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New folder',
|
||||
placeholder: 'folder name',
|
||||
confirmLabel: 'Create',
|
||||
title: this.i18n.t('fileActions.newFolderTitle'),
|
||||
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
const res = await this.rpc.request.mkdir({
|
||||
sessionId: panel.sessionId(),
|
||||
@@ -74,9 +76,9 @@ export class FileActionsService {
|
||||
|
||||
createFile(panel: PanelState): void {
|
||||
this.dialogs.prompt.set({
|
||||
title: 'New file',
|
||||
placeholder: 'file name',
|
||||
confirmLabel: 'Create',
|
||||
title: this.i18n.t('fileActions.newFileTitle'),
|
||||
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
|
||||
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
const res = await this.rpc.request.createFile({
|
||||
sessionId: panel.sessionId(),
|
||||
@@ -101,7 +103,7 @@ export class FileActionsService {
|
||||
if (!res.ok) {
|
||||
this.toast.error(res.error);
|
||||
} else if (res.watching) {
|
||||
this.toast.info(`Editing ${entry.name} — saves upload automatically`);
|
||||
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -120,9 +122,9 @@ export class FileActionsService {
|
||||
const entry = panel.sorted()[panel.cursor()];
|
||||
if (!entry || entry.name === '..') return;
|
||||
this.dialogs.prompt.set({
|
||||
title: `Rename "${entry.name}"`,
|
||||
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
|
||||
initial: entry.name,
|
||||
confirmLabel: 'Rename',
|
||||
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
|
||||
onConfirm: async (name) => {
|
||||
if (name === entry.name) return;
|
||||
const res = await this.rpc.request.rename({
|
||||
@@ -140,11 +142,13 @@ export class FileActionsService {
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
const what =
|
||||
targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`;
|
||||
targets.length === 1
|
||||
? `"${targets[0].name}"`
|
||||
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
|
||||
this.dialogs.confirm.set({
|
||||
title: 'Delete',
|
||||
body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
title: this.i18n.t('fileActions.deleteTitle'),
|
||||
body: this.i18n.t('fileActions.deleteBody', { what }),
|
||||
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
const res = await this.rpc.request.deleteEntries({
|
||||
|
||||
+15
-12
@@ -64,22 +64,25 @@ function categoryOf(name: string): Category | null {
|
||||
return EXT_CATEGORY.get(key) ?? null;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<Category, string> = {
|
||||
image: 'Image',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
archive: 'Archive',
|
||||
code: 'Code',
|
||||
doc: 'Document',
|
||||
const KIND_KEYS: Record<Category, string> = {
|
||||
image: 'image',
|
||||
audio: 'audio',
|
||||
video: 'video',
|
||||
archive: 'archive',
|
||||
code: 'code',
|
||||
doc: 'document',
|
||||
};
|
||||
|
||||
/** Human label for the optional Kind column (category-based, like the icons). */
|
||||
export function fileKind(entry: FileEntry): string {
|
||||
/**
|
||||
* i18n key suffix for the optional Kind column (category-based, like the
|
||||
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
|
||||
*/
|
||||
export function fileKindKey(entry: FileEntry): string {
|
||||
if (entry.name === '..') return '';
|
||||
if (entry.type === 'dir') return 'Folder';
|
||||
if (entry.type === 'symlink') return 'Symlink';
|
||||
if (entry.type === 'dir') return 'folder';
|
||||
if (entry.type === 'symlink') return 'symlink';
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? KIND_LABELS[category] : 'File';
|
||||
return category ? KIND_KEYS[category] : 'file';
|
||||
}
|
||||
|
||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<button
|
||||
(click)="toggleMenu()"
|
||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||
title="Choose what this panel shows"
|
||||
[title]="i18n.t('panel.choosePanel')"
|
||||
>
|
||||
@if (panel().connecting()) {
|
||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
||||
@@ -14,7 +14,7 @@
|
||||
size="var(--action-icon)"
|
||||
/>
|
||||
}
|
||||
<span class="truncate">{{ panel().title() }}</span>
|
||||
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
||||
<ng-icon
|
||||
name="mynaChevronDown"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
@@ -27,38 +27,38 @@
|
||||
labels when the panel is wide enough (container query). -->
|
||||
<button
|
||||
(click)="copyToOther()"
|
||||
[title]="side() === 'left' ? 'Copy to right panel (F5)' : 'Copy to left panel (F5)'"
|
||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
||||
>
|
||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Copy</span>
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('edit')"
|
||||
title="Edit file (F4)"
|
||||
[title]="i18n.t('panel.editFile')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Edit</span>
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('rename')"
|
||||
title="Rename (F2)"
|
||||
[title]="i18n.t('panel.rename')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Rename</span>
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('mkdir')"
|
||||
title="New folder (F7)"
|
||||
[title]="i18n.t('panel.newFolder')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
||||
</button>
|
||||
<button
|
||||
(click)="run('createFile')"
|
||||
title="New file"
|
||||
[title]="i18n.t('panel.newFile')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||
@@ -66,7 +66,7 @@
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
title="Permissions"
|
||||
[title]="i18n.t('panel.permissions')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||
@@ -74,11 +74,11 @@
|
||||
}
|
||||
<button
|
||||
(click)="run('delete')"
|
||||
title="Delete (F8 / Del)"
|
||||
[title]="i18n.t('panel.deleteHint')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">Delete</span>
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
||||
</button>
|
||||
|
||||
@if (menuOpen()) {
|
||||
@@ -91,7 +91,7 @@
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1">Local</span>
|
||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
||||
@if (!isRemote()) {
|
||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
<button
|
||||
(click)="panel().up()"
|
||||
[disabled]="blocked()"
|
||||
title="Parent directory (Backspace)"
|
||||
[title]="i18n.t('panel.parentDir')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||
@@ -180,7 +180,7 @@
|
||||
<button
|
||||
(click)="panel().refresh()"
|
||||
[disabled]="blocked()"
|
||||
title="Refresh"
|
||||
[title]="i18n.t('panel.refresh')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
@if (panel().loading()) {
|
||||
@@ -198,7 +198,7 @@
|
||||
class="grid border-b border-border text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||
Name {{ sortIndicator('name') }}
|
||||
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
||||
</button>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
<button
|
||||
@@ -207,7 +207,7 @@
|
||||
[class.text-left]="col.key !== 'size'"
|
||||
(click)="panel().toggleSort(col.key)"
|
||||
>
|
||||
{{ col.label }} {{ sortIndicator(col.key) }}
|
||||
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -218,11 +218,11 @@
|
||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground">
|
||||
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
|
||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="clearFilter()"
|
||||
title="Clear filter (Esc)"
|
||||
[title]="i18n.t('panel.clearFilter')"
|
||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.8rem" />
|
||||
@@ -245,13 +245,13 @@
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Connecting to {{ panel().connectingTo() }}…
|
||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="cancelConnect()"
|
||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,6 +290,8 @@
|
||||
<span class="truncate px-2 text-(length:--list-meta-font) text-muted-foreground">
|
||||
{{ fileKind(entry) }}
|
||||
</span>
|
||||
<!-- fileKind is localized in file-panel.ts via i18n -->
|
||||
|
||||
}
|
||||
@case ('rights') {
|
||||
<span class="px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
|
||||
@@ -310,10 +312,10 @@
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.emptyDir') }}</div>
|
||||
}
|
||||
@if (panel().filter() && filterMatches() === 0) {
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">{{ i18n.t('panel.noMatches') }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||
import { ConnectionsService } from '../connections.service';
|
||||
import { DialogsService } from '../dialogs.service';
|
||||
import { FileActionsService } from '../file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
||||
import { formatDate, formatMode, formatSize } from '../format';
|
||||
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
|
||||
import { formatMode } from '../format';
|
||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { IS_WINDOWS } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
@@ -38,6 +39,7 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
|
||||
})
|
||||
export class FilePanel {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly side = input.required<Side>();
|
||||
|
||||
readonly panels = inject(PanelsService);
|
||||
@@ -65,10 +67,15 @@ export class FilePanel {
|
||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||
private lastClickedIndex = 0;
|
||||
|
||||
readonly formatSize = formatSize;
|
||||
readonly formatDate = formatDate;
|
||||
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
||||
// cells re-render on a language switch (see i18n.service reactive t())
|
||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
||||
readonly formatDate = (ms: number): string => this.i18n.date(ms);
|
||||
readonly formatMode = formatMode;
|
||||
readonly fileKind = fileKind;
|
||||
readonly fileKind = (entry: FileEntry): string => {
|
||||
const key = fileKindKey(entry);
|
||||
return key ? this.i18n.t('kinds.' + key) : '';
|
||||
};
|
||||
|
||||
/** The optional columns currently shown, in canonical order. */
|
||||
readonly visibleColumns = computed(() => {
|
||||
|
||||
+28
-11
@@ -1,34 +1,51 @@
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
/** Locale-aware number formatting (decimal separator); cached per locale. */
|
||||
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
|
||||
function num(value: number, locale: string | undefined): string {
|
||||
const key = locale ?? '';
|
||||
let fmt = NUM_FORMATS.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
|
||||
NUM_FORMATS.set(key, fmt);
|
||||
}
|
||||
return fmt.format(value);
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number, locale?: string): string {
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
|
||||
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
|
||||
}
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
|
||||
export function formatDate(epochMs: number, locale?: string): string {
|
||||
if (!epochMs) return '';
|
||||
const key = locale ?? '';
|
||||
let fmt = DATE_FORMATS.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale, {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
export function formatDate(epochMs: number): string {
|
||||
if (!epochMs) return '';
|
||||
return DATE_FORMAT.format(new Date(epochMs));
|
||||
});
|
||||
DATE_FORMATS.set(key, fmt);
|
||||
}
|
||||
return fmt.format(new Date(epochMs));
|
||||
}
|
||||
|
||||
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
|
||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
|
||||
if (unit === 'Mbps') {
|
||||
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
|
||||
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
|
||||
}
|
||||
return `${formatSize(bytesPerSec)}/s`;
|
||||
return `${formatSize(bytesPerSec, locale)}/s`;
|
||||
}
|
||||
|
||||
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { DICTS, type Messages } from '@shared/i18n';
|
||||
import type { AppSettings, SupportedLocale } from '@shared/types';
|
||||
import { formatDate, formatSize, formatSpeed } from './format';
|
||||
|
||||
type Params = Record<string, string | number>;
|
||||
|
||||
function lookup(dict: Messages, key: string): string | Messages | undefined {
|
||||
let node: string | Messages | undefined = dict;
|
||||
for (const part of key.split('.')) {
|
||||
if (typeof node !== 'object') return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Params): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime translator. `t()` reads the `locale` signal, so any template binding
|
||||
* that calls it re-renders live on a language switch (the same reactive
|
||||
* function-call tracking the file-list icon classes rely on). Missing keys fall
|
||||
* back to English, then to the raw key so gaps are visible in dev.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class I18nService {
|
||||
readonly locale = signal<SupportedLocale>('en');
|
||||
private readonly dict = computed(() => DICTS[this.locale()]);
|
||||
|
||||
t(key: string, params?: Params): string {
|
||||
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
|
||||
// plural map: pick the CLDR category for params.count (fallback to English)
|
||||
if (value && typeof value === 'object') {
|
||||
const count = Number(params?.['count'] ?? NaN);
|
||||
if (Number.isFinite(count)) {
|
||||
const rule = new Intl.PluralRules(this.locale()).select(count);
|
||||
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
|
||||
value = typeof picked === 'string' ? picked : undefined;
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? interpolate(value, params) : key;
|
||||
}
|
||||
|
||||
// Locale-reactive formatting (live-updates the file list on switch).
|
||||
date(epochMs: number): string {
|
||||
return formatDate(epochMs, this.locale());
|
||||
}
|
||||
size(bytes: number): string {
|
||||
return formatSize(bytes, this.locale());
|
||||
}
|
||||
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
|
||||
return formatSpeed(bytesPerSec, unit, this.locale());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKind } from './file-icons';
|
||||
import { fileKindKey } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode, isRootPath, joinPath } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
export type Side = 'left' | 'right';
|
||||
@@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey;
|
||||
|
||||
/** Display string the text-based columns sort by. */
|
||||
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||
if (key === 'kind') return fileKind(entry);
|
||||
if (key === 'kind') return fileKindKey(entry);
|
||||
if (key === 'rights') return formatMode(entry.mode);
|
||||
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||
}
|
||||
@@ -225,6 +226,7 @@ export interface Workspace {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PanelsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
readonly workspaces = signal<Workspace[]>([]);
|
||||
readonly activeWorkspaceId = signal(0);
|
||||
readonly activeWorkspace = computed(() => {
|
||||
@@ -248,7 +250,12 @@ export class PanelsService {
|
||||
for (const workspace of this.workspaces()) {
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
if (panel.sessionId() === event.sessionId) {
|
||||
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
|
||||
this.toast.error(
|
||||
this.i18n.t('toasts.disconnectedReason', {
|
||||
title: panel.title(),
|
||||
reason: event.reason,
|
||||
}),
|
||||
);
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
@@ -453,7 +460,7 @@ export class PanelsService {
|
||||
const remoteTitles = [workspace.left, workspace.right]
|
||||
.filter((p) => p.sessionId() !== 'local')
|
||||
.map((p) => p.title());
|
||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local';
|
||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
|
||||
}
|
||||
|
||||
workspaceIsRemote(workspace: Workspace): boolean {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { resolveLocale } from '@shared/i18n';
|
||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
@@ -23,6 +26,7 @@ export class SettingsService {
|
||||
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||
readonly effectiveDark = computed(
|
||||
@@ -45,6 +49,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.locale.set(settings.locale);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
this.accent.set(settings.accent);
|
||||
@@ -61,6 +66,13 @@ export class SettingsService {
|
||||
this.applyDarkTone();
|
||||
this.applyAccent();
|
||||
this.applyTextSize();
|
||||
this.applyLocale();
|
||||
}
|
||||
|
||||
setLocale(locale: AppSettings['locale']): void {
|
||||
this.locale.set(locale);
|
||||
this.applyLocale();
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setTheme(theme: AppSettings['theme']): void {
|
||||
@@ -166,8 +178,13 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
private applyLocale(): void {
|
||||
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
|
||||
}
|
||||
|
||||
protected settings(): AppSettings {
|
||||
return {
|
||||
locale: this.locale(),
|
||||
theme: this.theme(),
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSpeed } from '../format';
|
||||
import { PanelsService } from '../panels.service';
|
||||
@@ -17,26 +18,26 @@ import { TransfersService } from '../transfers.service';
|
||||
<button
|
||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
||||
class="flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||
title="Show transfer details"
|
||||
[title]="i18n.t('transfer.showDetails')"
|
||||
>
|
||||
@if (transfers.currentJob(); as job) {
|
||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||
<span class="truncate">
|
||||
Copying {{ job.name }} · {{ percent(job) }}%{{ speedSuffix(job) }}
|
||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
||||
@if (transfers.queuedCount(); as queued) {
|
||||
· {{ queued }} queued
|
||||
· {{ i18n.t('transfer.queued', { count: queued }) }}
|
||||
}
|
||||
</span>
|
||||
} @else if (transfers.failedJobs().length; as failed) {
|
||||
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
||||
<span class="text-destructive">
|
||||
{{ failed }} transfer{{ failed === 1 ? '' : 's' }} failed
|
||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
||||
</span>
|
||||
} @else if (transfers.hasJobs()) {
|
||||
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
||||
<span>Transfers finished</span>
|
||||
<span>{{ i18n.t('transfer.finished') }}</span>
|
||||
} @else {
|
||||
<span>Ready</span>
|
||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@@ -47,16 +48,19 @@ import { TransfersService } from '../transfers.service';
|
||||
size="0.8rem"
|
||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
||||
/>
|
||||
<span class="max-w-56 truncate">{{ activePanel().title() }}</span>
|
||||
<span>· {{ activePanel().sorted().length }} items</span>
|
||||
<span class="max-w-56 truncate">
|
||||
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
|
||||
</span>
|
||||
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
|
||||
@if (activePanel().selected().size; as count) {
|
||||
<span>· {{ count }} selected</span>
|
||||
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class StatusBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
@@ -10,16 +12,17 @@ import { SettingsService } from '../settings.service';
|
||||
const THEMES = ['system', 'light', 'dark'] as const;
|
||||
const DARK_TONES = ['dark', 'darker'] as const;
|
||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
||||
// cards (the live --primary values would vanish against one of them).
|
||||
const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [
|
||||
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)', label: 'Graphite' },
|
||||
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)', label: 'Blue' },
|
||||
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)', label: 'Indigo' },
|
||||
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)', label: 'Purple' },
|
||||
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)', label: 'Rose' },
|
||||
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)', label: 'Orange' },
|
||||
{ value: 'green', swatch: 'oklch(0.65 0.15 150)', label: 'Green' },
|
||||
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)', label: 'Teal' },
|
||||
// cards (the live --primary values would vanish against one of them). Names are
|
||||
// translated via options.accentNames.<value>.
|
||||
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
|
||||
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
|
||||
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
|
||||
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
|
||||
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
|
||||
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
|
||||
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
|
||||
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
|
||||
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
|
||||
];
|
||||
const SPEED_UNITS = [
|
||||
{ value: 'MBps', label: 'MB/s' },
|
||||
@@ -78,7 +81,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@if (panels.workspaces().length > 1) {
|
||||
<button
|
||||
(click)="close($event, ws.id)"
|
||||
[title]="'Close tab (' + modLabel + 'W)'"
|
||||
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
|
||||
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.7rem" />
|
||||
@@ -94,7 +97,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
[title]="'New tab (' + modLabel + 'T)'"
|
||||
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size=".8rem" />
|
||||
@@ -105,17 +108,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
<!-- connection manager (own native window) -->
|
||||
<button
|
||||
(click)="openManager()"
|
||||
title="Manage connections"
|
||||
[title]="i18n.t('tabBar.manageConnections')"
|
||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
||||
Connections
|
||||
{{ i18n.t('tabBar.connections') }}
|
||||
</button>
|
||||
|
||||
<!-- global options -->
|
||||
<button
|
||||
(click)="settingsOpen.set(!settingsOpen())"
|
||||
title="Options"
|
||||
[title]="i18n.t('tabBar.options')"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaDots" size="1.5rem" />
|
||||
@@ -124,22 +127,39 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@if (settingsOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
||||
<div
|
||||
class="absolute top-full right-1.5 z-30 mt-1 w-64 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
||||
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
||||
>
|
||||
<h3 class="text-xs font-semibold">Options</h3>
|
||||
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Theme</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
||||
<select
|
||||
(change)="settings.setLocale($any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="system" [selected]="settings.locale() === 'system'">
|
||||
{{ i18n.t('options.system') }}
|
||||
</option>
|
||||
@for (loc of localeOptions; track loc) {
|
||||
<option [value]="loc" [selected]="settings.locale() === loc">
|
||||
{{ localeLabels[loc] }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (theme of themes; track theme) {
|
||||
<button
|
||||
(click)="settings.setTheme(theme)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.theme() === theme"
|
||||
[class.text-foreground]="settings.theme() === theme"
|
||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||
>
|
||||
{{ theme }}
|
||||
{{ i18n.t('options.theme.' + theme) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -147,17 +167,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
|
||||
@if (settings.effectiveDark()) {
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Tone</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (tone of darkTones; track tone) {
|
||||
<button
|
||||
(click)="settings.setDarkTone(tone)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.darkTone() === tone"
|
||||
[class.text-foreground]="settings.darkTone() === tone"
|
||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||
>
|
||||
{{ tone }}
|
||||
{{ i18n.t('options.tone.' + tone) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -165,12 +185,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Accent</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@for (tone of accents; track tone.value) {
|
||||
<button
|
||||
(click)="settings.setAccent(tone.value)"
|
||||
[title]="tone.label"
|
||||
[title]="i18n.t('options.accentNames.' + tone.value)"
|
||||
class="size-3 rounded-full border border-foreground/15"
|
||||
[style.background-color]="tone.swatch"
|
||||
[class.ring-2]="settings.accent() === tone.value"
|
||||
@@ -183,41 +203,41 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">File icons</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (mode of iconColorModes; track mode) {
|
||||
<button
|
||||
(click)="settings.setIconColors(mode)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.iconColors() === mode"
|
||||
[class.text-foreground]="settings.iconColors() === mode"
|
||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||
>
|
||||
{{ mode }}
|
||||
{{ i18n.t('options.fileIcons.' + mode) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Interface</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (size of textSizes; track size) {
|
||||
<button
|
||||
(click)="settings.setTextSize(size)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.textSize() === size"
|
||||
[class.text-foreground]="settings.textSize() === size"
|
||||
[class.text-muted-foreground]="settings.textSize() !== size"
|
||||
>
|
||||
{{ size }}
|
||||
{{ i18n.t('options.interface.' + size) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Speed unit</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (unit of speedUnits; track unit.value) {
|
||||
<button
|
||||
@@ -234,17 +254,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Transfers</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (mode of transferModes; track mode) {
|
||||
<button
|
||||
(click)="settings.setTransferMode(mode)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.transferMode() === mode"
|
||||
[class.text-foreground]="settings.transferMode() === mode"
|
||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||
>
|
||||
{{ mode }}
|
||||
{{ i18n.t('options.transfers.' + mode) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -252,7 +272,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
|
||||
@if (settings.transferMode() === 'multi') {
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||
<span class="text-xs text-muted-foreground">At once</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (count of concurrencyChoices; track count) {
|
||||
<button
|
||||
@@ -270,7 +290,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Editor</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setEditor('builtin')"
|
||||
@@ -279,7 +299,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||
>
|
||||
Built-in
|
||||
{{ i18n.t('options.editor.builtin') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setEditor('custom')"
|
||||
@@ -288,7 +308,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.text-foreground]="settings.editor() === 'custom'"
|
||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||
>
|
||||
Custom
|
||||
{{ i18n.t('options.editor.custom') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,12 +323,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
(click)="pickEditorApp()"
|
||||
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
>
|
||||
Choose…
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
@if (settings.editorApp()) {
|
||||
<button
|
||||
(click)="settings.setEditorApp('')"
|
||||
title="Reset to system default"
|
||||
[title]="i18n.t('options.resetDefault')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.7rem" />
|
||||
@@ -319,7 +339,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Finished transfers</span>
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setAutoClearCompleted(true)"
|
||||
@@ -328,7 +348,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.text-foreground]="settings.autoClearCompleted()"
|
||||
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
||||
>
|
||||
Auto-clear
|
||||
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setAutoClearCompleted(false)"
|
||||
@@ -337,7 +357,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
[class.text-foreground]="!settings.autoClearCompleted()"
|
||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||
>
|
||||
Keep
|
||||
{{ i18n.t('options.finishedTransfers.keep') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,8 +394,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
export class TabBar {
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
readonly localeLabels = LOCALE_LABELS;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
@@ -413,7 +436,7 @@ export class TabBar {
|
||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||
editorAppName(): string {
|
||||
const path = this.settings.editorApp();
|
||||
if (!path) return 'System default';
|
||||
if (!path) return this.i18n.t('options.systemDefault');
|
||||
const base = path.split('/').filter(Boolean).pop() ?? path;
|
||||
return base.replace(/\.app$/i, '');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||
<div class="flex items-center justify-between border-t border-border px-3 py-1 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{{ transfers.activeJobs().length }} active · {{ transfers.finishedJobs().length }} finished
|
||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="transfers.clearFinished()"
|
||||
[disabled]="!transfers.finishedJobs().length"
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" /> Clear finished
|
||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto border-t border-border">
|
||||
@@ -30,7 +30,7 @@
|
||||
@if (isActive(job)) {
|
||||
<button
|
||||
(click)="transfers.cancel(job.id)"
|
||||
title="Cancel"
|
||||
[title]="i18n.t('transfer.cancel')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
@@ -38,7 +38,7 @@
|
||||
} @else {
|
||||
<button
|
||||
(click)="transfers.dismiss(job.id)"
|
||||
title="Dismiss"
|
||||
[title]="i18n.t('transfer.dismiss')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatEta, formatSize, formatSpeed } from '../format';
|
||||
import { formatEta } from '../format';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@@ -11,18 +12,21 @@ import { TransfersService } from '../transfers.service';
|
||||
templateUrl: './transfer-bar.html',
|
||||
})
|
||||
export class TransferBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly formatSize = formatSize;
|
||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
||||
|
||||
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
||||
speedLabel(job: TransferSnapshot): string {
|
||||
if (job.state !== 'running') return '';
|
||||
const speed = this.transfers.speeds().get(job.id);
|
||||
if (!speed) return '';
|
||||
const parts = [formatSpeed(speed, this.settings.speedUnit())];
|
||||
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
|
||||
const remaining = job.totalBytes - job.doneBytes;
|
||||
if (remaining > 0 && speed > 0) parts.push(`${formatEta(remaining / speed)} left`);
|
||||
if (remaining > 0 && speed > 0) {
|
||||
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -61,15 +65,18 @@ export class TransferBar {
|
||||
}
|
||||
|
||||
progressLabel(job: TransferSnapshot): string {
|
||||
if (job.state === 'queued') return 'Queued';
|
||||
if (job.state === 'scanning') return 'Scanning…';
|
||||
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
|
||||
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
|
||||
if (job.state === 'failed')
|
||||
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? 'Failed'}`;
|
||||
if (job.state === 'cancelled') return 'Cancelled';
|
||||
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
|
||||
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
|
||||
if (job.state === 'completed')
|
||||
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
|
||||
return this.i18n.t('transfer.completedFiles', {
|
||||
count: job.doneFiles,
|
||||
size: this.i18n.size(job.doneBytes),
|
||||
});
|
||||
const speed = this.speedLabel(job);
|
||||
const base = `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
|
||||
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
|
||||
return speed ? `${base} · ${speed}` : base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
|
||||
import { PanelsService, type Side } from './panels.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
||||
@@ -13,6 +14,7 @@ const SPEED_SMOOTHING = 0.7;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TransfersService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
@@ -53,19 +55,19 @@ export class TransfersService {
|
||||
(e) => e.type !== 'symlink' && e.name !== '..',
|
||||
);
|
||||
if (!targets.length) {
|
||||
this.toast.info('Nothing to copy (symlinks are skipped)');
|
||||
this.toast.info(this.i18n.t('toasts.nothingToCopy'));
|
||||
return;
|
||||
}
|
||||
const sameFs = src.fsKey() === dst.fsKey();
|
||||
if (sameFs && src.cwd() === dst.cwd()) {
|
||||
this.toast.info('Both panels show the same folder');
|
||||
this.toast.info(this.i18n.t('toasts.sameFolder'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
|
||||
if (finalTargets === null) return; // user cancelled
|
||||
if (!finalTargets.length) {
|
||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
||||
this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,11 +107,11 @@ export class TransfersService {
|
||||
}
|
||||
if (sameFs && dstEntry.path === target.path) {
|
||||
// replacing a file with ITSELF would only destroy it — never offer that
|
||||
this.toast.info(`Skipped "${target.name}" — it is the same file`);
|
||||
this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
|
||||
continue;
|
||||
}
|
||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
||||
this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
|
||||
continue;
|
||||
}
|
||||
if (mode === 'all') {
|
||||
@@ -178,7 +180,12 @@ export class TransfersService {
|
||||
}
|
||||
} else if (snapshot.state === 'failed') {
|
||||
this.expanded.set(true);
|
||||
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);
|
||||
this.toast.error(
|
||||
this.i18n.t('toasts.transferFailed', {
|
||||
name: snapshot.name,
|
||||
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
|
||||
}),
|
||||
);
|
||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||
} else if (snapshot.state === 'cancelled') {
|
||||
this.refreshPanelsOn(snapshot.dstSessionId);
|
||||
|
||||
Reference in New Issue
Block a user