From 9b9e8c8475cdfa60ceedfe10706b73244c5bfdfd Mon Sep 17 00:00:00 2001 From: mhohlios Date: Fri, 10 Jul 2026 12:47:53 -0400 Subject: [PATCH] feat: project groups (collapsible folders in the sidebar project list) Projects can carry an optional UI-only group label. Grouped projects render under collapsible folder headers in the sidebar with drag and drop (into/out of/within folders, header drag to reorder groups), context-menu management, aggregated attention/done dots on collapsed folders, and localStorage-persisted collapse state. No filesystem effect; existing projects.json files load unchanged. --- docs/data-model.md | 2 +- src-tauri/src/lib.rs | 32 +- src/components/sidebar/Sidebar.tsx | 776 +++++++++++++++++++++++++++-- src/components/task/TabBar.tsx | 2 +- src/components/ui/ContextMenu.tsx | 58 ++- src/hooks/useShortcuts.ts | 10 +- src/index.css | 29 ++ src/lib/ipc.ts | 4 + src/lib/projectGroups.test.ts | 71 +++ src/lib/projectGroups.ts | 43 ++ src/lib/types.ts | 5 + src/store/app.test.ts | 76 +++ src/store/app.ts | 109 +++- 13 files changed, 1172 insertions(+), 45 deletions(-) create mode 100644 src/lib/projectGroups.test.ts create mode 100644 src/lib/projectGroups.ts diff --git a/docs/data-model.md b/docs/data-model.md index e0982a2..292f474 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -9,7 +9,7 @@ Three directories, different owners: ## Entities -- **Project** (`projects.json`, single JSON array) — git repo path + scripts + `preview_url` template + `files_to_copy` globs + `default_cli`. +- **Project** (`projects.json`, single JSON array) — git repo path + scripts + `preview_url` template + `files_to_copy` globs + `default_cli` + optional `group` label (UI-only collapsible folder in the sidebar; no filesystem effect; a group exists iff ≥1 project carries the label. All group reads go through `groupOf()` in `src/lib/projectGroups.ts`, THE normalization point: trim + ALL-CAPS, so mixed-case labels on disk converge to one group. Collapse state + folder color live in `localStorage` keyed by normalized name, pruned when a group disappears). - **Task** (`tasks/.json`) — git worktree branched from project's `base_branch`. Worktrees live at `~/termic/tasks///`. `is_main_checkout=true` tasks point at the project's live checkout (no worktree, archive skips `rm -rf`). - **Settings** (`settings.json`) — `repos_dir`, `welcomed`, `agents[]` (claude/gemini/codex defaults + customs; each has `command`/`args`/`yolo_args`/`runtime_yolo_command`). Defaults seeded if `agents` is empty. `schema_version` gates one-time on-disk migrations. - **Tab** (per task, in `useApp`) — `terminal` (PTY running a CLI), `edit` (CodeMirror), `diff` (vs HEAD). PTYs die with the app. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 47006b2..4a3cd7e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,13 @@ pub struct Project { /// existing project + the `Default` impl stay git-backed. #[serde(default)] pub non_git: bool, + + /// UI-only sidebar group label. Projects sharing the same non-empty + /// value render under one collapsible folder header in the project + /// list. Purely presentational — no effect on paths, git, or + /// workspaces. `None` / empty = ungrouped (renders at the top level). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default)] @@ -1725,6 +1732,8 @@ fn project_add(root_path: String, non_git: Option) -> Result) -> Result<(), String> { save_projects(&out).map_err(|e| e.to_string()) } +/// Set / clear the UI-only sidebar group label on a batch of projects in +/// ONE atomic projects.json write. Group rename / dissolve touch every +/// member — doing this per-project from the frontend could fail halfway +/// and leave a group half-renamed on disk, and full `project_update`s +/// would clobber concurrent edits to unrelated fields. +#[tauri::command] +fn project_set_group(ids: Vec, group: Option) -> Result<(), String> { + let group = group.and_then(|g| { + let t = g.trim().to_string(); + if t.is_empty() { None } else { Some(t) } + }); + let mut list = load_projects(); + for p in list.iter_mut() { + if ids.contains(&p.id) { + p.group = group.clone(); + } + } + save_projects(&list).map_err(|e| e.to_string()) +} + #[tauri::command] fn project_update(p: Project) -> Result<(), String> { let mut list = load_projects(); @@ -8247,7 +8277,7 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - projects_list, project_add, project_add_multi, project_set_members, project_update, project_remove, project_reorder, + projects_list, project_add, project_add_multi, project_set_members, project_update, project_remove, project_reorder, project_set_group, tasks_list, task_create, task_create_multi, task_open_repo, task_importable_worktrees, task_import_worktree, task_archive, task_set_cli, task_set_custom_command, task_set_resume_override, task_set_sandbox, task_set_yolo, sandbox_available, sandbox_deny_counts, sandbox_recent_denied_hosts, sandbox_recent_denied_paths, sandbox_access_counts, sandbox_recent_access_hosts, sandbox_recent_access_paths, sandbox_set_monitor_filters, task_sandbox_add_allowed_host, task_sandbox_add_allowed_path, task_sandbox_remove_allowed_path, agent_sandbox_add_allowed_path, agent_sandbox_add_allowed_host, task_recent_denials, repo_config_load, repo_config_load_at, repo_config_save, repo_config_scaffold, repo_config_add_allowed_host, repo_config_add_allowed_path, diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 59f0bf0..cc6b768 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -6,9 +6,9 @@ import { useApp, useTaskTabs, useActiveTabId } from "@/store/app"; import { usePrefs } from "@/store/prefs"; import { Button } from "@/components/ui/Button"; import { Tip } from "@/components/ui/Tooltip"; -import { LayoutGrid, History, FolderPlus, Settings, Plus, Archive, Layers, Moon, Cog, MoreVertical, GitBranch, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bell, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, Loader2, EyeOff, Trash2, FolderOpen, Megaphone, Keyboard } from "lucide-react"; +import { LayoutGrid, History, FolderPlus, Settings, Plus, Archive, Layers, Moon, Cog, MoreVertical, GitBranch, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bell, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, Loader2, EyeOff, Trash2, Folder, FolderMinus, FolderOpen, Megaphone, Keyboard } from "lucide-react"; import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSeparator, DropdownLabel } from "@/components/ui/Dropdown"; -import { ContextMenuRoot, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuLabel } from "@/components/ui/ContextMenu"; +import { ContextMenuRoot, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuLabel, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent } from "@/components/ui/ContextMenu"; import { ProjectActionsMenuItems } from "./ProjectActionsMenuItems"; import { UpdateCard } from "./UpdateCard"; import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; @@ -16,7 +16,8 @@ import { useUI } from "@/store/ui"; import { cn } from "@/lib/utils"; import { formatTerminalTitle } from "@/lib/terminalTitle"; import { requestCloseTab } from "@/lib/closeTab"; -import { taskRename, projectRename, openPath, projectReorder, taskSetYolo, projectRemove, projectUpdate } from "@/lib/ipc"; +import { taskRename, projectRename, openPath, projectReorder, taskSetYolo, projectRemove, projectUpdate, projectSetGroup } from "@/lib/ipc"; +import { groupOf, projectSections } from "@/lib/projectGroups"; import { createQuickTask, derivedBranch, type NewTaskMode } from "@/lib/quickTask"; import { confirmAndArchive } from "@/lib/archiveTask"; import { startSpotlight, stopSpotlight } from "@/lib/spotlight"; @@ -47,6 +48,24 @@ function defaultRepoRootName(cli: string, taskList: Task[]): string { return `${slug}-${n}`; } +// Group folder accent palette. Keys persist in localStorage (via +// useApp.groupColors); css points at the --color-palette-* tokens in index.css +// @theme; label is for screen readers only (the picker renders bare +// swatches). An unknown stored key (hand-edited storage, palette entry +// removed in a future version) resolves to undefined = default styling. +const GROUP_COLORS: { key: string; label: string; css: string }[] = [ + { key: "red", label: "Red", css: "var(--color-palette-red)" }, + { key: "orange", label: "Orange", css: "var(--color-palette-orange)" }, + { key: "yellow", label: "Yellow", css: "var(--color-palette-yellow)" }, + { key: "green", label: "Green", css: "var(--color-palette-green)" }, + { key: "teal", label: "Teal", css: "var(--color-palette-teal)" }, + { key: "blue", label: "Blue", css: "var(--color-palette-blue)" }, + { key: "purple", label: "Purple", css: "var(--color-palette-purple)" }, + { key: "pink", label: "Pink", css: "var(--color-palette-pink)" }, +]; +const groupColorCss = (key: string | undefined): string | undefined => + GROUP_COLORS.find(c => c.key === key)?.css; + // `compact` is normally read from the store, but the Arc-style hover reveal // (App.tsx) renders TWO instances at once: the 56px icon rail (`compact`) // plus a full-width overlay (`compact={false}`) that slides in on hover. The @@ -69,7 +88,12 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { const openNewTask = useUI(s => s.openNewTask); const collapsedProjects = useApp(s => s.collapsedProjects); const setProjectCollapsed = useApp(s => s.setProjectCollapsed); + const collapsedGroups = useApp(s => s.collapsedGroups); + const setGroupCollapsed = useApp(s => s.setGroupCollapsed); + const groupColors = useApp(s => s.groupColors); + const setGroupColor = useApp(s => s.setGroupColor); const setAllTasksCollapsed = useApp(s => s.setAllTasksCollapsed); + const setAllGroupsCollapsed = useApp(s => s.setAllGroupsCollapsed); // (agents subscription lives inside ProjectActionsMenuItems now — // Sidebar itself doesn't need the registry.) @@ -128,9 +152,11 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { const isLoaded = (taskId: string) => (tabs[taskId] || []).some(t => t.type === "terminal" && t.ptyId); - // Inline rename state for PROJECTS only. Task rename is managed - // inside TaskRow so it can co-exist with per-tab rename state. - const [renaming, setRenaming] = useState<{ kind: "proj"; id: string; value: string } | null>(null); + // Inline rename state for PROJECTS and GROUPS (for groups, `id` is the + // group NAME — groups are derived from Project.group labels and have no + // id). Task rename is managed inside TaskRow so it can co-exist with + // per-tab rename state. + const [renaming, setRenaming] = useState<{ kind: "proj" | "group"; id: string; value: string } | null>(null); // Project whose `+` dropdown is currently open. Used to keep the row // visually "hovered" (bg + Cog visible) while the menu is open; // otherwise the menu trigger looks like it un-selected its parent. @@ -180,16 +206,45 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { // smooth feel, same as the prompt library); the list still reorders live. const [dragTy, setDragTy] = useState(0); const dragArmed = useRef< - { id: string; x: number; y: number; started: boolean; grabOffsetY: number; appliedTy: number; pointerY: number } | null + { id: string; x: number; y: number; started: boolean; grabOffsetY: number; appliedTy: number; pointerY: number; origGroup: string; fromFold: boolean } | null >(null); const dragListenersRef = useRef<{ move: (e: PointerEvent) => void; up: (e: PointerEvent) => void } | null>(null); + // Folder header the cursor is currently over while dragging a project — + // "drop INTO this group". State drives the header highlight; the ref is + // what the document-level pointerup reads (the listener closure would + // otherwise see a stale state value). + const [dragOverGroup, setDragOverGroup] = useState(null); + const dragOverGroupRef = useRef(null); + // Clicking a group header to dismiss its open rename input must NOT also + // toggle collapse: the input's blur (→ commitRename → renaming=null) fires + // on mousedown, BEFORE the click event, so a state check in onClick sees + // rename already closed. Captured on pointerdown instead, when the input + // is still mounted. Also set after a completed group drag so the click + // that follows pointerup doesn't collapse the folder that was just moved. + const suppressGroupToggle = useRef(false); + + // ── Group header drag-to-reorder ────────────────────────────────────── + // Same pointer pattern as project rows, but the unit is the whole folder: + // its members move through the array as one contiguous block (labels + // never change), and the section container follows the cursor. + const [dragGroupName, setDragGroupName] = useState(null); + const [dragGroupTy, setDragGroupTy] = useState(0); + const groupDragArmed = useRef< + { name: string; x: number; y: number; started: boolean; grabOffsetY: number; appliedTy: number; pointerY: number } | null + >(null); + const groupDragListenersRef = useRef<{ move: (e: PointerEvent) => void; up: (e: PointerEvent) => void } | null>(null); + + // All drag hit-testing is scoped to THIS instance's DOM — the Arc-style + // hover reveal renders two Sidebars at once (compact rail + full overlay), + // and a document-wide query would also match the other instance's rows. + const dragRoot = (): ParentNode => asideRef.current ?? document; // translateY that keeps the dragged header under the cursor, self-correcting // against the live layout after each reorder (reads the header's real rect // minus the transform already applied to recover its untranslated slot). const computeProjectTy = (clientY: number): number => { const armed = dragArmed.current; - const el = armed && document.querySelector(`[data-project-id="${CSS.escape(armed.id)}"]`); + const el = armed && dragRoot().querySelector(`[data-project-id="${CSS.escape(armed.id)}"]`); if (!armed || !el) return 0; const layoutTop = el.getBoundingClientRect().top - armed.appliedTy; const ty = (clientY - armed.grabOffsetY) - layoutTop; @@ -206,13 +261,58 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { document.removeEventListener("pointercancel", ls.up); dragListenersRef.current = null; } - const wasStarted = dragArmed.current?.started ?? false; + const armed = dragArmed.current; + const wasStarted = armed?.started ?? false; + const hoverGroup = dragOverGroupRef.current; dragArmed.current = null; + dragOverGroupRef.current = null; + setDragOverGroup(null); setDragProjectId(null); setDragTy(0); - if (commit && wasStarted) { - const finalIds = useApp.getState().projects.map(x => x.id); - projectReorder(finalIds).catch(() => { void useApp.getState().loadAll(); }); + if (commit && wasStarted && armed) { + const list = [...useApp.getState().projects]; + const idx = list.findIndex(x => x.id === armed.id); + if (idx === -1) return; + let dragged = list[idx]; + if (hoverGroup) { + // Dropped on a folder header → become the group's first member + // (that's where the cursor is). Update the local order optimistically + // and expand the folder so the drop is visible. + dragged = { ...dragged, group: hoverGroup }; + list.splice(idx, 1); + // First VISIBLE member — same hidden-anchor guard as + // onDragPointerMove's firstIdOfGroup: with "Hide inactive + // projects" on, the group's array-first member can be an + // inactive row folded away at the bottom, and anchoring there + // would teleport the folder to that hidden position on drop. + const hideInactive = usePrefs.getState().hideInactiveProjects; + const wss = useApp.getState().tasks; + const firstIdx = list.findIndex(x => + groupOf(x) === hoverGroup + && (!hideInactive || wss.some(w => w.project_id === x.id && !w.archived)), + ); + // -1 = dragged is the folder's SOLE (visible) member (dropped on + // its own header): keep its slot instead of teleporting the + // folder to the end of the list. + list.splice(firstIdx === -1 ? idx : firstIdx, 0, dragged); + useApp.setState({ projects: list }); + setGroupCollapsed(hoverGroup, false); + } + // Group changed during the drag (live adoption between rows, or the + // header drop above) → persist it before the reorder. projectSetGroup + // touches ONLY the group field, so a stale snapshot of the dragged + // project can't clobber concurrent edits to its other fields. + const newGroup = groupOf(dragged); + const groupChanged = newGroup !== armed.origGroup; + const finalIds = list.map(x => x.id); + (async () => { + try { + if (groupChanged) await projectSetGroup([dragged.id], newGroup || null); + await projectReorder(finalIds); + } catch { + void useApp.getState().loadAll(); + } + })(); } }; @@ -237,49 +337,339 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { const all = useApp.getState().projects; const fromIdx = all.findIndex(x => x.id === dragId); if (fromIdx === -1) return; - const others = Array.from( - document.querySelectorAll('[data-project-id]'), - ).filter(el => el.dataset.projectId !== dragId); - let beforeId: string | null = null; - for (const el of others) { - const r = el.getBoundingClientRect(); - if (e.clientY < (r.top + r.bottom) / 2) { - beforeId = el.dataset.projectId ?? null; - break; + + // Anchors below must resolve against the projects the user can SEE — + // with "Hide inactive projects" on, a group's array-first member can be + // an inactive row folded away at the bottom; anchoring on it would + // teleport drops to the hidden member's array position. getState (not + // the render closure): the document-level listener is captured once at + // pointerdown and would otherwise read stale values. + const hideInactive = usePrefs.getState().hideInactiveProjects; + const wss = useApp.getState().tasks; + const shown = (x: typeof all[number]) => + !hideInactive || wss.some(w => w.project_id === x.id && !w.archived); + // First VISIBLE member of group `g` in array order (skipping the + // dragged row) — matches the first row rendered inside the folder. + const firstIdOfGroup = (g: string): string | null => + all.find(x => x.id !== dragId && shown(x) && groupOf(x) === g)?.id ?? null; + // Id of the project that FOLLOWS group `g`'s last VISIBLE member in + // array order (null = end of array) — "insert at the end of the folder". + const idAfterGroup = (g: string): string | null => { + let last = -1; + all.forEach((x, i) => { if (x.id !== dragId && shown(x) && groupOf(x) === g) last = i; }); + for (let i = last + 1; i < all.length; i++) if (all[i].id !== dragId) return all[i].id; + return null; + }; + // Project following the dragged row itself — the "stay at this index" + // slot, used when the dragged row is a folder's SOLE member (the + // group-relative anchors above have nothing to anchor to then). + const idAfterSelf = (): string | null => { + for (let i = fromIdx + 1; i < all.length; i++) if (all[i].id !== dragId) return all[i].id; + return null; + }; + + // Zone-based target: where the cursor is decides both the slot AND the + // dragged project's group. + // inside a folder's rendered container: + // header top half → before the folder, ungrouped + // header bottom half → INTO the folder (highlight; assign on drop) + // members area → before the member under the cursor, in-group; + // past the last member (but still inside the + // container) stays at the folder's end — so + // hovering the tail of your own folder doesn't + // eject you. + // outside any folder: walk loose headers + whole folder sections in + // document order; first midpoint below the cursor wins, ungrouped. + let hovered: string | null = null; + let slot: { beforeId: string | null; group: string } | null = null; + const curGroup = groupOf(all[fromIdx]); + // The revealed inactive fold is its own drag domain: rows there render + // flat in a separate section regardless of their group label, so a + // reorder across it must NOT rewrite the dragged project's group — + // neither stripping a grouped inactive row nor silently adopting a + // group when an inactive row passes an active folder's y-range. That + // cuts BOTH ways: a drag that ORIGINATES in the fold keeps its group + // everywhere (fold rows render flat, so an adoption or strip would be + // invisible until hide-inactive is toggled off) — sections are plain + // reorder boundaries for it, never drop targets. + const fold = dragRoot().querySelector("[data-inactive-fold]"); + const foldRect = fold?.getBoundingClientRect(); + const inFold = !!foldRect && e.clientY >= foldRect.top && e.clientY <= foldRect.bottom; + let inSection: HTMLElement | null = null; + if (!inFold && !armed.fromFold) { + for (const sec of Array.from(dragRoot().querySelectorAll("[data-group-section]"))) { + const r = sec.getBoundingClientRect(); + if (e.clientY >= r.top && e.clientY <= r.bottom) { inSection = sec; break; } + } + } + if (inFold && fold) { + slot = { beforeId: null, group: curGroup }; + for (const el of Array.from(fold.querySelectorAll("[data-project-id]"))) { + if (el.dataset.projectId === dragId) continue; + const r = el.getBoundingClientRect(); + if (e.clientY < (r.top + r.bottom) / 2) { + slot = { beforeId: el.dataset.projectId!, group: curGroup }; + break; + } + } + } else if (inSection) { + const g = inSection.dataset.groupSection!; + const header = inSection.querySelector("[data-group-name]"); + const hr = header?.getBoundingClientRect(); + if (hr && e.clientY < (hr.top + hr.bottom) / 2) { + // ?? idAfterSelf(): dragged is the folder's sole member — pulling + // it out above the header keeps its index, just ungroups it. + slot = { beforeId: firstIdOfGroup(g) ?? idAfterSelf(), group: "" }; + } else if (hr && e.clientY <= hr.bottom) { + hovered = g; + } else if (!firstIdOfGroup(g)) { + // Members area of a folder whose only member IS the dragged row: + // nothing to reorder against — stay put. + slot = null; + } else { + slot = { beforeId: idAfterGroup(g), group: g }; + for (const el of Array.from(inSection.querySelectorAll("[data-project-id]"))) { + if (el.dataset.projectId === dragId) continue; + const r = el.getBoundingClientRect(); + if (e.clientY < (r.top + r.bottom) / 2) { + slot = { beforeId: el.dataset.projectId!, group: g }; + break; + } + } + } + } else { + // Fold-origin drags keep their label (see the fold-domain note above); + // everything else dropped at top level becomes ungrouped. + slot = { beforeId: null, group: armed.fromFold ? curGroup : "" }; + const boundaries = Array.from( + dragRoot().querySelectorAll("[data-project-id], [data-group-section]"), + ).filter(el => + el.dataset.groupSection !== undefined + ? true + : el.dataset.projectId !== dragId + && !el.closest("[data-group-section]") + && !el.closest("[data-inactive-fold]")); + for (const el of boundaries) { + const r = el.getBoundingClientRect(); + if (e.clientY < (r.top + r.bottom) / 2) { + slot.beforeId = el.dataset.groupSection !== undefined + ? (firstIdOfGroup(el.dataset.groupSection) ?? idAfterSelf()) + : el.dataset.projectId!; + break; + } } } + if (hovered !== dragOverGroupRef.current) { + dragOverGroupRef.current = hovered; + setDragOverGroup(hovered); + } + if (hovered || !slot) return; + + const { beforeId, group: targetGroup } = slot; const nextIds = all.map(x => x.id).filter(id => id !== dragId); const insertAt = beforeId ? nextIds.findIndex(id => id === beforeId) : nextIds.length; const targetIdx = insertAt === -1 ? nextIds.length : insertAt; - if (targetIdx === fromIdx) return; + if (targetIdx === fromIdx && targetGroup === curGroup) return; nextIds.splice(targetIdx, 0, dragId); useApp.setState(s => ({ - projects: nextIds.map(id => s.projects.find(x => x.id === id)!).filter(Boolean), + projects: nextIds + .map(id => s.projects.find(x => x.id === id)!) + .filter(Boolean) + // Live group adoption: the row visually slides into / out of the + // folder as it crosses the boundary; persisted on drop by endDrag. + .map(p => p.id === dragId && groupOf(p) !== targetGroup + ? { ...p, group: targetGroup || undefined } + : p), })); }; + // translateY for the dragged SECTION (group drag) — same self-correcting + // scheme as computeProjectTy, measured on the section container. + const computeGroupTy = (clientY: number): number => { + const armed = groupDragArmed.current; + const el = armed && dragRoot().querySelector(`[data-group-section="${CSS.escape(armed.name)}"]`); + if (!armed || !el) return 0; + const layoutTop = el.getBoundingClientRect().top - armed.appliedTy; + const ty = (clientY - armed.grabOffsetY) - layoutTop; + armed.appliedTy = ty; + return ty; + }; + + const endGroupDrag = (commit: boolean) => { + const ls = groupDragListenersRef.current; + if (ls) { + document.removeEventListener("pointermove", ls.move); + document.removeEventListener("pointerup", ls.up); + document.removeEventListener("pointercancel", ls.up); + groupDragListenersRef.current = null; + } + const wasStarted = groupDragArmed.current?.started ?? false; + groupDragArmed.current = null; + setDragGroupName(null); + setDragGroupTy(0); + if (commit && wasStarted) { + const finalIds = useApp.getState().projects.map(x => x.id); + projectReorder(finalIds).catch(() => { void useApp.getState().loadAll(); }); + } + }; + + const onGroupDragPointerMove = (e: PointerEvent) => { + const armed = groupDragArmed.current; + if (!armed) return; + if (!armed.started) { + const dx = e.clientX - armed.x; + const dy = e.clientY - armed.y; + if (dx * dx + dy * dy < 16) return; + armed.started = true; + setDragGroupName(armed.name); + } + armed.pointerY = e.clientY; + setDragGroupTy(computeGroupTy(e.clientY)); + const name = armed.name; + const all = useApp.getState().projects; + const members = all.filter(x => groupOf(x) === name); + if (members.length === 0) return; + const rest = all.filter(x => groupOf(x) !== name); + const hideInactive = usePrefs.getState().hideInactiveProjects; + const wss = useApp.getState().tasks; + const shown = (x: typeof all[number]) => + !hideInactive || wss.some(w => w.project_id === x.id && !w.archived); + // Top-level boundaries only: loose rows + OTHER folder sections in + // document order. A folder can't nest, so its drop slots are always + // between top-level items; first midpoint below the cursor wins. + const boundaries = Array.from( + dragRoot().querySelectorAll("[data-project-id], [data-group-section]"), + ).filter(el => + el.dataset.groupSection !== undefined + ? el.dataset.groupSection !== name + : !el.closest("[data-group-section]") && !el.closest("[data-inactive-fold]")); + let beforeId: string | null = null; + for (const el of boundaries) { + const r = el.getBoundingClientRect(); + if (e.clientY < (r.top + r.bottom) / 2) { + beforeId = el.dataset.groupSection !== undefined + ? rest.find(x => shown(x) && groupOf(x) === el.dataset.groupSection)?.id ?? null + : el.dataset.projectId!; + break; + } + } + const at = beforeId ? rest.findIndex(x => x.id === beforeId) : rest.length; + const insertAt = at === -1 ? rest.length : at; + const next = [...rest.slice(0, insertAt), ...members, ...rest.slice(insertAt)]; + if (next.every((x, i) => x === all[i])) return; + useApp.setState({ projects: next }); + }; + // After a live reorder re-renders the list, the dragged header sits in a new // slot — re-derive its transform from the new layout BEFORE paint so it // doesn't jump for a frame. useLayoutEffect(() => { if (dragArmed.current?.started) setDragTy(computeProjectTy(dragArmed.current.pointerY)); + if (groupDragArmed.current?.started) setDragGroupTy(computeGroupTy(groupDragArmed.current.pointerY)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects]); async function commitRename() { if (!renaming) return; - const { id, value } = renaming; + const { kind, id, value } = renaming; setRenaming(null); const trimmed = value.trim(); if (!trimmed) return; try { - await projectRename(id, trimmed); + if (kind === "proj") { + await projectRename(id, trimmed); + } else { + // Group rename = relabel every member in ONE atomic write (groups + // are derived from Project.group, there is no group entity). + // Renaming onto an existing group's name merges the two. + // Uppercase again at commit (belt to the input's braces) — a + // lowercase label on disk would diverge from what groupOf renders. + const label = trimmed.toUpperCase(); + if (label === id) return; + const memberIds = useApp.getState().projects + .filter(p => groupOf(p) === id).map(p => p.id); + await projectSetGroup(memberIds, label); + useApp.getState().renameGroupState(id, label); + } await loadAll(); - } catch (e) { console.error("rename failed", e); } + } catch (e) { + console.error("rename failed", e); + // Resync from disk — the UI may be showing optimistic state. + void loadAll(); + } + } + + // ── Project groups (UI-only folders in this list) ────────────────────── + // A group exists iff ≥1 project carries its label; ordered by first + // appearance in the (user-orderable) projects array. Includes groups + // whose members are currently folded into the inactive section so the + // "Move to group" menu can still target them. + const allGroups: string[] = []; + for (const p of projects) { + const g = groupOf(p); + if (g && !allGroups.includes(g)) allGroups.push(g); } + const moveToGroup = async (p: typeof projects[number], group: string | null): Promise => { + try { + // Filing INTO an existing folder also repositions the project to the + // folder's end in the array. Without this a project that sits earlier + // in the array than the folder would drag the whole folder up to its + // old position (a section renders at its FIRST member's index). + let reorderIds: string[] | null = null; + if (group) { + const all = useApp.getState().projects; + if (all.some(x => x.id !== p.id && groupOf(x) === group)) { + const list = all.filter(x => x.id !== p.id); + let last = -1; + list.forEach((x, i) => { if (groupOf(x) === group) last = i; }); + list.splice(last + 1, 0, { ...p, group }); + useApp.setState({ projects: list }); + reorderIds = list.map(x => x.id); + } + } + await projectSetGroup([p.id], group); + if (reorderIds) await projectReorder(reorderIds); + // Expand the destination so the project doesn't silently vanish + // into a collapsed folder. + if (group) setGroupCollapsed(group, false); + await loadAll(); + return true; + } catch (e) { + console.error("move to group failed", e); + void loadAll(); + return false; + } + }; + + const createGroupWith = async (p: typeof projects[number]) => { + const names = new Set(allGroups); + let name = "NEW GROUP"; + for (let n = 2; names.has(name); n += 1) name = `NEW GROUP ${n}`; + const ok = await moveToGroup(p, name); + // Immediately offer the real name — but only when the fresh folder + // actually renders an editable header: full mode (compact has no inline + // rename, matching project rename) AND a visible member (a hidden + // inactive project renders no folder, which would strand the rename + // state with no input to commit or escape it). + if (ok && !compact && shownInline(p)) { + setRenaming({ kind: "group", id: name, value: name }); + } + }; + + const dissolveGroup = async (name: string) => { + const ids = useApp.getState().projects.filter(p => groupOf(p) === name).map(p => p.id); + try { + await projectSetGroup(ids, null); + await loadAll(); + } catch (e) { + console.error("ungroup failed", e); + void loadAll(); + } + }; + const asideRef = useRef(null); // Inactive = no active (non-archived) tasks. When the hide pref is on we @@ -336,11 +726,14 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { trigger, which would otherwise re-fire its (focus-triggered) tooltip and leave it stuck open after selecting an item. */} e.preventDefault()}> - setAllTasksCollapsed(false)}> + {/* Both actions cover group folders too — expanding agents + under a still-collapsed folder would look like a no-op, + and "collapse all" means the whole tree tidies up. */} + { setAllTasksCollapsed(false); setAllGroupsCollapsed(false); }}> Expand all agents - setAllTasksCollapsed(true)}> + { setAllTasksCollapsed(true); setAllGroupsCollapsed(true); }}> Collapse all agents @@ -400,7 +793,14 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { // expanded row). User overrides stick: explicit true / false // wins; undefined falls back to emptiness-based default. const explicit = collapsedProjects[p.id]; - const collapsed = explicit !== undefined ? explicit : taskList.length === 0; + // Render-only fold while THIS project is being dragged: the + // translateY rides on the header alone, so expanded task rows + // would sit frozen in place while their header floats away. + // Folding for the drag's duration makes the header read as + // "picking the project up"; persisted collapse state is + // untouched, so the rows return on drop exactly as they were. + const collapsed = dragProjectId === p.id + || (explicit !== undefined ? explicit : taskList.length === 0); // Compact + collapsed: surface aggregated activity on the // project monogram so a collapsed project still signals that // something underneath wants attention (attention > done). @@ -450,6 +850,13 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { id: p.id, x: e.clientX, y: e.clientY, started: false, grabOffsetY: e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top, appliedTy: 0, pointerY: e.clientY, + origGroup: groupOf(p), + // Fold rows render flat regardless of group label, so + // a drag that STARTS there must never rewrite the + // group — the change would be invisible until + // hide-inactive is toggled off (see the fold-domain + // note in onDragPointerMove). + fromFold: !!(e.currentTarget as HTMLElement).closest("[data-inactive-fold]"), }; // Attach document-level listeners so drag // tracking survives the dragged DOM node being @@ -504,7 +911,7 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { {(projAttention || projDone) && ( )} @@ -648,6 +1055,36 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { Rename )} + + + + Move to group + + + {allGroups.map(g => ( + { if (g !== groupOf(p)) moveToGroup(p, g); }} + > + {groupOf(p) === g + ? + : } + {g} + + ))} + {allGroups.length > 0 && } + createGroupWith(p)}> + + New group + + {!!groupOf(p) && ( + moveToGroup(p, null)}> + + Remove from group + + )} + + {isMulti && ( { await projectUpdate({ ...p, spotlight_enabled: !p.spotlight_enabled }); @@ -805,10 +1242,270 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { ); }; + // Collapsible group folder wrapping its member projects. Groups + // are pure UI (a label on Project) — this renders the header + + // indented members, with collapse state keyed by group NAME. + const renderGroup = (name: string, members: typeof projects) => { + // Object.hasOwn: the record round-trips through JSON.parse, so a + // group named "toString"/"constructor" would otherwise read an + // inherited function off the prototype and render collapsed. + const collapsed = Object.hasOwn(collapsedGroups, name) + ? collapsedGroups[name] === true + : false; + // Count ALL members (hidden inactive ones included) — the header + // count is also what Rename/Ungroup operate on, so it must not + // understate the group while "Hide inactive projects" is on. + const totalCount = projects.filter(x => groupOf(x) === name).length; + // Aggregated activity for a collapsed folder (attention > done) + // so hidden members can still call for the user — same signal + // the compact project monogram carries. + const memberIds = new Set(members.map(m => m.id)); + const grpWs = collapsed + ? tasks.filter(w => memberIds.has(w.project_id) && !w.archived) + : []; + const grpAttention = collapsed && grpWs.some(w => needsAttention(w.id)); + const grpDone = collapsed && !grpAttention && grpWs.some(w => isWorkDone(w.id)); + // User-assigned accent (Object.hasOwn: JSON-parsed record, see + // `collapsed` above). Unknown keys resolve to undefined = default. + const accent = groupColorCss( + Object.hasOwn(groupColors, name) ? groupColors[name] : undefined, + ); + if (compact) { + // Compact rail: a chevron-only divider row (no room for a + // label — the tooltip carries name + count). Members render + // as regular monogram tiles below, hidden when collapsed. + return ( +
+ + + + {!collapsed && members.map(renderProject)} +
+ ); + } + const isGroupRenaming = renaming?.kind === "group" && renaming.id === name; + return ( +
+ + +
{ + if (ev.button !== 0) return; + suppressGroupToggle.current = isGroupRenaming; + if (isGroupRenaming) return; + const target = ev.target as HTMLElement; + if (target.closest("button, input, a, [data-no-drag]")) return; + const section = (ev.currentTarget as HTMLElement).closest("[data-group-section]"); + if (!section) return; + groupDragArmed.current = { + name, x: ev.clientX, y: ev.clientY, started: false, + grabOffsetY: ev.clientY - section.getBoundingClientRect().top, + appliedTy: 0, pointerY: ev.clientY, + }; + const onUp = () => { + const wasStarted = groupDragArmed.current?.started ?? false; + endGroupDrag(true); + if (wasStarted) { + // The click that follows pointerup must not + // collapse the folder that was just dragged. + // Reset on a macrotask in case no click fires + // (pointerup landed off the header). + suppressGroupToggle.current = true; + setTimeout(() => { suppressGroupToggle.current = false; }, 0); + } + }; + groupDragListenersRef.current = { move: onGroupDragPointerMove, up: onUp }; + document.addEventListener("pointermove", onGroupDragPointerMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onUp); + }} + onClick={() => { + if (suppressGroupToggle.current) { suppressGroupToggle.current = false; return; } + setGroupCollapsed(name, !collapsed); + }} + onKeyDown={(ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + setGroupCollapsed(name, !collapsed); + } + }} + // Accent yields to the drag/drop states below — their + // classes lose to an inline style, so skip it while + // this header is a drop target or being dragged. + style={accent && dragOverGroup !== name && dragGroupName !== name + ? { color: accent } + : undefined} + className={cn( + // Same size/weight as project rows (an 11.5px header + // read weaker than its members), but the DEFAULT + // color is the muted fg-faint of inactive projects — + // folders are structure, not content, and only an + // assigned accent should make one loud. Matches the + // Default swatch in the color picker. + "flex cursor-pointer items-center gap-1.5 rounded-md py-1 pl-2 pr-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-[var(--color-fg-faint)] transition-colors hover:bg-[var(--color-hover)] hover:text-[var(--color-fg)]", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--color-accent)]", + // Drop target while a project drag hovers this header. + dragOverGroup === name && "bg-[var(--color-accent)]/15 text-[var(--color-accent)] ring-1 ring-inset ring-[var(--color-accent)]", + // The folder itself is being dragged. + dragGroupName === name && "bg-[var(--color-accent)]/15 text-[var(--color-accent)] shadow-lg", + )} + > + {collapsed + ? + : } + {/* No own color class when accented — inherits the + header's currentColor so glyph + name match. */} + + {renaming?.kind === "group" && renaming.id === name ? ( + setRenaming({ ...renaming, value: e.target.value.toUpperCase() })} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === "Enter") commitRename(); + else if (e.key === "Escape") setRenaming(null); + // Don't leak Enter/Space to the header's own + // keydown toggle. + e.stopPropagation(); + }} + onClick={e => e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} + className="w-full min-w-0 flex-1 rounded border border-[var(--color-accent)] bg-[var(--color-bg)] px-1.5 py-0.5 text-[12px] normal-case tracking-normal outline-none" + /> + ) : ( + {name} + )} + {(grpAttention || grpDone) && ( + + )} + {!isGroupRenaming && ( + {totalCount} + )} +
+
+ + {name} + {/* Finder-tag-style inline swatch row — no submenu to + aim through, the dots ARE the menu entry. Label-less + by design (a "Red" label would lie if a theme ever + re-tunes the hue); names survive as aria-labels. + Default leads as a fg-faint swatch — the muted tint + an uncolored folder actually renders with — and the + active pick carries a ring. */} +
+ setGroupColor(name, null)} + className="rounded-full p-1" + > + + + {GROUP_COLORS.map(c => ( + setGroupColor(name, c.key)} + className="rounded-full p-1" + > + + + ))} +
+ + setRenaming({ kind: "group", id: name, value: name })}> + + Rename group + + dissolveGroup(name)}> + + Ungroup projects + +
+
+ {/* Indented members with a tree guide line — reads as + "inside the folder" without stealing much width. */} + {!collapsed && ( +
+ {members.map(renderProject)} +
+ )} +
+ ); + }; + // Section the ACTIVE projects (see projectSections): ungrouped + // projects render in place; a group renders as one folder at its + // FIRST member's position, members in array order. Inactive + // projects keep the flat fold below regardless of group (a group + // whose members are all folded simply doesn't render). Keyboard + // nav (useShortcuts) walks the same visualProjectOrder. + const sections = projectSections(activeProjects); return ( <> {/* Active projects render in place (original order). */} - {activeProjects.map(renderProject)} + {sections.map(s => s.kind === "loose" ? renderProject(s.p) : renderGroup(s.name, s.members))} {/* "INACTIVE PROJECTS" section header — same type treatment as the PROJECTS header above. Clicking it toggles the fold; the inactive group renders BELOW it so revealing never reshuffles @@ -844,7 +1541,14 @@ export function Sidebar({ compact: compactProp }: { compact?: boolean } = {}) { )} )} - {hideInactiveProjects && showInactive && inactiveProjects.map(renderProject)} + {/* data-inactive-fold marks this as a separate drag domain: + rows here render flat regardless of group, so drags across + the fold reorder only and never touch group labels. */} + {hideInactiveProjects && showInactive && ( +
+ {inactiveProjects.map(renderProject)} +
+ )} ); })()} @@ -976,8 +1680,8 @@ function TabBadge({ reason }: { reason: "attention" | "done" | "working" }) {
); } - // done — solid blue bullet, iTerm2-style. Uses --color-info if defined, - // falls back to a literal blue. h-3.5 visually matches the bell + spinner. + // done — solid blue bullet, iTerm2-style, in --color-info (defined in + // @theme; themes can override). h-3.5 visually matches the bell + spinner. return ( ); @@ -1160,7 +1864,7 @@ function TaskRow({ w, compact }: { w: Task; compact: boolean }) { {(hasAttention || hasDone) ? ( ) : hasWorking ? ( // No room for a full spinner on the rail; a faint pulsing dot diff --git a/src/components/task/TabBar.tsx b/src/components/task/TabBar.tsx index dd9ac67..5be6faa 100644 --- a/src/components/task/TabBar.tsx +++ b/src/components/task/TabBar.tsx @@ -487,7 +487,7 @@ export function TabPill({ task, tab, active, paneFocused, compact, onSelect, onC )} diff --git a/src/components/ui/ContextMenu.tsx b/src/components/ui/ContextMenu.tsx index a81a29a..4c08ef3 100644 --- a/src/components/ui/ContextMenu.tsx +++ b/src/components/ui/ContextMenu.tsx @@ -1,5 +1,6 @@ import * as CM from "@radix-ui/react-context-menu"; import { type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; // Right-click context menu. Mirrors Dropdown.tsx (same visual language) but @@ -38,11 +39,21 @@ export function ContextMenuContent({ children, className }: { children: ReactNod ); } -export function ContextMenuItem({ children, className, onSelect, disabled, destructive }: { +export function ContextMenuItem({ children, className, onSelect, disabled, destructive, "aria-label": ariaLabel, checked }: { children: ReactNode; className?: string; onSelect?: () => void; disabled?: boolean; destructive?: boolean; + /** For icon-only items (e.g. color swatches) whose meaning isn't text. */ + "aria-label"?: string; + /** Pass a boolean to make the item announce as a radio choice + * (role=menuitemradio + aria-checked) — for exclusive picks whose + * selected state is only visual, like the color swatches. Leave + * undefined for plain action items. */ + checked?: boolean; }) { return ( ( ); + +// Nested submenu (e.g. "Move to group ▸"). Trigger renders like a normal +// item plus a trailing chevron; content reuses ContextMenuContent's chrome. +export const ContextMenuSub = CM.Sub; + +export function ContextMenuSubTrigger({ children, className, disabled }: { + children: ReactNode; className?: string; disabled?: boolean; +}) { + return ( + svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:shrink-0", + className, + )} + > + {children} + + + ); +} + +export function ContextMenuSubContent({ children, className }: { children: ReactNode; className?: string }) { + return ( + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + // Same cap as ContextMenuContent — without it overflow-y-auto never + // engages and a tall submenu (many groups) spills past the window. + style={{ maxHeight: "var(--radix-context-menu-content-available-height)" }} + className={cn( + "z-50 min-w-[160px] overflow-y-auto overflow-x-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-bg-1)] p-1 shadow-xl", + "data-[state=open]:animate-in data-[state=open]:fade-in-0", + className, + )} + >{children} + + ); +} export const ContextMenuLabel = ({ children }: { children: ReactNode }) => ( {children} diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts index 3f5aa5d..a6a7c14 100644 --- a/src/hooks/useShortcuts.ts +++ b/src/hooks/useShortcuts.ts @@ -27,6 +27,7 @@ import { requestCloseTab, requestClosePaneTab } from "@/lib/closeTab"; import { focusTerminalTab, focusMainTab, focusPaneTab } from "@/lib/tabFocus"; import { jumpToNextWaiting } from "@/lib/waitingAgents"; import { bindingMatches, eventKeyToken, IS_MAC, SHORTCUT_DEFS, type ShortcutId } from "@/lib/shortcuts"; +import { visualProjectOrder } from "@/lib/projectGroups"; import type { TerminalTab } from "@/lib/types"; import { findAdjacentPane, findLeaf, computeLeafBounds, getAllLeaves, treeHasDir } from "@/lib/splitTree"; import type { NavDir } from "@/lib/splitTree"; @@ -124,9 +125,10 @@ export function useShortcuts() { // Task nav cycles only AWAKE tasks — ones the user has opened // at least once + still has tabs in. Order MUST match the sidebar's - // visual grouping (project → its tasks → next project …) or the - // jumps feel random. Computed lazily; only some commands need it. - const awakeTasks = () => state.projects.flatMap(p => + // visual grouping (group folder → its projects; project → its + // tasks → next project …) or the jumps feel random. Computed + // lazily; only some commands need it. + const awakeTasks = () => visualProjectOrder(state.projects).flatMap(p => state.tasks.filter(w => w.project_id === p.id && !w.archived && (state.tabs[w.id]?.length ?? 0) > 0, ), @@ -140,7 +142,7 @@ export function useShortcuts() { case "sidebar-next": { type Row = { taskId: string; tabId?: string }; const rows: Row[] = []; - for (const p of state.projects) { + for (const p of visualProjectOrder(state.projects)) { for (const w of state.tasks) { if (w.project_id !== p.id || w.archived) continue; rows.push({ taskId: w.id }); diff --git a/src/index.css b/src/index.css index 6993d3f..1728493 100644 --- a/src/index.css +++ b/src/index.css @@ -65,6 +65,25 @@ --color-ok-fg: #ffffff; --color-warn: #f0b13a; --color-err: #ef5350; + /* "Work done" blue — sidebar activity dots + tab badges. Was previously + only a hard-coded fallback (var(--color-info, #4aa3ff)) sprinkled at + the point of use; defined here so themes can override it. */ + --color-info: #4aa3ff; + + /* Palette ramp: hue-named accents for user-assigned identity marks + (project-group folders today; the user's pick is stored by hue key, + e.g. "red"). One fixed set for every theme in v1 (the light palette + re-tunes shades for contrast on cream). If themes ever define their + own ramp, the contract is: tune SHADE, keep each hue recognizable, + never collapse the ramp — the marks are user data, not decoration. */ + --color-palette-red: #e5484d; + --color-palette-orange: #f76b15; + --color-palette-yellow: #ffb224; + --color-palette-green: #46a758; + --color-palette-teal: #12a594; + --color-palette-blue: #4aa3ff; + --color-palette-purple: #9d6ee8; + --color-palette-pink: #d6409f; /* Task location glyph tint (worktree + main checkout share it). A neutral warm-gray sitting a step below the task title (between fg-dim and @@ -146,6 +165,16 @@ slate in light mode so it doesn't disappear against the cream bg. */ --color-cli-grok: #64748b; --color-cli-opencode: #6b6965; + /* Palette ramp: the dark-bg defaults (esp. yellow/teal) wash out + on cream, so shift every hue darker. */ + --color-palette-red: #c2403f; + --color-palette-orange: #bf5b16; + --color-palette-yellow: #a17410; + --color-palette-green: #3a7d46; + --color-palette-teal: #0f7d72; + --color-palette-blue: #2273bd; + --color-palette-purple: #7c4dbc; + --color-palette-pink: #b03585; color-scheme: light; } diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 76e564d..e4962bd 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -33,6 +33,10 @@ export const projectUpdate = (p: Project) => invoke("project_update", { p export const projectRemove = (id: string) => invoke("project_remove", { id }); export const projectReorder = (ids: string[]) => invoke("project_reorder", { ids }); export const projectRename = (id: string, name: string) => invoke("project_rename", { id, name }); +/** Set (or clear, with null) the sidebar group label on a batch of + * projects in one atomic projects.json write. */ +export const projectSetGroup = (ids: string[], group: string | null) => + invoke("project_set_group", { ids, group }); // ───────────────────────────── tasks ───────────────────────────── diff --git a/src/lib/projectGroups.test.ts b/src/lib/projectGroups.test.ts new file mode 100644 index 0000000..64bc861 --- /dev/null +++ b/src/lib/projectGroups.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { groupOf, projectSections, visualProjectOrder } from "@/lib/projectGroups"; +import type { Project } from "@/lib/types"; + +// Only id/name/group matter to the helpers; keep fixtures terse. +const proj = (id: string, group?: string): Project => + ({ id, name: id, group } as Project); + +describe("groupOf", () => { + it("returns \"\" for a missing or whitespace-only label", () => { + expect(groupOf(proj("a"))).toBe(""); + expect(groupOf(proj("a", ""))).toBe(""); + expect(groupOf(proj("a", " "))).toBe(""); + }); + + it("trims and uppercases (THE normalization point)", () => { + expect(groupOf(proj("a", " backend "))).toBe("BACKEND"); + expect(groupOf(proj("a", "Frontend"))).toBe("FRONTEND"); + }); + + it("converges mixed-case labels onto one key", () => { + expect(groupOf(proj("a", "frontend"))).toBe(groupOf(proj("b", "FRONTEND"))); + }); +}); + +describe("projectSections", () => { + it("keeps ungrouped projects loose, in place", () => { + const sections = projectSections([proj("a"), proj("b")]); + expect(sections).toEqual([ + { kind: "loose", p: proj("a") }, + { kind: "loose", p: proj("b") }, + ]); + }); + + it("renders a group ONCE, at its first member's position, members in array order", () => { + const a = proj("a"), g1 = proj("g1", "G"), b = proj("b"), g2 = proj("g2", "G"); + const sections = projectSections([a, g1, b, g2]); + expect(sections).toEqual([ + { kind: "loose", p: a }, + { kind: "group", name: "G", members: [g1, g2] }, + { kind: "loose", p: b }, + ]); + }); + + it("merges differently-cased / untrimmed labels into one section", () => { + const g1 = proj("g1", "backend"), g2 = proj("g2", " BACKEND "); + const sections = projectSections([g1, g2]); + expect(sections).toEqual([ + { kind: "group", name: "BACKEND", members: [g1, g2] }, + ]); + }); + + it("keeps distinct groups distinct", () => { + const a = proj("a", "ONE"), b = proj("b", "TWO"); + const sections = projectSections([a, b]); + expect(sections.map(s => s.kind === "group" && s.name)).toEqual(["ONE", "TWO"]); + }); +}); + +describe("visualProjectOrder", () => { + it("flattens sections: group members pulled together at the group's position", () => { + const a = proj("a"), g1 = proj("g1", "G"), b = proj("b"), g2 = proj("g2", "G"); + expect(visualProjectOrder([a, g1, b, g2]).map(p => p.id)) + .toEqual(["a", "g1", "g2", "b"]); + }); + + it("is the identity for a group-free list", () => { + const list = [proj("a"), proj("b"), proj("c")]; + expect(visualProjectOrder(list)).toEqual(list); + }); +}); diff --git a/src/lib/projectGroups.ts b/src/lib/projectGroups.ts new file mode 100644 index 0000000..adbeddb --- /dev/null +++ b/src/lib/projectGroups.ts @@ -0,0 +1,43 @@ +// Sidebar project-group helpers. Groups are UI-only: a label on Project; +// a group exists iff at least one project carries it. Shared between the +// Sidebar (rendering + drag) and useShortcuts (keyboard nav must walk the +// same visual order the sidebar renders). + +import type { Project } from "./types"; + +/** Normalized group label; "" = ungrouped. The single normalization point — + * every group comparison must go through this so an untrimmed or + * differently-cased label on disk can't split one visual group into two + * keys. Group names are ALL-CAPS by design (the rename input enforces it + * at entry; this read-side uppercase converges any legacy mixed-case + * label written before that rule). */ +export const groupOf = (p: Project): string => (p.group ?? "").trim().toUpperCase(); + +export type ProjectSection = + | { kind: "loose"; p: Project } + | { kind: "group"; name: string; members: Project[] }; + +/** Section the given (already-filtered) project list in visual order: + * ungrouped projects render in place; a group renders as ONE section at + * its first member's position, members in array order. */ +export function projectSections(list: Project[]): ProjectSection[] { + const sections: ProjectSection[] = []; + const groupAt = new Map(); + for (const p of list) { + const g = groupOf(p); + if (!g) { sections.push({ kind: "loose", p }); continue; } + const at = groupAt.get(g); + if (at === undefined) { + groupAt.set(g, sections.length); + sections.push({ kind: "group", name: g, members: [p] }); + } else { + (sections[at] as Extract).members.push(p); + } + } + return sections; +} + +/** Flat project list in the ORDER the sidebar renders them (group members + * pulled together at the group's position). */ +export const visualProjectOrder = (list: Project[]): Project[] => + projectSections(list).flatMap(s => (s.kind === "loose" ? [s.p] : s.members)); diff --git a/src/lib/types.ts b/src/lib/types.ts index 28e1ab0..3aab0e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -81,6 +81,11 @@ export interface Project { * that member when used INSIDE this multi-repo project. Empty * scripts = skip. Only meaningful when `type == "multi"`. */ members?: ProjectMember[]; + /** UI-only sidebar group label. Projects sharing the same non-empty + * value render under one collapsible folder header in the project + * list. No effect on paths, git, or workspaces. Missing/empty = + * ungrouped. */ + group?: string; } /** Per-member entry on a multi-repo Project. Self-contained: a member is diff --git a/src/store/app.test.ts b/src/store/app.test.ts index c88e228..81d9f22 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -720,3 +720,79 @@ describe("openSettings / clearSettingsHighlight", () => { expect(useApp.getState().view).toMatchObject({ settingsOpen: true, settingsTab: "general", settingsHighlight: undefined }); }); }); + +// ── project-group UI state (collapse + color maps) ──────────────────── + +describe("group UI state", () => { + const projectWith = (id: string, group?: string) => + ({ id, name: id, group } as import("@/lib/types").Project); + + beforeEach(() => { + useApp.setState({ collapsedGroups: {}, groupColors: {} }); + localStorage.clear(); + }); + + it("setGroupCollapsed sets state and persists", () => { + useApp.getState().setGroupCollapsed("BACKEND", true); + expect(useApp.getState().collapsedGroups).toEqual({ BACKEND: true }); + expect(JSON.parse(localStorage.getItem("collapsedGroups")!)).toEqual({ BACKEND: true }); + }); + + it("setGroupColor sets a palette key; null clears it", () => { + useApp.getState().setGroupColor("BACKEND", "red"); + expect(useApp.getState().groupColors).toEqual({ BACKEND: "red" }); + useApp.getState().setGroupColor("BACKEND", null); + expect(useApp.getState().groupColors).toEqual({}); + expect(JSON.parse(localStorage.getItem("groupColors")!)).toEqual({}); + }); + + it("renameGroupState carries collapse + color to a fresh name", () => { + useApp.setState({ collapsedGroups: { OLD: true }, groupColors: { OLD: "teal" } }); + useApp.getState().renameGroupState("OLD", "NEW"); + expect(useApp.getState().collapsedGroups).toEqual({ NEW: true }); + expect(useApp.getState().groupColors).toEqual({ NEW: "teal" }); + }); + + it("renameGroupState onto an existing group merges, destination wins", () => { + useApp.setState({ + collapsedGroups: { SRC: true, DST: false }, + groupColors: { SRC: "red", DST: "blue" }, + }); + useApp.getState().renameGroupState("SRC", "DST"); + expect(useApp.getState().collapsedGroups).toEqual({ DST: false }); + expect(useApp.getState().groupColors).toEqual({ DST: "blue" }); + }); + + it("renameGroupState is a no-op for an unknown source", () => { + useApp.setState({ collapsedGroups: { A: true }, groupColors: {} }); + useApp.getState().renameGroupState("MISSING", "B"); + expect(useApp.getState().collapsedGroups).toEqual({ A: true }); + }); + + it("setAllGroupsCollapsed covers live groups and drops stale names", () => { + useApp.setState({ + projects: [projectWith("p1", "one"), projectWith("p2", " TWO "), projectWith("p3")], + collapsedGroups: { STALE: true }, + }); + useApp.getState().setAllGroupsCollapsed(true); + // Keys are normalized (trim + uppercase); STALE is gone. + expect(useApp.getState().collapsedGroups).toEqual({ ONE: true, TWO: true }); + useApp.getState().setAllGroupsCollapsed(false); + expect(useApp.getState().collapsedGroups).toEqual({ ONE: false, TWO: false }); + }); + + it("loadAll prunes collapse/color entries for groups that no longer exist", async () => { + (ipc.projectsList as ReturnType).mockResolvedValueOnce([ + projectWith("p1", "ALIVE"), + ]); + useApp.setState({ + collapsedGroups: { ALIVE: true, DEAD: true }, + groupColors: { DEAD: "pink" }, + }); + await useApp.getState().loadAll(); + expect(useApp.getState().collapsedGroups).toEqual({ ALIVE: true }); + expect(useApp.getState().groupColors).toEqual({}); + expect(JSON.parse(localStorage.getItem("collapsedGroups")!)).toEqual({ ALIVE: true }); + expect(JSON.parse(localStorage.getItem("groupColors")!)).toEqual({}); + }); +}); diff --git a/src/store/app.ts b/src/store/app.ts index d3baa20..760327f 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -9,6 +9,7 @@ import { updateSplitRatio, findAdjacentPane, equalizeSplitsOnAxis, } from "@/lib/splitTree"; import * as ipc from "@/lib/ipc"; +import { groupOf } from "@/lib/projectGroups"; import { focusTerminalTab, focusMainTab, focusPaneTab } from "@/lib/tabFocus"; import { agentDisplayName } from "@/lib/agents"; @@ -90,6 +91,14 @@ export interface AppState { * Persisted to localStorage so the user's tree shape survives launches. */ collapsedProjects: Record; collapsedTasks: Record; + /** Per-GROUP collapse state, keyed by group NAME (groups are derived + * from Project.group labels, they have no id). true = members hidden. + * Persisted to localStorage; renaming a group migrates its entry. */ + collapsedGroups: Record; + /** Per-GROUP accent color (palette key, e.g. "red"), keyed by group + * name like collapsedGroups. Persisted to localStorage; pruned and + * rename-migrated alongside the collapse map. */ + groupColors: Record; /** Editable agent registry from settings.json. Loaded by `loadAll` so * `spawnArgsForCli` can consult `agent.command + args + capabilities` * instead of hard-coding by CLI string. Empty until first loadAll. */ @@ -134,9 +143,23 @@ export interface AppState { disableFooterTerm: (taskId: string) => void; setProjectCollapsed: (projectId: string, collapsed: boolean) => void; setTaskCollapsed: (taskId: string, collapsed: boolean) => void; + /** Set a project-group's collapse state (keyed by group name). */ + setGroupCollapsed: (group: string, collapsed: boolean) => void; + /** Assign a palette color to a group folder (null clears back to the + * default). Keys are palette names ("red"…); the sidebar maps them to + * --color-palette-* tokens and ignores unknown keys. */ + setGroupColor: (group: string, color: string | null) => void; + /** Move a group's stored UI state (collapse + color) from an old name + * in one write, used by the sidebar's group-rename flow. Renaming onto + * an existing group merges; the destination's state wins. */ + renameGroupState: (from: string, to: string) => void; /** Bulk set: flips every task's explicit collapsed state to the * given value in one update (single localStorage write + render). */ setAllTasksCollapsed: (collapsed: boolean) => void; + /** Bulk set for GROUP folders — companion to setAllTasksCollapsed; + * the sidebar's "Expand/Collapse all agents" actions call both so a + * collapsed folder can't hide freshly-expanded agent rows. */ + setAllGroupsCollapsed: (collapsed: boolean) => void; setTerminalSplitHeight: (taskId: string, px: number) => void; toggleTerminalSplitCollapsed: (taskId: string) => void; toggleBottomTerminal: (taskId: string) => void; @@ -277,8 +300,12 @@ const LS_RPW = "rightPanelWidth"; const LS_RFH = "rightFooterHeight"; const LS_COLLAPSED_PROJ = "collapsedProjects"; // Record const LS_COLLAPSED_TASK = "collapsedTasks"; // Record +const LS_COLLAPSED_GRP = "collapsedGroups"; // Record +const LS_GROUP_COLORS = "groupColors"; // Record const initialCollapsed = (() => { try { return JSON.parse(localStorage.getItem(LS_COLLAPSED_PROJ) || "{}"); } catch { return {}; } })(); const initialCollapsedTask = (() => { try { return JSON.parse(localStorage.getItem(LS_COLLAPSED_TASK) || "{}"); } catch { return {}; } })(); +const initialCollapsedGrp = (() => { try { return JSON.parse(localStorage.getItem(LS_COLLAPSED_GRP) || "{}"); } catch { return {}; } })(); +const initialGroupColors = (() => { try { return JSON.parse(localStorage.getItem(LS_GROUP_COLORS) || "{}"); } catch { return {}; } })(); const initialCompact = (() => { try { return localStorage.getItem(LS_COMPACT) === "1"; } catch { return false; } })(); const initialHidden = (() => { try { return localStorage.getItem(LS_RPANEL) === "1"; } catch { return false; } })(); @@ -368,6 +395,8 @@ export const useApp = create((set, get) => ({ footerTerm: {}, collapsedProjects: initialCollapsed as Record, collapsedTasks: initialCollapsedTask as Record, + collapsedGroups: initialCollapsedGrp as Record, + groupColors: initialGroupColors as Record, agents: [], detectedClis: {}, spotlightTaskId: {}, @@ -389,7 +418,26 @@ export const useApp = create((set, get) => ({ ipc.tasksList(), ipc.settingsLoad().catch(() => ({ agents: [] } as Partial)), ]); - set({ projects, tasks, agents: (settings.agents as import("@/lib/types").Agent[]) ?? [] }); + // Prune UI state for groups that no longer exist (groups are + // derived from Project.group — dissolving / renaming one would + // otherwise leave its entries in localStorage forever, and a stale + // entry would haunt a future group reusing the name). + const liveGroups = new Set(projects.map(groupOf).filter(Boolean)); + let collapsedGroups = get().collapsedGroups; + if (Object.keys(collapsedGroups).some(k => !liveGroups.has(k))) { + collapsedGroups = Object.fromEntries( + Object.entries(collapsedGroups).filter(([k]) => liveGroups.has(k)), + ); + try { localStorage.setItem(LS_COLLAPSED_GRP, JSON.stringify(collapsedGroups)); } catch {} + } + let groupColors = get().groupColors; + if (Object.keys(groupColors).some(k => !liveGroups.has(k))) { + groupColors = Object.fromEntries( + Object.entries(groupColors).filter(([k]) => liveGroups.has(k)), + ); + try { localStorage.setItem(LS_GROUP_COLORS, JSON.stringify(groupColors)); } catch {} + } + set({ projects, tasks, collapsedGroups, groupColors, agents: (settings.agents as import("@/lib/types").Agent[]) ?? [] }); }, refreshClis: async () => { @@ -425,6 +473,7 @@ export const useApp = create((set, get) => ({ // collapsed, the new row would be hidden) AND ⌘1..9 / ⇧⌘[/] nav // to a task under a collapsed project. let nextCollapsed = get().collapsedProjects; + let nextCollapsedGroups = get().collapsedGroups; if (id) { const task = get().tasks.find(w => w.id === id); // Force the parent project expanded (explicit false) — covers the @@ -434,12 +483,23 @@ export const useApp = create((set, get) => ({ nextCollapsed = { ...nextCollapsed, [task.project_id]: false }; try { localStorage.setItem(LS_COLLAPSED_PROJ, JSON.stringify(nextCollapsed)); } catch {} } + // Same for the project's GROUP: a task activated under a + // collapsed group must become visible, so expand the folder too. + // groupOf — the sidebar keys collapse state by the normalized name. + const proj = task ? get().projects.find(p => p.id === task.project_id) : undefined; + const grp = proj ? groupOf(proj) : ""; + const cur = Object.hasOwn(nextCollapsedGroups, grp) ? nextCollapsedGroups[grp] : undefined; + if (grp && cur !== false) { + nextCollapsedGroups = { ...nextCollapsedGroups, [grp]: false }; + try { localStorage.setItem(LS_COLLAPSED_GRP, JSON.stringify(nextCollapsedGroups)); } catch {} + } } set({ activeTaskId: id, view: { page: id ? "dashboard" : get().view.page }, mountedTasks: nextMounted, collapsedProjects: nextCollapsed, + collapsedGroups: nextCollapsedGroups, }); if (id) { // Mark the WHOLE task as read on activation. Previously we @@ -614,6 +674,42 @@ export const useApp = create((set, get) => ({ try { localStorage.setItem(LS_COLLAPSED_TASK, JSON.stringify(next)); } catch {} return { collapsedTasks: next }; }), + setGroupCollapsed: (group, collapsed) => set(s => { + const next = { ...s.collapsedGroups, [group]: collapsed }; + try { localStorage.setItem(LS_COLLAPSED_GRP, JSON.stringify(next)); } catch {} + return { collapsedGroups: next }; + }), + setGroupColor: (group, color) => set(s => { + const next = { ...s.groupColors }; + if (color) next[group] = color; + else delete next[group]; + try { localStorage.setItem(LS_GROUP_COLORS, JSON.stringify(next)); } catch {} + return { groupColors: next }; + }), + renameGroupState: (from, to) => set(s => { + // Object.hasOwn (not `in`): the records round-trip through JSON.parse, + // so a group named "toString"/"constructor" would otherwise hit the + // prototype chain. Rename onto an existing group MERGES them — the + // destination's own state wins; only carry the source's entry to a + // fresh name. + const migrate = (map: Record): Record | null => { + if (!Object.hasOwn(map, from)) return null; + const { [from]: prev, ...rest } = map; + return Object.hasOwn(rest, to) ? rest : { ...rest, [to]: prev }; + }; + const out: Partial> = {}; + const collapsed = migrate(s.collapsedGroups); + if (collapsed) { + out.collapsedGroups = collapsed; + try { localStorage.setItem(LS_COLLAPSED_GRP, JSON.stringify(collapsed)); } catch {} + } + const colors = migrate(s.groupColors); + if (colors) { + out.groupColors = colors; + try { localStorage.setItem(LS_GROUP_COLORS, JSON.stringify(colors)); } catch {} + } + return out; + }), setAllTasksCollapsed: (collapsed) => set(s => { // Build a fresh map covering every task so the default-by-mode // fallback in TaskRow can't sneak back in for any of them. We @@ -624,6 +720,17 @@ export const useApp = create((set, get) => ({ try { localStorage.setItem(LS_COLLAPSED_TASK, JSON.stringify(next)); } catch {} return { collapsedTasks: next }; }), + setAllGroupsCollapsed: (collapsed) => set(s => { + // Fresh map over the LIVE groups (derived from loaded projects) — + // one write, and stale names fall out as a bonus. + const next: Record = {}; + for (const p of s.projects) { + const g = groupOf(p); + if (g) next[g] = collapsed; + } + try { localStorage.setItem(LS_COLLAPSED_GRP, JSON.stringify(next)); } catch {} + return { collapsedGroups: next }; + }), addBottomTab: (taskId, opts) => { const id = crypto.randomUUID();