Files
spaceshell/app/src/LayoutEngine.tsx
T
vasyansk daf87d3c09 feat(app): panel zoom — full-grid render + header toggle
Wire Cmd::SetZoom through Tauri bridge (set_zoom command), add zoomed
field to WorkspaceView, short-circuit LayoutEngine to render only the
zoomed panel full-grid, and toggle Maximize2/Minimize2 in panel header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:33:46 +07:00

170 lines
7.6 KiB
TypeScript

import { useRef } from "react";
import { Maximize2, Minimize2, RotateCw } from "lucide-react";
import { TerminalView } from "./TerminalView";
import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface, setZoom } 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;
}
/** 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 }: Props) {
if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
if (zoomed) {
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</div>
);
}
return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</div>
);
}
function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: {
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;
}) {
if ("leaf" in node) {
const id = node.leaf.surface_id;
const focused = focusedId === id;
const card = (inner: React.ReactNode) => (
<div
onMouseDown={() => onFocus(id)}
style={{
display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
boxSizing: "border-box",
}}
>
{inner}
</div>
);
if (running[id] === false) {
return card(
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => void restartSurface(id)}
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
</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>
);
}
const spec = surfaces[id]?.spec;
const agent = spec?.agent_label ?? "shell";
const state = states[id] ?? "idle";
return card(
<>
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<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); }} />}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} />
</div>
</>
);
}
const { orient, ratios, children } = node.split;
const dir = orient === "h" ? "row" : "column";
return (
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
{children.map((child, i) => (
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient}
onResize={(deltaFrac) => {
const next = [...ratios];
next[i] = Math.max(0.05, next[i] + deltaFrac);
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
void setRatios(workspaceId, path, next);
}}>
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
</Pane>
))}
</div>
);
}
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const startDrag = (e: React.MouseEvent) => {
e.preventDefault();
const parent = ref.current?.parentElement;
if (!parent) return;
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
let last = orient === "h" ? e.clientX : e.clientY;
const move = (ev: MouseEvent) => {
const cur = orient === "h" ? ev.clientX : ev.clientY;
const delta = (cur - last) / total;
last = cur;
onResize(delta);
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
return (
<>
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}>
{children}
</div>
{!isLast && (
<div onMouseDown={startDrag}
style={{
flex: "0 0 10px",
cursor: orient === "h" ? "col-resize" : "row-resize",
background: "transparent",
}} />
)}
</>
);
}