- window_state.rs: persist only the main window's workspaces. The aggregator flattened every window's tabs into the saved file; main then adopted the whole blob on launch, so detached windows' ephemeral tabs (and Pane N drag-out artifacts) accumulated without bound. - TabStrip: portal the close-confirm popover to <body> with fixed, viewport-clamped positioning so the horizontally-scrolling strip can't clip it and it never runs off a window edge. - styles.css: make themed ::-webkit-scrollbar global, not just xterm viewport. - LeafPane: B1 drag-out ghost chip (portal, edge-pinned, orange detach state). - App.tsx: moveToNewWindow waits briefly for pane registration instead of failing instantly on an in-flight spawn/adopt. - gitignore cargo-test.lo*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
638 lines
22 KiB
TypeScript
638 lines
22 KiB
TypeScript
import {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useCallback,
|
||
type KeyboardEvent,
|
||
type MouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||
import { useOrchestration } from "./orchestration";
|
||
import XtermPane from "../../components/XtermPane";
|
||
import type { SpawnSpec } from "../../ipc";
|
||
import "./LeafPane.css";
|
||
|
||
const IDLE_THRESHOLD_MS = 5000;
|
||
|
||
/** How far past a viewport edge the cursor must travel before a release is
|
||
* treated as "drag pane out of window" instead of "drop on empty space
|
||
* inside this window". Picked so an accidental release on the OS titlebar
|
||
* (~30px tall) stays inside the threshold. */
|
||
const PANE_DRAG_OUT_MARGIN = 60;
|
||
|
||
/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */
|
||
const isFarOutsideViewport = (x: number, y: number) =>
|
||
x < -PANE_DRAG_OUT_MARGIN ||
|
||
x > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
|
||
y < -PANE_DRAG_OUT_MARGIN ||
|
||
y > window.innerHeight + PANE_DRAG_OUT_MARGIN;
|
||
|
||
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.
|
||
const lastDataTimeRef = useRef(Date.now());
|
||
const [isIdle, setIsIdle] = useState(false);
|
||
const onDataReceived = useCallback(() => {
|
||
lastDataTimeRef.current = Date.now();
|
||
setIsIdle((cur) => {
|
||
if (cur) orch.reportLeafIdle(leaf.id, false);
|
||
return false;
|
||
});
|
||
}, [orch.reportLeafIdle, leaf.id]);
|
||
useEffect(() => {
|
||
const id = window.setInterval(() => {
|
||
const dt = Date.now() - lastDataTimeRef.current;
|
||
const nowIdle = dt >= IDLE_THRESHOLD_MS;
|
||
setIsIdle((cur) => {
|
||
if (cur === nowIdle) return cur;
|
||
orch.reportLeafIdle(leaf.id, nowIdle);
|
||
return nowIdle;
|
||
});
|
||
}, 1000);
|
||
return () => clearInterval(id);
|
||
}, [leaf.id, orch.reportLeafIdle]);
|
||
// 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) => {
|
||
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 () => 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);
|
||
}, []);
|
||
|
||
// ---- right-click context menu ------------------------------------------
|
||
// Single entry in v1: "Move to new window" (pops the pane out into a
|
||
// fresh top-level tiletopia window without losing the PTY).
|
||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||
const openContextMenu = useCallback(
|
||
(e: MouseEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||
},
|
||
[],
|
||
);
|
||
const closeContextMenu = useCallback(() => setMenuPos(null), []);
|
||
useEffect(() => {
|
||
if (!menuPos) return;
|
||
const onDocClick = () => setMenuPos(null);
|
||
const onEsc = (e: globalThis.KeyboardEvent) => {
|
||
if (e.key === "Escape") setMenuPos(null);
|
||
};
|
||
// Defer attaching the click listener so the click that opened the menu
|
||
// doesn't immediately close it.
|
||
const t = window.setTimeout(() => {
|
||
window.addEventListener("click", onDocClick);
|
||
window.addEventListener("keydown", onEsc, true);
|
||
}, 0);
|
||
return () => {
|
||
clearTimeout(t);
|
||
window.removeEventListener("click", onDocClick);
|
||
window.removeEventListener("keydown", onEsc, true);
|
||
};
|
||
}, [menuPos]);
|
||
|
||
// ---- 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,
|
||
);
|
||
// Cursor-following ghost shown while dragging the toolbar. `detach` flips
|
||
// true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN,
|
||
// mirroring the release condition in onToolbarPointerUp so the ghost
|
||
// previews what a release right now would do.
|
||
const [dragGhost, setDragGhost] = useState<{
|
||
x: number;
|
||
y: number;
|
||
detach: boolean;
|
||
flipX: boolean;
|
||
flipY: 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);
|
||
// Move the cursor-following ghost (B1). It has pointer-events:none so
|
||
// it doesn't interfere with the elementFromPoint hit-test above.
|
||
// A webview can't paint outside its own OS window, so once the cursor
|
||
// crosses the edge we clamp the chip to the viewport (and flip it to
|
||
// the cursor's inner side near right/bottom) so it stays visible and
|
||
// its `detach` styling is what previews the release. `detach` itself
|
||
// is computed from the RAW cursor position so the preview is accurate.
|
||
const GHOST_PAD = 4;
|
||
const FLIP_X_ZONE = 180; // ~max chip width
|
||
const FLIP_Y_ZONE = 48;
|
||
setDragGhost({
|
||
x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)),
|
||
y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)),
|
||
detach: isFarOutsideViewport(e.clientX, e.clientY),
|
||
flipX: e.clientX > window.innerWidth - FLIP_X_ZONE,
|
||
flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE,
|
||
});
|
||
},
|
||
[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;
|
||
setDragGhost(null);
|
||
if (!wasDragging) return;
|
||
document.body.style.cursor = "";
|
||
|
||
const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY);
|
||
|
||
if (releasedFarOutside) {
|
||
// Cancel any in-flight swap state without committing, then pop
|
||
// this pane into a fresh window. moveToNewWindow handles the
|
||
// PTY-handoff + closeLeaf in the source.
|
||
orch.endHeaderDrag(false);
|
||
orch.moveToNewWindow(leaf.id);
|
||
} else {
|
||
orch.endHeaderDrag(true);
|
||
}
|
||
},
|
||
[orch.endHeaderDrag, orch.moveToNewWindow, leaf.id],
|
||
);
|
||
|
||
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;
|
||
setDragGhost(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}
|
||
onContextMenu={openContextMenu}
|
||
>
|
||
{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}
|
||
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
|
||
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>
|
||
{menuPos && (
|
||
<div
|
||
className="pane-context-menu"
|
||
style={{
|
||
position: "fixed",
|
||
top: menuPos.y,
|
||
left: menuPos.x,
|
||
}}
|
||
role="menu"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="pane-context-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
closeContextMenu();
|
||
orch.moveToNewWindow(leaf.id);
|
||
}}
|
||
>
|
||
Move to new window
|
||
</button>
|
||
</div>
|
||
)}
|
||
{dragGhost &&
|
||
createPortal(
|
||
<div
|
||
className={`pane-drag-ghost${dragGhost.detach ? " detach" : ""}`}
|
||
style={{
|
||
left: dragGhost.x,
|
||
top: dragGhost.y,
|
||
transform: `translate(${
|
||
dragGhost.flipX ? "calc(-100% - 12px)" : "12px"
|
||
}, ${dragGhost.flipY ? "calc(-100% - 12px)" : "12px"})`,
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
<span className="pane-drag-ghost-label">{labelText}</span>
|
||
{dragGhost.detach && (
|
||
<span className="pane-drag-ghost-hint">↗ New window</span>
|
||
)}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
);
|
||
}
|