Add SSH connections: saved hosts manager and hierarchical shell picker

This commit is contained in:
megaproxy 2026-05-25 19:47:37 +01:00
parent 4e5bc7e081
commit 872fb0e80e
14 changed files with 1324 additions and 171 deletions

View file

@ -3,7 +3,8 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::pty::{list_wsl_distros, PaneId, PtyManager}; use crate::hosts::{self, SshHost};
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
const WORKSPACE_FILE: &str = "workspace.json"; const WORKSPACE_FILE: &str = "workspace.json";
@ -16,14 +17,11 @@ pub async fn list_distros() -> Result<Vec<String>, String> {
pub async fn spawn_pane( pub async fn spawn_pane(
app: AppHandle, app: AppHandle,
manager: tauri::State<'_, PtyManager>, manager: tauri::State<'_, PtyManager>,
distro: Option<String>, spec: SpawnSpec,
cwd: Option<String>,
cols: u16, cols: u16,
rows: u16, rows: u16,
) -> Result<PaneId, String> { ) -> Result<PaneId, String> {
manager manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string())
.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 /// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits
@ -92,3 +90,13 @@ pub async fn load_workspace(app: AppHandle) -> Result<Option<String>, String> {
let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?; let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
Ok(Some(s)) 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())
}

74
src-tauri/src/hosts.rs Normal file
View file

@ -0,0 +1,74 @@
//! Saved SSH hosts. Persisted to `%APPDATA%\com.megaproxy.tiletopia\hosts.json`
//! alongside `workspace.json`. The frontend owns the in-memory state and the
//! add/edit/delete UX; the backend just reads/writes the whole list.
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
const HOSTS_FILE: &str = "hosts.json";
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshHost {
pub id: String,
pub label: String,
pub hostname: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<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,6 +1,7 @@
//! Library entry point. `main.rs` calls `run()`. //! Library entry point. `main.rs` calls `run()`.
mod commands; mod commands;
mod hosts;
mod pty; mod pty;
use crate::pty::PtyManager; use crate::pty::PtyManager;
@ -26,6 +27,8 @@ pub fn run() {
commands::kill_pane, commands::kill_pane,
commands::save_workspace, commands::save_workspace,
commands::load_workspace, commands::load_workspace,
commands::list_ssh_hosts,
commands::save_ssh_hosts,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -1,6 +1,6 @@
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty, //! PTY backend. Spawns a shell (`wsl.exe`, `powershell.exe`, or `ssh.exe`)
//! reads its output on a background thread, and forwards chunks to the //! through portable-pty, reads its output on a background thread, and
//! frontend as `pane://{id}/data` events. //! forwards chunks to the frontend as `pane://{id}/data` events.
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Read, Write}; use std::io::{Read, Write};
@ -9,16 +9,35 @@ use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use parking_lot::Mutex; use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::Serialize; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
/// Sentinel "distro" name used to spawn Windows PowerShell instead of WSL.
/// Frontend appends this to the distro list it shows in the dropdown.
pub const POWERSHELL_DISTRO: &str = "PowerShell";
pub type PaneId = u64; pub type PaneId = u64;
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SpawnSpec {
Wsl {
distro: Option<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>>,
},
}
/// What we keep alive for each spawned PTY. /// What we keep alive for each spawned PTY.
/// ///
/// `master` stays in scope to keep the PTY alive; we never write through it /// `master` stays in scope to keep the PTY alive; we never write through it
@ -45,14 +64,13 @@ impl PtyManager {
} }
} }
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`). /// Spawn the shell described by `spec` into a fresh PTY. Returns the
/// Returns the new pane id. A background thread starts reading the PTY /// new pane id; a background thread immediately starts reading and
/// immediately and emits `pane://{id}/data` events. /// emits `pane://{id}/data` events.
pub fn spawn_wsl( pub fn spawn(
&self, &self,
app: AppHandle, app: AppHandle,
distro: Option<String>, spec: SpawnSpec,
cwd: Option<String>,
cols: u16, cols: u16,
rows: u16, rows: u16,
) -> Result<PaneId> { ) -> Result<PaneId> {
@ -66,39 +84,7 @@ impl PtyManager {
}) })
.context("openpty failed")?; .context("openpty failed")?;
let is_powershell = distro.as_deref() == Some(POWERSHELL_DISTRO); let (cmd, spawn_err) = build_command(&spec)?;
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)?; let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
// We need to keep the master alive (drop = close the PTY), but we // We need to keep the master alive (drop = close the PTY), but we
@ -197,6 +183,102 @@ struct DataChunk {
b64: String, 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 ----------------------------------------------------- // ---- distro enumeration -----------------------------------------------------
/// Run a process without flashing a console window on Windows. /// Run a process without flashing a console window on Windows.

View file

@ -3,22 +3,26 @@ import {
listDistros, listDistros,
loadWorkspace, loadWorkspace,
saveWorkspace, saveWorkspace,
listSshHosts,
saveSshHosts,
writeToPane, writeToPane,
killPane, killPane,
type PaneId, type PaneId,
type SshHost,
} from "./ipc"; } from "./ipc";
import { import {
type TreeNode, type TreeNode,
type NodeId, type NodeId,
type Orientation, type Orientation,
type LeafNode, type LeafNode,
type LeafShellSpec,
newLeaf, newLeaf,
splitLeaf, splitLeaf,
closeLeaf, closeLeaf,
findLeaf, findLeaf,
leafCount, leafCount,
walkLeaves, walkLeaves,
changeDistro, setLeafShell,
changeLabel, changeLabel,
toggleBroadcast as toggleBroadcastInTree, toggleBroadcast as toggleBroadcastInTree,
setAllBroadcast, setAllBroadcast,
@ -44,25 +48,39 @@ import LeafPane from "./lib/layout/LeafPane";
import Gutter from "./lib/layout/Gutter"; import Gutter from "./lib/layout/Gutter";
import Notifications, { type Toast } from "./components/Notifications"; import Notifications, { type Toast } from "./components/Notifications";
import Palette from "./components/Palette"; import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import "./App.css"; import "./App.css";
import "./lib/layout/Gutter.css"; import "./lib/layout/Gutter.css";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
const SAVE_DEBOUNCE_MS = 500; const SAVE_DEBOUNCE_MS = 500;
/** Sentinel "distro" the backend recognises to spawn powershell.exe instead
* of wsl.exe. Must match `POWERSHELL_DISTRO` in `src-tauri/src/pty.rs`. */ /** Picker default for *new* panes. SSH never lives here SSH connections
const POWERSHELL_DISTRO = "PowerShell"; * are always explicit, never a default. */
type DefaultShell =
| { shellKind: "wsl"; distro?: string }
| { shellKind: "powershell" };
function isInteractiveDistro(name: string): boolean { function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop"); 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() { export default function App() {
// ---- top-level state ----------------------------------------------------- // ---- top-level state -----------------------------------------------------
const [tree, setTree] = useState<TreeNode>(() => newLeaf()); const [tree, setTree] = useState<TreeNode>(() => newLeaf());
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null); const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
const [distros, setDistros] = useState<string[]>([]); const [distros, setDistros] = useState<string[]>([]);
const [defaultDistro, setDefaultDistro] = useState<string | undefined>(undefined); const [defaultShell, setDefaultShell] = useState<DefaultShell>({
shellKind: "wsl",
});
const [hosts, setHosts] = useState<SshHost[]>([]);
const [hostManagerOpen, setHostManagerOpen] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]); const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
@ -75,7 +93,7 @@ export default function App() {
treeRef.current = tree; treeRef.current = tree;
}, [tree]); }, [tree]);
// ---- mount: load workspace + distros ------------------------------------ // ---- mount: load workspace + distros + hosts ----------------------------
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@ -100,27 +118,39 @@ export default function App() {
} }
let resolvedDistros: string[] = []; let resolvedDistros: string[] = [];
let resolvedDefault: string | undefined;
try { try {
resolvedDistros = await listDistros(); resolvedDistros = await listDistros();
} catch (e) { } catch (e) {
console.warn("list_distros failed:", e); console.warn("list_distros failed:", e);
} }
// Append PowerShell as a pseudo-distro so it appears in the titlebar
// default-picker and the per-pane dropdown. let resolvedHosts: SshHost[] = [];
resolvedDistros = [...resolvedDistros, POWERSHELL_DISTRO]; try {
resolvedDefault = resolvedHosts = await listSshHosts();
resolvedDistros.find(isInteractiveDistro) ?? resolvedDistros[0]; } catch (e) {
console.warn("listSshHosts failed:", e);
}
const initialDefault: DefaultShell = (() => {
const wslDefault = resolvedDistros.find(isInteractiveDistro);
if (wslDefault) return { shellKind: "wsl", distro: wslDefault };
if (resolvedDistros.length > 0) return { shellKind: "wsl", distro: resolvedDistros[0] };
// No WSL distros — fall back to PowerShell as default.
return { shellKind: "powershell" };
})();
if (cancelled) return; if (cancelled) return;
if (loaded) { if (loaded) {
if (resolvedDefault) backfillDistro(loaded, resolvedDefault); if (initialDefault.shellKind === "wsl" && initialDefault.distro) {
backfillWslDistro(loaded, initialDefault.distro);
}
setTree(loaded); setTree(loaded);
} else if (resolvedDefault) { } else {
setTree(newLeaf({ distro: resolvedDefault })); setTree(newLeaf(defaultShellAsLeafProps(initialDefault)));
} }
setDistros(resolvedDistros); setDistros(resolvedDistros);
setDefaultDistro(resolvedDefault); setHosts(resolvedHosts);
setDefaultShell(initialDefault);
setReady(true); setReady(true);
})(); })();
return () => { return () => {
@ -191,13 +221,11 @@ export default function App() {
} }
setTree((t) => { setTree((t) => {
const parent = findLeaf(t, leafId); const parent = findLeaf(t, leafId);
const inherit = parent const inherit = inheritShellFromParent(parent, defaultShell);
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
: { distro: defaultDistro };
return splitLeaf(t, leafId, orientation, inherit); return splitLeaf(t, leafId, orientation, inherit);
}); });
}, },
[defaultDistro, notify], [defaultShell, notify],
); );
const close = useCallback( const close = useCallback(
@ -207,14 +235,17 @@ export default function App() {
void killPane(paneId).catch((e) => console.warn("killPane failed:", e)); void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
paneIdByLeafRef.current.delete(leafId); paneIdByLeafRef.current.delete(leafId);
} }
setTree((t) => closeLeaf(t, leafId) ?? newLeaf({ distro: defaultDistro })); setTree(
(t) =>
closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)),
);
setActiveLeafId((cur) => (cur === leafId ? null : cur)); setActiveLeafId((cur) => (cur === leafId ? null : cur));
}, },
[defaultDistro], [defaultShell],
); );
const setDistro = useCallback((leafId: NodeId, distro: string) => { const setShell = useCallback((leafId: NodeId, spec: LeafShellSpec) => {
setTree((t) => changeDistro(t, leafId, distro)); setTree((t) => setLeafShell(t, leafId, spec));
}, []); }, []);
const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { const setLabel = useCallback((leafId: NodeId, label: string | undefined) => {
@ -229,6 +260,15 @@ export default function App() {
setActiveLeafId(leafId); 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 ------------------------------------------ // ---- global keyboard shortcuts ------------------------------------------
// Capture phase beats xterm.js's own keystroke handlers. We intentionally // Capture phase beats xterm.js's own keystroke handlers. We intentionally
// don't intercept when the user is typing into a regular <input> (label // don't intercept when the user is typing into a regular <input> (label
@ -422,11 +462,13 @@ export default function App() {
() => ({ () => ({
activeLeafId, activeLeafId,
distros, distros,
hosts,
split, split,
close, close,
setDistro, setShell,
setLabel, setLabel,
toggleBroadcast, toggleBroadcast,
openHostManager,
setActive, setActive,
registerPaneId, registerPaneId,
broadcastFrom, broadcastFrom,
@ -441,11 +483,13 @@ export default function App() {
[ [
activeLeafId, activeLeafId,
distros, distros,
hosts,
split, split,
close, close,
setDistro, setShell,
setLabel, setLabel,
toggleBroadcast, toggleBroadcast,
openHostManager,
setActive, setActive,
registerPaneId, registerPaneId,
broadcastFrom, broadcastFrom,
@ -460,10 +504,12 @@ export default function App() {
); );
const applyPreset = useCallback( const applyPreset = useCallback(
(make: (d: { distro?: string }) => TreeNode) => { (make: (d: Partial<LeafNode>) => TreeNode) => {
const { tree: nextTree, dropped } = reshapeToPreset(tree, make, { const { tree: nextTree, dropped } = reshapeToPreset(
distro: defaultDistro, tree,
}); make,
defaultShellAsLeafProps(defaultShell),
);
if (dropped.length > 0) { if (dropped.length > 0) {
const ok = window.confirm( const ok = window.confirm(
@ -487,7 +533,7 @@ export default function App() {
setTree(nextTree); setTree(nextTree);
}, },
[tree, defaultDistro, activeLeafId], [tree, defaultShell, activeLeafId],
); );
const paletteLeaves = useMemo<LeafNode[]>( const paletteLeaves = useMemo<LeafNode[]>(
@ -533,29 +579,47 @@ export default function App() {
setPaletteOpen(false); 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 ( return (
<div className="app"> <div className="app">
<header className="titlebar"> <header className="titlebar">
<span className="label">tiletopia</span> <span className="label">tiletopia</span>
<span className="distros"> <span className="distros">
{distros.length === 0 ? (
<span className="muted">no distros enumerated</span>
) : (
<>
<span className="muted">default:</span> <span className="muted">default:</span>
{distros.map((d) => ( {distros.length === 0 ? (
<span className="muted">no WSL distros</span>
) : (
distros.map((d) => (
<button <button
key={d} key={d}
className={`distro-btn${d === defaultDistro ? " active" : ""}`} className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
onClick={() => setDefaultDistro(d)} onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
title="Set default distro for new panes" title="Set default shell for new panes"
> >
{d} {d}
</button> </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>
<span className="presets"> <span className="presets">
@ -646,15 +710,48 @@ export default function App() {
onClose={() => setPaletteOpen(false)} onClose={() => setPaletteOpen(false)}
/> />
)} )}
{hostManagerOpen && (
<HostManager
hosts={hosts}
onSave={saveHosts}
onClose={closeHostManager}
/>
)}
</div> </div>
); );
} }
function backfillDistro(node: TreeNode, fallback: string) { /** When splitting a leaf, the new sibling defaults to whatever the parent
* is running so "split right" inside an Ubuntu pane gives you another
* Ubuntu pane, same SSH host gives you another connection to that host,
* etc. If no parent (shouldn't happen with current callers), fall back to
* the app-level default. */
function inheritShellFromParent(
parent: LeafNode | null,
fallback: DefaultShell,
): Partial<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) {
if (node.kind === "leaf") { if (node.kind === "leaf") {
if (!node.distro) node.distro = fallback; if (node.shellKind === "wsl" && !node.distro) node.distro = fallback;
} else { } else {
backfillDistro(node.a, fallback); backfillWslDistro(node.a, fallback);
backfillDistro(node.b, fallback); backfillWslDistro(node.b, fallback);
} }
} }

View file

@ -0,0 +1,209 @@
.host-mgr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.host-mgr-panel {
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.7);
width: min(620px, 96vw);
max-height: 86vh;
display: flex;
flex-direction: column;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.host-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.host-mgr-title {
font-weight: 600;
font-size: 13px;
}
.host-mgr-close {
background: transparent;
border: none;
color: #888;
font-size: 18px;
line-height: 1;
padding: 2px 8px;
cursor: pointer;
border-radius: 3px;
}
.host-mgr-close:hover {
background: #2a2a2a;
color: #ddd;
}
.host-mgr-body {
overflow-y: auto;
padding: 12px 14px;
flex: 1 1 auto;
min-height: 0;
}
.host-mgr-empty {
color: #666;
font-size: 12px;
margin: 12px 0;
}
.host-mgr-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.host-row {
background: #1c1c1c;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 8px 10px;
}
.host-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.host-summary-label {
font-weight: 600;
color: #e6e6e6;
font-size: 12px;
}
.host-summary-detail {
color: #888;
font-size: 11px;
margin-top: 1px;
}
.host-edit-btn {
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 3px 10px;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.host-edit-btn:hover {
background: #2a2a3a;
color: #cce;
}
.host-form {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
}
.host-form label {
display: flex;
flex-direction: column;
gap: 2px;
color: #888;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.host-form input {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
background: #0c0c0c;
color: #e6e6e6;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 4px 6px;
outline: none;
text-transform: none;
letter-spacing: normal;
}
.host-form input:focus {
border-color: #3a5a8c;
}
.host-form-row {
display: flex;
gap: 8px;
}
.host-form-row > label {
flex: 1 1 auto;
}
.host-form-port {
flex: 0 0 90px !important;
}
.host-form .required {
color: #d66;
}
.host-form-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.host-form-actions button {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
background: #222;
color: #ccc;
border: 1px solid #2a2a2a;
}
.host-form-actions button:hover {
background: #2a2a2a;
}
.host-form-actions button.primary {
background: #1a3a5c;
color: #cce6ff;
border-color: #3a5a8c;
}
.host-form-actions button.primary:hover {
background: #245080;
}
.host-form-actions button.danger {
margin-left: auto;
color: #d88;
border-color: #3a1a1a;
}
.host-form-actions button.danger:hover {
background: #3a1a1a;
color: #fcc;
}
.host-add-btn {
margin-top: 10px;
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #1c1c1c;
color: #88c;
border: 1px dashed #3a3a4a;
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
width: 100%;
text-align: center;
}
.host-add-btn:hover {
background: #222;
color: #aac;
border-color: #4a4a5a;
}

View file

@ -0,0 +1,301 @@
import {
useState,
useCallback,
useEffect,
useRef,
type FormEvent,
} from "react";
import type { SshHost } from "../ipc";
import "./HostManager.css";
function newId(): string {
return (
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 12)
);
}
function blankHost(): SshHost {
return { id: newId(), label: "", hostname: "" };
}
interface HostManagerProps {
hosts: SshHost[];
/** Called when the user clicks Save on a row. Returns a fresh list (with
* the edit applied) to persist. The parent owns the canonical state. */
onSave: (hosts: SshHost[]) => void;
onClose: () => void;
}
export default function HostManager({
hosts,
onSave,
onClose,
}: HostManagerProps) {
// Local editable copy. Any save / delete acts on this and pushes the
// whole list back up via onSave.
const [draft, setDraft] = useState<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,6 +16,7 @@ import {
onPaneData, onPaneData,
onPaneExit, onPaneExit,
type PaneId, type PaneId,
type SpawnSpec,
} from "../ipc"; } from "../ipc";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -45,8 +46,10 @@ function stringToB64(s: string): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface XtermPaneProps { interface XtermPaneProps {
distro?: string; /** Spec describing what to spawn into this pane's PTY. Read once at mount;
cwd?: string; * changing it later does NOT respawn callers force a respawn by
* changing the React `key` (see Pane.svelte / LeafPane). */
spec: SpawnSpec;
onStatus?: (msg: string, ok: boolean) => void; onStatus?: (msg: string, ok: boolean) => void;
/** Fired once when the backend PTY is alive and we have its PaneId. */ /** Fired once when the backend PTY is alive and we have its PaneId. */
onSpawn?: (paneId: PaneId) => void; onSpawn?: (paneId: PaneId) => void;
@ -69,8 +72,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function XtermPane({ export default function XtermPane({
distro, spec,
cwd,
onStatus, onStatus,
onSpawn, onSpawn,
onInput, onInput,
@ -152,7 +154,7 @@ export default function XtermPane({
const rows = term!.rows; const rows = term!.rows;
try { try {
paneId = await spawnPane({ distro, cwd, cols, rows }); paneId = await spawnPane({ spec, cols, rows });
if (destroyed) { if (destroyed) {
void killPane(paneId); void killPane(paneId);
return; return;
@ -287,8 +289,9 @@ export default function XtermPane({
fitRef.current = null; fitRef.current = null;
paneIdRef.current = null; paneIdRef.current = null;
}; };
// distro/cwd are only used at spawn time; intentionally omitted from deps // spec is read once at mount; intentionally omitted from deps so we
// so remounting doesn't happen if a parent re-renders with the same values. // don't remount on parent re-renders. Callers force a respawn by
// bumping the React `key` (changeShell swaps the leaf id for that).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);

View file

@ -3,11 +3,36 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type PaneId = number; 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 listDistros = (): Promise<string[]> => invoke("list_distros");
export const spawnPane = (args: { export const spawnPane = (args: {
distro?: string; spec: SpawnSpec;
cwd?: string;
cols: number; cols: number;
rows: number; rows: number;
}): Promise<PaneId> => invoke("spawn_pane", args); }): Promise<PaneId> => invoke("spawn_pane", args);
@ -38,3 +63,10 @@ export const saveWorkspace = (json: string): Promise<void> =>
export const loadWorkspace = (): Promise<string | null> => export const loadWorkspace = (): Promise<string | null> =>
invoke("load_workspace"); 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,6 +159,61 @@
color: #cce6ff; 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 { .pane-status {
margin-left: auto; margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;

View file

@ -7,9 +7,10 @@ import {
type MouseEvent, type MouseEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { type LeafNode, resolveFontSize } from "./tree"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration"; import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane"; import XtermPane from "../../components/XtermPane";
import type { SpawnSpec } from "../../ipc";
import "./LeafPane.css"; import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000; const IDLE_THRESHOLD_MS = 5000;
@ -57,26 +58,60 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
[commitLabel, cancelLabel], [commitLabel, cancelLabel],
); );
// ---- distro popover ---------------------------------------------------- // ---- shell-picker popover ----------------------------------------------
const [distroOpen, setDistroOpen] = useState(false); // Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
const toggleDistroMenu = useCallback((e: MouseEvent) => { // hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
// (forces respawn).
const [shellMenuOpen, setShellMenuOpen] = useState(false);
const toggleShellMenu = useCallback((e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setDistroOpen((v) => !v); setShellMenuOpen((v) => !v);
}, []); }, []);
const pickDistro = useCallback( const pickShell = useCallback(
(d: string) => { (spec: LeafShellSpec) => {
setDistroOpen(false); setShellMenuOpen(false);
if (d !== leaf.distro) orch.setDistro(leaf.id, d); // Only respawn if the spec is actually different from what's running.
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
return;
}
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
return;
}
if (
spec.shellKind === "ssh" &&
leaf.shellKind === "ssh" &&
spec.sshHostId === leaf.sshHostId
) {
return;
}
orch.setShell(leaf.id, spec);
}, },
[orch.setDistro, leaf.id, leaf.distro], [orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId],
);
const onManageHosts = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setShellMenuOpen(false);
orch.openHostManager();
},
[orch.openHostManager],
); );
// Dismiss popover on outside click // Dismiss popover on outside click
useEffect(() => { useEffect(() => {
if (!distroOpen) return; if (!shellMenuOpen) return;
const onDocClick = () => setDistroOpen(false); const onDocClick = () => setShellMenuOpen(false);
window.addEventListener("click", onDocClick); window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick);
}, [distroOpen]); }, [shellMenuOpen]);
// Label shown on the dropdown chip — tells the user what's currently
// running without expanding the menu.
const chipLabel =
leaf.shellKind === "powershell"
? "PowerShell"
: leaf.shellKind === "ssh"
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
: (leaf.distro ?? "(default)");
// ---- idle detection ---------------------------------------------------- // ---- idle detection ----------------------------------------------------
// Local boolean for the red border + status text on this pane; reported // Local boolean for the red border + status text on this pane; reported
@ -233,6 +268,29 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const labelText = leaf.label ?? "(unnamed)"; 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 ( return (
<div <div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`} className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
@ -271,26 +329,74 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
<span className="distro-wrap"> <span className="distro-wrap">
<button <button
className="distro-chip" className="distro-chip"
onClick={toggleDistroMenu} onClick={toggleShellMenu}
title="Change distro (respawns the pane)" title="Change shell (respawns the pane)"
> >
{leaf.distro ?? "(default)"} {chipLabel}
</button> </button>
{distroOpen && ( {shellMenuOpen && (
<div <div
className="distro-menu" className="distro-menu shell-menu"
role="menu" role="menu"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{orch.distros.map((d) => ( {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 <button
key={d} key={`wsl-${d}`}
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`} className={`distro-menu-item${active ? " active" : ""}`}
onClick={() => pickDistro(d)} onClick={() => pickShell({ shellKind: "wsl", distro: d })}
> >
{d} {d}
</button> </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>
</div> </div>
)} )}
</span> </span>
@ -356,9 +462,9 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</span> </span>
</div> </div>
<div className="xterm-wrap"> <div className="xterm-wrap">
{spec ? (
<XtermPane <XtermPane
distro={leaf.distro} spec={spec}
cwd={leaf.cwd}
onStatus={onStatus} onStatus={onStatus}
onSpawn={onPaneSpawned} onSpawn={onPaneSpawned}
onInput={onTerminalInput} onInput={onTerminalInput}
@ -367,6 +473,15 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
focusTrigger={focusTrigger} focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)} 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>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react"; import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId } from "./tree"; import type { Orientation, NodeId, LeafShellSpec } from "./tree";
import type { PaneId } from "../../ipc"; import type { PaneId, SshHost } from "../../ipc";
/** /**
* Orchestration context every piece of shared state and every operation * Orchestration context every piece of shared state and every operation
@ -15,15 +15,26 @@ import type { PaneId } from "../../ipc";
export interface Orchestration { export interface Orchestration {
// Read-only state // Read-only state
activeLeafId: NodeId | null; activeLeafId: NodeId | null;
/** WSL distros enumerated from `wsl.exe -l -q`. PowerShell is a separate
* shell kind, not in this list. */
distros: string[]; distros: string[];
/** Saved SSH hosts loaded from `hosts.json`. Reactive changes when the
* user edits hosts via {@link openHostManager}. */
hosts: SshHost[];
// Tree mutations // Tree mutations
split: (leafId: NodeId, orientation: Orientation) => void; split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void; close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void; /** Change the shell on a leaf (WSL distro / PowerShell / SSH host).
* Always forces a respawn the helper in tree.ts swaps the leaf id so
* the renderer remounts XtermPane. */
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void; setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void; toggleBroadcast: (leafId: NodeId) => void;
// SSH host management
openHostManager: () => void;
// Per-pane orchestration // Per-pane orchestration
setActive: (leafId: NodeId) => void; setActive: (leafId: NodeId) => void;
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void; registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;

View file

@ -9,6 +9,7 @@ import {
leafCount, leafCount,
walkLeaves, walkLeaves,
changeDistro, changeDistro,
setLeafShell,
changeLabel, changeLabel,
toggleBroadcast, toggleBroadcast,
adjustFontSize, adjustFontSize,
@ -38,14 +39,16 @@ function leafDistros(root: TreeNode): (string | undefined)[] {
} }
describe("newLeaf", () => { describe("newLeaf", () => {
it("returns a leaf with a unique id and no extra metadata", () => { it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => {
const a = newLeaf(); const a = newLeaf();
const b = newLeaf(); const b = newLeaf();
expect(a.kind).toBe("leaf"); expect(a.kind).toBe("leaf");
expect(typeof a.id).toBe("string"); expect(typeof a.id).toBe("string");
expect(a.id).not.toEqual(b.id); expect(a.id).not.toEqual(b.id);
expect(a.shellKind).toBe("wsl");
expect(a.distro).toBeUndefined(); expect(a.distro).toBeUndefined();
expect(a.cwd).toBeUndefined(); expect(a.cwd).toBeUndefined();
expect(a.sshHostId).toBeUndefined();
expect(a.label).toBeUndefined(); expect(a.label).toBeUndefined();
expect(a.broadcast).toBeUndefined(); expect(a.broadcast).toBeUndefined();
}); });
@ -56,6 +59,14 @@ describe("newLeaf", () => {
expect(leaf.cwd).toBe("/home"); expect(leaf.cwd).toBe("/home");
expect(leaf.label).toBe("ml"); 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", () => { describe("newSplit", () => {
@ -232,10 +243,11 @@ describe("walkLeaves", () => {
}); });
describe("changeDistro", () => { describe("changeDistro", () => {
it("sets the distro on the leaf", () => { it("sets the distro on the leaf and forces shellKind back to wsl", () => {
const leaf = newLeaf({ distro: "Ubuntu" }); const leaf = newLeaf({ shellKind: "powershell" });
const next = changeDistro(leaf, leaf.id, "Debian"); const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
expect((next as LeafNode).distro).toBe("Debian"); expect(next.distro).toBe("Debian");
expect(next.shellKind).toBe("wsl");
}); });
it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => { it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => {
@ -254,6 +266,52 @@ describe("changeDistro", () => {
}); });
}); });
describe("setLeafShell", () => {
it("switches a wsl leaf to powershell (and clears wsl-specific fields)", () => {
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/work", label: "keep" });
const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell" }) as LeafNode;
expect(next.shellKind).toBe("powershell");
expect(next.distro).toBeUndefined();
expect(next.cwd).toBeUndefined();
expect(next.label).toBe("keep");
});
it("switches a leaf to ssh and records sshHostId", () => {
const leaf = newLeaf({ distro: "Ubuntu" });
const next = setLeafShell(leaf, leaf.id, {
shellKind: "ssh",
sshHostId: "host-abc",
}) as LeafNode;
expect(next.shellKind).toBe("ssh");
expect(next.sshHostId).toBe("host-abc");
expect(next.distro).toBeUndefined();
});
it("MUST swap the leaf id (forces PTY respawn)", () => {
const leaf = newLeaf({ shellKind: "powershell" });
const next = setLeafShell(leaf, leaf.id, {
shellKind: "ssh",
sshHostId: "h1",
}) as LeafNode;
expect(next.id).not.toBe(leaf.id);
});
it("preserves label / broadcast / fontSizeOffset across the shell change", () => {
const leaf = newLeaf({
distro: "Ubuntu",
label: "my pane",
broadcast: true,
fontSizeOffset: 2,
});
const next = setLeafShell(leaf, leaf.id, {
shellKind: "powershell",
}) as LeafNode;
expect(next.label).toBe("my pane");
expect(next.broadcast).toBe(true);
expect(next.fontSizeOffset).toBe(2);
});
});
describe("changeLabel", () => { describe("changeLabel", () => {
it("sets a label", () => { it("sets a label", () => {
const leaf = newLeaf(); const leaf = newLeaf();
@ -466,10 +524,41 @@ describe("serialize / deserialize", () => {
).toBeNull(); // missing ratio + children ).toBeNull(); // missing ratio + children
}); });
it("accepts a minimal leaf shape", () => { it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => {
expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({ expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({
kind: "leaf", kind: "leaf",
id: "x", 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,13 +10,25 @@ export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */ /** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
export type Orientation = "h" | "v"; 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 { export interface LeafNode {
kind: "leaf"; kind: "leaf";
id: NodeId; id: NodeId;
/** WSL distro the pane was spawned against. */ /** Discriminator: which shell-type this pane runs. */
shellKind: ShellKind;
/** WSL distro the pane was spawned against. Only meaningful when
* shellKind === "wsl". */
distro?: string; distro?: string;
/** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */ /** Working directory the pane was started in. Only meaningful when
* shellKind === "wsl". */
cwd?: string; cwd?: string;
/** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */
sshHostId?: string;
/** Optional user label shown in the pane toolbar. */ /** Optional user label shown in the pane toolbar. */
label?: string; label?: string;
/** /**
@ -60,7 +72,47 @@ function newId(): NodeId {
} }
export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode { export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode {
return { kind: "leaf", id: newId(), ...props }; return { kind: "leaf", id: newId(), shellKind: "wsl", ...props };
}
/** Spec for switching a leaf's shell. Discriminated by shellKind. Used by
* {@link setLeafShell}; the helper always swaps the leaf id so the renderer
* remounts XtermPane (kills the old PTY spawns a fresh one with the new
* spec). */
export type LeafShellSpec =
| { shellKind: "wsl"; distro?: string; cwd?: string }
| { shellKind: "powershell" }
| { shellKind: "ssh"; sshHostId: string };
/**
* Replace the leaf's shell-kind and shell-specific fields, then swap its id
* so the renderer's `key={leaf.id}` block remounts XtermPane (kills the old
* PTY spawns a fresh one). Metadata like label / broadcast / font-size
* survives.
*/
export function setLeafShell(
root: TreeNode,
leafId: NodeId,
spec: LeafShellSpec,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const base: LeafNode = {
kind: "leaf",
id: newId(),
shellKind: spec.shellKind,
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
if (spec.cwd !== undefined) base.cwd = spec.cwd;
} else if (spec.shellKind === "ssh") {
base.sshHostId = spec.sshHostId;
}
return base;
});
} }
export function newSplit( export function newSplit(
@ -128,19 +180,18 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
} }
/** /**
* Swap the distro on a leaf. The leaf gets a **new id** so the rendering * Swap the WSL 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 * layer remounts XtermPane the old PTY is killed and a fresh one spawns
* and a fresh one spawns with the new distro. * against the new distro. Also forces shellKind back to "wsl" if the leaf
* had been a non-WSL kind (which is what the existing per-pane dropdown
* does when the user picks a WSL distro entry).
*/ */
export function changeDistro( export function changeDistro(
root: TreeNode, root: TreeNode,
leafId: NodeId, leafId: NodeId,
distro: string, distro: string,
): TreeNode { ): TreeNode {
return replaceById(root, leafId, (node) => { return setLeafShell(root, leafId, { shellKind: "wsl", distro });
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). */ /** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
@ -293,8 +344,10 @@ export function reshapeToPreset(
if (!src) break; if (!src) break;
const slot = slots[i]; const slot = slots[i];
slot.id = src.id; slot.id = src.id;
slot.shellKind = src.shellKind;
if (src.distro !== undefined) slot.distro = src.distro; if (src.distro !== undefined) slot.distro = src.distro;
if (src.cwd !== undefined) slot.cwd = src.cwd; 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.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast; if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset; if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
@ -518,17 +571,38 @@ export function serialize(root: TreeNode): string {
return JSON.stringify(root); return JSON.stringify(root);
} }
/** Parse JSON back to a tree. Returns null on invalid input. */ /** Parse JSON back to a tree. Returns null on invalid input. Pre-shellKind
* workspaces are migrated in place: leaves without `shellKind` get one
* inferred from the legacy `distro` sentinel (`"PowerShell"` powershell,
* anything else wsl). */
export function deserialize(json: string): TreeNode | null { export function deserialize(json: string): TreeNode | null {
try { try {
const parsed = JSON.parse(json); const parsed = JSON.parse(json);
if (!isTreeNode(parsed)) return null; if (!isTreeNode(parsed)) return null;
return parsed; return migrateLegacyLeaves(parsed);
} catch { } catch {
return null; 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 { function isTreeNode(x: unknown): x is TreeNode {
if (typeof x !== "object" || x === null) return false; if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>; const o = x as Record<string, unknown>;