Add SSH connections: saved hosts manager and hierarchical shell picker
This commit is contained in:
parent
4e5bc7e081
commit
872fb0e80e
14 changed files with 1324 additions and 171 deletions
|
|
@ -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
74
src-tauri/src/hosts.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
195
src/App.tsx
195
src/App.tsx
|
|
@ -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">
|
||||||
|
<span className="muted">default:</span>
|
||||||
{distros.length === 0 ? (
|
{distros.length === 0 ? (
|
||||||
<span className="muted">no distros enumerated</span>
|
<span className="muted">no WSL distros</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
distros.map((d) => (
|
||||||
<span className="muted">default:</span>
|
<button
|
||||||
{distros.map((d) => (
|
key={d}
|
||||||
<button
|
className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
|
||||||
key={d}
|
onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
|
||||||
className={`distro-btn${d === defaultDistro ? " active" : ""}`}
|
title="Set default shell for new panes"
|
||||||
onClick={() => setDefaultDistro(d)}
|
>
|
||||||
title="Set default distro for new panes"
|
{d}
|
||||||
>
|
</button>
|
||||||
{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>
|
||||||
|
|
||||||
<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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
src/components/HostManager.css
Normal file
209
src/components/HostManager.css
Normal 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;
|
||||||
|
}
|
||||||
301
src/components/HostManager.tsx
Normal file
301
src/components/HostManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
36
src/ipc.ts
36
src/ipc.ts
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<button
|
<>
|
||||||
key={d}
|
<div className="shell-menu-header">WSL</div>
|
||||||
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
|
{orch.distros.map((d) => {
|
||||||
onClick={() => pickDistro(d)}
|
const active = leaf.shellKind === "wsl" && d === leaf.distro;
|
||||||
>
|
return (
|
||||||
{d}
|
<button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -356,17 +462,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="xterm-wrap">
|
<div className="xterm-wrap">
|
||||||
<XtermPane
|
{spec ? (
|
||||||
distro={leaf.distro}
|
<XtermPane
|
||||||
cwd={leaf.cwd}
|
spec={spec}
|
||||||
onStatus={onStatus}
|
onStatus={onStatus}
|
||||||
onSpawn={onPaneSpawned}
|
onSpawn={onPaneSpawned}
|
||||||
onInput={onTerminalInput}
|
onInput={onTerminalInput}
|
||||||
onDataReceived={onDataReceived}
|
onDataReceived={onDataReceived}
|
||||||
onFocus={onXtermFocus}
|
onFocus={onXtermFocus}
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue