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