Files
spaceshell/app/src/Sidebar.tsx
T
vasyansk ee845e15b3 Add full disk access checks and settings
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
2026-06-15 22:26:06 +07:00

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>
);
}