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)); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m`; if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; } function aggregate(w: WorkspaceView): SurfaceState | "stopped" { const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"]; const running = Object.values(w.surfaces).filter((s) => s.running); if (running.length === 0) return "stopped"; for (const st of order) { if (running.some((s) => s.state === st)) return st; } return "idle"; } interface DropAt { section: string; index: number } export function Sidebar({ railMode, groups, workspaces, activeId, onSelect, onNew, onDelete, health, connected, }: { railMode: boolean; 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 [editing, setEditing] = useState<{ id: string; draft: string } | null>(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 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 togglePin = (w: WorkspaceView) => { void setWorkspaceMeta(w.id, { pinned: !w.pinned }); }; const commitRename = () => { setEditing((cur) => { if (cur) { const name = cur.draft.trim(); const w = workspaces.find((x) => x.id === cur.id); if (name && w && name !== w.name) void setWorkspaceMeta(cur.id, { name }); } return null; }); }; // 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 (
{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, }}> {editing?.id === w.id ? ( e.target.select()} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onChange={(e) => setEditing({ id: w.id, draft: e.target.value })} onBlur={commitRename} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); commitRename(); } else if (e.key === "Escape") { e.preventDefault(); setEditing(null); } }} style={{ flex: 1, minWidth: 0, background: COLORS.bgApp, color: COLORS.textPrimary, border: `1px solid ${COLORS.accent}`, borderRadius: 4, padding: "2px 6px", fontFamily: FONT.ui, fontSize: 13, outline: "none" }} /> ) : ( { e.stopPropagation(); setEditing({ id: w.id, draft: w.name }); }} title="Двойной клик — переименовать" style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> {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)); // Collapsed: a narrow rail of status rings so terminal activity stays visible. if (railMode) { const rail = [ ...pinned, ...groups.slice().sort((a, b) => a.order - b.order).flatMap((g) => byGroup(g.id)), ...ungrouped, ]; return (
{rail.map((w) => { const isActive = w.id === activeId; return ( ); })}
); } return (
{pinned.length > 0 && (
FAVORITES
{section("fav", pinned)}
)} {groups.sort((a, b) => a.order - b.order).map((g) => { const open = !collapsed[g.id]; const items = byGroup(g.id); return (
setCollapsed((c) => ({ ...c, [g.id]: open }))} style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}> {open ? : } {g.name.toUpperCase()}
{open && section(`g:${g.id}`, items)}
); })} {ungrouped.length > 0 &&
{section("ungrouped", ungrouped)}
}
{connected ? "spaceshd · live" : "spaceshd · offline"} {health ? fmtUptime(health.started_at_ms) : ""}
); }