Files
spaceshell/app/src/LayoutEngine.tsx
T
vasyansk ee845e15b3 Add full disk access checks and settings
Add background themes and custom images

Add shell command logging toggle

Add UTF-8 locale guarantee for PTY

Add Claude hook settings injection

Add hotkey system for GUI

Add glass panel styling

Add search disabled state for agent panels

Add zoom toggle command

Add device report filtering

Add entitlements for notarization

Update version to 0.1.27
2026-06-15 22:26:06 +07:00

329 lines
16 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } from "lucide-react";
import { Terminal } from "@xterm/xterm";
import { TerminalView } from "./TerminalView";
import { SearchBar } from "./SearchBar";
import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface, closeSurfaceCmd } from "./socketBridge";
interface Props {
workspaceId: string;
layout: LayoutNode | null;
/** surface_id -> running flag, from the latest status/events. */
running: Record<string, boolean>;
states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>;
focusedId: string | null;
onFocus: (id: string) => void;
zoomed: string | null;
/** The surface whose scrollback search bar is open, or null. Anchored to the
* panel it was opened on — it does NOT follow focus. */
searchSurfaceId: string | null;
searchNonce: number;
onCloseSearch: () => void;
font: { family: string; size: number } | null;
palette: Record<string, string> | null;
}
type Edge = "left" | "right" | "top" | "bottom";
interface DropTarget { id: string; edge: Edge }
function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
const px = (clientX - r.left) / r.width;
const py = (clientY - r.top) / r.height;
const d: Record<Edge, number> = { left: px, right: 1 - px, top: py, bottom: 1 - py };
return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left");
}
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
function shortPath(cwd: string): string {
const leaf = cwd.split("/").filter(Boolean).pop();
return leaf ? `~/${leaf}` : cwd;
}
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) {
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
const [drop, setDrop] = useState<DropTarget | null>(null);
const dropRef = useRef<DropTarget | null>(null);
dropRef.current = drop;
const startPanelDrag = (srcId: string, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
let active = false;
const prevUserSelect = document.body.style.userSelect;
const move = (ev: MouseEvent) => {
if (!active) {
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return;
active = true;
document.body.style.userSelect = "none";
}
const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest("[data-surface-id]") as HTMLElement | null;
const tid = el?.getAttribute("data-surface-id");
if (!el || !tid || tid === srcId) { setDrop(null); return; }
setDrop({ id: tid, edge: edgeAt(ev.clientX, ev.clientY, el.getBoundingClientRect()) });
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
document.body.style.userSelect = prevUserSelect;
const d = dropRef.current;
setDrop(null);
if (active && d && d.id !== srcId) void moveSurface(srcId, d.id, d.edge);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette };
if (zoomed) {
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node node={{ leaf: { surface_id: zoomed } }} path={[]} {...shared} />
</div>
);
}
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node node={layout} path={[]} {...shared} />
</div>
);
}
interface NodeProps {
workspaceId: string; node: LayoutNode; path: number[];
running: Record<string, boolean>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
zoomed: string | null;
drop: DropTarget | null;
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
searchSurfaceId: string | null;
searchNonce: number;
onCloseSearch: () => void;
font: { family: string; size: number } | null;
palette: Record<string, string> | null;
}
function Node({ node, path, ...rest }: NodeProps) {
if ("leaf" in node) {
return <Leaf id={node.leaf.surface_id} {...rest} />;
}
return <SplitView split={node.split} path={path} {...rest} />;
}
const NERD_FALLBACK_LE = "'Symbols Nerd Font Mono'";
const fontStackLE = (family: string | null) =>
family ? `'${family}', ${NERD_FALLBACK_LE}, monospace`
: `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK_LE}, monospace`;
function xtermThemeLE(p: Record<string, string>) {
return {
background: p["term-bg"] ?? p["bg-panel"],
foreground: p["text-primary"],
cursor: p["text-primary"],
selectionBackground: p["search-match"],
};
}
function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record<string, string> | null }) {
const hostRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const term = new Terminal({
fontFamily: fontStackLE(font?.family ?? null),
fontSize: font?.size ?? 13,
theme: palette ? xtermThemeLE(palette) : undefined,
allowTransparency: true, // term-bg may be transparent under a background theme
cursorBlink: false,
disableStdin: true,
scrollback: 0,
});
term.open(host);
let disposed = false;
void attachSurface(surfaceId, () => {}).then((res) => {
if (!disposed && res.snapshot) term.write(res.snapshot);
});
return () => { disposed = true; term.dispose(); void detachSurface(surfaceId); };
}, [surfaceId, font, palette]); // eslint-disable-line react-hooks/exhaustive-deps
return <div ref={hostRef} style={{ position: "absolute", inset: 0, opacity: 0.45, pointerEvents: "none" }} />;
}
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit<NodeProps, "node" | "path"> & { id: string }) {
const focused = focusedId === id;
const dropEdge = drop && drop.id === id ? drop.edge : null;
const card = (inner: React.ReactNode) => (
<div
data-surface-id={id}
onMouseDown={() => onFocus(id)}
style={{
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: "transparent",
borderRadius: 8, overflow: "hidden",
// Constant 2px border, color-only on focus. A width change (1px<->2px)
// would resize the inner content box, fire ResizeObserver -> fit -> PTY
// SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus
// switch (the "stacked prompts" bug).
border: `2px solid ${focused ? COLORS.accent : COLORS.borderSubtle}`,
boxSizing: "border-box",
}}
>
{/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent
cells show this through. Crucially the terminal canvas is NOT a descendant
of a backdrop-filter element — under WKWebView that clips/smears the WebGL
canvas (first-glyph clip at column 0, smearing on scroll). With "none" the
glass is the solid bg-panel so the classic look is unchanged. */}
<div style={{ position: "absolute", inset: 0, zIndex: 0, background: COLORS.panelGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, pointerEvents: "none" }} />
<div style={{ position: "relative", zIndex: 1, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
{inner}
</div>
{dropEdge && <DropIndicator edge={dropEdge} />}
</div>
);
if (running[id] === false) {
return card(
<div style={{ position: "relative", height: "100%", width: "100%" }}>
<StoppedSnapshot surfaceId={id} font={font} palette={palette} />
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, color: COLORS.textSecondary, background: "rgba(0,0,0,0.35)" }}>
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Stopped</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => void restartSurface(id, true)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.accent, color: COLORS.bgApp, border: "none", borderRadius: 7, fontSize: 12, fontWeight: 600 }}>
<Play size={13} /> Resume
</button>
<button onClick={() => void restartSurface(id, false)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<RotateCw size={13} /> Restart fresh
</button>
<button onClick={() => void closeSurfaceCmd(id)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<X size={13} /> Close
</button>
{zoomed === id && (
<button onClick={() => void setZoom(workspaceId, null)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<Minimize2 size={13} /> Exit zoom
</button>
)}
</div>
</div>
</div>
);
}
const spec = surfaces[id]?.spec;
const agent = spec?.agent_label ?? "shell";
const state = states[id] ?? "idle";
return card(
<>
<div
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
title="Drag to move this panel"
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
>
<GripVertical size={13} color={COLORS.textMuted} />
<StatusRing state={state} running={true} />
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
<span style={{ flex: 1 }} />
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
{state}
</span>
{zoomed === id
? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
<X size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Close panel"
onMouseDown={(e) => { e.stopPropagation(); void closeSurfaceCmd(id); }}
onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }}
onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} font={font} palette={palette} focused={focused} />
</div>
{searchSurfaceId === id && (
<SearchBar surfaceId={id} reopenNonce={searchNonce} onClose={onCloseSearch} />
)}
</>
);
}
function DropIndicator({ edge }: { edge: Edge }) {
const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 };
const map: Record<Edge, React.CSSProperties> = {
left: { ...base, top: 0, bottom: 0, left: 0, width: "50%" },
right: { ...base, top: 0, bottom: 0, right: 0, width: "50%" },
top: { ...base, left: 0, right: 0, top: 0, height: "50%" },
bottom: { ...base, left: 0, right: 0, bottom: 0, height: "50%" },
};
return <div style={map[edge]} />;
}
function SplitView({ split, path, ...rest }: Omit<NodeProps, "node"> & { split: Extract<LayoutNode, { split: unknown }>["split"] }) {
const { orient, ratios, children } = split;
const { workspaceId } = rest;
const dir = orient === "h" ? "row" : "column";
const containerRef = useRef<HTMLDivElement>(null);
const [live, setLive] = useState<number[] | null>(null);
// Drop any local override once the authoritative ratios arrive from the daemon.
useEffect(() => { setLive(null); }, [ratios.join(",")]);
const effective = live ?? ratios;
const startDrag = (i: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const container = containerRef.current;
if (!container) return;
const total = orient === "h" ? container.clientWidth : container.clientHeight;
if (total <= 0) return;
const start = orient === "h" ? e.clientX : e.clientY;
const base = [...ratios];
const sum = base.reduce((a, b) => a + b, 0) || 1;
const move = (ev: MouseEvent) => {
const cur = orient === "h" ? ev.clientX : ev.clientY;
// Accumulated delta from drag start — not an incremental step — so the
// panel tracks the pointer 1:1 instead of crawling one echo at a time.
const deltaFrac = ((cur - start) / total) * sum;
const next = [...base];
next[i] = Math.max(0.05, base[i] + deltaFrac);
next[i + 1] = Math.max(0.05, (base[i + 1] ?? 1) - deltaFrac);
setLive(next);
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; });
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
return (
<div ref={containerRef} style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
{children.map((child, i) => (
<div key={i} style={{ flexGrow: effective[i] ?? 1, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "visible", position: "relative", display: "flex" }}>
<Node node={child} path={[...path, i]} {...rest} />
{i < children.length - 1 && (
<div onMouseDown={(e) => startDrag(i, e)}
style={{
position: "absolute", zIndex: 4,
...(orient === "h"
? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" }
: { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }),
}} />
)}
</div>
))}
</div>
);
}