Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>();
let listEl = $state<HTMLUListElement>();
Expand All @@ -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
Expand Down Expand Up @@ -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')}
>
<button
type="button"
tabindex="-1"
aria-label="Dismiss"
aria-label={$t('common.close')}
class="absolute inset-0 bg-overlay"
onclick={() => palette.close()}
></button>
Expand Down Expand Up @@ -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}
<li class={sectionHead}>Sessions</li>
<li class={sectionHead}>{$t('palette.sessions')}</li>
{/if}
{#if $palette.mode === 'navigate' && i === firstHost}
<li class={sectionHead}>Hosts</li>
<li class={sectionHead}>{$t('palette.hosts')}</li>
{/if}
<li>
<button
Expand All @@ -198,12 +199,12 @@
{#if item.kind === 'session'}
<StatusDot status={sessionStatusDot[item.session.status]} />
<Icon name={item.session.kind} size={16} />
<span class="min-w-0 flex-1 truncate">{sessionLabel(item.session)}</span>
<span class="min-w-0 flex-1 truncate">{displayHostTitle(sessionLabel(item.session), $streamerMode)}</span>
{:else}
<StatusDot status={hostStatusDot($statuses.get(item.host.name))} />
<span class="min-w-0 flex-1 truncate font-medium">{item.host.name}</span>
<span class="min-w-0 flex-1 truncate font-medium">{displayHostTitle(item.host.name, $streamerMode, item.host.hostname)}</span>
<span class="shrink-0 truncate font-mono text-xs {selected === i ? '' : 'text-faint'}">
{item.host.user}@{displayHostname(item.host.hostname, $streamerMode)}
{displayUser(item.host.user, $streamerMode)}@{displayHostname(item.host.hostname, $streamerMode)}
</span>
{/if}
</button>
Expand All @@ -215,9 +216,7 @@
<div
class="flex items-center gap-4 border-t border-default px-4 py-2 font-mono text-[11px] text-faint"
>
<span>↑↓ navigate</span>
<span>↵ select</span>
<span>esc close</span>
<span>{$t('palette.shortcuts')}</span>
</div>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion crates/omnyssh-gui/ui/src/lib/components/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,7 +32,7 @@
<button
type="button"
tabindex="-1"
aria-label="Dismiss"
aria-label={$t('common.close')}
class="absolute inset-0 bg-overlay"
onclick={onClose}
></button>
Expand Down
44 changes: 23 additions & 21 deletions crates/omnyssh-gui/ui/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand All @@ -59,7 +61,7 @@
{/if}
<Button
variant="icon"
title={$sidebarCollapsed ? 'Expand sidebar (⌘B)' : 'Collapse sidebar (⌘B)'}
title={$sidebarCollapsed ? $t('sidebar.expand') : $t('sidebar.collapse')}
onclick={() => sidebarCollapsed.toggle()}
>
<Icon name={$sidebarCollapsed ? 'expand' : 'collapse'} />
Expand All @@ -76,13 +78,13 @@
class="{rowBase} {focusRing} {rowState($activeEntity.kind === sel.kind)} {$sidebarCollapsed
? 'justify-center'
: ''}"
title={sel.label}
title={$t(sel.key)}
aria-current={$activeEntity.kind === sel.kind ? 'page' : undefined}
onclick={() =>
sel.kind === 'dashboard' ? activeEntity.selectDashboard() : activeEntity.selectSnippets()}
>
<Icon name={sel.icon} />
{#if !$sidebarCollapsed}<span class="truncate">{sel.label}</span>{/if}
{#if !$sidebarCollapsed}<span class="truncate">{$t(sel.key)}</span>{/if}
</button>
</li>
{/each}
Expand All @@ -91,11 +93,11 @@
<button
type="button"
class="{rowBase} {focusRing} {rowState(false)} {$sidebarCollapsed ? 'justify-center' : ''}"
title={sp.label}
title={$t(sp.key)}
onclick={() => pickAndSpawn(sp.kind)}
>
<Icon name={sp.icon} />
{#if !$sidebarCollapsed}<span class="truncate">{sp.label}</span>{/if}
{#if !$sidebarCollapsed}<span class="truncate">{$t(sp.key)}</span>{/if}
</button>
</li>
{/each}
Expand All @@ -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)}
>
Expand All @@ -129,15 +131,15 @@
{:else}
<StatusDot status={sessionStatusDot[s.status]} />
<Icon name={s.kind} size={16} />
<span class="min-w-0 flex-1 truncate">{sessionLabel(s)}</span>
<span class="min-w-0 flex-1 truncate">{displayHostTitle(sessionLabel(s), $streamerMode)}</span>
{/if}
</button>
{#if !$sidebarCollapsed}
<button
type="button"
class="shrink-0 rounded p-1 opacity-60 transition hover:opacity-100 {focusRing}"
title="Close {sessionLabel(s)}"
aria-label="Close {sessionLabel(s)}"
title="{$t('sidebar.close_session')} ({displayHostTitle(sessionLabel(s), $streamerMode)})"
aria-label="{$t('sidebar.close_session')} ({displayHostTitle(sessionLabel(s), $streamerMode)})"
onclick={() => closeSession(s.id)}
>
<Icon name="close" size={14} />
Expand All @@ -155,15 +157,15 @@
? 'flex flex-col items-center gap-1'
: 'flex items-center gap-1'}"
>
<Button variant="icon" title="Command palette (⌘K)" onclick={() => palette.open()}>
<Button variant="icon" title={$t('sidebar.palette')} onclick={() => palette.open()}>
<Icon name="command" />
</Button>
<ThemeToggle />
<!-- Support/about overlay: free + open-source note and the two ways to help.
Opens a modal, not a screen, so it holds no highlight and never becomes the
active entity (§2). Sits left of the gear, icon-only so it survives collapse. -->
<Button variant="icon" title="Support OmnySSH" onclick={() => support.open()}>
<Icon name="telegram" />
<Button variant="icon" title={$t('support.title')} onclick={() => support.open()}>
<Icon name={$locale === 'zh-CN' ? 'star' : 'telegram'} />
</Button>
<!-- Settings is a selector-like screen; the gear holds the active highlight like
Dashboard/Snippets do, and stays icon-only so it survives collapse (§5.1). -->
Expand All @@ -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()}
>
Expand Down
14 changes: 9 additions & 5 deletions crates/omnyssh-gui/ui/src/lib/components/StatusBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,31 @@
// (§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';
</script>

<footer
class="col-span-2 col-start-1 row-start-2 flex items-center justify-between gap-4 border-t border-default bg-surface px-5 py-2 text-xs text-muted"
>
{#if $lastError}
<span class="min-w-0 truncate text-status-crit">{$lastError}</span>
{:else if $streamerMode}
<span class="min-w-0 truncate text-accent">{$t('statusbar.streamer_active')}</span>
{:else}
<span class="min-w-0 truncate">Ready</span>
<span class="min-w-0 truncate">{$t('statusbar.ready')}</span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<span>{$hostSummary.total} {$hostSummary.total === 1 ? 'host' : 'hosts'}</span>
<span>{$hostSummary.total} {$hostSummary.total === 1 ? $t('statusbar.host_singular') : $t('statusbar.host_plural')}</span>
<span class="flex items-center gap-1.5">
<StatusDot status="ok" label="online" />{$hostSummary.online} online
<StatusDot status="ok" label={$t('statusbar.online')} />{$hostSummary.online} {$t('statusbar.online')}
</span>
<span class="flex items-center gap-1.5">
<StatusDot status="warn" label="alert" />{$hostSummary.alert} alert
<StatusDot status="warn" label={$t('statusbar.alert')} />{$hostSummary.alert} {$t('statusbar.alert')}
</span>
<span class="flex items-center gap-1.5">
<StatusDot status="off" label="offline" />{$hostSummary.offline} offline
<StatusDot status="off" label={$t('statusbar.offline')} />{$hostSummary.offline} {$t('statusbar.offline')}
</span>
</div>
</footer>
65 changes: 39 additions & 26 deletions crates/omnyssh-gui/ui/src/lib/components/SupportModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<Link[]>(
$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<void> {
try {
Expand All @@ -38,13 +50,13 @@
}
</script>

<Modal label="Support OmnySSH" onClose={support.close}>
<Modal label={$t('support.title')} onClose={support.close}>
<div class="relative p-6">
<button
type="button"
class="absolute right-4 top-4 rounded-full p-1.5 text-muted transition hover:bg-surface-inset hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"
title="Close"
aria-label="Close"
title={$t('common.close')}
aria-label={$t('common.close')}
onclick={support.close}
>
<Icon name="close" size={16} />
Expand All @@ -56,15 +68,14 @@
</div>

<h2 class="text-xl font-light leading-snug">
Free and <span class="font-bold">open source</span>, forever.
{$t('support.headline_prefix')}<span class="font-bold">{$t('support.headline_highlight')}</span>{$t('support.headline_suffix')}
</h2>
<p class="mt-3 text-sm leading-relaxed text-muted">
No paid tiers, no ads, no upsells. I build OmnySSH in the open and I do not plan to
monetize it.
{$t('support.body')}
</p>

<p class="mt-5 text-[11px] font-medium uppercase tracking-[0.18em] text-faint">
Two small things help it grow
{$locale === 'zh-CN' ? $t('support.support_ways') : $t('support.two_things')}
</p>

<div class="mt-2.5 space-y-2.5">
Expand All @@ -80,7 +91,7 @@
<Icon name={link.icon} />
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm font-medium">{link.title}</span>
<span class="block text-sm font-medium">{$t(link.titleKey)}</span>
<span class="block truncate font-mono text-xs text-muted">{link.locator}</span>
</span>
<span
Expand All @@ -91,8 +102,10 @@
{/each}
</div>

<p class="mt-4 text-xs leading-relaxed text-faint">
Telegram is where I post release notes and the rest of what I build.
</p>
{#if $locale !== 'zh-CN'}
<p class="mt-4 text-xs leading-relaxed text-faint">
{$t('support.telegram_desc')}
</p>
{/if}
</div>
</Modal>
Loading