diff --git a/app/package-lock.json b/app/package-lock.json index f6be5ad..75a4d5c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", @@ -1463,6 +1464,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/addon-search": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", diff --git a/app/package.json b/app/package.json index e751bc6..fe0da1d 100644 --- a/app/package.json +++ b/app/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", + "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index bbf58cc..70c2648 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,6 +13,15 @@ import type { EventRecord, DaemonHealth } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; +/** Read a boolean UI flag from localStorage, falling back to `def`. */ +function loadFlag(key: string, def: boolean): boolean { + try { const v = localStorage.getItem(key); return v === null ? def : v === "1"; } + catch { return def; } +} +function saveFlag(key: string, value: boolean): void { + try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ } +} + export function App() { const [groups, setGroups] = useState([]); const [workspaces, setWorkspaces] = useState([]); @@ -21,7 +30,8 @@ export function App() { const [states, setStates] = useState>({}); const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); - const [eventsOpen, setEventsOpen] = useState(true); + const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); + const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true)); const [health, setHealth] = useState(null); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); @@ -104,6 +114,9 @@ export function App() { return () => window.removeEventListener("keydown", onKey); }, []); + useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]); + useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]); + const unread = useMemo(() => events.filter((e) => !e.read).length, [events]); const active = workspaces.find((w) => w.id === activeId) ?? null; const leaves = active ? leafIds(active.layout) : []; @@ -117,9 +130,9 @@ export function App() { return (
- setEventsOpen((v) => !v)} unread={unread} /> + setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
- setWizard(true)} health={health} connected={connected} /> + {sidebarOpen && setWizard(true)} health={health} connected={connected} />}
{active && ( { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index e1c9e8b..623ffba 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -1,10 +1,10 @@ -import { useRef } from "react"; -import { Maximize2, Minimize2, RotateCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Maximize2, Minimize2, RotateCw, GripVertical } 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"; +import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge"; interface Props { workspaceId: string; @@ -18,6 +18,16 @@ interface Props { zoomed: 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 = { 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 ~/ style label for the panel header. */ function shortPath(cwd: string): string { const leaf = cwd.split("/").filter(Boolean).pop(); @@ -25,145 +35,211 @@ function shortPath(cwd: string): string { } export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: 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(null); + const dropRef = useRef(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
Empty workspace — apply a preset to add panels.
; } + const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag }; if (zoomed) { return (
- +
); } return (
- +
); } -function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: { +interface NodeProps { workspaceId: string; node: LayoutNode; path: number[]; running: Record; states: Record; surfaces: Record; focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; -}) { + drop: DropTarget | null; + onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; +} + +function Node({ node, path, ...rest }: NodeProps) { if ("leaf" in node) { - const id = node.leaf.surface_id; - const focused = focusedId === id; - const card = (inner: React.ReactNode) => ( -
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} -
- ); + return ; + } + return ; +} - if (running[id] === false) { - return card( -
-
Process exited
-
- - {zoomed === id && ( - - )} -
-
- ); - } +function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit & { id: string }) { + const focused = focusedId === id; + const dropEdge = drop && drop.id === id ? drop.edge : null; - const spec = surfaces[id]?.spec; - const agent = spec?.agent_label ?? "shell"; - const state = states[id] ?? "idle"; + const card = (inner: React.ReactNode) => ( +
onFocus(id)} + style={{ + position: "relative", 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} + {dropEdge && } +
+ ); + + if (running[id] === false) { return card( - <> -
- - {agent} - {spec?.cwd && {shortPath(spec.cwd)}} - - - {state} - - {zoomed === id - ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> - : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} +
+
Process exited
+
+ + {zoomed === id && ( + + )}
-
- -
- +
); } - const { orient, ratios, children } = node.split; - const dir = orient === "h" ? "row" : "column"; - return ( -
- {children.map((child, i) => ( - { - 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); - }}> - - - ))} -
+ const spec = surfaces[id]?.spec; + const agent = spec?.agent_label ?? "shell"; + const state = states[id] ?? "idle"; + return card( + <> +
{ 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.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }} + > + + + {agent} + {spec?.cwd && {shortPath(spec.cwd)}} + + + {state} + + {zoomed === id + ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> + : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} +
+
+ +
+ ); } -function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) { - const ref = useRef(null); - const startDrag = (e: React.MouseEvent) => { +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 = { + 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
; +} + +function SplitView({ split, path, ...rest }: Omit & { split: Extract["split"] }) { + const { orient, ratios, children } = split; + const { workspaceId } = rest; + const dir = orient === "h" ? "row" : "column"; + const containerRef = useRef(null); + const [live, setLive] = useState(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(); - const parent = ref.current?.parentElement; - if (!parent) return; - const total = orient === "h" ? parent.clientWidth : parent.clientHeight; - let last = orient === "h" ? e.clientX : e.clientY; + 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; - const delta = (cur - last) / total; - last = cur; - onResize(delta); + // 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 ( - <> -
- {children} -
- {!isLast && ( -
- )} - +
+ {children.map((child, i) => ( +
+ + {i < children.length - 1 && ( +
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" }), + }} /> + )} +
+ ))} +
); } diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index 52e0116..661ca68 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; import { SearchAddon } from "@xterm/addon-search"; +import { FitAddon } from "@xterm/addon-fit"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; import { registerSearch, unregisterSearch } from "./searchRegistry"; @@ -25,6 +26,27 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { term.loadAddon(search); registerSearch(surfaceId, search); + const fit = new FitAddon(); + term.loadAddon(fit); + + // Fit the grid to the container and tell the daemon the new size. Coalesced + // through rAF so a burst of resize callbacks yields one resize per frame. + let rafId = 0; + let lastCols = 0, lastRows = 0; + const doFit = () => { + rafId = 0; + try { fit.fit(); } catch { return; } + if (term.cols !== lastCols || term.rows !== lastRows) { + lastCols = term.cols; + lastRows = term.rows; + void resizeSurface(surfaceId, term.cols, term.rows); + } + }; + const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); }; + + const ro = new ResizeObserver(scheduleFit); + ro.observe(ref.current); + // Input → daemon. const inputDisposable = term.onData((data) => { void sendInput(surfaceId, encoder.encode(data)); @@ -38,14 +60,15 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) { }).then((res) => { if (disposed) return; if (res.snapshot) term.write(res.snapshot); - if (res.cols && res.rows) { - term.resize(res.cols, res.rows); - void resizeSurface(surfaceId, res.cols, res.rows); - } + // Fit to the actual container rather than the daemon's stored geometry, + // then push the resulting size back so the PTY reflows to match. + scheduleFit(); }); return () => { disposed = true; + if (rafId) cancelAnimationFrame(rafId); + ro.disconnect(); inputDisposable.dispose(); void detachSurface(surfaceId); unregisterSearch(surfaceId); diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx index 72e3342..df90abc 100644 --- a/app/src/TopBar.tsx +++ b/app/src/TopBar.tsx @@ -1,4 +1,4 @@ -import { FolderGit2, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react"; +import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react"; import { COLORS, FONT } from "./theme"; import type { WorkspaceView } from "./layoutTypes"; import { leafIds } from "./layoutTypes"; @@ -29,11 +29,14 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl } export function TopBar({ - active, eventsOpen, onToggleEvents, unread, + active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, }: { active: WorkspaceView | null; eventsOpen: boolean; onToggleEvents: () => void; + onShowEvents: () => void; + sidebarOpen: boolean; + onToggleSidebar: () => void; unread: number; }) { return ( @@ -44,8 +47,8 @@ export function TopBar({ borderBottom: `1px solid ${COLORS.borderSubtle}`, }} > - {/* macOS traffic-light spacer — real lights are drawn by the window chrome. */} -
+ {/* Left: sidebar toggle, flush to the left edge. */} + } onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" /> {/* Workspace breadcrumb */}
@@ -67,10 +70,9 @@ export function TopBar({ {/* Right cluster */}
- } onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> } title="Search (mock)" />
- } title="Notifications (mock)" /> + } onClick={onShowEvents} active={eventsOpen} title="Open activity log" /> {unread > 0 && ( )}
+ } onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> } title="Settings (mock)" /> - +