From 4e5bc7e08174e8d1b89c87e984e973a46d42a5b7 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Mon, 25 May 2026 19:47:24 +0100 Subject: [PATCH 1/2] Scope opener plugin to http/https/mailto so clicks open the browser --- src-tauri/Cargo.lock | 448 +++++++++++++++++++++++++++- src-tauri/capabilities/default.json | 9 +- 2 files changed, 455 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c490423..13fe735 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -68,6 +68,137 @@ dependencies = [ "x11rb", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -163,6 +294,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -367,6 +511,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -750,6 +903,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -783,6 +963,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -933,6 +1134,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1319,6 +1533,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1627,6 +1847,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2245,12 +2484,34 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2286,6 +2547,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2309,6 +2576,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2391,6 +2664,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2436,6 +2720,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -3466,6 +3764,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -3566,6 +3886,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3650,7 +3983,7 @@ dependencies = [ [[package]] name = "tiletopia" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "base64 0.22.1", @@ -3662,6 +3995,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-opener", "tokio", "tracing", "tracing-subscriber", @@ -4041,6 +4375,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5127,6 +5472,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -5221,3 +5627,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8071b47..0b5585b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,13 @@ "core:window:default", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", - "opener:allow-open-url" + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "http://*" }, + { "url": "https://*" }, + { "url": "mailto:*" } + ] + } ] } From 872fb0e80ed258b7af13614a64f2c6d27673a702 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Mon, 25 May 2026 19:47:37 +0100 Subject: [PATCH 2/2] Add SSH connections: saved hosts manager and hierarchical shell picker --- src-tauri/src/commands.rs | 20 +- src-tauri/src/hosts.rs | 74 ++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/pty.rs | 178 +++++++++++++----- src/App.tsx | 195 +++++++++++++++----- src/components/HostManager.css | 209 +++++++++++++++++++++ src/components/HostManager.tsx | 301 +++++++++++++++++++++++++++++++ src/components/XtermPane.tsx | 17 +- src/ipc.ts | 36 +++- src/lib/layout/LeafPane.css | 55 ++++++ src/lib/layout/LeafPane.tsx | 191 ++++++++++++++++---- src/lib/layout/orchestration.tsx | 17 +- src/lib/layout/tree.test.ts | 101 ++++++++++- src/lib/layout/tree.ts | 98 ++++++++-- 14 files changed, 1324 insertions(+), 171 deletions(-) create mode 100644 src-tauri/src/hosts.rs create mode 100644 src/components/HostManager.css create mode 100644 src/components/HostManager.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index db65aa5..30f7e77 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,8 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use tauri::{AppHandle, Manager}; -use crate::pty::{list_wsl_distros, PaneId, PtyManager}; +use crate::hosts::{self, SshHost}; +use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -16,14 +17,11 @@ pub async fn list_distros() -> Result, String> { pub async fn spawn_pane( app: AppHandle, manager: tauri::State<'_, PtyManager>, - distro: Option, - cwd: Option, + spec: SpawnSpec, cols: u16, rows: u16, ) -> Result { - manager - .spawn_wsl(app, distro, cwd, cols, rows) - .map_err(|e| e.to_string()) + manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string()) } /// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits @@ -92,3 +90,13 @@ pub async fn load_workspace(app: AppHandle) -> Result, String> { let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?; Ok(Some(s)) } + +#[tauri::command] +pub async fn list_ssh_hosts(app: AppHandle) -> Result, String> { + hosts::load(&app).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec) -> Result<(), String> { + crate::hosts::save(&app, &hosts).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/hosts.rs b/src-tauri/src/hosts.rs new file mode 100644 index 0000000..588d782 --- /dev/null +++ b/src-tauri/src/hosts.rs @@ -0,0 +1,74 @@ +//! Saved SSH hosts. Persisted to `%APPDATA%\com.megaproxy.tiletopia\hosts.json` +//! alongside `workspace.json`. The frontend owns the in-memory state and the +//! add/edit/delete UX; the backend just reads/writes the whole list. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +const HOSTS_FILE: &str = "hosts.json"; + +/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall +/// back to `~/.ssh/config` and its own defaults for anything we don't pass. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshHost { + pub id: String, + pub label: String, + pub hostname: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde( + default, + rename = "identityFile", + skip_serializing_if = "Option::is_none" + )] + pub identity_file: Option, + #[serde( + default, + rename = "jumpHost", + skip_serializing_if = "Option::is_none" + )] + pub jump_host: Option, + #[serde( + default, + rename = "extraArgs", + skip_serializing_if = "Option::is_none" + )] + pub extra_args: Option>, +} + +fn hosts_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; + Ok(dir.join(HOSTS_FILE)) +} + +pub fn load(app: &AppHandle) -> Result> { + let path = hosts_path(app)?; + if !path.exists() { + return Ok(Vec::new()); + } + let raw = std::fs::read_to_string(&path).context("read hosts.json")?; + let hosts: Vec = serde_json::from_str(&raw).context("parse hosts.json")?; + Ok(hosts) +} + +pub fn save(app: &AppHandle, hosts: &[SshHost]) -> Result<()> { + let path = hosts_path(app)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).context("create_dir_all")?; + } + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(hosts).context("serialize hosts")?; + std::fs::write(&tmp, json.as_bytes()).context("write tmp hosts.json")?; + // `std::fs::rename` is atomic on Unix and uses MoveFileEx with + // REPLACE_EXISTING on Windows — same pattern as save_workspace. + std::fs::rename(&tmp, &path).context("rename hosts.json")?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfbf1aa..d4e6f2d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! Library entry point. `main.rs` calls `run()`. mod commands; +mod hosts; mod pty; use crate::pty::PtyManager; @@ -26,6 +27,8 @@ pub fn run() { commands::kill_pane, commands::save_workspace, commands::load_workspace, + commands::list_ssh_hosts, + commands::save_ssh_hosts, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 117d97a..9c17b50 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -1,6 +1,6 @@ -//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty, -//! reads its output on a background thread, and forwards chunks to the -//! frontend as `pane://{id}/data` events. +//! PTY backend. Spawns a shell (`wsl.exe`, `powershell.exe`, or `ssh.exe`) +//! through portable-pty, reads its output on a background thread, and +//! forwards chunks to the frontend as `pane://{id}/data` events. use std::collections::HashMap; use std::io::{Read, Write}; @@ -9,16 +9,35 @@ use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use parking_lot::Mutex; -use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; -use serde::Serialize; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; -/// Sentinel "distro" name used to spawn Windows PowerShell instead of WSL. -/// Frontend appends this to the distro list it shows in the dropdown. -pub const POWERSHELL_DISTRO: &str = "PowerShell"; - pub type PaneId = u64; +/// Discriminated union describing what to spawn into a fresh PTY. Serialized +/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SpawnSpec { + Wsl { + distro: Option, + cwd: Option, + }, + Powershell, + Ssh { + host: String, + user: Option, + port: Option, + #[serde(rename = "identityFile")] + identity_file: Option, + #[serde(rename = "jumpHost")] + jump_host: Option, + #[serde(rename = "extraArgs")] + extra_args: Option>, + }, +} + /// What we keep alive for each spawned PTY. /// /// `master` stays in scope to keep the PTY alive; we never write through it @@ -45,14 +64,13 @@ impl PtyManager { } } - /// Spawn `wsl.exe` (optionally `-d `, optionally `--cd `). - /// Returns the new pane id. A background thread starts reading the PTY - /// immediately and emits `pane://{id}/data` events. - pub fn spawn_wsl( + /// Spawn the shell described by `spec` into a fresh PTY. Returns the + /// new pane id; a background thread immediately starts reading and + /// emits `pane://{id}/data` events. + pub fn spawn( &self, app: AppHandle, - distro: Option, - cwd: Option, + spec: SpawnSpec, cols: u16, rows: u16, ) -> Result { @@ -66,39 +84,7 @@ impl PtyManager { }) .context("openpty failed")?; - let is_powershell = distro.as_deref() == Some(POWERSHELL_DISTRO); - - let cmd = if is_powershell { - // cwd from the leaf is ignored — leaves may carry Linux-style - // paths (e.g. `~`, `/mnt/d/...`) from a previously-assigned WSL - // distro that PowerShell wouldn't understand. PowerShell starts - // in its own default cwd; user can `cd` if they want. - let mut c = CommandBuilder::new("powershell.exe"); - c.arg("-NoLogo"); - c - } else { - let mut c = CommandBuilder::new("wsl.exe"); - if let Some(d) = distro.as_deref() { - c.arg("-d"); - c.arg(d); - } - // Default new panes to the WSL user's home (~) rather than the - // Windows-side cwd we inherit from the launcher (typically - // C:\Users\, which shows up as /mnt/c/Users/ inside WSL). - // wsl.exe resolves `~` against the distro's default shell. - let resolved_cwd = cwd.as_deref().unwrap_or("~"); - c.arg("--cd"); - c.arg(resolved_cwd); - // wsl.exe without an explicit command launches the default shell - // interactively, which is exactly what we want. - c - }; - - let spawn_err = if is_powershell { - "failed to spawn powershell.exe" - } else { - "failed to spawn wsl.exe; is WSL installed?" - }; + let (cmd, spawn_err) = build_command(&spec)?; let child = pair.slave.spawn_command(cmd).context(spawn_err)?; // We need to keep the master alive (drop = close the PTY), but we @@ -197,6 +183,102 @@ struct DataChunk { b64: String, } +// ---- command construction --------------------------------------------------- + +/// Reject hostnames / usernames that would let an attacker smuggle in a +/// flag (`-oProxyCommand=...`) or a shell metacharacter via OpenSSH's token +/// expansion. We additionally pass `--` before the host on the command line, +/// but rejecting up front gives a clearer error and avoids ever handing the +/// bad value to ssh.exe. +fn validate_ssh_token(label: &str, value: &str) -> Result<()> { + if value.is_empty() { + return Err(anyhow!("ssh: {label} must not be empty")); + } + if value.starts_with('-') { + return Err(anyhow!("ssh: {label} must not start with '-' (got {value:?})")); + } + if value.chars().any(|c| c.is_control() || c == '\n' || c == '\r') { + return Err(anyhow!("ssh: {label} must not contain control characters")); + } + Ok(()) +} + +fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> { + match spec { + SpawnSpec::Wsl { distro, cwd } => { + let mut c = CommandBuilder::new("wsl.exe"); + if let Some(d) = distro.as_deref() { + c.arg("-d"); + c.arg(d); + } + // Default new panes to the WSL user's home (~) rather than the + // Windows-side cwd we inherit from the launcher (typically + // C:\Users\, which shows up as /mnt/c/Users/ inside WSL). + // wsl.exe resolves `~` against the distro's default shell. + let resolved_cwd = cwd.as_deref().unwrap_or("~"); + c.arg("--cd"); + c.arg(resolved_cwd); + Ok((c, "failed to spawn wsl.exe; is WSL installed?")) + } + SpawnSpec::Powershell => { + // cwd intentionally ignored — see commit history. + let mut c = CommandBuilder::new("powershell.exe"); + c.arg("-NoLogo"); + Ok((c, "failed to spawn powershell.exe")) + } + SpawnSpec::Ssh { + host, + user, + port, + identity_file, + jump_host, + extra_args, + } => { + validate_ssh_token("host", host)?; + if let Some(u) = user.as_deref() { + validate_ssh_token("user", u)?; + } + if let Some(jh) = jump_host.as_deref() { + validate_ssh_token("jump host", jh)?; + } + + let mut c = CommandBuilder::new("ssh.exe"); + // ssh would auto-detect a tty here, but force it explicitly so + // remote-side TUI apps don't accidentally see a non-tty stdin. + c.arg("-t"); + if let Some(u) = user.as_deref() { + c.arg("-l"); + c.arg(u); + } + if let Some(p) = port { + c.arg("-p"); + c.arg(p.to_string()); + } + if let Some(idf) = identity_file.as_deref() { + c.arg("-i"); + c.arg(idf); + } + if let Some(jh) = jump_host.as_deref() { + c.arg("-J"); + c.arg(jh); + } + if let Some(extra) = extra_args.as_deref() { + for a in extra { + c.arg(a); + } + } + // `--` ends option parsing — a hostname starting with `-` can't + // smuggle in flags via OpenSSH's option parser. + c.arg("--"); + c.arg(host); + // Some Windows OpenSSH builds otherwise advertise a TERM the + // remote side doesn't recognise; xterm.js speaks xterm-256color. + c.env("TERM", "xterm-256color"); + Ok((c, "failed to spawn ssh.exe; is OpenSSH installed?")) + } + } +} + // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. diff --git a/src/App.tsx b/src/App.tsx index f3c72c9..737f362 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,22 +3,26 @@ import { listDistros, loadWorkspace, saveWorkspace, + listSshHosts, + saveSshHosts, writeToPane, killPane, type PaneId, + type SshHost, } from "./ipc"; import { type TreeNode, type NodeId, type Orientation, type LeafNode, + type LeafShellSpec, newLeaf, splitLeaf, closeLeaf, findLeaf, leafCount, walkLeaves, - changeDistro, + setLeafShell, changeLabel, toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, @@ -44,25 +48,39 @@ import LeafPane from "./lib/layout/LeafPane"; import Gutter from "./lib/layout/Gutter"; import Notifications, { type Toast } from "./components/Notifications"; import Palette from "./components/Palette"; +import HostManager from "./components/HostManager"; import "./App.css"; import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; const SAVE_DEBOUNCE_MS = 500; -/** Sentinel "distro" the backend recognises to spawn powershell.exe instead - * of wsl.exe. Must match `POWERSHELL_DISTRO` in `src-tauri/src/pty.rs`. */ -const POWERSHELL_DISTRO = "PowerShell"; + +/** Picker default for *new* panes. SSH never lives here — SSH connections + * are always explicit, never a default. */ +type DefaultShell = + | { shellKind: "wsl"; distro?: string } + | { shellKind: "powershell" }; function isInteractiveDistro(name: string): boolean { return !name.toLowerCase().startsWith("docker-desktop"); } +/** Map a {@link DefaultShell} onto the props newLeaf expects. */ +function defaultShellAsLeafProps(d: DefaultShell): Partial { + if (d.shellKind === "powershell") return { shellKind: "powershell" }; + return { shellKind: "wsl", distro: d.distro }; +} + export default function App() { // ---- top-level state ----------------------------------------------------- const [tree, setTree] = useState(() => newLeaf()); const [activeLeafId, setActiveLeafId] = useState(null); const [distros, setDistros] = useState([]); - const [defaultDistro, setDefaultDistro] = useState(undefined); + const [defaultShell, setDefaultShell] = useState({ + shellKind: "wsl", + }); + const [hosts, setHosts] = useState([]); + const [hostManagerOpen, setHostManagerOpen] = useState(false); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -75,7 +93,7 @@ export default function App() { treeRef.current = tree; }, [tree]); - // ---- mount: load workspace + distros ------------------------------------ + // ---- mount: load workspace + distros + hosts ---------------------------- useEffect(() => { let cancelled = false; (async () => { @@ -100,27 +118,39 @@ export default function App() { } let resolvedDistros: string[] = []; - let resolvedDefault: string | undefined; try { resolvedDistros = await listDistros(); } catch (e) { console.warn("list_distros failed:", e); } - // Append PowerShell as a pseudo-distro so it appears in the titlebar - // default-picker and the per-pane dropdown. - resolvedDistros = [...resolvedDistros, POWERSHELL_DISTRO]; - resolvedDefault = - resolvedDistros.find(isInteractiveDistro) ?? resolvedDistros[0]; + + let resolvedHosts: SshHost[] = []; + try { + resolvedHosts = await listSshHosts(); + } catch (e) { + console.warn("listSshHosts failed:", e); + } + + const initialDefault: DefaultShell = (() => { + const wslDefault = resolvedDistros.find(isInteractiveDistro); + if (wslDefault) return { shellKind: "wsl", distro: wslDefault }; + if (resolvedDistros.length > 0) return { shellKind: "wsl", distro: resolvedDistros[0] }; + // No WSL distros — fall back to PowerShell as default. + return { shellKind: "powershell" }; + })(); if (cancelled) return; if (loaded) { - if (resolvedDefault) backfillDistro(loaded, resolvedDefault); + if (initialDefault.shellKind === "wsl" && initialDefault.distro) { + backfillWslDistro(loaded, initialDefault.distro); + } setTree(loaded); - } else if (resolvedDefault) { - setTree(newLeaf({ distro: resolvedDefault })); + } else { + setTree(newLeaf(defaultShellAsLeafProps(initialDefault))); } setDistros(resolvedDistros); - setDefaultDistro(resolvedDefault); + setHosts(resolvedHosts); + setDefaultShell(initialDefault); setReady(true); })(); return () => { @@ -191,13 +221,11 @@ export default function App() { } setTree((t) => { const parent = findLeaf(t, leafId); - const inherit = parent - ? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd } - : { distro: defaultDistro }; + const inherit = inheritShellFromParent(parent, defaultShell); return splitLeaf(t, leafId, orientation, inherit); }); }, - [defaultDistro, notify], + [defaultShell, notify], ); const close = useCallback( @@ -207,14 +235,17 @@ export default function App() { void killPane(paneId).catch((e) => console.warn("killPane failed:", e)); paneIdByLeafRef.current.delete(leafId); } - setTree((t) => closeLeaf(t, leafId) ?? newLeaf({ distro: defaultDistro })); + setTree( + (t) => + closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)), + ); setActiveLeafId((cur) => (cur === leafId ? null : cur)); }, - [defaultDistro], + [defaultShell], ); - const setDistro = useCallback((leafId: NodeId, distro: string) => { - setTree((t) => changeDistro(t, leafId, distro)); + const setShell = useCallback((leafId: NodeId, spec: LeafShellSpec) => { + setTree((t) => setLeafShell(t, leafId, spec)); }, []); const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { @@ -229,6 +260,15 @@ export default function App() { setActiveLeafId(leafId); }, []); + const openHostManager = useCallback(() => setHostManagerOpen(true), []); + const closeHostManager = useCallback(() => setHostManagerOpen(false), []); + const saveHosts = useCallback((next: SshHost[]) => { + setHosts(next); + saveSshHosts(next).catch((e) => + console.warn("saveSshHosts failed:", e), + ); + }, []); + // ---- global keyboard shortcuts ------------------------------------------ // Capture phase beats xterm.js's own keystroke handlers. We intentionally // don't intercept when the user is typing into a regular (label @@ -422,11 +462,13 @@ export default function App() { () => ({ activeLeafId, distros, + hosts, split, close, - setDistro, + setShell, setLabel, toggleBroadcast, + openHostManager, setActive, registerPaneId, broadcastFrom, @@ -441,11 +483,13 @@ export default function App() { [ activeLeafId, distros, + hosts, split, close, - setDistro, + setShell, setLabel, toggleBroadcast, + openHostManager, setActive, registerPaneId, broadcastFrom, @@ -460,10 +504,12 @@ export default function App() { ); const applyPreset = useCallback( - (make: (d: { distro?: string }) => TreeNode) => { - const { tree: nextTree, dropped } = reshapeToPreset(tree, make, { - distro: defaultDistro, - }); + (make: (d: Partial) => TreeNode) => { + const { tree: nextTree, dropped } = reshapeToPreset( + tree, + make, + defaultShellAsLeafProps(defaultShell), + ); if (dropped.length > 0) { const ok = window.confirm( @@ -487,7 +533,7 @@ export default function App() { setTree(nextTree); }, - [tree, defaultDistro, activeLeafId], + [tree, defaultShell, activeLeafId], ); const paletteLeaves = useMemo( @@ -533,29 +579,47 @@ export default function App() { setPaletteOpen(false); }, []); + // Titlebar default-shell picker: WSL distros + a single PowerShell button. + // SSH never lives here — connections are always per-pane and explicit. + const isDefaultDistro = (d: string) => + defaultShell.shellKind === "wsl" && defaultShell.distro === d; + const isDefaultPowershell = defaultShell.shellKind === "powershell"; + return (
tiletopia + default: {distros.length === 0 ? ( - no distros enumerated + no WSL distros ) : ( - <> - default: - {distros.map((d) => ( - - ))} - + distros.map((d) => ( + + )) )} + + @@ -646,15 +710,48 @@ export default function App() { onClose={() => setPaletteOpen(false)} /> )} + + {hostManagerOpen && ( + + )}
); } -function backfillDistro(node: TreeNode, fallback: string) { +/** When splitting a leaf, the new sibling defaults to whatever the parent + * is running — so "split right" inside an Ubuntu pane gives you another + * Ubuntu pane, same SSH host gives you another connection to that host, + * etc. If no parent (shouldn't happen with current callers), fall back to + * the app-level default. */ +function inheritShellFromParent( + parent: LeafNode | null, + fallback: DefaultShell, +): Partial { + if (!parent) return defaultShellAsLeafProps(fallback); + if (parent.shellKind === "wsl") { + return { + shellKind: "wsl", + distro: parent.distro ?? (fallback.shellKind === "wsl" ? fallback.distro : undefined), + cwd: parent.cwd, + }; + } + if (parent.shellKind === "powershell") { + return { shellKind: "powershell" }; + } + return { shellKind: "ssh", sshHostId: parent.sshHostId }; +} + +/** For previously-saved workspaces written before shellKind existed: any + * WSL leaf without an explicit distro inherits the resolved default. */ +function backfillWslDistro(node: TreeNode, fallback: string) { if (node.kind === "leaf") { - if (!node.distro) node.distro = fallback; + if (node.shellKind === "wsl" && !node.distro) node.distro = fallback; } else { - backfillDistro(node.a, fallback); - backfillDistro(node.b, fallback); + backfillWslDistro(node.a, fallback); + backfillWslDistro(node.b, fallback); } } diff --git a/src/components/HostManager.css b/src/components/HostManager.css new file mode 100644 index 0000000..9fe4cde --- /dev/null +++ b/src/components/HostManager.css @@ -0,0 +1,209 @@ +.host-mgr-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} + +.host-mgr-panel { + background: #161616; + color: #ccc; + border: 1px solid #2a2a2a; + border-radius: 8px; + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.7); + width: min(620px, 96vw); + max-height: 86vh; + display: flex; + flex-direction: column; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; +} + +.host-mgr-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid #2a2a2a; +} +.host-mgr-title { + font-weight: 600; + font-size: 13px; +} +.host-mgr-close { + background: transparent; + border: none; + color: #888; + font-size: 18px; + line-height: 1; + padding: 2px 8px; + cursor: pointer; + border-radius: 3px; +} +.host-mgr-close:hover { + background: #2a2a2a; + color: #ddd; +} + +.host-mgr-body { + overflow-y: auto; + padding: 12px 14px; + flex: 1 1 auto; + min-height: 0; +} + +.host-mgr-empty { + color: #666; + font-size: 12px; + margin: 12px 0; +} + +.host-mgr-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.host-row { + background: #1c1c1c; + border: 1px solid #2a2a2a; + border-radius: 6px; + padding: 8px 10px; +} + +.host-display { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.host-summary-label { + font-weight: 600; + color: #e6e6e6; + font-size: 12px; +} +.host-summary-detail { + color: #888; + font-size: 11px; + margin-top: 1px; +} +.host-edit-btn { + background: #222; + color: #aac; + border: 1px solid #2a2a3a; + border-radius: 3px; + padding: 3px 10px; + font: inherit; + font-size: 11px; + cursor: pointer; +} +.host-edit-btn:hover { + background: #2a2a3a; + color: #cce; +} + +.host-form { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 11px; +} +.host-form label { + display: flex; + flex-direction: column; + gap: 2px; + color: #888; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.host-form input { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 12px; + background: #0c0c0c; + color: #e6e6e6; + border: 1px solid #2a2a2a; + border-radius: 3px; + padding: 4px 6px; + outline: none; + text-transform: none; + letter-spacing: normal; +} +.host-form input:focus { + border-color: #3a5a8c; +} +.host-form-row { + display: flex; + gap: 8px; +} +.host-form-row > label { + flex: 1 1 auto; +} +.host-form-port { + flex: 0 0 90px !important; +} +.host-form .required { + color: #d66; +} +.host-form-actions { + display: flex; + gap: 6px; + margin-top: 4px; +} +.host-form-actions button { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + padding: 4px 12px; + border-radius: 3px; + cursor: pointer; + background: #222; + color: #ccc; + border: 1px solid #2a2a2a; +} +.host-form-actions button:hover { + background: #2a2a2a; +} +.host-form-actions button.primary { + background: #1a3a5c; + color: #cce6ff; + border-color: #3a5a8c; +} +.host-form-actions button.primary:hover { + background: #245080; +} +.host-form-actions button.danger { + margin-left: auto; + color: #d88; + border-color: #3a1a1a; +} +.host-form-actions button.danger:hover { + background: #3a1a1a; + color: #fcc; +} + +.host-add-btn { + margin-top: 10px; + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + background: #1c1c1c; + color: #88c; + border: 1px dashed #3a3a4a; + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; + width: 100%; + text-align: center; +} +.host-add-btn:hover { + background: #222; + color: #aac; + border-color: #4a4a5a; +} diff --git a/src/components/HostManager.tsx b/src/components/HostManager.tsx new file mode 100644 index 0000000..4e81b73 --- /dev/null +++ b/src/components/HostManager.tsx @@ -0,0 +1,301 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type FormEvent, +} from "react"; +import type { SshHost } from "../ipc"; +import "./HostManager.css"; + +function newId(): string { + return ( + globalThis.crypto?.randomUUID?.() ?? + Math.random().toString(36).slice(2, 12) + ); +} + +function blankHost(): SshHost { + return { id: newId(), label: "", hostname: "" }; +} + +interface HostManagerProps { + hosts: SshHost[]; + /** Called when the user clicks Save on a row. Returns a fresh list (with + * the edit applied) to persist. The parent owns the canonical state. */ + onSave: (hosts: SshHost[]) => void; + onClose: () => void; +} + +export default function HostManager({ + hosts, + onSave, + onClose, +}: HostManagerProps) { + // Local editable copy. Any save / delete acts on this and pushes the + // whole list back up via onSave. + const [draft, setDraft] = useState(() => hosts.map((h) => ({ ...h }))); + // Which row is being edited. null = list view only. + const [editingId, setEditingId] = useState(null); + const dialogRef = useRef(null); + + // Escape closes; click outside the panel closes. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const startEdit = useCallback((id: string) => setEditingId(id), []); + const cancelEdit = useCallback(() => { + // Revert any unsaved edits to that row from props. + setDraft((cur) => + cur.map((h) => { + if (h.id !== editingId) return h; + const original = hosts.find((o) => o.id === editingId); + // Newly-added row that was never saved? Drop it entirely on cancel. + return original ?? h; + }).filter((h) => { + if (h.id !== editingId) return true; + return hosts.some((o) => o.id === editingId); + }), + ); + setEditingId(null); + }, [editingId, hosts]); + + const onFieldChange = useCallback( + (id: string, field: keyof SshHost, value: string) => { + setDraft((cur) => + cur.map((h) => { + if (h.id !== id) return h; + if (field === "port") { + if (value.trim() === "") return { ...h, port: undefined }; + const n = Number(value); + if (!Number.isFinite(n) || n < 1 || n > 65535) return h; + return { ...h, port: n }; + } + if (field === "extraArgs") { + const parts = value + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return { ...h, extraArgs: parts.length > 0 ? parts : undefined }; + } + if (value.trim() === "" && field !== "label" && field !== "hostname") { + const next = { ...h }; + delete next[field]; + return next; + } + return { ...h, [field]: value }; + }), + ); + }, + [], + ); + + const saveRow = useCallback( + (id: string, e: FormEvent) => { + e.preventDefault(); + const row = draft.find((h) => h.id === id); + if (!row) return; + if (!row.hostname.trim()) { + // Hostname is the only truly required field. Refuse the save instead + // of silently persisting a useless entry. + return; + } + // Auto-fill label from hostname if the user left it blank. + const cleaned: SshHost = { + ...row, + label: row.label.trim() || row.hostname.trim(), + hostname: row.hostname.trim(), + }; + const next = draft.map((h) => (h.id === id ? cleaned : h)); + setDraft(next); + onSave(next); + setEditingId(null); + }, + [draft, onSave], + ); + + const removeRow = useCallback( + (id: string) => { + const next = draft.filter((h) => h.id !== id); + setDraft(next); + onSave(next); + if (editingId === id) setEditingId(null); + }, + [draft, editingId, onSave], + ); + + const addRow = useCallback(() => { + const fresh = blankHost(); + setDraft((cur) => [...cur, fresh]); + setEditingId(fresh.id); + }, []); + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Manage SSH hosts" + > +
+ SSH hosts + +
+ +
+ {draft.length === 0 ? ( +

+ No saved hosts. Click Add host to create one. +

+ ) : ( +
    + {draft.map((h) => ( +
  • + {editingId === h.id ? ( +
    saveRow(h.id, e)}> + + +
    + + +
    + + + +
    + + + +
    +
    + ) : ( +
    +
    +
    + {h.label || h.hostname} +
    +
    + {h.user ? `${h.user}@` : ""} + {h.hostname} + {h.port ? `:${h.port}` : ""} + {h.jumpHost ? ` via ${h.jumpHost}` : ""} +
    +
    + +
    + )} +
  • + ))} +
+ )} + + +
+
+
+ ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 7222800..36f5839 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -16,6 +16,7 @@ import { onPaneData, onPaneExit, type PaneId, + type SpawnSpec, } from "../ipc"; // --------------------------------------------------------------------------- @@ -45,8 +46,10 @@ function stringToB64(s: string): string { // --------------------------------------------------------------------------- interface XtermPaneProps { - distro?: string; - cwd?: string; + /** Spec describing what to spawn into this pane's PTY. Read once at mount; + * changing it later does NOT respawn — callers force a respawn by + * changing the React `key` (see Pane.svelte / LeafPane). */ + spec: SpawnSpec; onStatus?: (msg: string, ok: boolean) => void; /** Fired once when the backend PTY is alive and we have its PaneId. */ onSpawn?: (paneId: PaneId) => void; @@ -69,8 +72,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13; // --------------------------------------------------------------------------- export default function XtermPane({ - distro, - cwd, + spec, onStatus, onSpawn, onInput, @@ -152,7 +154,7 @@ export default function XtermPane({ const rows = term!.rows; try { - paneId = await spawnPane({ distro, cwd, cols, rows }); + paneId = await spawnPane({ spec, cols, rows }); if (destroyed) { void killPane(paneId); return; @@ -287,8 +289,9 @@ export default function XtermPane({ fitRef.current = null; paneIdRef.current = null; }; - // distro/cwd are only used at spawn time; intentionally omitted from deps - // so remounting doesn't happen if a parent re-renders with the same values. + // spec is read once at mount; intentionally omitted from deps so we + // don't remount on parent re-renders. Callers force a respawn by + // bumping the React `key` (changeShell swaps the leaf id for that). // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/ipc.ts b/src/ipc.ts index 710663a..2cf8921 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,11 +3,36 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; export type PaneId = number; +/** What to spawn into a fresh PTY. Mirrors the Rust `SpawnSpec` enum. */ +export type SpawnSpec = + | { kind: "wsl"; distro?: string; cwd?: string } + | { kind: "powershell" } + | { + kind: "ssh"; + host: string; + user?: string; + port?: number; + identityFile?: string; + jumpHost?: string; + extraArgs?: string[]; + }; + +/** One saved SSH host. Mirrors the Rust `SshHost` struct. */ +export interface SshHost { + id: string; + label: string; + hostname: string; + user?: string; + port?: number; + identityFile?: string; + jumpHost?: string; + extraArgs?: string[]; +} + export const listDistros = (): Promise => invoke("list_distros"); export const spawnPane = (args: { - distro?: string; - cwd?: string; + spec: SpawnSpec; cols: number; rows: number; }): Promise => invoke("spawn_pane", args); @@ -38,3 +63,10 @@ export const saveWorkspace = (json: string): Promise => export const loadWorkspace = (): Promise => invoke("load_workspace"); + +// ---- SSH hosts ------------------------------------------------------------- + +export const listSshHosts = (): Promise => invoke("list_ssh_hosts"); + +export const saveSshHosts = (hosts: SshHost[]): Promise => + invoke("save_ssh_hosts", { hosts }); diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 390de89..34d9d16 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -159,6 +159,61 @@ color: #cce6ff; } +.shell-menu { + min-width: 200px; + max-height: 60vh; + overflow-y: auto; +} +.shell-menu-header { + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #666; + padding: 6px 8px 2px 8px; + margin-top: 2px; + border-top: 1px solid #2a2a2a; +} +.shell-menu-header:first-child { + border-top: none; + margin-top: 0; +} +.shell-menu-empty { + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 10px; + color: #555; + padding: 3px 8px; + font-style: italic; +} +.distro-menu-item.shell-menu-manage { + margin-top: 4px; + border-top: 1px solid #2a2a2a; + padding-top: 6px; + color: #88c; +} + +.leaf-missing-host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + padding: 16px; + background: #0c0c0c; + color: #d66; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 12px; +} +.leaf-missing-host p { + margin: 4px 0; +} +.leaf-missing-host .hint { + color: #888; + font-size: 11px; + max-width: 36ch; +} + .pane-status { margin-left: auto; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index e6308f2..6911a1d 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -7,9 +7,10 @@ import { type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import { type LeafNode, resolveFontSize } from "./tree"; +import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; +import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; @@ -57,26 +58,60 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { [commitLabel, cancelLabel], ); - // ---- distro popover ---------------------------------------------------- - const [distroOpen, setDistroOpen] = useState(false); - const toggleDistroMenu = useCallback((e: MouseEvent) => { + // ---- shell-picker popover ---------------------------------------------- + // Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH + // hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id + // (forces respawn). + const [shellMenuOpen, setShellMenuOpen] = useState(false); + const toggleShellMenu = useCallback((e: MouseEvent) => { e.stopPropagation(); - setDistroOpen((v) => !v); + setShellMenuOpen((v) => !v); }, []); - const pickDistro = useCallback( - (d: string) => { - setDistroOpen(false); - if (d !== leaf.distro) orch.setDistro(leaf.id, d); + const pickShell = useCallback( + (spec: LeafShellSpec) => { + setShellMenuOpen(false); + // Only respawn if the spec is actually different from what's running. + if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) { + return; + } + if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") { + return; + } + if ( + spec.shellKind === "ssh" && + leaf.shellKind === "ssh" && + spec.sshHostId === leaf.sshHostId + ) { + return; + } + orch.setShell(leaf.id, spec); }, - [orch.setDistro, leaf.id, leaf.distro], + [orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId], + ); + const onManageHosts = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setShellMenuOpen(false); + orch.openHostManager(); + }, + [orch.openHostManager], ); // Dismiss popover on outside click useEffect(() => { - if (!distroOpen) return; - const onDocClick = () => setDistroOpen(false); + if (!shellMenuOpen) return; + const onDocClick = () => setShellMenuOpen(false); window.addEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick); - }, [distroOpen]); + }, [shellMenuOpen]); + + // Label shown on the dropdown chip — tells the user what's currently + // running without expanding the menu. + const chipLabel = + leaf.shellKind === "powershell" + ? "PowerShell" + : leaf.shellKind === "ssh" + ? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}` + : (leaf.distro ?? "(default)"); // ---- idle detection ---------------------------------------------------- // Local boolean for the red border + status text on this pane; reported @@ -233,6 +268,29 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const labelText = leaf.label ?? "(unnamed)"; + // Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but + // the referenced host was deleted, we surface an error in the toolbar + // status instead of spawning an unrelated shell. + const spec: SpawnSpec | null = (() => { + if (leaf.shellKind === "wsl") { + return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd }; + } + if (leaf.shellKind === "powershell") { + return { kind: "powershell" }; + } + const host = orch.hosts.find((h) => h.id === leaf.sshHostId); + if (!host) return null; + return { + kind: "ssh", + host: host.hostname, + user: host.user, + port: host.port, + identityFile: host.identityFile, + jumpHost: host.jumpHost, + extraArgs: host.extraArgs, + }; + })(); + return (
- {distroOpen && ( + {shellMenuOpen && (
e.stopPropagation()} > - {orch.distros.map((d) => ( - - ))} + {orch.distros.length > 0 && ( + <> +
WSL
+ {orch.distros.map((d) => { + const active = leaf.shellKind === "wsl" && d === leaf.distro; + return ( + + ); + })} + + )} + +
Windows
+ + +
SSH
+ {orch.hosts.length === 0 ? ( +
(no saved hosts)
+ ) : ( + orch.hosts.map((h) => { + const active = + leaf.shellKind === "ssh" && h.id === leaf.sshHostId; + return ( + + ); + }) + )} +
)} @@ -356,17 +462,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
- + {spec ? ( + + ) : ( +
+

SSH host not found

+

+ Open the shell menu and pick another host, or add this host back + via Manage hosts…. +

+
+ )}
); diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index 058ad5c..5e3f1f4 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; -import type { Orientation, NodeId } from "./tree"; -import type { PaneId } from "../../ipc"; +import type { Orientation, NodeId, LeafShellSpec } from "./tree"; +import type { PaneId, SshHost } from "../../ipc"; /** * Orchestration context — every piece of shared state and every operation @@ -15,15 +15,26 @@ import type { PaneId } from "../../ipc"; export interface Orchestration { // Read-only state activeLeafId: NodeId | null; + /** WSL distros enumerated from `wsl.exe -l -q`. PowerShell is a separate + * shell kind, not in this list. */ distros: string[]; + /** Saved SSH hosts loaded from `hosts.json`. Reactive — changes when the + * user edits hosts via {@link openHostManager}. */ + hosts: SshHost[]; // Tree mutations split: (leafId: NodeId, orientation: Orientation) => void; close: (leafId: NodeId) => void; - setDistro: (leafId: NodeId, distro: string) => void; + /** Change the shell on a leaf (WSL distro / PowerShell / SSH host). + * Always forces a respawn — the helper in tree.ts swaps the leaf id so + * the renderer remounts XtermPane. */ + setShell: (leafId: NodeId, spec: LeafShellSpec) => void; setLabel: (leafId: NodeId, label: string | undefined) => void; toggleBroadcast: (leafId: NodeId) => void; + // SSH host management + openHostManager: () => void; + // Per-pane orchestration setActive: (leafId: NodeId) => void; registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void; diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 031642f..0e028bf 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -9,6 +9,7 @@ import { leafCount, walkLeaves, changeDistro, + setLeafShell, changeLabel, toggleBroadcast, adjustFontSize, @@ -38,14 +39,16 @@ function leafDistros(root: TreeNode): (string | undefined)[] { } describe("newLeaf", () => { - it("returns a leaf with a unique id and no extra metadata", () => { + it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => { const a = newLeaf(); const b = newLeaf(); expect(a.kind).toBe("leaf"); expect(typeof a.id).toBe("string"); expect(a.id).not.toEqual(b.id); + expect(a.shellKind).toBe("wsl"); expect(a.distro).toBeUndefined(); expect(a.cwd).toBeUndefined(); + expect(a.sshHostId).toBeUndefined(); expect(a.label).toBeUndefined(); expect(a.broadcast).toBeUndefined(); }); @@ -56,6 +59,14 @@ describe("newLeaf", () => { expect(leaf.cwd).toBe("/home"); expect(leaf.label).toBe("ml"); }); + + it("respects an explicit non-wsl shellKind", () => { + const ps = newLeaf({ shellKind: "powershell" }); + expect(ps.shellKind).toBe("powershell"); + const ssh = newLeaf({ shellKind: "ssh", sshHostId: "host-1" }); + expect(ssh.shellKind).toBe("ssh"); + expect(ssh.sshHostId).toBe("host-1"); + }); }); describe("newSplit", () => { @@ -232,10 +243,11 @@ describe("walkLeaves", () => { }); describe("changeDistro", () => { - it("sets the distro on the leaf", () => { - const leaf = newLeaf({ distro: "Ubuntu" }); - const next = changeDistro(leaf, leaf.id, "Debian"); - expect((next as LeafNode).distro).toBe("Debian"); + it("sets the distro on the leaf and forces shellKind back to wsl", () => { + const leaf = newLeaf({ shellKind: "powershell" }); + const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode; + expect(next.distro).toBe("Debian"); + expect(next.shellKind).toBe("wsl"); }); it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => { @@ -254,6 +266,52 @@ describe("changeDistro", () => { }); }); +describe("setLeafShell", () => { + it("switches a wsl leaf to powershell (and clears wsl-specific fields)", () => { + const leaf = newLeaf({ distro: "Ubuntu", cwd: "/work", label: "keep" }); + const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell" }) as LeafNode; + expect(next.shellKind).toBe("powershell"); + expect(next.distro).toBeUndefined(); + expect(next.cwd).toBeUndefined(); + expect(next.label).toBe("keep"); + }); + + it("switches a leaf to ssh and records sshHostId", () => { + const leaf = newLeaf({ distro: "Ubuntu" }); + const next = setLeafShell(leaf, leaf.id, { + shellKind: "ssh", + sshHostId: "host-abc", + }) as LeafNode; + expect(next.shellKind).toBe("ssh"); + expect(next.sshHostId).toBe("host-abc"); + expect(next.distro).toBeUndefined(); + }); + + it("MUST swap the leaf id (forces PTY respawn)", () => { + const leaf = newLeaf({ shellKind: "powershell" }); + const next = setLeafShell(leaf, leaf.id, { + shellKind: "ssh", + sshHostId: "h1", + }) as LeafNode; + expect(next.id).not.toBe(leaf.id); + }); + + it("preserves label / broadcast / fontSizeOffset across the shell change", () => { + const leaf = newLeaf({ + distro: "Ubuntu", + label: "my pane", + broadcast: true, + fontSizeOffset: 2, + }); + const next = setLeafShell(leaf, leaf.id, { + shellKind: "powershell", + }) as LeafNode; + expect(next.label).toBe("my pane"); + expect(next.broadcast).toBe(true); + expect(next.fontSizeOffset).toBe(2); + }); +}); + describe("changeLabel", () => { it("sets a label", () => { const leaf = newLeaf(); @@ -466,10 +524,41 @@ describe("serialize / deserialize", () => { ).toBeNull(); // missing ratio + children }); - it("accepts a minimal leaf shape", () => { + it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => { expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({ kind: "leaf", id: "x", + shellKind: "wsl", }); }); + + it("migrates legacy PowerShell-sentinel leaves to shellKind=powershell", () => { + const legacy = JSON.stringify({ + kind: "split", + id: "s1", + orientation: "h", + ratio: 0.5, + a: { kind: "leaf", id: "a", distro: "PowerShell" }, + b: { kind: "leaf", id: "b", distro: "Ubuntu" }, + }); + const back = deserialize(legacy) as SplitNode; + const left = back.a as LeafNode; + const right = back.b as LeafNode; + expect(left.shellKind).toBe("powershell"); + expect(left.distro).toBeUndefined(); + expect(right.shellKind).toBe("wsl"); + expect(right.distro).toBe("Ubuntu"); + }); + + it("leaves shellKind alone on already-migrated leaves", () => { + const fresh = JSON.stringify({ + kind: "leaf", + id: "x", + shellKind: "ssh", + sshHostId: "h-1", + }); + const back = deserialize(fresh) as LeafNode; + expect(back.shellKind).toBe("ssh"); + expect(back.sshHostId).toBe("h-1"); + }); }); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 383deff..f2cb988 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -10,13 +10,25 @@ export type NodeId = string; /** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */ export type Orientation = "h" | "v"; +/** What kind of shell a leaf is running. Determines which fields on + * LeafNode are meaningful at spawn time and which spawn-spec the backend + * receives. Migration on deserialize backfills this for pre-shellKind + * workspaces (PowerShell was previously a sentinel `distro` string). */ +export type ShellKind = "wsl" | "powershell" | "ssh"; + export interface LeafNode { kind: "leaf"; id: NodeId; - /** WSL distro the pane was spawned against. */ + /** Discriminator: which shell-type this pane runs. */ + shellKind: ShellKind; + /** WSL distro the pane was spawned against. Only meaningful when + * shellKind === "wsl". */ distro?: string; - /** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */ + /** Working directory the pane was started in. Only meaningful when + * shellKind === "wsl". */ cwd?: string; + /** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */ + sshHostId?: string; /** Optional user label shown in the pane toolbar. */ label?: string; /** @@ -60,7 +72,47 @@ function newId(): NodeId { } export function newLeaf(props: Partial> = {}): LeafNode { - return { kind: "leaf", id: newId(), ...props }; + return { kind: "leaf", id: newId(), shellKind: "wsl", ...props }; +} + +/** Spec for switching a leaf's shell. Discriminated by shellKind. Used by + * {@link setLeafShell}; the helper always swaps the leaf id so the renderer + * remounts XtermPane (kills the old PTY → spawns a fresh one with the new + * spec). */ +export type LeafShellSpec = + | { shellKind: "wsl"; distro?: string; cwd?: string } + | { shellKind: "powershell" } + | { shellKind: "ssh"; sshHostId: string }; + +/** + * Replace the leaf's shell-kind and shell-specific fields, then swap its id + * so the renderer's `key={leaf.id}` block remounts XtermPane (kills the old + * PTY → spawns a fresh one). Metadata like label / broadcast / font-size + * survives. + */ +export function setLeafShell( + root: TreeNode, + leafId: NodeId, + spec: LeafShellSpec, +): TreeNode { + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + const base: LeafNode = { + kind: "leaf", + id: newId(), + shellKind: spec.shellKind, + label: node.label, + broadcast: node.broadcast, + fontSizeOffset: node.fontSizeOffset, + }; + if (spec.shellKind === "wsl") { + if (spec.distro !== undefined) base.distro = spec.distro; + if (spec.cwd !== undefined) base.cwd = spec.cwd; + } else if (spec.shellKind === "ssh") { + base.sshHostId = spec.sshHostId; + } + return base; + }); } export function newSplit( @@ -128,19 +180,18 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null { } /** - * Swap the distro on a leaf. The leaf gets a **new id** so the rendering - * layer's `{#key node.id}` block remounts XtermPane — the old PTY is killed - * and a fresh one spawns with the new distro. + * Swap the WSL distro on a leaf. The leaf gets a **new id** so the rendering + * layer remounts XtermPane — the old PTY is killed and a fresh one spawns + * against the new distro. Also forces shellKind back to "wsl" if the leaf + * had been a non-WSL kind (which is what the existing per-pane dropdown + * does when the user picks a WSL distro entry). */ export function changeDistro( root: TreeNode, leafId: NodeId, distro: string, ): TreeNode { - return replaceById(root, leafId, (node) => { - if (node.kind !== "leaf") return node; - return { ...node, id: newId(), distro }; - }); + return setLeafShell(root, leafId, { shellKind: "wsl", distro }); } /** Set or clear a leaf's label. Does NOT remount (label is metadata only). */ @@ -293,8 +344,10 @@ export function reshapeToPreset( if (!src) break; const slot = slots[i]; slot.id = src.id; + slot.shellKind = src.shellKind; if (src.distro !== undefined) slot.distro = src.distro; if (src.cwd !== undefined) slot.cwd = src.cwd; + if (src.sshHostId !== undefined) slot.sshHostId = src.sshHostId; if (src.label !== undefined) slot.label = src.label; if (src.broadcast !== undefined) slot.broadcast = src.broadcast; if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset; @@ -518,17 +571,38 @@ export function serialize(root: TreeNode): string { return JSON.stringify(root); } -/** Parse JSON back to a tree. Returns null on invalid input. */ +/** Parse JSON back to a tree. Returns null on invalid input. Pre-shellKind + * workspaces are migrated in place: leaves without `shellKind` get one + * inferred from the legacy `distro` sentinel (`"PowerShell"` → powershell, + * anything else → wsl). */ export function deserialize(json: string): TreeNode | null { try { const parsed = JSON.parse(json); if (!isTreeNode(parsed)) return null; - return parsed; + return migrateLegacyLeaves(parsed); } catch { return null; } } +/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */ +const LEGACY_POWERSHELL_DISTRO = "PowerShell"; + +function migrateLegacyLeaves(node: TreeNode): TreeNode { + if (node.kind === "leaf") { + if (node.shellKind) return node; + if (node.distro === LEGACY_POWERSHELL_DISTRO) { + const { distro: _distro, ...rest } = node; + return { ...rest, shellKind: "powershell" }; + } + return { ...node, shellKind: "wsl" }; + } + const a = migrateLegacyLeaves(node.a); + const b = migrateLegacyLeaves(node.b); + if (a === node.a && b === node.b) return node; + return { ...node, a, b }; +} + function isTreeNode(x: unknown): x is TreeNode { if (typeof x !== "object" || x === null) return false; const o = x as Record;