From 91654d8682961cf068afd3f14686872ab87542f4 Mon Sep 17 00:00:00 2001 From: AnonyJcy Date: Mon, 24 Aug 2026 16:12:14 +0800 Subject: [PATCH] feat(gui): introduce i18n framework & Simplified Chinese (zh-CN) localization (#84) --- .../src/lib/components/CommandPalette.svelte | 31 ++- .../ui/src/lib/components/Modal.svelte | 3 +- .../ui/src/lib/components/Sidebar.svelte | 44 ++-- .../ui/src/lib/components/StatusBar.svelte | 14 +- .../ui/src/lib/components/SupportModal.svelte | 65 +++-- .../ui/src/lib/components/UpdateBanner.svelte | 13 +- .../ui/src/lib/components/select.test.ts | 4 +- .../omnyssh-gui/ui/src/lib/demo/demoData.ts | 203 +++++++++++++++ .../omnyssh-gui/ui/src/lib/i18n/i18n.test.ts | 41 +++ crates/omnyssh-gui/ui/src/lib/i18n/index.ts | 128 +++++++++ .../omnyssh-gui/ui/src/lib/i18n/locales/en.ts | 244 ++++++++++++++++++ .../ui/src/lib/i18n/locales/zh-CN.ts | 244 ++++++++++++++++++ .../ui/src/lib/screens/Dashboard.svelte | 108 +++++--- .../ui/src/lib/screens/HostEditor.svelte | 45 ++-- .../src/lib/screens/KeySetupProgress.svelte | 26 +- .../ui/src/lib/screens/Settings.svelte | 70 +++-- .../ui/src/lib/screens/SftpPane.svelte | 5 +- .../ui/src/lib/screens/SftpView.svelte | 53 ++-- .../ui/src/lib/screens/SnippetEditor.svelte | 25 +- .../ui/src/lib/screens/SnippetResults.svelte | 16 +- .../ui/src/lib/screens/SnippetRunner.svelte | 26 +- .../ui/src/lib/screens/Snippets.svelte | 35 +-- .../ui/src/lib/screens/TerminalView.svelte | 9 +- .../omnyssh-gui/ui/src/lib/stores/sessions.ts | 3 + .../ui/src/lib/stores/streamer.test.ts | 45 ++-- .../omnyssh-gui/ui/src/lib/stores/streamer.ts | 38 ++- .../omnyssh-gui/ui/src/routes/+layout.svelte | 29 ++- crates/omnyssh-gui/ui/src/routes/+page.svelte | 1 + 28 files changed, 1276 insertions(+), 292 deletions(-) create mode 100644 crates/omnyssh-gui/ui/src/lib/demo/demoData.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/i18n/i18n.test.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/i18n/index.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/i18n/locales/en.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/i18n/locales/zh-CN.ts diff --git a/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte b/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte index aec878d..deca2d1 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte @@ -11,8 +11,9 @@ import { sessions, sessionLabel, sessionStatusDot } from '$lib/stores/sessions'; import { activeEntity } from '$lib/stores/activeEntity'; import { spawnSession } from '$lib/stores/navigation'; - import { streamerMode, displayHostname } from '$lib/stores/streamer'; + import { streamerMode, displayHostname, displayUser, displayHostTitle } from '$lib/stores/streamer'; import { isPaletteChord } from '$lib/stores/ui'; + import { t } from '$lib/i18n'; let inputEl = $state(); let listEl = $state(); @@ -27,16 +28,16 @@ const firstHost = $derived(items.findIndex((it) => it.kind === 'host')); const placeholder = $derived( - $palette.mode === 'pickHost' ? 'Pick a host…' : 'Search hosts and sessions…' + $palette.mode === 'pickHost' ? $t('palette.pick_host_placeholder') : $t('palette.search_placeholder') ); const emptyMessage = $derived( $palette.mode === 'pickHost' ? query - ? 'No matching hosts.' - : 'No hosts configured.' + ? $t('palette.no_matching_hosts') + : $t('palette.no_hosts_configured') : query - ? 'No matches.' - : 'No hosts or sessions yet.' + ? $t('palette.no_matches') + : $t('palette.no_hosts_or_sessions') ); // Focus returns here when the overlay closes, so a keyboard user is not dropped to @@ -146,12 +147,12 @@ class="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[14vh]" role="dialog" aria-modal="true" - aria-label={$palette.mode === 'pickHost' ? 'Pick a host' : 'Command palette'} + aria-label={$palette.mode === 'pickHost' ? $t('palette.pick_host_placeholder') : $t('sidebar.palette')} > @@ -181,10 +182,10 @@ unique, so a name key could throw each_key_duplicate. --> {#each items as item, i (i)} {#if $palette.mode === 'navigate' && i === firstSession} -
  • Sessions
  • +
  • {$t('palette.sessions')}
  • {/if} {#if $palette.mode === 'navigate' && i === firstHost} -
  • Hosts
  • +
  • {$t('palette.hosts')}
  • {/if}
  • @@ -215,9 +216,7 @@
    - ↑↓ navigate - ↵ select - esc close + {$t('palette.shortcuts')}
    diff --git a/crates/omnyssh-gui/ui/src/lib/components/Modal.svelte b/crates/omnyssh-gui/ui/src/lib/components/Modal.svelte index c19deaf..08bbeb3 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/Modal.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/Modal.svelte @@ -5,6 +5,7 @@ // passed in; the chrome (scrim, box, key handling) is fixed here so the snippet // dialogs don't each re-implement it. import type { Snippet } from 'svelte'; + import { t } from '$lib/i18n'; let { label, @@ -31,7 +32,7 @@ diff --git a/crates/omnyssh-gui/ui/src/lib/components/Sidebar.svelte b/crates/omnyssh-gui/ui/src/lib/components/Sidebar.svelte index b57aafc..e9cc6ab 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/Sidebar.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/Sidebar.svelte @@ -19,6 +19,8 @@ import { spawnSession, closeSession } from '$lib/stores/navigation'; import { palette } from '$lib/stores/palette'; import { support } from '$lib/stores/support'; + import { streamerMode, displayHostTitle } from '$lib/stores/streamer'; + import { t, locale, type TranslationKey } from '$lib/i18n'; // Action-first spawn (tech-gui.md §2): a spawner opens the host-picker, then creates // a session of its kind for the chosen host. A dismissed picker spawns nothing. @@ -27,16 +29,16 @@ if (host) spawnSession(kind, host.name); } - type Selector = { kind: 'dashboard' | 'snippets'; label: string; icon: IconName }; - type Spawner = { kind: SessionKind; label: string; icon: IconName }; + type Selector = { kind: 'dashboard' | 'snippets'; key: TranslationKey; icon: IconName }; + type Spawner = { kind: SessionKind; key: TranslationKey; icon: IconName }; const selectors: Selector[] = [ - { kind: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, - { kind: 'snippets', label: 'Snippets', icon: 'snippets' } + { kind: 'dashboard', key: 'sidebar.dashboard', icon: 'dashboard' }, + { kind: 'snippets', key: 'sidebar.snippets', icon: 'snippets' } ]; const spawners: Spawner[] = [ - { kind: 'sftp', label: 'SFTP', icon: 'sftp' }, - { kind: 'terminal', label: 'Terminal', icon: 'terminal' } + { kind: 'sftp', key: 'sidebar.sftp', icon: 'sftp' }, + { kind: 'terminal', key: 'sidebar.terminal', icon: 'terminal' } ]; const rowBase = 'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition'; @@ -59,7 +61,7 @@ {/if}
  • {/each} @@ -91,11 +93,11 @@ {/each} @@ -114,8 +116,8 @@ class="flex min-w-0 items-center gap-2.5 rounded text-left {focusRing} {$sidebarCollapsed ? '' : 'flex-1'}" - title={sessionTitle(s)} - aria-label={sessionTitle(s)} + title={$streamerMode ? `${displayHostTitle(s.hostName, true)} · ${s.kind}` : sessionTitle(s)} + aria-label={$streamerMode ? `${displayHostTitle(s.hostName, true)} · ${s.kind}` : sessionTitle(s)} aria-current={active ? 'true' : undefined} onclick={() => activeEntity.activateSession(s.id)} > @@ -129,15 +131,15 @@ {:else} - {sessionLabel(s)} + {displayHostTitle(sessionLabel(s), $streamerMode)} {/if} {#if !$sidebarCollapsed} - @@ -173,8 +175,8 @@ 'settings' ? 'bg-accent text-accent-fg' : 'text-muted hover:bg-surface-inset hover:text-fg'}" - title="Settings" - aria-label="Settings" + title={$t('sidebar.settings')} + aria-label={$t('sidebar.settings')} aria-current={$activeEntity.kind === 'settings' ? 'page' : undefined} onclick={() => activeEntity.selectSettings()} > diff --git a/crates/omnyssh-gui/ui/src/lib/components/StatusBar.svelte b/crates/omnyssh-gui/ui/src/lib/components/StatusBar.svelte index 63e603c..42f580c 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/StatusBar.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/StatusBar.svelte @@ -4,7 +4,9 @@ // (§4.1); colour lives only in the status dots, per the brandbook. import { lastError } from '$lib/stores/notifications'; import { hostSummary } from '$lib/stores/hostSummary'; + import { streamerMode } from '$lib/stores/streamer'; import { StatusDot } from '$lib/theme'; + import { t } from '$lib/i18n';
    {#if $lastError} {$lastError} + {:else if $streamerMode} + {$t('statusbar.streamer_active')} {:else} - Ready + {$t('statusbar.ready')} {/if}
    - {$hostSummary.total} {$hostSummary.total === 1 ? 'host' : 'hosts'} + {$hostSummary.total} {$hostSummary.total === 1 ? $t('statusbar.host_singular') : $t('statusbar.host_plural')} - {$hostSummary.online} online + {$hostSummary.online} {$t('statusbar.online')} - {$hostSummary.alert} alert + {$hostSummary.alert} {$t('statusbar.alert')} - {$hostSummary.offline} offline + {$hostSummary.offline} {$t('statusbar.offline')}
    diff --git a/crates/omnyssh-gui/ui/src/lib/components/SupportModal.svelte b/crates/omnyssh-gui/ui/src/lib/components/SupportModal.svelte index af34a4f..b614322 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/SupportModal.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/SupportModal.svelte @@ -11,23 +11,35 @@ import { support } from '$lib/stores/support'; import { openExternal } from '$lib/ipc/openExternal'; import { lastError } from '$lib/stores/notifications'; + import { t, locale } from '$lib/i18n'; - type Link = { icon: IconName; title: string; locator: string; url: string }; + type Link = { icon: IconName; titleKey: 'support.star_title' | 'support.telegram_title'; locator: string; url: string }; - const links: Link[] = [ - { - icon: 'star', - title: 'Star it on GitHub', - locator: 'github.com/timhartmann7/omnyssh', - url: 'https://github.com/timhartmann7/omnyssh' - }, - { - icon: 'telegram', - title: 'Follow on Telegram', - locator: '@timhartmanndev', - url: 'https://t.me/timhartmanndev' - } - ]; + const links = $derived( + $locale === 'zh-CN' + ? [ + { + icon: 'star', + titleKey: 'support.star_title', + locator: 'github.com/timhartmann7/omnyssh', + url: 'https://github.com/timhartmann7/omnyssh' + } + ] + : [ + { + icon: 'star', + titleKey: 'support.star_title', + locator: 'github.com/timhartmann7/omnyssh', + url: 'https://github.com/timhartmann7/omnyssh' + }, + { + icon: 'telegram', + titleKey: 'support.telegram_title', + locator: '@timhartmanndev', + url: 'https://t.me/timhartmanndev' + } + ] + ); async function go(url: string): Promise { try { @@ -38,13 +50,13 @@ } - +

    - Free and open source, forever. + {$t('support.headline_prefix')}{$t('support.headline_highlight')}{$t('support.headline_suffix')}

    - No paid tiers, no ads, no upsells. I build OmnySSH in the open and I do not plan to - monetize it. + {$t('support.body')}

    - Two small things help it grow + {$locale === 'zh-CN' ? $t('support.support_ways') : $t('support.two_things')}

    @@ -80,7 +91,7 @@ - {link.title} + {$t(link.titleKey)} {link.locator} -

    - Telegram is where I post release notes and the rest of what I build. -

    + {#if $locale !== 'zh-CN'} +

    + {$t('support.telegram_desc')} +

    + {/if}
    diff --git a/crates/omnyssh-gui/ui/src/lib/components/UpdateBanner.svelte b/crates/omnyssh-gui/ui/src/lib/components/UpdateBanner.svelte index 9a3a647..3a9ca1d 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/UpdateBanner.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/UpdateBanner.svelte @@ -10,6 +10,7 @@ import { loadUpdateConfig, saveUpdateConfig } from '$lib/ipc/commands'; import { openExternal } from '$lib/ipc/openExternal'; import { lastError } from '$lib/stores/notifications'; + import { t } from '$lib/i18n'; const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); let busy = $state(false); @@ -49,8 +50,8 @@ >
    -

    Update available — v{info.version}

    -

    A newer OmnySSH release is ready.

    +

    {$t('update_banner.available', { version: info.version })}

    +

    {$t('update_banner.desc')}

    {#if $serverCards.length === 0}
    -

    No servers yet

    -

    Add a host, or import one from your SSH config, to see it here.

    +

    {$t('dashboard.no_servers')}

    +

    {$t('dashboard.no_servers_desc')}

    {:else if visibleCards.length === 0}
    -

    No hosts match “{query}”.

    +

    {$t('dashboard.no_match', { query })}

    {:else}
    @@ -197,17 +198,17 @@
    - +
    - {card.host.name} + {displayHostTitle(card.host.name, $streamerMode, card.host.hostname)} {#if card.host.source === 'sshConfig'} - ssh config + {$t('dashboard.ssh_config_badge')} {/if} + +

    {$t('settings.language')}

    +
    +
    +

    {$t('settings.language')}

    +

    {$t('settings.language_desc')}

    +
    +
    + {#each SUPPORTED_LOCALES as loc (loc.code)} + + {/each} +
    +
    +
    + -

    Appearance

    +

    {$t('settings.appearance')}

    -

    Theme

    -

    Mirrors the sidebar toggle.

    +

    {$t('settings.theme')}

    +

    {$t('settings.theme_desc')}

    @@ -109,19 +133,19 @@ -

    Privacy

    +

    {$t('settings.streamer_mode')}

    -

    Streamer mode

    +

    {$t('settings.streamer_mode')}

    - Mask host addresses with realistic fakes, so real IPs stay off-screen while recording. + {$t('settings.streamer_mode_desc')}

    diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte index 0a74a3c..d3db26a 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte @@ -7,6 +7,7 @@ import { Icon } from '$lib/theme'; import type { FileEntryDto } from '$lib/bindings'; import { formatBytes, type Pane } from '$lib/stores/sftp'; + import { t } from '$lib/i18n'; let { title, @@ -51,9 +52,9 @@ {#if pane.error}

    {pane.error}

    {:else if pane.loading && pane.entries.length === 0} -

    Loading…

    +

    {$t('sftp.loading')}

    {:else if pane.entries.length === 0} -

    Empty directory

    +

    {$t('sftp.empty_directory')}

    {:else}
      {#each pane.entries as entry, i (i)} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte index 3441080..e8c0d5f 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte @@ -14,6 +14,8 @@ import { sessions, type Session } from '$lib/stores/sessions'; import { sftp, markedEntries, formatBytes, type PaneSide } from '$lib/stores/sftp'; import { lastError } from '$lib/stores/notifications'; + import { streamerMode, displayHostTitle } from '$lib/stores/streamer'; + import { t } from '$lib/i18n'; import { sftpOpen, sftpList, @@ -94,6 +96,9 @@ } catch { home = '/'; } + if (typeof window !== 'undefined' && !('__TAURI_INTERNALS__' in window)) { + return; + } let id: number; try { id = await sftpOpen(session.hostName); @@ -282,17 +287,17 @@
      {#if openError}
      -

      Could not open SFTP on {session.hostName}

      +

      {$t('sftp.open_failed', { name: displayHostTitle(session.hostName, $streamerMode) })}

      {openError}

      {:else if !view}
      -

      Connecting to {session.hostName}…

      +

      {$t('sftp.connecting', { name: displayHostTitle(session.hostName, $streamerMode) })}

      {:else}
      navigate('local', e)} onToggleMark={(p) => toggleMark('local', p)} @@ -302,17 +307,17 @@ - @@ -447,7 +452,7 @@ {/if} {#if active && view?.preview} - +

      {view.preview.path} @@ -455,7 +460,7 @@

      {#if view.preview.content.length === 0} -

      Empty file.

      +

      {$t('sftp.empty_file')}

      {:else}
      {view.preview
                   .content}
      @@ -467,7 +472,7 @@ class="rounded-full px-4 py-2 text-sm text-muted transition hover:bg-surface-inset hover:text-fg" onclick={closePreview} > - Close + {$t('sftp.close')} diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SnippetEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SnippetEditor.svelte index 36cb43f..f6a826e 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SnippetEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SnippetEditor.svelte @@ -8,6 +8,7 @@ import Modal from '$lib/components/Modal.svelte'; import Select from '$lib/components/Select.svelte'; import { formToSnippet, type SnippetFormFields } from './snippetForm'; + import { t } from '$lib/i18n'; let { mode, @@ -56,7 +57,7 @@ 'focus-visible:ring-2 focus-visible:ring-focus placeholder:text-faint'; - +
      { e.preventDefault(); @@ -65,17 +66,17 @@ class="flex min-h-0 flex-col" >
      -

      {mode === 'add' ? 'New snippet' : 'Edit snippet'}

      +

      {mode === 'add' ? $t('snippet_editor.add_title') : $t('snippet_editor.edit_title')}