From d71091b0815f1e0cd218afc968a7bdb3db575281 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 20 Aug 2026 11:24:19 +0400 Subject: [PATCH 1/4] docs(readme): describe the desktop app as it behaves --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 78cede3..32b97b0 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,16 @@ You add a server once. After that it sits on the dashboard as a card with live C Cards for every host with CPU, RAM and disk bars, uptime, OS version, top processes, and a Docker badge showing how many containers are up. Bars turn yellow, then red, so a sick server is obvious from across the room. ### Real terminals -Full PTY sessions in tabs. Open several servers at once, split the view, keep them running while you work in the dashboard. +Full PTY sessions in tabs. Open as many servers as you need, switch between them from the sidebar, and keep them running while you work in the dashboard. ### Two panel SFTP -Local on the left, remote on the right. Drag files across, watch the progress bar, select many at once. Nobody remembers `scp -r` syntax anyway. +Local on the left, remote on the right. Tick the files you want and move them across, watch the progress bar, select many at once. Nobody remembers `scp -r` syntax anyway. ### Snippets -Save the commands you paste every week. Run one on a host with a keypress, or broadcast it to every server you have. Snippets take parameters, so `sudo systemctl restart {{service}}` asks you for the name. +Save the commands you paste every week. Pick a snippet, tick the hosts to send it to, and it runs on all of them at once. Snippets take parameters, so `sudo systemctl restart {{service}}` asks you for the name. ### Search everything -Hit ⌘K and start typing. Hosts, snippets, screens. It gets you there in three keystrokes. +Hit ⌘K and start typing. Every host you have, plus every session already open. Enter drops you into a terminal on the host you picked, or back into the session you left. ### Streamer mode Swaps every real IP on screen for a fake one. Record a demo or share your screen without leaking client infrastructure. @@ -78,7 +78,7 @@ Around 130 MB of RAM with several sessions open, on a 20 MB download. Termius on Password auth on a fresh VPS is the thing you always mean to fix and never do. OmnySSH does it in one click. -Pick a password based host, hit **Set up SSH key**, and the app generates an Ed25519 key, appends the public half to `authorized_keys`, and switches the host over to key auth. It then opens a fresh connection with the new key to prove the key works. Only after that does it offer to turn off password login. +Pick a host you added yourself that has no key configured, hit **Set up SSH key**, and the app generates an Ed25519 key, appends the public half to `authorized_keys`, and switches the host over to key auth. It then opens a fresh connection with the new key to prove the key works, and only after that does it turn password login off. There is no confirmation step in between: starting the flow means going through with it. Before touching `sshd_config` it saves a backup on the server. If any step fails, it restores the backup and leaves your access exactly as it was. Your private key never leaves your machine, and nothing gets sent anywhere except the server you chose. From 67e4ee723ee1163381930a183e5db0d91302d7f2 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 20 Aug 2026 11:24:19 +0400 Subject: [PATCH 2/4] fix(gui): render Nerd Font glyphs in the terminal and remote output --- .../ui/src/lib/screens/TerminalView.svelte | 14 +++- .../ui/src/lib/theme/nerdFontFallback.test.ts | 70 +++++++++++++++++++ crates/omnyssh-gui/ui/tailwind.config.ts | 23 +++++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 crates/omnyssh-gui/ui/src/lib/theme/nerdFontFallback.test.ts diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index ea4f521..3e1e0ab 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -22,7 +22,19 @@ let { session, active }: { session: Session; active: boolean } = $props(); - const MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'; + // The Nerd Font families come after the generic `monospace`, not merely after the + // named system ones: the named list is macOS/Windows-only, so on a Linux desktop a + // patched font ahead of the generic would become the terminal's Latin face and size + // its cell from itself. Per-character fallback continues past a generic family, so the + // Private Use Area glyphs (starship, powerlevel10k, eza --icons) still reach the tail. + // Within the tail, the single-width variants come first — Nerd Fonts v3 ships icons at + // double width in the bare family and one cell wide in its `Mono` twin. + const MONO = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace, ' + + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "MesloLGS NF", ' + + '"JetBrainsMono Nerd Font Mono", "JetBrainsMono Nerd Font", ' + + '"Hack Nerd Font Mono", "Hack Nerd Font", ' + + '"FiraCode Nerd Font Mono", "FiraCode Nerd Font"'; // One encoder for the keystroke hot path instead of one per input event. const ENCODER = new TextEncoder(); diff --git a/crates/omnyssh-gui/ui/src/lib/theme/nerdFontFallback.test.ts b/crates/omnyssh-gui/ui/src/lib/theme/nerdFontFallback.test.ts new file mode 100644 index 0000000..defd266 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/theme/nerdFontFallback.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// Nerd Font glyphs live in the Private Use Area, which no system monospace family +// carries. Two surfaces render raw remote output — xterm (its own `fontFamily`) and +// the chrome's `font-mono` utility, used by snippet results and SFTP previews — and +// their stacks are declared in different files, so they drift silently. +const UI = new URL('../../../', import.meta.url); + +const NERD_FAMILIES = [ + 'Symbols Nerd Font Mono', + 'Symbols Nerd Font', + 'MesloLGS NF', + 'JetBrainsMono Nerd Font Mono', + 'JetBrainsMono Nerd Font', + 'Hack Nerd Font Mono', + 'Hack Nerd Font', + 'FiraCode Nerd Font Mono', + 'FiraCode Nerd Font' +]; + +const STACKS: [string, string][] = [ + ['the terminal stack', 'src/lib/screens/TerminalView.svelte'], + ['the chrome font-mono stack', 'tailwind.config.ts'] +]; + +const read = (relative: string): string => + readFileSync(fileURLToPath(new URL(relative, UI)), 'utf8'); + +// Prose names these families too, and an index taken over a comment proves nothing +// about the declaration — so comments come out before anything is measured. Cut from +// `//` to end of line rather than dropping the line, and never inside a `://` scheme. +const declarations = (relative: string): string => + read(relative) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(//g, '') + .replace(/(? new RegExp(`${family}["']`); + +describe('Nerd Font fallback', () => { + for (const [label, file] of STACKS) { + it(`${label} names every fallback family`, () => { + const source = declarations(file); + for (const family of NERD_FAMILIES) { + expect(source, `${file} no longer falls back to ${family}`).toMatch(entry(family)); + } + }); + + it(`${label} keeps every patched family behind the generic monospace`, () => { + // The load-bearing rule, and not the same as "behind the named system families": + // the named ones are macOS/Windows-only, so on Linux anything ahead of the generic + // becomes the Latin face and sizes the terminal cell from itself. + const source = declarations(file); + const generic = source.search(GENERIC); + expect(generic, `${file} no longer names the generic monospace family`).toBeGreaterThan(-1); + for (const family of NERD_FAMILIES) { + expect(source.search(entry(family)), `${file} puts ${family} ahead of the generic`).toBeGreaterThan( + generic + ); + } + }); + } +}); diff --git a/crates/omnyssh-gui/ui/tailwind.config.ts b/crates/omnyssh-gui/ui/tailwind.config.ts index d70ad63..6deb140 100644 --- a/crates/omnyssh-gui/ui/tailwind.config.ts +++ b/crates/omnyssh-gui/ui/tailwind.config.ts @@ -32,7 +32,28 @@ export default { extend: { fontFamily: { sans: ['Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], - mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'] + // Same tail as the terminal's stack (screens/TerminalView.svelte), and for the + // same reason: the chrome's `font-mono` also renders raw remote output — snippet + // results and SFTP file previews, and any remote path or filename it lists. The + // patched families stay behind the generic `monospace` so they can never become + // the chrome's typeface. + mono: [ + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + 'monospace', + 'Symbols Nerd Font Mono', + 'Symbols Nerd Font', + 'MesloLGS NF', + 'JetBrainsMono Nerd Font Mono', + 'JetBrainsMono Nerd Font', + 'Hack Nerd Font Mono', + 'Hack Nerd Font', + 'FiraCode Nerd Font Mono', + 'FiraCode Nerd Font' + ] }, // Border tokens live under borderColor only, so `border-default`/ // `border-strong` work without polluting text-/bg-/ring- with a hairline From 07263a6133fbe92e588d95567ed0081536409fab Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 20 Aug 2026 11:24:19 +0400 Subject: [PATCH 3/4] feat(gui): let an ssh_config host be edited by adopting it --- crates/omnyssh-gui/src/commands/hosts.rs | 111 +++++++++++++++--- crates/omnyssh-gui/src/dto.rs | 9 +- crates/omnyssh-gui/ui/e2e/hosts.spec.ts | 21 +++- crates/omnyssh-gui/ui/src/lib/bindings.ts | 16 +-- .../ui/src/lib/screens/Dashboard.svelte | 36 ++++-- .../ui/src/lib/screens/HostEditor.svelte | 14 ++- 6 files changed, 162 insertions(+), 45 deletions(-) diff --git a/crates/omnyssh-gui/src/commands/hosts.rs b/crates/omnyssh-gui/src/commands/hosts.rs index 7a6ef40..e05d5f3 100644 --- a/crates/omnyssh-gui/src/commands/hosts.rs +++ b/crates/omnyssh-gui/src/commands/hosts.rs @@ -2,7 +2,7 @@ use tauri::{AppHandle, State}; use tauri_specta::Event; use omnyssh_core::config::{load_hosts, save_hosts}; -use omnyssh_core::ssh::client::Host; +use omnyssh_core::ssh::client::{Host, HostSource}; use crate::dto::{HostDto, HostInputDto}; use crate::error::CommandError; @@ -56,15 +56,25 @@ pub async fn reload_hosts(app: AppHandle, state: State<'_, GuiState>) -> Result< Ok(()) } -/// Add or edit a **manual** host and persist to `hosts.toml` (tech-gui.md §4.2, Stage -/// 4.1). Upserts by name; SSH-config hosts are read-only imports and are never written -/// (this operates on the manual `hosts.toml` alone). The frontend calls `reload_hosts` -/// afterwards to refresh the merged cache + restart the pollers. Secrets stay -/// backend-side (§3.4): the payload's password/identity never left the backend. +/// Add or edit a host and persist to `hosts.toml` (tech-gui.md §4.2, Stage 4.1). +/// Upserts by name. Editing an SSH-config import adopts it: the saved copy is a manual +/// entry that `merge_hosts` then prefers over the parsed one, so `~/.ssh/config` itself +/// is still never written. The frontend calls `reload_hosts` afterwards to refresh the +/// merged cache + restart the pollers. Secrets stay backend-side (§3.4): the payload's +/// password/identity never left the backend. #[tauri::command] #[specta::specta] -pub async fn save_host(input: HostInputDto) -> Result<(), CommandError> { - persist(move |hosts| upsert(hosts, input)).await +pub async fn save_host( + input: HostInputDto, + state: State<'_, GuiState>, +) -> Result<(), CommandError> { + // The parsed import is the only record of its bastion and key path, and neither + // crosses the boundary (§3.4), so read them off the cache before the write moves + // to a blocking task. + let imported = state + .host_by_name(&input.name) + .filter(|h| h.source == HostSource::SshConfig); + persist(move |hosts| upsert(hosts, input, imported)).await } /// Delete a manual host by name and persist (tech-gui.md §4.2, Stage 4.1). Only manual @@ -83,7 +93,7 @@ pub async fn delete_host(name: String) -> Result<(), CommandError> { /// SSH-config rename origin, and a monitoring mode the payload left out. Editing /// e.g. notes therefore never drops a stored secret or a recorded key setup. A /// provided secret still overwrites the old one. -fn upsert(hosts: &mut Vec, input: HostInputDto) { +fn upsert(hosts: &mut Vec, input: HostInputDto, imported: Option) { // An omitted monitoring mode means "unchanged", not "back to SSH" — losing it // would silently start logging in to a device chosen for reachability only. let monitoring_given = input.monitoring.is_some(); @@ -105,7 +115,23 @@ fn upsert(hosts: &mut Vec, input: HostInputDto) { host.original_ssh_host = existing.original_ssh_host.clone(); hosts[i] = host; } - None => hosts.push(host), + None => { + // A brand-new host, or the first save of an SSH-config import — only the + // import has anything to salvage. The form never saw its `ProxyJump` or + // identity file, and a copy without the bastion would dial the target + // address direct, which is exactly the hazard fixed in 1.1.1. + if let Some(imported) = imported { + host.proxy_jump = host.proxy_jump.or(imported.proxy_jump); + host.identity_file = host.identity_file.or(imported.identity_file); + // Which `~/.ssh/config` entry this copy stands in for. Inert while the + // names match — `merge_hosts` already drops the import on the name — but + // it is what keeps the import hidden once the copy is renamed in the TUI, + // and what another host's `ProxyJump` alias resolves through. The TUI + // records the same thing when it adopts (app/host.rs). + host.original_ssh_host = Some(host.name.clone()); + } + hosts.push(host); + } } } @@ -158,7 +184,7 @@ mod tests { #[test] fn upsert_appends_a_new_manual_host() { let mut hosts = vec![]; - upsert(&mut hosts, input("web")); + upsert(&mut hosts, input("web"), None); assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].name, "web"); assert_eq!(hosts[0].source, HostSource::Manual); @@ -176,7 +202,7 @@ mod tests { let mut edit = input("web"); edit.hostname = "new.example.com".to_string(); edit.notes = Some("new".to_string()); - upsert(&mut hosts, edit); + upsert(&mut hosts, edit, None); assert_eq!(hosts.len(), 1, "edit is in-place, not an append"); assert_eq!(hosts[0].hostname, "new.example.com"); assert_eq!(hosts[0].notes.as_deref(), Some("new")); @@ -197,7 +223,7 @@ mod tests { source: HostSource::Manual, ..Host::default() }]; - upsert(&mut hosts, input("web")); + upsert(&mut hosts, input("web"), None); let h = &hosts[0]; assert_eq!(h.password.as_deref(), Some("keep-me")); assert_eq!(h.identity_file.as_deref(), Some("/keys/id")); @@ -207,6 +233,59 @@ mod tests { assert_eq!(h.original_ssh_host.as_deref(), Some("web-old")); } + #[test] + fn adopting_an_ssh_config_host_keeps_its_bastion_and_key() { + // Editing an import writes a manual copy. `HostDto` carries neither `proxyJump` + // nor the identity path (§3.4), so the form submits both blank — dropping them + // would leave the copy dialling the target address direct. + let imported = Host { + name: "internal".to_string(), + hostname: "10.0.0.9".to_string(), + proxy_jump: Some("public-proxy".to_string()), + identity_file: Some("/keys/id_ed25519".to_string()), + source: HostSource::SshConfig, + ..Host::default() + }; + let mut hosts = vec![]; + let mut edit = input("internal"); + edit.notes = Some("adopted".to_string()); + upsert(&mut hosts, edit, Some(imported)); + + assert_eq!(hosts.len(), 1); + let h = &hosts[0]; + assert_eq!( + h.source, + HostSource::Manual, + "the copy is what hosts.toml holds" + ); + assert_eq!(h.proxy_jump.as_deref(), Some("public-proxy")); + assert_eq!(h.identity_file.as_deref(), Some("/keys/id_ed25519")); + assert_eq!(h.notes.as_deref(), Some("adopted")); + assert_eq!( + h.original_ssh_host.as_deref(), + Some("internal"), + "the copy records which import it shadows, so a later rename still hides it" + ); + } + + #[test] + fn an_explicit_identity_wins_over_the_imported_one() { + let imported = Host { + name: "internal".to_string(), + identity_file: Some("/keys/from-ssh-config".to_string()), + source: HostSource::SshConfig, + ..Host::default() + }; + let mut hosts = vec![]; + let mut edit = input("internal"); + edit.identity_file = Some("/keys/typed-by-hand".to_string()); + upsert(&mut hosts, edit, Some(imported)); + assert_eq!( + hosts[0].identity_file.as_deref(), + Some("/keys/typed-by-hand") + ); + } + #[test] fn upsert_keeps_a_monitoring_mode_the_payload_left_out() { let mut hosts = vec![Host { @@ -217,7 +296,7 @@ mod tests { ..Host::default() }]; - upsert(&mut hosts, input("fw")); + upsert(&mut hosts, input("fw"), None); // Silently reverting to SSH would start logging in to a device the user // deliberately put on a reachability probe. @@ -237,7 +316,7 @@ mod tests { let mut back_to_ssh = input("fw"); back_to_ssh.monitoring = Some(MonitorModeDto::Ssh); - upsert(&mut hosts, back_to_ssh); + upsert(&mut hosts, back_to_ssh, None); assert_eq!(hosts[0].monitoring, MonitorMode::Ssh); } @@ -252,7 +331,7 @@ mod tests { }]; let mut edit = input("web"); edit.password = Some("rotated".to_string()); - upsert(&mut hosts, edit); + upsert(&mut hosts, edit, None); assert_eq!(hosts[0].password.as_deref(), Some("rotated")); } diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index 71bd7e5..35bb3cb 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -71,8 +71,9 @@ pub struct HostDto { pub monitor_port: Option, } -/// Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Builds a -/// **manual** `Host` — SSH-config hosts are read-only imports and are never saved. +/// Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always +/// builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows +/// it, and `~/.ssh/config` itself is never written. /// `password`/`identityFile` arrive here (the create/edit form owns them) but never /// travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it /// derives `Deserialize` (not `Serialize`). @@ -582,8 +583,8 @@ mod tests { #[test] fn host_input_maps_to_a_manual_host() { - // The form only authors manual entries; SSH-config hosts are read-only imports - // (tech-gui.md §4.1, Stage 4.1) — so `source` is forced regardless of input. + // The form only ever authors manual entries — editing an import produces a + // manual copy (tech-gui.md §4.1) — so `source` is forced regardless of input. let host = Host::from(full_input()); assert_eq!(host.name, "web-prod-1"); assert_eq!(host.hostname, "10.0.0.1"); diff --git a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts index 6b72b4d..c8712f9 100644 --- a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts @@ -126,13 +126,28 @@ test('deletes a manual host after confirmation', async ({ page }) => { await expect(page.getByText('web-1', { exact: true })).toHaveCount(0); }); -test('SSH-config hosts are read-only imports', async ({ page }) => { +test('an SSH-config host is adopted by editing it', async ({ page }) => { await boot(page); - // The imported host is marked and offers no edit/delete affordances (§4.1). + // The import is marked, and there is nothing here to delete: it lives in + // ~/.ssh/config, which this app never writes. await expect(page.getByText('ssh config')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Edit imported' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Delete imported' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Edit imported' }).click(); + const editor = page.getByRole('dialog', { name: 'Edit host' }); + await expect(editor).toBeVisible(); + // The form states what saving does, since the SSH config file itself does not change. + await expect(editor.getByText(/never written/)).toBeVisible(); + + await editor.getByLabel('Hostname / IP').fill('adopted.example.com'); + await editor.getByRole('button', { name: 'Save' }).click(); + + // Saved as a manual copy: the import badge is gone and delete is now offered. + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText('root@adopted.example.com:22')).toBeVisible(); + await expect(page.getByText('ssh config')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Delete imported' })).toHaveCount(1); }); test('rejects a new host whose name already exists', async ({ page }) => { diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index 6c3af67..f9315d1 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -30,11 +30,12 @@ async reloadHosts() : Promise> { } }, /** - * Add or edit a **manual** host and persist to `hosts.toml` (tech-gui.md §4.2, Stage - * 4.1). Upserts by name; SSH-config hosts are read-only imports and are never written - * (this operates on the manual `hosts.toml` alone). The frontend calls `reload_hosts` - * afterwards to refresh the merged cache + restart the pollers. Secrets stay - * backend-side (§3.4): the payload's password/identity never left the backend. + * Add or edit a host and persist to `hosts.toml` (tech-gui.md §4.2, Stage 4.1). + * Upserts by name. Editing an SSH-config import adopts it: the saved copy is a manual + * entry that `merge_hosts` then prefers over the parsed one, so `~/.ssh/config` itself + * is still never written. The frontend calls `reload_hosts` afterwards to refresh the + * merged cache + restart the pollers. Secrets stay backend-side (§3.4): the payload's + * password/identity never left the backend. */ async saveHost(input: HostInputDto) : Promise> { try { @@ -437,8 +438,9 @@ export type FilePreview = { sessionId: number; path: string; content: string } */ export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null } /** - * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Builds a - * **manual** `Host` — SSH-config hosts are read-only imports and are never saved. + * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always + * builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows + * it, and `~/.ssh/config` itself is never written. * `password`/`identityFile` arrive here (the create/edit form owns them) but never * travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it * derives `Deserialize` (not `Serialize`). diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte index 9406fba..a8012cf 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte @@ -3,8 +3,9 @@ // detected services. Colour is reserved for semantic state — the header dot and // the metric fills read from `statusToken`; everything else is ink-on-paper. The // per-card `sh`/`files` buttons are the host-first spawn path (§2). Host management - // (add/edit/delete of manual hosts, §4.1) lives here — there is no separate Hosts - // screen (§2) — with SSH-config hosts shown read-only. + // (add/edit/delete, §4.1) lives here — there is no separate Hosts screen (§2). + // Editing an SSH-config host adopts it into hosts.toml; the file itself is never + // written, so only Delete stays manual-only. import { get } from 'svelte/store'; import type { HostDto, HostInputDto } from '$lib/bindings'; import { Surface, Chip, StatusDot, Icon, Button, statusToken } from '$lib/theme'; @@ -204,7 +205,7 @@ {#if card.host.source === 'sshConfig'} ssh config @@ -247,6 +248,10 @@ {action.label} {/each} + {#if card.host.source === 'manual' && !card.host.hasKey} {/if} + + {#if card.host.source === 'manual'} - diff --git a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte index 50a9fb2..5e1683f 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte @@ -1,6 +1,6 @@