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
195
src/App.tsx
195
src/App.tsx
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue