Per-distro suppression (shipped earlier today) broke tiletopia's primary use case — multiple claude panes per distro means as soon as one runs claude, ALL Ubuntu panes go silent. Tested live: user couldn't reproduce idle on any pane because PID 46848 (their main session) tripped the gate. New mechanism, per-pane via env-var marker: 1. pty.rs tags every WSL spawn with TILETOPIA_PANE_ID=<id> as a Windows env var, plus WSLENV=...TILETOPIA_PANE_ID/u (appended to any pre- existing WSLENV) so the var forwards into the distro. Pane id is now reserved BEFORE build_command so the tag is available at spawn time. 2. probe.rs rewritten — is_watch_process_running(distro, pane_id) runs a bash one-liner that pgreps for each watched name, then for each PID checks /proc/<pid>/environ for the matching TILETOPIA_PANE_ID line. Env inheritance does the work: shell inherits from wsl.exe, claude inherits from shell. Cache keyed by (distro, pane_id). 3. Fail-safe INVERTED: probe failure now returns false (don't suppress) instead of true (suppress). A transient error should never silence the idle indicator permanently. Frontend catch updated to match. 4. LeafPane tracks PaneId in paneIdRef set by onPaneSpawned; idle ticks before spawn-completion pass 0, which won't match any real marker so the pane idles normally. Existing panes won't have the marker until respawned — they'll always show idle (since probe never matches). User opens fresh panes once after deploying this. Documented in memory.md follow-ups. pnpm check clean. Rust validation: cargo test --lib on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
596 lines
20 KiB
TypeScript
596 lines
20 KiB
TypeScript
import {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useCallback,
|
||
type KeyboardEvent,
|
||
type MouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react";
|
||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||
import { useOrchestration } from "./orchestration";
|
||
import XtermPane from "../../components/XtermPane";
|
||
import { isWatchProcessRunning, type SpawnSpec } from "../../ipc";
|
||
import "./LeafPane.css";
|
||
|
||
const IDLE_THRESHOLD_MS = 5000;
|
||
|
||
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||
const orch = useOrchestration();
|
||
const isActive = orch.activeLeafId === leaf.id;
|
||
const isBroadcasting = !!leaf.broadcast;
|
||
|
||
// ---- status (from XtermPane) -------------------------------------------
|
||
const [status, setStatus] = useState("starting…");
|
||
const [statusOk, setStatusOk] = useState(true);
|
||
|
||
// ---- label editing -----------------------------------------------------
|
||
const [editingLabel, setEditingLabel] = useState(false);
|
||
const [labelDraft, setLabelDraft] = useState("");
|
||
const labelInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const startEditLabel = useCallback(
|
||
(e: MouseEvent) => {
|
||
e.stopPropagation();
|
||
setLabelDraft(leaf.label ?? "");
|
||
setEditingLabel(true);
|
||
// Focus on next tick so input is mounted
|
||
queueMicrotask(() => labelInputRef.current?.select());
|
||
},
|
||
[leaf.label],
|
||
);
|
||
const commitLabel = useCallback(() => {
|
||
if (!editingLabel) return;
|
||
orch.setLabel(leaf.id, labelDraft);
|
||
setEditingLabel(false);
|
||
}, [editingLabel, orch.setLabel, leaf.id, labelDraft]);
|
||
const cancelLabel = useCallback(() => setEditingLabel(false), []);
|
||
const onLabelKey = useCallback(
|
||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
commitLabel();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
cancelLabel();
|
||
}
|
||
},
|
||
[commitLabel, cancelLabel],
|
||
);
|
||
|
||
// ---- shell-picker popover ----------------------------------------------
|
||
// Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
|
||
// hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
|
||
// (forces respawn).
|
||
const [shellMenuOpen, setShellMenuOpen] = useState(false);
|
||
const toggleShellMenu = useCallback((e: MouseEvent) => {
|
||
e.stopPropagation();
|
||
setShellMenuOpen((v) => !v);
|
||
}, []);
|
||
const pickShell = useCallback(
|
||
(spec: LeafShellSpec) => {
|
||
setShellMenuOpen(false);
|
||
// Only respawn if the spec is actually different from what's running.
|
||
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
|
||
return;
|
||
}
|
||
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
|
||
return;
|
||
}
|
||
if (
|
||
spec.shellKind === "ssh" &&
|
||
leaf.shellKind === "ssh" &&
|
||
spec.sshHostId === leaf.sshHostId
|
||
) {
|
||
return;
|
||
}
|
||
orch.setShell(leaf.id, spec);
|
||
},
|
||
[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
|
||
useEffect(() => {
|
||
if (!shellMenuOpen) return;
|
||
const onDocClick = () => setShellMenuOpen(false);
|
||
window.addEventListener("click", onDocClick);
|
||
return () => window.removeEventListener("click", onDocClick);
|
||
}, [shellMenuOpen]);
|
||
|
||
// Label shown on the dropdown chip — tells the user what's currently
|
||
// running without expanding the menu.
|
||
const chipLabel =
|
||
leaf.shellKind === "powershell"
|
||
? "PowerShell"
|
||
: leaf.shellKind === "ssh"
|
||
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
|
||
: (leaf.distro ?? "(default)");
|
||
|
||
// ---- idle detection ----------------------------------------------------
|
||
// Local boolean for the red border + status text on this pane; reported
|
||
// up to App via orch.reportLeafIdle for the titlebar's "N idle" badge.
|
||
//
|
||
// Filter: for WSL panes, before flagging idle we probe the backend to see
|
||
// if any "watched" process (currently just `claude`) is running in THIS
|
||
// pane specifically — per-pane, not per-distro. Per-pane is essential for
|
||
// tiletopia's primary use case (multiple claude sessions across panes in
|
||
// the same distro). The backend matches by reading `TILETOPIA_PANE_ID`
|
||
// out of each candidate process's `/proc/<pid>/environ` (the env var is
|
||
// injected at spawn time; see src-tauri/src/pty.rs WSLENV setup).
|
||
//
|
||
// PowerShell + SSH skip the probe and fall through to legacy behaviour
|
||
// (PS has no portable `ps`; SSH processes live on a remote box).
|
||
const lastDataTimeRef = useRef(Date.now());
|
||
const [isIdle, setIsIdle] = useState(false);
|
||
const isWslPane = leaf.shellKind === "wsl";
|
||
// Captures the distro name into the interval callback. Empty string when
|
||
// the leaf doesn't have one yet — the probe returns "not running" for
|
||
// empty input so the pane goes idle normally.
|
||
const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
|
||
// Backend pane id (PaneId, the u64 used inside Rust). Set by the
|
||
// XtermPane onSpawn callback; null until the spawn round-trip completes.
|
||
// Idle ticks before that point pass 0 — won't match any real pane's
|
||
// TILETOPIA_PANE_ID env, so the probe returns false (no suppression).
|
||
const paneIdRef = useRef<number | null>(null);
|
||
const onDataReceived = useCallback(() => {
|
||
lastDataTimeRef.current = Date.now();
|
||
setIsIdle((cur) => {
|
||
if (cur) orch.reportLeafIdle(leaf.id, false);
|
||
return false;
|
||
});
|
||
}, [orch.reportLeafIdle, leaf.id]);
|
||
useEffect(() => {
|
||
// Guard against late-resolving probes after unmount or another tick
|
||
// already shipping a fresher answer.
|
||
let cancelled = false;
|
||
let inFlight = false;
|
||
|
||
const tick = () => {
|
||
const dt = Date.now() - lastDataTimeRef.current;
|
||
const nowIdle = dt >= IDLE_THRESHOLD_MS;
|
||
|
||
// Transitioning out of idle is unconditional — fresh output beats
|
||
// any probe answer.
|
||
if (!nowIdle) {
|
||
setIsIdle((cur) => {
|
||
if (!cur) return cur;
|
||
orch.reportLeafIdle(leaf.id, false);
|
||
return false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Transitioning into idle. Non-WSL panes: report immediately (legacy
|
||
// behaviour). WSL panes: gate on the probe; suppress if a watched
|
||
// process is running in the distro.
|
||
if (!isWslPane) {
|
||
setIsIdle((cur) => {
|
||
if (cur) return cur;
|
||
orch.reportLeafIdle(leaf.id, true);
|
||
return true;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// WSL path. Don't stack probes — one in flight per pane at a time.
|
||
if (inFlight) return;
|
||
inFlight = true;
|
||
const paneIdForProbe = paneIdRef.current ?? 0;
|
||
void isWatchProcessRunning(wslDistro, paneIdForProbe)
|
||
.then((suppress) => {
|
||
if (cancelled) return;
|
||
// If output arrived while the probe was in flight, the next tick
|
||
// (or onDataReceived) will reconcile; don't flip-flop here.
|
||
if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return;
|
||
if (suppress) {
|
||
// claude (or another watched proc) is running in THIS pane —
|
||
// treat the silence as expected; stay out of the idle set.
|
||
setIsIdle((cur) => {
|
||
if (!cur) return cur;
|
||
orch.reportLeafIdle(leaf.id, false);
|
||
return false;
|
||
});
|
||
} else {
|
||
setIsIdle((cur) => {
|
||
if (cur) return cur;
|
||
orch.reportLeafIdle(leaf.id, true);
|
||
return true;
|
||
});
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
// Probe IPC errored — don't flip idle either way; next tick retries.
|
||
// The Rust side now also fails-safe to "not running" so the pane
|
||
// will flag idle eventually if the probe stays broken.
|
||
if (cancelled) return;
|
||
// eslint-disable-next-line no-console
|
||
console.debug("idle probe failed", e);
|
||
})
|
||
.finally(() => {
|
||
inFlight = false;
|
||
});
|
||
};
|
||
|
||
const id = window.setInterval(tick, 1000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(id);
|
||
};
|
||
}, [leaf.id, orch.reportLeafIdle, isWslPane, wslDistro]);
|
||
// Clear from the app-level idle set when this pane unmounts.
|
||
useEffect(() => {
|
||
return () => orch.reportLeafIdle(leaf.id, false);
|
||
}, [leaf.id, orch.reportLeafIdle]);
|
||
|
||
// ---- broadcast ---------------------------------------------------------
|
||
const onTerminalInput = useCallback(
|
||
(b64: string) => {
|
||
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
|
||
},
|
||
[isBroadcasting, orch.broadcastFrom, leaf.id],
|
||
);
|
||
|
||
// ---- focus / active highlighting ---------------------------------------
|
||
const [focusTrigger, setFocusTrigger] = useState(0);
|
||
// When this leaf becomes active, bump focusTrigger so XtermPane refocuses.
|
||
useEffect(() => {
|
||
if (isActive) setFocusTrigger((n) => n + 1);
|
||
}, [isActive]);
|
||
|
||
const onPaneClick = useCallback(() => {
|
||
orch.setActive(leaf.id);
|
||
}, [orch.setActive, leaf.id]);
|
||
|
||
const onPaneSpawned = useCallback(
|
||
(paneId: number) => {
|
||
paneIdRef.current = paneId;
|
||
orch.registerPaneId(leaf.id, paneId);
|
||
},
|
||
[orch.registerPaneId, leaf.id],
|
||
);
|
||
// Unregister on TRUE unmount only — depending on `orch` here would
|
||
// delete the paneId from App's lookup on every activeLeafId change,
|
||
// which broke broadcast routing (peers found, but their paneIds
|
||
// had been silently removed from the map).
|
||
useEffect(() => {
|
||
return () => {
|
||
paneIdRef.current = null;
|
||
orch.registerPaneId(leaf.id, null);
|
||
};
|
||
}, [orch.registerPaneId, leaf.id]);
|
||
|
||
const onXtermFocus = useCallback(
|
||
() => orch.setActive(leaf.id),
|
||
[orch.setActive, leaf.id],
|
||
);
|
||
|
||
const onStatus = useCallback((msg: string, ok: boolean) => {
|
||
setStatus(msg);
|
||
setStatusOk(ok);
|
||
}, []);
|
||
|
||
// ---- header-drag swap ---------------------------------------------------
|
||
// Drag the toolbar onto another pane's toolbar/body to swap their tree
|
||
// positions. Uses a movement threshold so accidental tiny moves while
|
||
// clicking a label etc don't initiate a drag.
|
||
const DRAG_THRESHOLD_PX = 5;
|
||
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
|
||
null,
|
||
);
|
||
const isDragSource = orch.dragSourceId === leaf.id;
|
||
const isDragTarget =
|
||
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
|
||
|
||
const onToolbarPointerDown = useCallback(
|
||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const target = e.target as HTMLElement;
|
||
// Skip if the click landed on an interactive child.
|
||
if (target.closest("button, input, .distro-menu")) return;
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
dragStartRef.current = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
armed: true,
|
||
dragging: false,
|
||
};
|
||
// Make this pane active (since clicking the toolbar should focus it).
|
||
orch.setActive(leaf.id);
|
||
},
|
||
[orch.setActive, leaf.id],
|
||
);
|
||
|
||
const onToolbarPointerMove = useCallback(
|
||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const st = dragStartRef.current;
|
||
if (!st || !st.armed) return;
|
||
const dx = e.clientX - st.x;
|
||
const dy = e.clientY - st.y;
|
||
if (!st.dragging) {
|
||
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
||
st.dragging = true;
|
||
orch.beginHeaderDrag(leaf.id);
|
||
document.body.style.cursor = "grabbing";
|
||
}
|
||
// Find the leaf under the cursor.
|
||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||
const tEl = el?.closest("[data-leaf-id]");
|
||
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
|
||
orch.setHeaderDragOver(targetId);
|
||
},
|
||
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
|
||
);
|
||
|
||
const onToolbarPointerUp = useCallback(
|
||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const st = dragStartRef.current;
|
||
if (!st) return;
|
||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||
const wasDragging = st.dragging;
|
||
dragStartRef.current = null;
|
||
if (wasDragging) {
|
||
document.body.style.cursor = "";
|
||
orch.endHeaderDrag(true);
|
||
}
|
||
},
|
||
[orch.endHeaderDrag],
|
||
);
|
||
|
||
const onToolbarPointerCancel = useCallback(
|
||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const st = dragStartRef.current;
|
||
if (!st) return;
|
||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||
const wasDragging = st.dragging;
|
||
dragStartRef.current = null;
|
||
if (wasDragging) {
|
||
document.body.style.cursor = "";
|
||
orch.endHeaderDrag(false);
|
||
}
|
||
},
|
||
[orch.endHeaderDrag],
|
||
);
|
||
|
||
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,
|
||
hostId: host.id,
|
||
};
|
||
})();
|
||
|
||
return (
|
||
<div
|
||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||
role="group"
|
||
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||
data-leaf-id={leaf.id}
|
||
onPointerDown={onPaneClick}
|
||
>
|
||
<div
|
||
className="pane-toolbar"
|
||
onPointerDown={onToolbarPointerDown}
|
||
onPointerMove={onToolbarPointerMove}
|
||
onPointerUp={onToolbarPointerUp}
|
||
onPointerCancel={onToolbarPointerCancel}
|
||
>
|
||
{editingLabel ? (
|
||
<input
|
||
ref={labelInputRef}
|
||
className="label-input"
|
||
value={labelDraft}
|
||
onChange={(e) => setLabelDraft(e.target.value)}
|
||
onKeyDown={onLabelKey}
|
||
onBlur={commitLabel}
|
||
placeholder="(label)"
|
||
/>
|
||
) : (
|
||
<button
|
||
className="pane-label"
|
||
onClick={startEditLabel}
|
||
title="Click to rename pane"
|
||
>
|
||
{labelText}
|
||
</button>
|
||
)}
|
||
|
||
<span className="distro-wrap">
|
||
<button
|
||
className="distro-chip"
|
||
onClick={toggleShellMenu}
|
||
title="Change shell (respawns the pane)"
|
||
>
|
||
{chipLabel} ▾
|
||
</button>
|
||
{shellMenuOpen && (
|
||
<div
|
||
className="distro-menu shell-menu"
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{orch.distros.length > 0 && (
|
||
<>
|
||
<div className="shell-menu-header">WSL</div>
|
||
{orch.distros.map((d) => {
|
||
const active = leaf.shellKind === "wsl" && d === leaf.distro;
|
||
return (
|
||
<button
|
||
key={`wsl-${d}`}
|
||
className={`distro-menu-item${active ? " active" : ""}`}
|
||
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
|
||
>
|
||
{d}
|
||
</button>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
|
||
<div className="shell-menu-header">Windows</div>
|
||
<button
|
||
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
|
||
onClick={() => pickShell({ shellKind: "powershell" })}
|
||
>
|
||
PowerShell
|
||
</button>
|
||
|
||
<div className="shell-menu-header">SSH</div>
|
||
{orch.hosts.length === 0 ? (
|
||
<div className="shell-menu-empty">(no saved hosts)</div>
|
||
) : (
|
||
orch.hosts.map((h) => {
|
||
const active =
|
||
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
|
||
return (
|
||
<button
|
||
key={`ssh-${h.id}`}
|
||
className={`distro-menu-item${active ? " active" : ""}`}
|
||
onClick={() =>
|
||
pickShell({ shellKind: "ssh", sshHostId: h.id })
|
||
}
|
||
title={
|
||
h.user
|
||
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
|
||
: `${h.hostname}${h.port ? ":" + h.port : ""}`
|
||
}
|
||
>
|
||
{h.label || h.hostname}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
<button
|
||
className="distro-menu-item shell-menu-manage"
|
||
onClick={onManageHosts}
|
||
>
|
||
Manage hosts…
|
||
</button>
|
||
</div>
|
||
)}
|
||
</span>
|
||
|
||
<button
|
||
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.toggleBroadcast(leaf.id);
|
||
}}
|
||
title={
|
||
isBroadcasting
|
||
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
|
||
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
|
||
}
|
||
aria-pressed={isBroadcasting ? "true" : "false"}
|
||
>
|
||
📡
|
||
</button>
|
||
|
||
<button
|
||
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.toggleMcpAllow(leaf.id);
|
||
}}
|
||
title={
|
||
leaf.mcpAllow
|
||
? "MCP can see this pane — click to revoke"
|
||
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
|
||
}
|
||
aria-pressed={leaf.mcpAllow ? "true" : "false"}
|
||
>
|
||
🤖
|
||
</button>
|
||
|
||
{isIdle && statusOk ? (
|
||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||
idle
|
||
</span>
|
||
) : (
|
||
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
|
||
)}
|
||
|
||
<span className="pane-actions">
|
||
<button
|
||
className="pane-btn"
|
||
title="Split right (Ctrl+Shift+E)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.split(leaf.id, "h");
|
||
}}
|
||
aria-label="Split right"
|
||
>
|
||
⇥
|
||
</button>
|
||
<button
|
||
className="pane-btn"
|
||
title="Split down (Ctrl+Shift+O)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.split(leaf.id, "v");
|
||
}}
|
||
aria-label="Split down"
|
||
>
|
||
⇣
|
||
</button>
|
||
<button
|
||
className="pane-btn close"
|
||
title="Close pane (Ctrl+Shift+W)"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
orch.close(leaf.id);
|
||
}}
|
||
aria-label="Close pane"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div className="xterm-wrap">
|
||
{spec ? (
|
||
<XtermPane
|
||
spec={spec}
|
||
onStatus={onStatus}
|
||
onSpawn={onPaneSpawned}
|
||
onInput={onTerminalInput}
|
||
onDataReceived={onDataReceived}
|
||
onFocus={onXtermFocus}
|
||
focusTrigger={focusTrigger}
|
||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||
/>
|
||
) : (
|
||
<div className="leaf-missing-host">
|
||
<p>SSH host not found</p>
|
||
<p className="hint">
|
||
Open the shell menu and pick another host, or add this host back
|
||
via Manage hosts….
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|