Compare commits

..

No commits in common. "872fb0e80ed258b7af13614a64f2c6d27673a702" and "a24f7de7dfccbcfed520cc1b1fe1ddcba79a7775" have entirely different histories.

16 changed files with 173 additions and 1779 deletions

448
src-tauri/Cargo.lock generated
View file

@ -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",
]

View file

@ -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"
]
}

View file

@ -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<Vec<String>, String> {
pub async fn spawn_pane(
app: AppHandle,
manager: tauri::State<'_, PtyManager>,
spec: SpawnSpec,
distro: Option<String>,
cwd: Option<String>,
cols: u16,
rows: u16,
) -> Result<PaneId, String> {
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<Option<String>, 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<Vec<SshHost>, String> {
hosts::load(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
}

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(
default,
rename = "identityFile",
skip_serializing_if = "Option::is_none"
)]
pub identity_file: Option<String>,
#[serde(
default,
rename = "jumpHost",
skip_serializing_if = "Option::is_none"
)]
pub jump_host: Option<String>,
#[serde(
default,
rename = "extraArgs",
skip_serializing_if = "Option::is_none"
)]
pub extra_args: Option<Vec<String>>,
}
fn hosts_path(app: &AppHandle) -> Result<PathBuf> {
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<Vec<SshHost>> {
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<SshHost> = 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(())
}

View file

@ -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");

View file

@ -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<String>,
cwd: Option<String>,
},
Powershell,
Ssh {
host: String,
user: Option<String>,
port: Option<u16>,
#[serde(rename = "identityFile")]
identity_file: Option<String>,
#[serde(rename = "jumpHost")]
jump_host: Option<String>,
#[serde(rename = "extraArgs")]
extra_args: Option<Vec<String>>,
},
}
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 <distro>`, optionally `--cd <cwd>`).
/// 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<String>,
cwd: Option<String>,
cols: u16,
rows: u16,
) -> Result<PaneId> {
@ -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\<you>, which shows up as /mnt/c/Users/<you> 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\<you>, which shows up as /mnt/c/Users/<you> 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.

View file

@ -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<LeafNode> {
if (d.shellKind === "powershell") return { shellKind: "powershell" };
return { shellKind: "wsl", distro: d.distro };
}
export default function App() {
// ---- top-level state -----------------------------------------------------
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
const [distros, setDistros] = useState<string[]>([]);
const [defaultShell, setDefaultShell] = useState<DefaultShell>({
shellKind: "wsl",
});
const [hosts, setHosts] = useState<SshHost[]>([]);
const [hostManagerOpen, setHostManagerOpen] = useState(false);
const [defaultDistro, setDefaultDistro] = useState<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
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 <input> (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<LeafNode>) => 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<LeafNode[]>(
@ -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 (
<div className="app">
<header className="titlebar">
<span className="label">tiletopia</span>
<span className="distros">
<span className="muted">default:</span>
{distros.length === 0 ? (
<span className="muted">no WSL distros</span>
<span className="muted">no distros enumerated</span>
) : (
distros.map((d) => (
<button
key={d}
className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
title="Set default shell for new panes"
>
{d}
</button>
))
<>
<span className="muted">default:</span>
{distros.map((d) => (
<button
key={d}
className={`distro-btn${d === defaultDistro ? " active" : ""}`}
onClick={() => setDefaultDistro(d)}
title="Set default distro for new panes"
>
{d}
</button>
))}
</>
)}
<button
className={`distro-btn${isDefaultPowershell ? " active" : ""}`}
onClick={() => setDefaultShell({ shellKind: "powershell" })}
title="Default new panes to PowerShell"
>
PowerShell
</button>
<button
className="distro-btn"
onClick={openHostManager}
title="Add, edit, or remove saved SSH hosts"
>
🔑 SSH hosts
</button>
</span>
<span className="presets">
@ -710,48 +646,15 @@ export default function App() {
onClose={() => setPaletteOpen(false)}
/>
)}
{hostManagerOpen && (
<HostManager
hosts={hosts}
onSave={saveHosts}
onClose={closeHostManager}
/>
)}
</div>
);
}
/** 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<LeafNode> {
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);
}
}

View file

@ -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;
}

View file

@ -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<SshHost[]>(() => hosts.map((h) => ({ ...h })));
// Which row is being edited. null = list view only.
const [editingId, setEditingId] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(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 (
<div className="host-mgr-overlay" onClick={onClose}>
<div
className="host-mgr-panel"
ref={dialogRef}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Manage SSH hosts"
>
<header className="host-mgr-header">
<span className="host-mgr-title">SSH hosts</span>
<button className="host-mgr-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="host-mgr-body">
{draft.length === 0 ? (
<p className="host-mgr-empty">
No saved hosts. Click <strong>Add host</strong> to create one.
</p>
) : (
<ul className="host-mgr-list">
{draft.map((h) => (
<li key={h.id} className="host-row">
{editingId === h.id ? (
<form className="host-form" onSubmit={(e) => saveRow(h.id, e)}>
<label>
Label
<input
type="text"
value={h.label}
onChange={(e) =>
onFieldChange(h.id, "label", e.target.value)
}
placeholder="prod-web"
autoFocus
/>
</label>
<label>
Hostname <span className="required">*</span>
<input
type="text"
required
value={h.hostname}
onChange={(e) =>
onFieldChange(h.id, "hostname", e.target.value)
}
placeholder="example.com or 10.0.0.5"
/>
</label>
<div className="host-form-row">
<label>
User
<input
type="text"
value={h.user ?? ""}
onChange={(e) =>
onFieldChange(h.id, "user", e.target.value)
}
placeholder="(default)"
/>
</label>
<label className="host-form-port">
Port
<input
type="number"
min={1}
max={65535}
value={h.port ?? ""}
onChange={(e) =>
onFieldChange(h.id, "port", e.target.value)
}
placeholder="22"
/>
</label>
</div>
<label>
Identity file
<input
type="text"
value={h.identityFile ?? ""}
onChange={(e) =>
onFieldChange(h.id, "identityFile", e.target.value)
}
placeholder="(uses ssh-agent / default)"
/>
</label>
<label>
Jump host
<input
type="text"
value={h.jumpHost ?? ""}
onChange={(e) =>
onFieldChange(h.id, "jumpHost", e.target.value)
}
placeholder="user@bastion[:port]"
/>
</label>
<label>
Extra ssh args
<input
type="text"
value={(h.extraArgs ?? []).join(" ")}
onChange={(e) =>
onFieldChange(h.id, "extraArgs", e.target.value)
}
placeholder="-o ServerAliveInterval=30"
/>
</label>
<div className="host-form-actions">
<button type="submit" className="primary">
Save
</button>
<button
type="button"
onClick={cancelEdit}
>
Cancel
</button>
<button
type="button"
className="danger"
onClick={() => removeRow(h.id)}
>
Delete
</button>
</div>
</form>
) : (
<div className="host-display">
<div className="host-summary">
<div className="host-summary-label">
{h.label || h.hostname}
</div>
<div className="host-summary-detail">
{h.user ? `${h.user}@` : ""}
{h.hostname}
{h.port ? `:${h.port}` : ""}
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
</div>
</div>
<button
className="host-edit-btn"
onClick={() => startEdit(h.id)}
>
Edit
</button>
</div>
)}
</li>
))}
</ul>
)}
<button className="host-add-btn" onClick={addRow}>
+ Add host
</button>
</div>
</div>
</div>
);
}

View file

@ -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
}, []);

View file

@ -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<string[]> => invoke("list_distros");
export const spawnPane = (args: {
spec: SpawnSpec;
distro?: string;
cwd?: string;
cols: number;
rows: number;
}): Promise<PaneId> => invoke("spawn_pane", args);
@ -63,10 +38,3 @@ export const saveWorkspace = (json: string): Promise<void> =>
export const loadWorkspace = (): Promise<string | null> =>
invoke("load_workspace");
// ---- SSH hosts -------------------------------------------------------------
export const listSshHosts = (): Promise<SshHost[]> => invoke("list_ssh_hosts");
export const saveSshHosts = (hosts: SshHost[]): Promise<void> =>
invoke("save_ssh_hosts", { hosts });

View file

@ -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;

View file

@ -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 (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
@ -329,74 +271,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
<span className="distro-wrap">
<button
className="distro-chip"
onClick={toggleShellMenu}
title="Change shell (respawns the pane)"
onClick={toggleDistroMenu}
title="Change distro (respawns the pane)"
>
{chipLabel}
{leaf.distro ?? "(default)"}
</button>
{shellMenuOpen && (
{distroOpen && (
<div
className="distro-menu shell-menu"
className="distro-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{orch.distros.length > 0 && (
<>
<div className="shell-menu-header">WSL</div>
{orch.distros.map((d) => {
const active = leaf.shellKind === "wsl" && d === leaf.distro;
return (
<button
key={`wsl-${d}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
>
{d}
</button>
);
})}
</>
)}
<div className="shell-menu-header">Windows</div>
<button
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "powershell" })}
>
PowerShell
</button>
<div className="shell-menu-header">SSH</div>
{orch.hosts.length === 0 ? (
<div className="shell-menu-empty">(no saved hosts)</div>
) : (
orch.hosts.map((h) => {
const active =
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
return (
<button
key={`ssh-${h.id}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() =>
pickShell({ shellKind: "ssh", sshHostId: h.id })
}
title={
h.user
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
: `${h.hostname}${h.port ? ":" + h.port : ""}`
}
>
{h.label || h.hostname}
</button>
);
})
)}
<button
className="distro-menu-item shell-menu-manage"
onClick={onManageHosts}
>
Manage hosts
</button>
{orch.distros.map((d) => (
<button
key={d}
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
onClick={() => pickDistro(d)}
>
{d}
</button>
))}
</div>
)}
</span>
@ -462,26 +356,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</span>
</div>
<div className="xterm-wrap">
{spec ? (
<XtermPane
spec={spec}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
/>
) : (
<div className="leaf-missing-host">
<p>SSH host not found</p>
<p className="hint">
Open the shell menu and pick another host, or add this host back
via Manage hosts.
</p>
</div>
)}
<XtermPane
distro={leaf.distro}
cwd={leaf.cwd}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
/>
</div>
</div>
);

View file

@ -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;

View file

@ -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");
});
});

View file

@ -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<Omit<LeafNode, "kind" | "id">> = {}): 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<string, unknown>;