diff --git a/app/src/App.tsx b/app/src/App.tsx index 16d6023..b9b4d2e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,10 +4,11 @@ import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; import { Wizard } from "./Wizard"; +import { ConfirmDelete } from "./ConfirmDelete"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; import { COLORS } from "./theme"; -import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth } from "./socketBridge"; +import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd } from "./socketBridge"; import type { EventRecord, DaemonHealth } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; @@ -29,6 +30,7 @@ export function App() { const [states, setStates] = useState>({}); const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true)); const [health, setHealth] = useState(null); @@ -137,7 +139,7 @@ export function App() {
setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
- {sidebarOpen && setWizard(true)} health={health} connected={connected} />} + {sidebarOpen && setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
{active && ( { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> @@ -157,6 +159,24 @@ export function App() { )}
{wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} + {deleteTarget && ( + s.running).length} + onCancel={() => setDeleteTarget(null)} + onConfirm={() => { + const tgt = deleteTarget; + setDeleteTarget(null); + void closeWorkspaceCmd(tgt.id).then(() => { + if (activeId === tgt.id) { + const next = workspaces.find((w) => w.id !== tgt.id); + setActiveId(next ? next.id : null); + } + void refresh(); + }); + }} + /> + )}
); } diff --git a/app/src/ConfirmDelete.tsx b/app/src/ConfirmDelete.tsx new file mode 100644 index 0000000..9832767 --- /dev/null +++ b/app/src/ConfirmDelete.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from "react"; +import { COLORS, FONT } from "./theme"; + +/** Confirmation modal for deleting a workspace. Warns when live terminals + * would be killed, but still allows the delete (per product decision). */ +export function ConfirmDelete({ + name, + activeCount, + onConfirm, + onCancel, +}: { + name: string; + activeCount: number; + onConfirm: () => void; + onCancel: () => void; +}) { + const confirmRef = useRef(null); + useEffect(() => { confirmRef.current?.focus(); }, []); + + function onKeyDown(e: React.KeyboardEvent) { + e.stopPropagation(); + if (e.key === "Escape") { e.preventDefault(); onCancel(); } + else if (e.key === "Enter") { e.preventDefault(); onConfirm(); } + } + + return ( +
+
e.stopPropagation()} + onKeyDown={onKeyDown} + style={{ width: 420, background: COLORS.bgApp, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 14, padding: 24, color: COLORS.textPrimary, fontFamily: FONT.ui }} + > +
Delete workspace
+
+ Delete {name}? This removes the workspace and its layout. +
+ {activeCount > 0 && ( +
+ {activeCount} active terminal{activeCount === 1 ? "" : "s"} will be terminated. +
+ )} +
+ + +
+
+
+ ); +} diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx index 2d44737..3c7553c 100644 --- a/app/src/Sidebar.tsx +++ b/app/src/Sidebar.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from "react"; -import { Plus, ChevronDown, ChevronRight } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Plus, ChevronDown, ChevronRight, Star, Trash2 } from "lucide-react"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { DaemonHealth } from "./socketBridge"; +import { setWorkspaceMeta } from "./socketBridge"; function fmtUptime(startedMs: number): string { const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); @@ -22,45 +23,131 @@ function aggregate(w: WorkspaceView): SurfaceState | "stopped" { return "idle"; } +interface DropAt { section: string; index: number } + export function Sidebar({ - groups, workspaces, activeId, onSelect, onNew, health, connected, + groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected, }: { groups: Group[]; workspaces: WorkspaceView[]; activeId: string | null; onSelect: (id: string) => void; onNew: () => void; + onDelete: (w: WorkspaceView) => void; health: DaemonHealth | null; connected: boolean; }) { const [collapsed, setCollapsed] = useState>({}); + const [hovered, setHovered] = useState(null); + const [drag, setDrag] = useState<{ id: string; section: string } | null>(null); + const [dropAt, setDropAt] = useState(null); + const dragRef = useRef<{ id: string; section: string } | null>(null); + const dropRef = useRef(null); const [, setTick] = useState(0); + dragRef.current = drag; + dropRef.current = dropAt; + useEffect(() => { const t = setInterval(() => setTick((n) => n + 1), 30000); return () => clearInterval(t); }, []); - const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); + + const pinned = workspaces.filter((w) => w.pinned).sort((a, b) => a.order - b.order); + const byGroup = (gid: string | null) => + workspaces.filter((w) => !w.pinned && (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); const ungrouped = byGroup(null); - const row = (w: WorkspaceView) => { + const togglePin = (w: WorkspaceView) => { void setWorkspaceMeta(w.id, { pinned: !w.pinned }); }; + + // Persist a new ordering for one section by reassigning sequential `order` + // values (per-section; values never compared across sections). + const commitReorder = (items: WorkspaceView[], fromId: string, toIndex: number) => { + const fromIndex = items.findIndex((w) => w.id === fromId); + if (fromIndex < 0) return; + const next = [...items]; + const [moved] = next.splice(fromIndex, 1); + next.splice(Math.min(toIndex, next.length), 0, moved); + next.forEach((w, i) => { if (w.order !== i) void setWorkspaceMeta(w.id, { order: i }); }); + }; + + const startDrag = (w: WorkspaceView, section: string, items: WorkspaceView[], e: React.MouseEvent) => { + if (e.button !== 0) return; + 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"; + setDrag({ id: w.id, section }); + } + const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest("[data-ws-id]") as HTMLElement | null; + if (!el || el.getAttribute("data-ws-section") !== section) { setDropAt(null); return; } + const idx = Number(el.getAttribute("data-ws-index")); + const r = el.getBoundingClientRect(); + const after = ev.clientY > r.top + r.height / 2; + setDropAt({ section, index: after ? idx + 1 : idx }); + }; + const up = () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + document.body.style.userSelect = prevUserSelect; + const d = dropRef.current; + const dr = dragRef.current; + setDrag(null); setDropAt(null); + if (active && d && dr && d.section === section) commitReorder(items, dr.id, d.index); + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + }; + + const row = (w: WorkspaceView, section: string, items: WorkspaceView[], index: number) => { const isActive = w.id === activeId; + const showLine = dropAt && dropAt.section === section && dropAt.index === index; + const showLineEnd = dropAt && dropAt.section === section && dropAt.index === items.length && index === items.length - 1; return ( -
onSelect(w.id)} - style={{ - display: "flex", alignItems: "center", gap: 10, height: 34, padding: "0 8px", borderRadius: 6, cursor: "pointer", - background: isActive ? COLORS.bgElevated : "transparent", fontFamily: FONT.ui, fontSize: 13, - color: isActive ? COLORS.textPrimary : COLORS.textSecondary, - }}> - - {w.name} - {w.unread && } - - {Object.keys(w.surfaces).length} - +
+ {showLine &&
} +
onSelect(w.id)} + onMouseDown={(e) => startDrag(w, section, items, e)} + onMouseEnter={() => setHovered(w.id)} + onMouseLeave={() => setHovered((h) => (h === w.id ? null : h))} + style={{ + display: "flex", alignItems: "center", gap: 8, height: 34, padding: "0 8px", borderRadius: 6, + cursor: drag?.id === w.id ? "grabbing" : "pointer", + opacity: drag?.id === w.id ? 0.5 : 1, + background: isActive ? COLORS.bgElevated : "transparent", fontFamily: FONT.ui, fontSize: 13, + color: isActive ? COLORS.textPrimary : COLORS.textSecondary, + }}> + + {w.name} + {(hovered === w.id || w.pinned) && ( + e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); togglePin(w); }} /> + )} + {hovered === w.id && ( + e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); onDelete(w); }} /> + )} + {w.unread && } + + {Object.keys(w.surfaces).length} + +
+ {showLineEnd &&
}
); }; + const section = (key: string, items: WorkspaceView[]) => items.map((w, i) => row(w, key, items, i)); + return (