diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cff8e0054..71d495f32 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -152,12 +152,22 @@ pub(crate) struct WorktreeInfo { pub(crate) branch: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub(crate) struct WorkspaceGroup { + pub(crate) id: String, + pub(crate) name: String, + #[serde(default, rename = "sortOrder")] + pub(crate) sort_order: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub(crate) struct WorkspaceSettings { #[serde(default, rename = "sidebarCollapsed")] pub(crate) sidebar_collapsed: bool, #[serde(default, rename = "sortOrder")] pub(crate) sort_order: Option, + #[serde(default, rename = "groupId")] + pub(crate) group_id: Option, #[serde(default, rename = "gitRoot")] pub(crate) git_root: Option, } @@ -210,6 +220,8 @@ pub(crate) struct AppSettings { rename = "dictationHoldKey" )] pub(crate) dictation_hold_key: String, + #[serde(default = "default_workspace_groups", rename = "workspaceGroups")] + pub(crate) workspace_groups: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -265,6 +277,10 @@ fn default_dictation_hold_key() -> String { "alt".to_string() } +fn default_workspace_groups() -> Vec { + Vec::new() +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -282,6 +298,7 @@ impl Default for AppSettings { dictation_model_id: default_dictation_model_id(), dictation_preferred_language: None, dictation_hold_key: default_dictation_hold_key(), + workspace_groups: default_workspace_groups(), } } } @@ -305,6 +322,7 @@ mod tests { assert_eq!(settings.dictation_model_id, "base"); assert!(settings.dictation_preferred_language.is_none()); assert_eq!(settings.dictation_hold_key, "alt"); + assert!(settings.workspace_groups.is_empty()); } #[test] @@ -317,5 +335,6 @@ mod tests { assert!(entry.parent_id.is_none()); assert!(entry.worktree.is_none()); assert!(entry.settings.sort_order.is_none()); + assert!(entry.settings.group_id.is_none()); } } diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 340cecb7b..30e033622 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -586,6 +586,7 @@ mod tests { settings: WorkspaceSettings { sidebar_collapsed: false, sort_order, + group_id: None, git_root: None, }, } diff --git a/src/App.tsx b/src/App.tsx index fcb5f3813..41868eef4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -274,6 +274,10 @@ function MainApp() { const { workspaces, + workspaceGroups, + groupedWorkspaces, + getWorkspaceGroupName, + ungroupedLabel, activeWorkspace, activeWorkspaceId, setActiveWorkspaceId, @@ -283,13 +287,20 @@ function MainApp() { markWorkspaceConnected, updateWorkspaceSettings, updateWorkspaceCodexBin, + createWorkspaceGroup, + renameWorkspaceGroup, + moveWorkspaceGroup, + deleteWorkspaceGroup, + assignWorkspaceGroup, removeWorkspace, removeWorktree, hasLoaded, refreshWorkspaces } = useWorkspaces({ onDebug: addDebugEntry, - defaultCodexBin: appSettings.codexBin + defaultCodexBin: appSettings.codexBin, + appSettings, + onUpdateAppSettings: queueSaveSettings, }); useEffect(() => { @@ -557,6 +568,7 @@ function MainApp() { message: string; timestamp: number; projectName: string; + groupName?: string | null; workspaceId: string; isProcessing: boolean; }> = []; @@ -572,6 +584,7 @@ function MainApp() { message: entry.text, timestamp: entry.timestamp, projectName: workspace.name, + groupName: getWorkspaceGroupName(workspace.id), workspaceId: workspace.id, isProcessing: threadStatusById[thread.id]?.isProcessing ?? false }); @@ -580,6 +593,7 @@ function MainApp() { return entries.sort((a, b) => b.timestamp - a.timestamp).slice(0, 3); }, [ lastAgentMessageByThread, + getWorkspaceGroupName, threadStatusById, threadsByWorkspace, workspaces @@ -793,8 +807,17 @@ function MainApp() { workspaceId: string, direction: "up" | "down" ) => { + const target = workspaces.find((entry) => entry.id === workspaceId); + if (!target || (target.kind ?? "main") === "worktree") { + return; + } + const targetGroupId = target.settings.groupId ?? null; const ordered = workspaces - .filter((entry) => (entry.kind ?? "main") !== "worktree") + .filter( + (entry) => + (entry.kind ?? "main") !== "worktree" && + (entry.settings.groupId ?? null) === targetGroupId, + ) .slice() .sort((a, b) => { const orderDiff = orderValue(a) - orderValue(b); @@ -882,6 +905,8 @@ function MainApp() { compactGitBackNode, } = useLayoutNodes({ workspaces, + groupedWorkspaces, + hasWorkspaceGroups: workspaceGroups.length > 0, threadsByWorkspace, threadStatusById, threadListLoadingByWorkspace, @@ -1235,7 +1260,9 @@ function MainApp() { )} {settingsOpen && ( { setSettingsOpen(false); setSettingsSection(null); @@ -1244,6 +1271,11 @@ function MainApp() { onDeleteWorkspace={(workspaceId) => { void removeWorkspace(workspaceId); }} + onCreateWorkspaceGroup={createWorkspaceGroup} + onRenameWorkspaceGroup={renameWorkspaceGroup} + onMoveWorkspaceGroup={moveWorkspaceGroup} + onDeleteWorkspaceGroup={deleteWorkspaceGroup} + onAssignWorkspaceGroup={assignWorkspaceGroup} reduceTransparency={reduceTransparency} onToggleTransparency={setReduceTransparency} appSettings={appSettings} diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 6a7ac8e3b..47d65c745 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -7,8 +7,18 @@ import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { formatRelativeTime, formatRelativeTimeShort } from "../../../utils/time"; +const COLLAPSED_GROUPS_STORAGE_KEY = "codexmonitor.collapsedGroups"; + +type WorkspaceGroupSection = { + id: string | null; + name: string; + workspaces: WorkspaceInfo[]; +}; + type SidebarProps = { workspaces: WorkspaceInfo[]; + groupedWorkspaces: WorkspaceGroupSection[]; + hasWorkspaceGroups: boolean; threadsByWorkspace: Record; threadStatusById: Record< string, @@ -41,6 +51,8 @@ type SidebarProps = { export function Sidebar({ workspaces, + groupedWorkspaces, + hasWorkspaceGroups, threadsByWorkspace, threadStatusById, threadListLoadingByWorkspace, @@ -70,6 +82,24 @@ export function Sidebar({ const [expandedWorkspaces, setExpandedWorkspaces] = useState( new Set(), ); + const [collapsedGroups, setCollapsedGroups] = useState>(() => { + if (typeof window === "undefined") { + return new Set(); + } + const raw = window.localStorage.getItem(COLLAPSED_GROUPS_STORAGE_KEY); + if (!raw) { + return new Set(); + } + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return new Set(parsed.filter((value) => typeof value === "string")); + } + } catch { + // Ignore invalid stored data. + } + return new Set(); + }); const [addMenuAnchor, setAddMenuAnchor] = useState<{ workspaceId: string; top: number; @@ -96,6 +126,32 @@ export function Sidebar({ ); }, []); + const persistCollapsedGroups = useCallback((next: Set) => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + COLLAPSED_GROUPS_STORAGE_KEY, + JSON.stringify(Array.from(next)), + ); + }, []); + + const toggleGroupCollapse = useCallback( + (groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + persistCollapsedGroups(next); + return next; + }); + }, + [persistCollapsedGroups], + ); + const getThreadTime = useCallback( (thread: ThreadSummary) => { const lastMessage = lastAgentMessageByThread[thread.id]; @@ -127,7 +183,7 @@ export function Sidebar({ useEffect(() => { const frame = requestAnimationFrame(updateScrollFade); return () => cancelAnimationFrame(frame); - }, [updateScrollFade, workspaces, threadsByWorkspace, expandedWorkspaces]); + }, [updateScrollFade, groupedWorkspaces, threadsByWorkspace, expandedWorkspaces]); async function showThreadMenu( event: React.MouseEvent, @@ -245,23 +301,6 @@ export function Sidebar({ return `Resets ${relative}`; })(); - const rootWorkspaces = workspaces - .filter((entry) => (entry.kind ?? "main") !== "worktree" && !entry.parentId) - .slice() - .sort((a, b) => { - const aOrder = - typeof a.settings.sortOrder === "number" - ? a.settings.sortOrder - : Number.MAX_SAFE_INTEGER; - const bOrder = - typeof b.settings.sortOrder === "number" - ? b.settings.sortOrder - : Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; - } - return a.name.localeCompare(b.name); - }); const worktreesByParent = new Map(); workspaces .filter((entry) => (entry.kind ?? "main") === "worktree" && entry.parentId) @@ -306,458 +345,506 @@ export function Sidebar({ ref={sidebarBodyRef} >
- {rootWorkspaces.map((entry) => { - const threads = threadsByWorkspace[entry.id] ?? []; - const isCollapsed = entry.settings.sidebarCollapsed; - const showThreads = !isCollapsed && threads.length > 0; - const isLoadingThreads = - threadListLoadingByWorkspace[entry.id] ?? false; - const showThreadLoader = - !isCollapsed && isLoadingThreads && threads.length === 0; - const nextCursor = threadListCursorByWorkspace[entry.id] ?? null; - const isPaging = threadListPagingByWorkspace[entry.id] ?? false; - const worktrees = worktreesByParent.get(entry.id) ?? []; + {groupedWorkspaces.map((group) => { + const groupId = group.id; + const isGroupCollapsed = Boolean( + groupId && collapsedGroups.has(groupId), + ); + const showGroupHeader = Boolean(groupId) || hasWorkspaceGroups; return ( -
-
onSelectWorkspace(entry.id)} - onContextMenu={(event) => showWorkspaceMenu(event, entry.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectWorkspace(entry.id); - } - }} - > -
-
-
- {entry.name} - -
+
+ {showGroupHeader && ( +
+
{group.name}
+ {groupId && ( -
+ )}
- {!entry.connected && ( - { - event.stopPropagation(); - onConnectWorkspace(entry); - }} - > - connect - - )} -
- {addMenuAnchor?.workspaceId === entry.id && - createPortal( -
- - -
, - document.body, - )} - {!isCollapsed && worktrees.length > 0 && ( -
-
- - Worktrees -
-
- {worktrees.map((worktree) => { - const worktreeThreads = - threadsByWorkspace[worktree.id] ?? []; - const worktreeCollapsed = - worktree.settings.sidebarCollapsed; - const showWorktreeThreads = - !worktreeCollapsed && worktreeThreads.length > 0; - const isLoadingWorktreeThreads = - threadListLoadingByWorkspace[worktree.id] ?? false; - const showWorktreeLoader = - !worktreeCollapsed && - isLoadingWorktreeThreads && - worktreeThreads.length === 0; - const worktreeNextCursor = - threadListCursorByWorkspace[worktree.id] ?? null; - const isWorktreePaging = - threadListPagingByWorkspace[worktree.id] ?? false; - const worktreeBranch = worktree.worktree?.branch ?? ""; - - return ( -
-
onSelectWorkspace(worktree.id)} - onContextMenu={(event) => - showWorktreeMenu(event, worktree.id) - } - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectWorkspace(worktree.id); +
+
+
+ {entry.name} + +
+ - {!worktree.connected && ( - { - event.stopPropagation(); - onConnectWorkspace(worktree); + + + +
+
+ {!entry.connected && ( + { + event.stopPropagation(); + onConnectWorkspace(entry); + }} + > + connect + + )} +
+ {addMenuAnchor?.workspaceId === entry.id && + createPortal( +
+ + +
, + document.body, + )} + {!isCollapsed && worktrees.length > 0 && ( +
+
+ + Worktrees +
+
+ {worktrees.map((worktree) => { + const worktreeThreads = + threadsByWorkspace[worktree.id] ?? []; + const worktreeCollapsed = + worktree.settings.sidebarCollapsed; + const showWorktreeThreads = + !worktreeCollapsed && worktreeThreads.length > 0; + const isLoadingWorktreeThreads = + threadListLoadingByWorkspace[worktree.id] ?? false; + const showWorktreeLoader = + !worktreeCollapsed && + isLoadingWorktreeThreads && + worktreeThreads.length === 0; + const worktreeNextCursor = + threadListCursorByWorkspace[worktree.id] ?? null; + const isWorktreePaging = + threadListPagingByWorkspace[worktree.id] ?? false; + const worktreeBranch = worktree.worktree?.branch ?? ""; + + return ( +
+
onSelectWorkspace(worktree.id)} + onContextMenu={(event) => + showWorktreeMenu(event, worktree.id) + } + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectWorkspace(worktree.id); + } }} > - connect - - )} -
-
- {showWorktreeThreads && ( -
- {(expandedWorkspaces.has(worktree.id) - ? worktreeThreads - : worktreeThreads.slice(0, 3) - ).map((thread) => { - const relativeTime = getThreadTime(thread); - return ( -
- onSelectThread(worktree.id, thread.id) - } - onContextMenu={(event) => - showThreadMenu(event, worktree.id, thread.id) - } - role="button" - tabIndex={0} - onKeyDown={(event) => { - if ( - event.key === "Enter" || - event.key === " " - ) { - event.preventDefault(); - onSelectThread(worktree.id, thread.id); - } - }} - > - + {worktreeBranch || worktree.name} +
+
+ + {!worktree.connected && ( + { + event.stopPropagation(); + onConnectWorkspace(worktree); + }} + > + connect + + )} +
+
+ {showWorktreeThreads && ( +
+ {(expandedWorkspaces.has(worktree.id) + ? worktreeThreads + : worktreeThreads.slice(0, 3) + ).map((thread) => { + const relativeTime = getThreadTime(thread); + return ( +
+ onSelectThread(worktree.id, thread.id) } - onClick={(event) => + onContextMenu={(event) => showThreadMenu( event, worktree.id, thread.id, ) } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if ( + event.key === "Enter" || + event.key === " " + ) { + event.preventDefault(); + onSelectThread(worktree.id, thread.id); + } + }} + > + + + {thread.name} + +
+ {relativeTime && ( + + {relativeTime} + + )} +
+ +
+
+
+ ); + })} + {worktreeThreads.length > 3 && ( + + )} + {expandedWorkspaces.has(worktree.id) && + worktreeNextCursor && ( + -
-
+ )}
- ); - })} - {worktreeThreads.length > 3 && ( - - )} - {expandedWorkspaces.has(worktree.id) && - worktreeNextCursor && ( - + + + +
)} -
- )} - {showWorktreeLoader && ( -
- - - -
- )} +
+ ); + })}
- ); - })} -
-
- )} - {showThreads && ( -
- {(expandedWorkspaces.has(entry.id) - ? threads - : threads.slice(0, 3) - ).map((thread) => { - const relativeTime = getThreadTime(thread); - return ( -
onSelectThread(entry.id, thread.id)} - onContextMenu={(event) => - showThreadMenu(event, entry.id, thread.id) - } - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectThread(entry.id, thread.id); - } - }} - > - - {thread.name} -
- {relativeTime && ( - - {relativeTime} - - )} -
-
+ )} + {showThreads && ( +
+ {(expandedWorkspaces.has(entry.id) + ? threads + : threads.slice(0, 3) + ).map((thread) => { + const relativeTime = getThreadTime(thread); + return ( +
onSelectThread(entry.id, thread.id)} + onContextMenu={(event) => showThreadMenu(event, entry.id, thread.id) } + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectThread(entry.id, thread.id); + } + }} > - ... - -
-
+ + {thread.name} +
+ {relativeTime && ( + + {relativeTime} + + )} +
+ +
+
+
+ ); + })} + {threads.length > 3 && ( + + )} + {expandedWorkspaces.has(entry.id) && nextCursor && ( + + )}
- ); - })} - {threads.length > 3 && ( - - )} - {expandedWorkspaces.has(entry.id) && nextCursor && ( - - )} -
- )} - {showThreadLoader && ( -
- - - -
- )} + )} + {showThreadLoader && ( +
+ + + +
+ )} +
+ ); + })} + ); })} - {!rootWorkspaces.length && ( + {!groupedWorkspaces.length && (
Add a workspace to start.
)} diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index b894f9ebb..9e8ea034b 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -4,6 +4,7 @@ type LatestAgentRun = { message: string; timestamp: number; projectName: string; + groupName?: string | null; workspaceId: string; threadId: string; isProcessing: boolean; @@ -46,7 +47,12 @@ export function Home({ type="button" >
-
{run.projectName}
+
+ {run.projectName} + {run.groupName && ( + {run.groupName} + )} +
{formatRelativeTime(run.timestamp)}
diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 315b55f74..d18cfaeb8 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -59,6 +59,12 @@ type GitDiffViewerItem = { type LayoutNodesOptions = { workspaces: WorkspaceInfo[]; + groupedWorkspaces: Array<{ + id: string | null; + name: string; + workspaces: WorkspaceInfo[]; + }>; + hasWorkspaceGroups: boolean; threadsByWorkspace: Record; threadStatusById: Record; threadListLoadingByWorkspace: Record; @@ -99,6 +105,7 @@ type LayoutNodesOptions = { message: string; timestamp: number; projectName: string; + groupName?: string | null; workspaceId: string; isProcessing: boolean; }>; @@ -266,6 +273,8 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { const sidebarNode = ( ; + ungroupedLabel: string; onClose: () => void; onMoveWorkspace: (id: string, direction: "up" | "down") => void; onDeleteWorkspace: (id: string) => void; + onCreateWorkspaceGroup: (name: string) => Promise; + onRenameWorkspaceGroup: (id: string, name: string) => Promise; + onMoveWorkspaceGroup: (id: string, direction: "up" | "down") => Promise; + onDeleteWorkspaceGroup: (id: string) => Promise; + onAssignWorkspaceGroup: ( + workspaceId: string, + groupId: string | null, + ) => Promise; reduceTransparency: boolean; onToggleTransparency: (value: boolean) => void; appSettings: AppSettings; @@ -53,16 +68,18 @@ type SettingsViewProps = { type SettingsSection = "projects" | "display" | "dictation"; type CodexSection = SettingsSection | "codex" | "experimental"; -function orderValue(workspace: WorkspaceInfo) { - const value = workspace.settings.sortOrder; - return typeof value === "number" ? value : Number.MAX_SAFE_INTEGER; -} - export function SettingsView({ - workspaces, + workspaceGroups, + groupedWorkspaces, + ungroupedLabel, onClose, onMoveWorkspace, onDeleteWorkspace, + onCreateWorkspaceGroup, + onRenameWorkspaceGroup, + onMoveWorkspaceGroup, + onDeleteWorkspaceGroup, + onAssignWorkspaceGroup, reduceTransparency, onToggleTransparency, appSettings, @@ -86,6 +103,9 @@ export function SettingsView({ `${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`, ); const [overrideDrafts, setOverrideDrafts] = useState>({}); + const [groupDrafts, setGroupDrafts] = useState>({}); + const [newGroupName, setNewGroupName] = useState(""); + const [groupError, setGroupError] = useState(null); const [doctorState, setDoctorState] = useState<{ status: "idle" | "running" | "done"; result: CodexDoctorResult | null; @@ -101,18 +121,10 @@ export function SettingsView({ ); }, [appSettings.dictationModelId]); - const projects = useMemo(() => { - return workspaces - .filter((entry) => (entry.kind ?? "main") !== "worktree") - .slice() - .sort((a, b) => { - const orderDiff = orderValue(a) - orderValue(b); - if (orderDiff !== 0) { - return orderDiff; - } - return a.name.localeCompare(b.name); - }); - }, [workspaces]); + const projects = useMemo( + () => groupedWorkspaces.flatMap((group) => group.workspaces), + [groupedWorkspaces], + ); useEffect(() => { setCodexPathDraft(appSettings.codexBin ?? ""); @@ -141,6 +153,16 @@ export function SettingsView({ }); }, [projects]); + useEffect(() => { + setGroupDrafts((prev) => { + const next: Record = {}; + workspaceGroups.forEach((group) => { + next[group.id] = prev[group.id] ?? group.name; + }); + return next; + }); + }, [workspaceGroups]); + useEffect(() => { if (initialSection) { setActiveSection(initialSection); @@ -253,6 +275,70 @@ export function SettingsView({ } }; + const trimmedGroupName = newGroupName.trim(); + const canCreateGroup = Boolean(trimmedGroupName); + + const handleCreateGroup = async () => { + setGroupError(null); + try { + const created = await onCreateWorkspaceGroup(newGroupName); + if (created) { + setNewGroupName(""); + } + } catch (error) { + setGroupError(error instanceof Error ? error.message : String(error)); + } + }; + + const handleRenameGroup = async (group: WorkspaceGroup) => { + const draft = groupDrafts[group.id] ?? ""; + const trimmed = draft.trim(); + if (!trimmed || trimmed === group.name) { + setGroupDrafts((prev) => ({ + ...prev, + [group.id]: group.name, + })); + return; + } + setGroupError(null); + try { + await onRenameWorkspaceGroup(group.id, trimmed); + } catch (error) { + setGroupError(error instanceof Error ? error.message : String(error)); + setGroupDrafts((prev) => ({ + ...prev, + [group.id]: group.name, + })); + } + }; + + const handleDeleteGroup = async (group: WorkspaceGroup) => { + const groupProjects = + groupedWorkspaces.find((entry) => entry.id === group.id)?.workspaces ?? []; + const detail = + groupProjects.length > 0 + ? `\n\nProjects in this group will move to "${ungroupedLabel}".` + : ""; + const confirmed = await ask( + `Delete "${group.name}"?${detail}`, + { + title: "Delete Group", + kind: "warning", + okLabel: "Delete", + cancelLabel: "Cancel", + }, + ); + if (!confirmed) { + return; + } + setGroupError(null); + try { + await onDeleteWorkspaceGroup(group.id); + } catch (error) { + setGroupError(error instanceof Error ? error.message : String(error)); + } + }; + return (
@@ -316,43 +402,172 @@ export function SettingsView({
Projects
- Reorder your projects and remove unused workspaces. + Group related workspaces and reorder projects within each group. +
+
Groups
+
+ Create group labels for related repositories. +
+
+
+ setNewGroupName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && canCreateGroup) { + event.preventDefault(); + void handleCreateGroup(); + } + }} + /> + +
+ {groupError &&
{groupError}
} + {workspaceGroups.length > 0 ? ( +
+ {workspaceGroups.map((group, index) => ( +
+ + setGroupDrafts((prev) => ({ + ...prev, + [group.id]: event.target.value, + })) + } + onBlur={() => { + void handleRenameGroup(group); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRenameGroup(group); + } + }} + /> +
+ + + +
+
+ ))} +
+ ) : ( +
No groups yet.
+ )} +
+
Projects
+
+ Assign projects to groups and adjust their order.
- {projects.map((workspace, index) => ( -
-
-
{workspace.name}
-
{workspace.path}
-
-
- - - -
+ {groupedWorkspaces.map((group) => ( +
+
{group.name}
+ {group.workspaces.map((workspace, index) => { + const groupValue = + workspaceGroups.some( + (entry) => entry.id === workspace.settings.groupId, + ) + ? workspace.settings.groupId ?? "" + : ""; + return ( +
+
+
{workspace.name}
+
{workspace.path}
+
+
+ + + + +
+
+ ); + })}
))} {projects.length === 0 && ( diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 326483973..3525b4c1c 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -18,6 +18,7 @@ const defaultSettings: AppSettings = { dictationModelId: "base", dictationPreferredLanguage: null, dictationHoldKey: "alt", + workspaceGroups: [], }; function normalizeAppSettings(settings: AppSettings): AppSettings { diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index d79ef41c7..0e592dddc 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { DebugEntry } from "../../../types"; -import type { WorkspaceInfo, WorkspaceSettings } from "../../../types"; +import type { + AppSettings, + DebugEntry, + WorkspaceGroup, + WorkspaceInfo, + WorkspaceSettings, +} from "../../../types"; import { ask } from "@tauri-apps/plugin-dialog"; import { addWorkspace as addWorkspaceService, @@ -14,16 +19,61 @@ import { updateWorkspaceSettings as updateWorkspaceSettingsService, } from "../../../services/tauri"; +const GROUP_ID_RANDOM_MODULUS = 1_000_000; +const RESERVED_GROUP_NAME = "Ungrouped"; +const RESERVED_GROUP_NAME_NORMALIZED = RESERVED_GROUP_NAME.toLowerCase(); +const SORT_ORDER_FALLBACK = Number.MAX_SAFE_INTEGER; + type UseWorkspacesOptions = { onDebug?: (entry: DebugEntry) => void; defaultCodexBin?: string | null; + appSettings?: AppSettings; + onUpdateAppSettings?: (next: AppSettings) => Promise; +}; + +type WorkspaceGroupSection = { + id: string | null; + name: string; + workspaces: WorkspaceInfo[]; }; +function normalizeGroupName(name: string) { + return name.trim(); +} + +function getSortOrderValue(value: number | null | undefined) { + return typeof value === "number" ? value : SORT_ORDER_FALLBACK; +} + +function isReservedGroupName(name: string) { + return normalizeGroupName(name).toLowerCase() === RESERVED_GROUP_NAME_NORMALIZED; +} + +function isDuplicateGroupName( + name: string, + groups: WorkspaceGroup[], + excludeId?: string, +) { + const normalized = normalizeGroupName(name).toLowerCase(); + return groups.some( + (group) => + group.id !== excludeId && + normalizeGroupName(group.name).toLowerCase() === normalized, + ); +} + +function createGroupId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.floor(Math.random() * GROUP_ID_RANDOM_MODULUS)}`; +} + export function useWorkspaces(options: UseWorkspacesOptions = {}) { const [workspaces, setWorkspaces] = useState([]); const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); const [hasLoaded, setHasLoaded] = useState(false); - const { onDebug, defaultCodexBin } = options; + const { onDebug, defaultCodexBin, appSettings, onUpdateAppSettings } = options; const refreshWorkspaces = useCallback(async () => { try { @@ -53,6 +103,105 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { [activeWorkspaceId, workspaces], ); + const workspaceById = useMemo(() => { + const map = new Map(); + workspaces.forEach((entry) => { + map.set(entry.id, entry); + }); + return map; + }, [workspaces]); + + const workspaceGroups = useMemo(() => { + const groups = appSettings?.workspaceGroups ?? []; + return groups.slice().sort((a, b) => { + const orderDiff = getSortOrderValue(a.sortOrder) - getSortOrderValue(b.sortOrder); + if (orderDiff !== 0) { + return orderDiff; + } + return a.name.localeCompare(b.name); + }); + }, [appSettings?.workspaceGroups]); + + const workspaceGroupById = useMemo(() => { + const map = new Map(); + workspaceGroups.forEach((group) => { + map.set(group.id, group); + }); + return map; + }, [workspaceGroups]); + + const getWorkspaceGroupId = useCallback( + (workspace: WorkspaceInfo) => { + if ((workspace.kind ?? "main") === "worktree" && workspace.parentId) { + const parent = workspaceById.get(workspace.parentId); + return parent?.settings.groupId ?? null; + } + return workspace.settings.groupId ?? null; + }, + [workspaceById], + ); + + const groupedWorkspaces = useMemo(() => { + const rootWorkspaces = workspaces.filter( + (entry) => (entry.kind ?? "main") !== "worktree" && !entry.parentId, + ); + const buckets = new Map(); + workspaceGroups.forEach((group) => { + buckets.set(group.id, []); + }); + const ungrouped: WorkspaceInfo[] = []; + rootWorkspaces.forEach((workspace) => { + const groupId = workspace.settings.groupId ?? null; + const bucket = groupId ? buckets.get(groupId) : null; + if (bucket) { + bucket.push(workspace); + } else { + ungrouped.push(workspace); + } + }); + + const sortWorkspaces = (list: WorkspaceInfo[]) => + list.slice().sort((a, b) => { + const orderDiff = + getSortOrderValue(a.settings.sortOrder) - getSortOrderValue(b.settings.sortOrder); + if (orderDiff !== 0) { + return orderDiff; + } + return a.name.localeCompare(b.name); + }); + + const sections: WorkspaceGroupSection[] = workspaceGroups.map((group) => ({ + id: group.id, + name: group.name, + workspaces: sortWorkspaces(buckets.get(group.id) ?? []), + })); + + if (ungrouped.length > 0) { + sections.push({ + id: null, + name: RESERVED_GROUP_NAME, + workspaces: sortWorkspaces(ungrouped), + }); + } + + return sections.filter((section) => section.workspaces.length > 0); + }, [workspaces, workspaceGroups]); + + const getWorkspaceGroupName = useCallback( + (workspaceId: string) => { + const workspace = workspaceById.get(workspaceId); + if (!workspace) { + return null; + } + const groupId = getWorkspaceGroupId(workspace); + if (!groupId) { + return null; + } + return workspaceGroupById.get(groupId)?.name ?? null; + }, + [getWorkspaceGroupId, workspaceById, workspaceGroupById], + ); + async function addWorkspace() { const selection = await pickWorkspacePath(); if (!selection) { @@ -139,50 +288,50 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { ); } - async function updateWorkspaceSettings( - workspaceId: string, - settings: WorkspaceSettings, - ) { - onDebug?.({ - id: `${Date.now()}-client-update-workspace-settings`, - timestamp: Date.now(), - source: "client", - label: "workspace/settings", - payload: { workspaceId, settings }, - }); - let previous: WorkspaceInfo | null = null; - setWorkspaces((prev) => - prev.map((entry) => { - if (entry.id !== workspaceId) { - return entry; - } - previous = entry; - return { ...entry, settings }; - }), - ); - try { - const updated = await updateWorkspaceSettingsService(workspaceId, settings); + const updateWorkspaceSettings = useCallback( + async (workspaceId: string, settings: WorkspaceSettings) => { + onDebug?.({ + id: `${Date.now()}-client-update-workspace-settings`, + timestamp: Date.now(), + source: "client", + label: "workspace/settings", + payload: { workspaceId, settings }, + }); + let previous: WorkspaceInfo | null = null; setWorkspaces((prev) => - prev.map((entry) => (entry.id === workspaceId ? updated : entry)), + prev.map((entry) => { + if (entry.id !== workspaceId) { + return entry; + } + previous = entry; + return { ...entry, settings }; + }), ); - return updated; - } catch (error) { - if (previous) { - const restore = previous; + try { + const updated = await updateWorkspaceSettingsService(workspaceId, settings); setWorkspaces((prev) => - prev.map((entry) => (entry.id === workspaceId ? restore : entry)), + prev.map((entry) => (entry.id === workspaceId ? updated : entry)), ); + return updated; + } catch (error) { + if (previous) { + const restore = previous; + setWorkspaces((prev) => + prev.map((entry) => (entry.id === workspaceId ? restore : entry)), + ); + } + onDebug?.({ + id: `${Date.now()}-client-update-workspace-settings-error`, + timestamp: Date.now(), + source: "error", + label: "workspace/settings error", + payload: error instanceof Error ? error.message : String(error), + }); + throw error; } - onDebug?.({ - id: `${Date.now()}-client-update-workspace-settings-error`, - timestamp: Date.now(), - source: "error", - label: "workspace/settings error", - payload: error instanceof Error ? error.message : String(error), - }); - throw error; - } - } + }, + [onDebug], + ); async function updateWorkspaceCodexBin(workspaceId: string, codexBin: string | null) { onDebug?.({ @@ -223,6 +372,161 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { } } + const updateWorkspaceGroups = useCallback( + async (nextGroups: WorkspaceGroup[]) => { + if (!appSettings || !onUpdateAppSettings) { + return null; + } + const nextSettings = { + ...appSettings, + workspaceGroups: nextGroups, + }; + return onUpdateAppSettings(nextSettings); + }, + [appSettings, onUpdateAppSettings], + ); + + const createWorkspaceGroup = useCallback( + async (name: string) => { + if (!appSettings || !onUpdateAppSettings) { + return null; + } + const trimmed = normalizeGroupName(name); + if (!trimmed) { + throw new Error("Group name is required."); + } + if (isReservedGroupName(trimmed)) { + throw new Error(`"${RESERVED_GROUP_NAME}" is reserved.`); + } + const currentGroups = appSettings.workspaceGroups ?? []; + if (isDuplicateGroupName(trimmed, currentGroups)) { + throw new Error("Group name already exists."); + } + const nextSortOrder = + currentGroups.reduce((max, group) => { + if (typeof group.sortOrder === "number") { + return Math.max(max, group.sortOrder); + } + return max; + }, -1) + 1; + const nextGroup: WorkspaceGroup = { + id: createGroupId(), + name: trimmed, + sortOrder: nextSortOrder, + }; + await updateWorkspaceGroups([...currentGroups, nextGroup]); + return nextGroup; + }, + [appSettings, onUpdateAppSettings, updateWorkspaceGroups], + ); + + const renameWorkspaceGroup = useCallback( + async (groupId: string, name: string) => { + if (!appSettings || !onUpdateAppSettings) { + return null; + } + const trimmed = normalizeGroupName(name); + if (!trimmed) { + throw new Error("Group name is required."); + } + if (isReservedGroupName(trimmed)) { + throw new Error(`"${RESERVED_GROUP_NAME}" is reserved.`); + } + const currentGroups = appSettings.workspaceGroups ?? []; + if (isDuplicateGroupName(trimmed, currentGroups, groupId)) { + throw new Error("Group name already exists."); + } + const nextGroups = currentGroups.map((group) => + group.id === groupId ? { ...group, name: trimmed } : group, + ); + await updateWorkspaceGroups(nextGroups); + return true; + }, + [appSettings, onUpdateAppSettings, updateWorkspaceGroups], + ); + + const moveWorkspaceGroup = useCallback( + async (groupId: string, direction: "up" | "down") => { + if (!appSettings || !onUpdateAppSettings) { + return null; + } + const ordered = workspaceGroups.slice(); + const index = ordered.findIndex((group) => group.id === groupId); + if (index === -1) { + return null; + } + const nextIndex = direction === "up" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= ordered.length) { + return null; + } + const nextOrdered = ordered.slice(); + const temp = nextOrdered[index]; + nextOrdered[index] = nextOrdered[nextIndex]; + nextOrdered[nextIndex] = temp; + const nextOrderById = new Map( + nextOrdered.map((group, idx) => [group.id, idx]), + ); + const currentGroups = appSettings.workspaceGroups ?? []; + const nextGroups = currentGroups.map((group) => { + const nextOrder = nextOrderById.get(group.id); + if (typeof nextOrder !== "number") { + return group; + } + return { ...group, sortOrder: nextOrder }; + }); + await updateWorkspaceGroups(nextGroups); + return true; + }, + [appSettings, onUpdateAppSettings, updateWorkspaceGroups, workspaceGroups], + ); + + const deleteWorkspaceGroup = useCallback( + async (groupId: string) => { + if (!appSettings || !onUpdateAppSettings) { + return null; + } + const currentGroups = appSettings.workspaceGroups ?? []; + const nextGroups = currentGroups.filter((group) => group.id !== groupId); + const workspacesToUpdate = workspaces.filter( + (workspace) => (workspace.settings.groupId ?? null) === groupId, + ); + await Promise.all([ + ...workspacesToUpdate.map((workspace) => + updateWorkspaceSettings(workspace.id, { + ...workspace.settings, + groupId: null, + }), + ), + updateWorkspaceGroups(nextGroups), + ]); + return true; + }, + [ + appSettings, + onUpdateAppSettings, + updateWorkspaceGroups, + updateWorkspaceSettings, + workspaces, + ], + ); + + const assignWorkspaceGroup = useCallback( + async (workspaceId: string, groupId: string | null) => { + const target = workspaces.find((workspace) => workspace.id === workspaceId); + if (!target || (target.kind ?? "main") === "worktree") { + return null; + } + const resolvedGroupId = + groupId && workspaceGroupById.has(groupId) ? groupId : null; + await updateWorkspaceSettings(target.id, { + ...target.settings, + groupId: resolvedGroupId, + }); + return true; + }, + [updateWorkspaceSettings, workspaceGroupById, workspaces], + ); + async function removeWorkspace(workspaceId: string) { const workspace = workspaces.find((entry) => entry.id === workspaceId); const workspaceName = workspace?.name || "this workspace"; @@ -328,6 +632,10 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { return { workspaces, + workspaceGroups, + groupedWorkspaces, + getWorkspaceGroupName, + ungroupedLabel: RESERVED_GROUP_NAME, activeWorkspace, activeWorkspaceId, setActiveWorkspaceId, @@ -337,6 +645,11 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { markWorkspaceConnected, updateWorkspaceSettings, updateWorkspaceCodexBin, + createWorkspaceGroup, + renameWorkspaceGroup, + moveWorkspaceGroup, + deleteWorkspaceGroup, + assignWorkspaceGroup, removeWorkspace, removeWorktree, hasLoaded, diff --git a/src/styles/home.css b/src/styles/home.css index 8afbafd42..ca1cb13db 100644 --- a/src/styles/home.css +++ b/src/styles/home.css @@ -156,11 +156,29 @@ } .home-latest-project { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.home-latest-project-name { font-size: 12px; font-weight: 600; color: var(--text-stronger); } +.home-latest-group { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); + border: 1px solid var(--border-subtle); + padding: 2px 6px; + border-radius: 999px; +} + .home-latest-message { font-size: 13px; color: var(--text-stronger); diff --git a/src/styles/settings.css b/src/styles/settings.css index ae662c9d2..d5af4e7fb 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -213,6 +213,11 @@ font-size: 12px; } +.settings-select--compact { + padding: 6px 8px; + font-size: 11px; +} + .settings-section-title { font-size: 15px; font-weight: 600; @@ -248,6 +253,64 @@ gap: 10px; } +.settings-groups { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-group-create { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-group-error { + font-size: 11px; + color: var(--status-error); +} + +.settings-group-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-group-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border-muted); + background: var(--surface-card); +} + +.settings-group-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-project-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-project-group + .settings-project-group { + margin-top: 12px; +} + +.settings-project-group-label { + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.08em; + color: var(--text-faint); + padding-left: 4px; +} + .settings-project-row { display: flex; align-items: center; diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index 5c2909b7c..6a0ab44c8 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -116,6 +116,77 @@ -webkit-app-region: no-drag; } +.workspace-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-group + .workspace-group { + margin-top: 6px; +} + +.workspace-group-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-right: 4px; +} + +.workspace-group-label { + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.08em; + color: var(--text-faint); + padding-left: 4px; +} + +.workspace-group-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.workspace-group-list.collapsed { + display: none; +} + +.group-toggle { + border: none; + background: transparent; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + padding: 0 2px; + -webkit-app-region: no-drag; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.group-toggle:hover { + color: var(--text-strong); +} + +.workspace-group-header:hover .group-toggle, +.group-toggle:focus-visible { + opacity: 1; + pointer-events: auto; +} + +.group-toggle-icon { + display: inline-block; + transition: transform 0.15s ease; +} + +.group-toggle.expanded .group-toggle-icon { + transform: rotate(90deg); +} + .workspace-card { display: flex; flex-direction: column; diff --git a/src/types.ts b/src/types.ts index c20ea9f15..15ee3a8e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,16 @@ export type WorkspaceSettings = { sidebarCollapsed: boolean; sortOrder?: number | null; + groupId?: string | null; gitRoot?: string | null; }; +export type WorkspaceGroup = { + id: string; + name: string; + sortOrder?: number | null; +}; + export type WorkspaceKind = "main" | "worktree"; export type WorktreeInfo = { @@ -79,6 +86,7 @@ export type AppSettings = { dictationModelId: string; dictationPreferredLanguage: string | null; dictationHoldKey: string | null; + workspaceGroups: WorkspaceGroup[]; }; export type CodexDoctorResult = {