diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 13fe735..c490423 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -68,137 +68,6 @@ 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" @@ -294,19 +163,6 @@ 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" @@ -511,15 +367,6 @@ 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" @@ -903,33 +750,6 @@ 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" @@ -963,27 +783,6 @@ 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" @@ -1134,19 +933,6 @@ 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" @@ -1533,12 +1319,6 @@ 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" @@ -1847,25 +1627,6 @@ 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" @@ -2484,34 +2245,12 @@ 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" @@ -2547,12 +2286,6 @@ 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" @@ -2576,12 +2309,6 @@ 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" @@ -2664,17 +2391,6 @@ 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" @@ -2720,20 +2436,6 @@ 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" @@ -3764,28 +3466,6 @@ 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" @@ -3886,19 +3566,6 @@ 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" @@ -3983,7 +3650,7 @@ dependencies = [ [[package]] name = "tiletopia" -version = "0.2.3" +version = "0.2.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -3995,7 +3662,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", - "tauri-plugin-opener", "tokio", "tracing", "tracing-subscriber", @@ -4375,17 +4041,6 @@ 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" @@ -5472,67 +5127,6 @@ 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" @@ -5627,43 +5221,3 @@ 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 0b5585b..8071b47 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,13 +9,6 @@ "core:window:default", "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-text", - { - "identifier": "opener:allow-open-url", - "allow": [ - { "url": "http://*" }, - { "url": "https://*" }, - { "url": "mailto:*" } - ] - } + "opener:allow-open-url" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 30f7e77..db65aa5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,8 +3,7 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use tauri::{AppHandle, Manager}; -use crate::hosts::{self, SshHost}; -use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; +use crate::pty::{list_wsl_distros, PaneId, PtyManager}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -17,11 +16,14 @@ pub async fn list_distros() -> Result, String> { pub async fn spawn_pane( app: AppHandle, manager: tauri::State<'_, PtyManager>, - spec: SpawnSpec, + distro: Option, + cwd: Option, cols: u16, rows: u16, ) -> Result { - manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string()) + manager + .spawn_wsl(app, distro, cwd, cols, rows) + .map_err(|e| e.to_string()) } /// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits @@ -90,13 +92,3 @@ 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 deleted file mode 100644 index 588d782..0000000 --- a/src-tauri/src/hosts.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! 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 d4e6f2d..dfbf1aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,6 @@ //! Library entry point. `main.rs` calls `run()`. mod commands; -mod hosts; mod pty; use crate::pty::PtyManager; @@ -27,8 +26,6 @@ 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 9c17b50..117d97a 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -1,6 +1,6 @@ -//! 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. +//! 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. use std::collections::HashMap; use std::io::{Read, Write}; @@ -9,34 +9,15 @@ 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::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use serde::{Deserialize, Serialize}; +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; +use serde::Serialize; use tauri::{AppHandle, Emitter}; -pub type PaneId = u64; +/// 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"; -/// 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>, - }, -} +pub type PaneId = u64; /// What we keep alive for each spawned PTY. /// @@ -64,13 +45,14 @@ impl PtyManager { } } - /// 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( + /// 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( &self, app: AppHandle, - spec: SpawnSpec, + distro: Option, + cwd: Option, cols: u16, rows: u16, ) -> Result { @@ -84,7 +66,39 @@ impl PtyManager { }) .context("openpty failed")?; - let (cmd, spawn_err) = build_command(&spec)?; + 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 child = pair.slave.spawn_command(cmd).context(spawn_err)?; // We need to keep the master alive (drop = close the PTY), but we @@ -183,102 +197,6 @@ 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 737f362..f3c72c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,26 +3,22 @@ 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, - setLeafShell, + changeDistro, changeLabel, toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, @@ -48,39 +44,25 @@ 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; - -/** 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" }; +/** 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"; 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 [defaultShell, setDefaultShell] = useState({ - shellKind: "wsl", - }); - const [hosts, setHosts] = useState([]); - const [hostManagerOpen, setHostManagerOpen] = useState(false); + const [defaultDistro, setDefaultDistro] = useState(undefined); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -93,7 +75,7 @@ export default function App() { treeRef.current = tree; }, [tree]); - // ---- mount: load workspace + distros + hosts ---------------------------- + // ---- mount: load workspace + distros ------------------------------------ useEffect(() => { let cancelled = false; (async () => { @@ -118,39 +100,27 @@ export default function App() { } let resolvedDistros: string[] = []; + let resolvedDefault: string | undefined; try { resolvedDistros = await listDistros(); } catch (e) { console.warn("list_distros failed:", e); } - - 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" }; - })(); + // 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]; if (cancelled) return; if (loaded) { - if (initialDefault.shellKind === "wsl" && initialDefault.distro) { - backfillWslDistro(loaded, initialDefault.distro); - } + if (resolvedDefault) backfillDistro(loaded, resolvedDefault); setTree(loaded); - } else { - setTree(newLeaf(defaultShellAsLeafProps(initialDefault))); + } else if (resolvedDefault) { + setTree(newLeaf({ distro: resolvedDefault })); } setDistros(resolvedDistros); - setHosts(resolvedHosts); - setDefaultShell(initialDefault); + setDefaultDistro(resolvedDefault); setReady(true); })(); return () => { @@ -221,11 +191,13 @@ export default function App() { } setTree((t) => { const parent = findLeaf(t, leafId); - const inherit = inheritShellFromParent(parent, defaultShell); + const inherit = parent + ? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd } + : { distro: defaultDistro }; return splitLeaf(t, leafId, orientation, inherit); }); }, - [defaultShell, notify], + [defaultDistro, notify], ); const close = useCallback( @@ -235,17 +207,14 @@ export default function App() { void killPane(paneId).catch((e) => console.warn("killPane failed:", e)); paneIdByLeafRef.current.delete(leafId); } - setTree( - (t) => - closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)), - ); + setTree((t) => closeLeaf(t, leafId) ?? newLeaf({ distro: defaultDistro })); setActiveLeafId((cur) => (cur === leafId ? null : cur)); }, - [defaultShell], + [defaultDistro], ); - const setShell = useCallback((leafId: NodeId, spec: LeafShellSpec) => { - setTree((t) => setLeafShell(t, leafId, spec)); + const setDistro = useCallback((leafId: NodeId, distro: string) => { + setTree((t) => changeDistro(t, leafId, distro)); }, []); const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { @@ -260,15 +229,6 @@ 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 @@ -462,13 +422,11 @@ export default function App() { () => ({ activeLeafId, distros, - hosts, split, close, - setShell, + setDistro, setLabel, toggleBroadcast, - openHostManager, setActive, registerPaneId, broadcastFrom, @@ -483,13 +441,11 @@ export default function App() { [ activeLeafId, distros, - hosts, split, close, - setShell, + setDistro, setLabel, toggleBroadcast, - openHostManager, setActive, registerPaneId, broadcastFrom, @@ -504,12 +460,10 @@ export default function App() { ); const applyPreset = useCallback( - (make: (d: Partial) => TreeNode) => { - const { tree: nextTree, dropped } = reshapeToPreset( - tree, - make, - defaultShellAsLeafProps(defaultShell), - ); + (make: (d: { distro?: string }) => TreeNode) => { + const { tree: nextTree, dropped } = reshapeToPreset(tree, make, { + distro: defaultDistro, + }); if (dropped.length > 0) { const ok = window.confirm( @@ -533,7 +487,7 @@ export default function App() { setTree(nextTree); }, - [tree, defaultShell, activeLeafId], + [tree, defaultDistro, activeLeafId], ); const paletteLeaves = useMemo( @@ -579,47 +533,29 @@ 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 WSL distros + no distros enumerated ) : ( - distros.map((d) => ( - - )) + <> + default: + {distros.map((d) => ( + + ))} + )} - - @@ -710,48 +646,15 @@ export default function App() { onClose={() => setPaletteOpen(false)} /> )} - - {hostManagerOpen && ( - - )}
); } -/** 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) { +function backfillDistro(node: TreeNode, fallback: string) { if (node.kind === "leaf") { - if (node.shellKind === "wsl" && !node.distro) node.distro = fallback; + if (!node.distro) node.distro = fallback; } else { - backfillWslDistro(node.a, fallback); - backfillWslDistro(node.b, fallback); + backfillDistro(node.a, fallback); + backfillDistro(node.b, fallback); } } diff --git a/src/components/HostManager.css b/src/components/HostManager.css deleted file mode 100644 index 9fe4cde..0000000 --- a/src/components/HostManager.css +++ /dev/null @@ -1,209 +0,0 @@ -.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 deleted file mode 100644 index 4e81b73..0000000 --- a/src/components/HostManager.tsx +++ /dev/null @@ -1,301 +0,0 @@ -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 36f5839..7222800 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -16,7 +16,6 @@ import { onPaneData, onPaneExit, type PaneId, - type SpawnSpec, } from "../ipc"; // --------------------------------------------------------------------------- @@ -46,10 +45,8 @@ function stringToB64(s: string): string { // --------------------------------------------------------------------------- interface XtermPaneProps { - /** 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; + distro?: string; + cwd?: string; onStatus?: (msg: string, ok: boolean) => void; /** Fired once when the backend PTY is alive and we have its PaneId. */ onSpawn?: (paneId: PaneId) => void; @@ -72,7 +69,8 @@ const DEFAULT_XTERM_FONT_SIZE = 13; // --------------------------------------------------------------------------- export default function XtermPane({ - spec, + distro, + cwd, onStatus, onSpawn, onInput, @@ -154,7 +152,7 @@ export default function XtermPane({ const rows = term!.rows; try { - paneId = await spawnPane({ spec, cols, rows }); + paneId = await spawnPane({ distro, cwd, cols, rows }); if (destroyed) { void killPane(paneId); return; @@ -289,9 +287,8 @@ export default function XtermPane({ fitRef.current = null; paneIdRef.current = null; }; - // 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). + // 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. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/ipc.ts b/src/ipc.ts index 2cf8921..710663a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,36 +3,11 @@ 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: { - spec: SpawnSpec; + distro?: string; + cwd?: string; cols: number; rows: number; }): Promise => invoke("spawn_pane", args); @@ -63,10 +38,3 @@ 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 34d9d16..390de89 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -159,61 +159,6 @@ 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 6911a1d..e6308f2 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -7,10 +7,9 @@ import { type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; +import { type LeafNode, resolveFontSize } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; -import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; @@ -58,60 +57,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { [commitLabel, cancelLabel], ); - // ---- 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) => { + // ---- distro popover ---------------------------------------------------- + const [distroOpen, setDistroOpen] = useState(false); + const toggleDistroMenu = useCallback((e: MouseEvent) => { e.stopPropagation(); - setShellMenuOpen((v) => !v); + setDistroOpen((v) => !v); }, []); - 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); + const pickDistro = useCallback( + (d: string) => { + setDistroOpen(false); + if (d !== leaf.distro) orch.setDistro(leaf.id, d); }, - [orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId], - ); - const onManageHosts = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setShellMenuOpen(false); - orch.openHostManager(); - }, - [orch.openHostManager], + [orch.setDistro, leaf.id, leaf.distro], ); // Dismiss popover on outside click useEffect(() => { - if (!shellMenuOpen) return; - const onDocClick = () => setShellMenuOpen(false); + if (!distroOpen) return; + const onDocClick = () => setDistroOpen(false); window.addEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick); - }, [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)"); + }, [distroOpen]); // ---- idle detection ---------------------------------------------------- // Local boolean for the red border + status text on this pane; reported @@ -268,29 +233,6 @@ 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 (
- {shellMenuOpen && ( + {distroOpen && (
e.stopPropagation()} > - {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 ( - - ); - }) - )} - + {orch.distros.map((d) => ( + + ))}
)} @@ -462,26 +356,17 @@ 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 5e3f1f4..058ad5c 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, LeafShellSpec } from "./tree"; -import type { PaneId, SshHost } from "../../ipc"; +import type { Orientation, NodeId } from "./tree"; +import type { PaneId } from "../../ipc"; /** * Orchestration context — every piece of shared state and every operation @@ -15,26 +15,15 @@ import type { PaneId, SshHost } 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; - /** 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; + setDistro: (leafId: NodeId, distro: string) => 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 0e028bf..031642f 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -9,7 +9,6 @@ import { leafCount, walkLeaves, changeDistro, - setLeafShell, changeLabel, toggleBroadcast, adjustFontSize, @@ -39,16 +38,14 @@ function leafDistros(root: TreeNode): (string | undefined)[] { } describe("newLeaf", () => { - it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => { + it("returns a leaf with a unique id and no extra 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(); }); @@ -59,14 +56,6 @@ 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", () => { @@ -243,11 +232,10 @@ describe("walkLeaves", () => { }); describe("changeDistro", () => { - 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("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("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => { @@ -266,52 +254,6 @@ 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(); @@ -524,41 +466,10 @@ describe("serialize / deserialize", () => { ).toBeNull(); // missing ratio + children }); - it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => { + it("accepts a minimal leaf shape", () => { 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 f2cb988..383deff 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -10,25 +10,13 @@ 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; - /** Discriminator: which shell-type this pane runs. */ - shellKind: ShellKind; - /** WSL distro the pane was spawned against. Only meaningful when - * shellKind === "wsl". */ + /** WSL distro the pane was spawned against. */ distro?: string; - /** Working directory the pane was started in. Only meaningful when - * shellKind === "wsl". */ + /** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */ cwd?: string; - /** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */ - sshHostId?: string; /** Optional user label shown in the pane toolbar. */ label?: string; /** @@ -72,47 +60,7 @@ function newId(): NodeId { } export function newLeaf(props: Partial> = {}): LeafNode { - 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; - }); + return { kind: "leaf", id: newId(), ...props }; } export function newSplit( @@ -180,18 +128,19 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null { } /** - * 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). + * 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. */ export function changeDistro( root: TreeNode, leafId: NodeId, distro: string, ): TreeNode { - return setLeafShell(root, leafId, { shellKind: "wsl", distro }); + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + return { ...node, id: newId(), distro }; + }); } /** Set or clear a leaf's label. Does NOT remount (label is metadata only). */ @@ -344,10 +293,8 @@ 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; @@ -571,38 +518,17 @@ export function serialize(root: TreeNode): string { return JSON.stringify(root); } -/** 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). */ +/** Parse JSON back to a tree. Returns null on invalid input. */ export function deserialize(json: string): TreeNode | null { try { const parsed = JSON.parse(json); if (!isTreeNode(parsed)) return null; - return migrateLegacyLeaves(parsed); + return 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;