From 7b47052a6fd6afb8d8e82e24e19230fff89072a1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 08:56:09 +0700 Subject: [PATCH 1/2] feat(spaceshd): pinned workspace field Add a `pinned` bool to the workspace model, threaded through proto (Workspace + WorkspaceView + SetWorkspaceMeta), the registry, the set_workspace_meta handler, persistence, the CLI mapping, and the Tauri bridge. serde(default) keeps existing state.json compatible (pinned=false). Backs the sidebar Favorites section. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/bridge.rs | 4 ++-- crates/spacesh-cli/src/mapping.rs | 1 + crates/spacesh-proto/src/message.rs | 2 ++ crates/spacesh-proto/src/workspace.rs | 23 ++++++++++++++++++++++- crates/spaceshd/src/registry.rs | 4 ++-- crates/spaceshd/src/server.rs | 3 ++- crates/spaceshd/src/state_store.rs | 1 + 7 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index 82c484d..1b20ade 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -269,14 +269,14 @@ pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Re } #[tauri::command] -pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option, group_id: Option, unread: Option, order: Option) -> Result { +pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option, group_id: Option, unread: Option, order: Option, pinned: Option) -> Result { // group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup". let gid = match group_id { None => None, Some(s) if s.is_empty() => Some(None), Some(s) => Some(Some(GroupId(s))), }; - data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?) + data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order, pinned }).await.map_err(|e| e.to_string())?) } #[tauri::command] diff --git a/crates/spacesh-cli/src/mapping.rs b/crates/spacesh-cli/src/mapping.rs index 9c964d7..cd6e52a 100644 --- a/crates/spacesh-cli/src/mapping.rs +++ b/crates/spacesh-cli/src/mapping.rs @@ -61,6 +61,7 @@ pub fn to_cmd(sub: Sub) -> Cmd { group_id: group.map(|g| if g.is_empty() { None } else { Some(GroupId(g)) }), unread, order, + pinned: None, }, Sub::Shutdown => Cmd::Shutdown, Sub::Completions { .. } => unreachable!("completions handled before dispatch"), diff --git a/crates/spacesh-proto/src/message.rs b/crates/spacesh-proto/src/message.rs index ea38600..ed8015a 100644 --- a/crates/spacesh-proto/src/message.rs +++ b/crates/spacesh-proto/src/message.rs @@ -104,6 +104,8 @@ pub enum Cmd { unread: Option, #[serde(default, skip_serializing_if = "Option::is_none")] order: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pinned: Option, }, CreateGroup { name: String, color: String }, SetGroup { diff --git a/crates/spacesh-proto/src/workspace.rs b/crates/spacesh-proto/src/workspace.rs index 6dfc0bd..52e71c7 100644 --- a/crates/spacesh-proto/src/workspace.rs +++ b/crates/spacesh-proto/src/workspace.rs @@ -39,6 +39,9 @@ pub struct Workspace { pub order: u32, #[serde(default)] pub unread: bool, + /// Pinned to the sidebar's Favorites section. + #[serde(default)] + pub pinned: bool, /// None = empty workspace (no panels yet). #[serde(default)] pub layout: Option, @@ -68,6 +71,8 @@ pub struct WorkspaceView { pub group_id: Option, pub order: u32, pub unread: bool, + #[serde(default)] + pub pinned: bool, pub layout: Option, #[serde(default)] pub zoomed: Option, @@ -103,6 +108,7 @@ mod tests { group_id: None, order: 0, unread: false, + pinned: false, layout: None, zoomed: None, surfaces: HashMap::new(), @@ -112,11 +118,26 @@ mod tests { assert_eq!(back, w); } + #[test] + fn workspace_pinned_round_trips_and_defaults_false() { + let w = Workspace { + id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), + group_id: None, order: 0, unread: false, pinned: true, layout: None, + zoomed: None, surfaces: HashMap::new(), + }; + let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap(); + assert!(back.pinned); + // Old state.json without the field deserializes to pinned=false. + let legacy = r#"{"id":"w_1","path":"/tmp/p","name":"p","order":0,"surfaces":{}}"#; + let old: Workspace = serde_json::from_str(legacy).unwrap(); + assert!(!old.pinned); + } + #[test] fn workspace_round_trips_with_zoom() { let w = Workspace { id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), - group_id: None, order: 0, unread: false, layout: None, + group_id: None, order: 0, unread: false, pinned: false, layout: None, zoomed: Some(SurfaceId("s_1".into())), surfaces: HashMap::new(), }; let back: Workspace = serde_json::from_str(&serde_json::to_string(&w).unwrap()).unwrap(); diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index ac9f679..a121cb3 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -51,7 +51,7 @@ impl Registry { let order = self.workspaces.len() as u32; self.workspaces.insert(id.clone(), Workspace { id: id.clone(), path: key.clone(), name, group_id: None, order, - unread: false, layout: None, zoomed: None, surfaces: HashMap::new(), + unread: false, pinned: false, layout: None, zoomed: None, surfaces: HashMap::new(), }); self.by_path.insert(key, id.clone()); (id, true) @@ -168,7 +168,7 @@ impl Registry { }).collect(); WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), - group_id: w.group_id.clone(), order: w.order, unread: w.unread, + group_id: w.group_id.clone(), order: w.order, unread: w.unread, pinned: w.pinned, layout: w.layout.clone(), zoomed: w.zoomed.clone(), surfaces, } } diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index e5a9c2b..74ac084 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -461,12 +461,13 @@ async fn handle_request( let _ = out.send(ok(id, serde_json::Value::Null)).await; } - Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order } => { + Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order, pinned } => { let found = reg.workspace_mut(&workspace_id).map(|w| { if let Some(n) = name { w.name = n; } if let Some(g) = group_id { w.group_id = g; } if let Some(u) = unread { w.unread = u; } if let Some(o) = order { w.order = o; } + if let Some(p) = pinned { w.pinned = p; } }).is_some(); if found { if let Some(view) = reg.workspace_view(&workspace_id) { diff --git a/crates/spaceshd/src/state_store.rs b/crates/spaceshd/src/state_store.rs index 7a7c9b6..fe5951a 100644 --- a/crates/spaceshd/src/state_store.rs +++ b/crates/spaceshd/src/state_store.rs @@ -92,6 +92,7 @@ mod tests { group_id: None, order: 0, unread: false, + pinned: false, layout: None, zoomed: None, surfaces: std::collections::HashMap::new(), From a55555983b2fadb1bc3115d22ed7e0fc4f722d31 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sun, 14 Jun 2026 08:56:20 +0700 Subject: [PATCH 2/2] feat(app): sidebar favorites, drag-reorder, and delete-with-confirm - FAVORITES section at the top collects pinned workspaces (removed from their group listing); a star toggle on each row pins/unpins via setWorkspaceMeta. - Drag-to-reorder within a section using raw pointer events (HTML5 DnD is unreliable in the macOS WKWebView), with a drop-line indicator; on drop the section's `order` is reassigned sequentially and persisted. Cross-section drops are ignored (group membership unchanged). - Trash icon on row hover opens a ConfirmDelete modal that shows the live terminal count and warns before terminating them, then calls close_workspace and re-points the active workspace. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 24 ++++++- app/src/ConfirmDelete.tsx | 63 ++++++++++++++++++ app/src/Sidebar.tsx | 135 ++++++++++++++++++++++++++++++++------ app/src/layoutTypes.ts | 1 + app/src/socketBridge.ts | 3 +- 5 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 app/src/ConfirmDelete.tsx 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 (