ee845e15b3
Add background themes and custom images Add shell command logging toggle Add UTF-8 locale guarantee for PTY Add Claude hook settings injection Add hotkey system for GUI Add glass panel styling Add search disabled state for agent panels Add zoom toggle command Add device report filtering Add entitlements for notarization Update version to 0.1.27
269 lines
14 KiB
TypeScript
269 lines
14 KiB
TypeScript
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<Record<string, boolean>>({});
|
|
const [hovered, setHovered] = useState<string | null>(null);
|
|
const [drag, setDrag] = useState<{ id: string; section: string } | null>(null);
|
|
const [dropAt, setDropAt] = useState<DropAt | null>(null);
|
|
const [editing, setEditing] = useState<{ id: string; draft: string } | null>(null);
|
|
const dragRef = useRef<{ id: string; section: string } | null>(null);
|
|
const dropRef = useRef<DropAt | null>(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 (
|
|
<div key={w.id}>
|
|
{showLine && <div style={{ height: 2, background: COLORS.accent, borderRadius: 2, margin: "1px 0" }} />}
|
|
<div
|
|
data-ws-id={w.id} data-ws-section={section} data-ws-index={index}
|
|
onClick={() => 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,
|
|
}}>
|
|
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
|
|
{editing?.id === w.id ? (
|
|
<input
|
|
autoFocus
|
|
value={editing.draft}
|
|
onFocus={(e) => 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" }}
|
|
/>
|
|
) : (
|
|
<span
|
|
onDoubleClick={(e) => { 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}
|
|
</span>
|
|
)}
|
|
{(hovered === w.id || w.pinned) && (
|
|
<Star size={14} fill={w.pinned ? COLORS.stWait : "none"} color={w.pinned ? COLORS.stWait : COLORS.textMuted}
|
|
style={{ cursor: "pointer", flex: "0 0 14px" }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => { e.stopPropagation(); togglePin(w); }} />
|
|
)}
|
|
{hovered === w.id && (
|
|
<Trash2 size={14} color={COLORS.textMuted}
|
|
style={{ cursor: "pointer", flex: "0 0 14px" }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => { e.stopPropagation(); onDelete(w); }} />
|
|
)}
|
|
{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>
|
|
{showLineEnd && <div style={{ height: 2, background: COLORS.accent, borderRadius: 2, margin: "1px 0" }} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div style={{ position: "relative", zIndex: 20, display: "flex", flexDirection: "column", alignItems: "center", width: 48, flex: "0 0 48px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, height: "100%", padding: "10px 0", boxSizing: "border-box", borderRight: `1px solid ${COLORS.borderSubtle}`, gap: 8 }}>
|
|
<button onClick={onNew} title="New workspace"
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: 8, background: COLORS.bgElevated, border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textPrimary, cursor: "pointer" }}>
|
|
<Plus size={15} />
|
|
</button>
|
|
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", alignItems: "center", gap: 6, minHeight: 0 }}>
|
|
{rail.map((w) => {
|
|
const isActive = w.id === activeId;
|
|
return (
|
|
<button key={w.id} onClick={() => onSelect(w.id)} title={w.name}
|
|
style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, borderRadius: 8, cursor: "pointer", background: isActive ? COLORS.bgElevated : "transparent", border: `1px solid ${isActive ? COLORS.borderStrong : "transparent"}` }}>
|
|
<span style={{ width: 12, height: 12, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box" }} />
|
|
{w.unread && <span style={{ position: "absolute", top: 3, right: 3, width: 7, height: 7, borderRadius: "50%", background: COLORS.accent }} />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<span title={connected ? "spaceshd · live" : "spaceshd · offline"}
|
|
style={{ width: 8, height: 8, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 8px" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.sidebarGlass, backdropFilter: COLORS.panelBlur, WebkitBackdropFilter: COLORS.panelBlur, 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 }}>
|
|
{pinned.length > 0 && (
|
|
<div style={{ marginBottom: 8 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px" }}>
|
|
<Star size={12} fill={COLORS.stWait} color={COLORS.stWait} />
|
|
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>FAVORITES</span>
|
|
</div>
|
|
{section("fav", pinned)}
|
|
</div>
|
|
)}
|
|
{groups.sort((a, b) => a.order - b.order).map((g) => {
|
|
const open = !collapsed[g.id];
|
|
const items = byGroup(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 && section(`g:${g.id}`, items)}
|
|
</div>
|
|
);
|
|
})}
|
|
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{section("ungrouped", ungrouped)}</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>
|
|
);
|
|
}
|