Files
spaceshell/app/src/Sidebar.tsx
T
2026-06-10 12:09:35 +07:00

105 lines
5.5 KiB
TypeScript

import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
import type { DaemonHealth } 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";
}
export function Sidebar({
groups, workspaces, activeId, onSelect, onNew, health, connected,
}: {
groups: Group[];
workspaces: WorkspaceView[];
activeId: string | null;
onSelect: (id: string) => void;
onNew: () => void;
health: DaemonHealth | null;
connected: boolean;
}) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [, setTick] = useState(0);
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 ungrouped = byGroup(null);
const row = (w: WorkspaceView) => {
const isActive = w.id === activeId;
return (
<div key={w.id} onClick={() => 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,
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
<span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, flex: "0 0 7px" }} />}
<span style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 18, minWidth: 18, padding: "0 6px", borderRadius: 9, background: COLORS.bgApp, fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>
{Object.keys(w.surfaces).length}
</span>
</div>
);
};
return (
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
<button onClick={onNew}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7,
fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
}}>
<Plus size={15} />
New workspace
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>N</span>
</button>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2, minHeight: 0 }}>
{groups.sort((a, b) => a.order - b.order).map((g) => {
const open = !collapsed[g.id];
return (
<div key={g.id} style={{ marginBottom: 8 }}>
<div onClick={() => setCollapsed((c) => ({ ...c, [g.id]: open }))}
style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}>
{open ? <ChevronDown size={13} color={COLORS.textMuted} /> : <ChevronRight size={13} color={COLORS.textMuted} />}
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>{g.name.toUpperCase()}</span>
</div>
{open && byGroup(g.id).map(row)}
</div>
);
})}
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
</div>
<div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
</div>
</div>
);
}