From 36f8e94235821f8205bfc87e5e58d4567b2341d1 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Thu, 2 Jul 2026 21:57:38 +0200 Subject: [PATCH] feat(workspaces): add workspace management to the command palette Phase 3 kick: a new Workspaces palette group lists every workspace (Default included, active check-marked, repo-count meta) and switches via openWorkspace; shown once a named workspace exists and included in the empty-query set, so switching is Ctrl/Cmd+K -> pick. Actions gain "New workspace..." and "Manage workspaces...". Create routes into WorkspaceManagerDialog in a new create mode (initialCreate): the manager grew its own "+ New workspace" row (creating was switcher-menu-only before) that spawns the workspace with a placeholder name and autofocuses the name field with the text selected. Ref-guarded against StrictMode double-mount (caught live: it created two workspaces in dev). Verified: tsc, vitest (158), vite build, and a live Playwright pass in browser mode (gating without named workspaces, palette create -> rename -> switch -> fuzzy round trip back to Default). --- README.md | 3 +- ROADMAP.md | 15 ++++++++ TASKS.md | 19 ++++++++-- ui/src/App.tsx | 41 +++++++++++++++++++-- ui/src/styles/features.css | 8 ++++ ui/src/views/Palette.tsx | 12 +++--- ui/src/views/WorkspaceManagerDialog.tsx | 49 ++++++++++++++++++++++--- 7 files changed, 127 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 09aa7f5..6095a3c 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ keyboard alone, and the mouse stays first-class. vertical icon rail or horizontal toolbar tabs, your pick in Appearance) persisted across launches, saveable as named **workspaces** (the repos behind one product — open a workspace to focus the rail on just those repos, close it - to return to your default set; a manage dialog curates each), native macOS + to return to your default set; a manage dialog curates each, and creating, +switching, and managing are all in ⌘K), native macOS menubar, open in your editor or terminal, settings (⌘,) for appearance / diff / git / integrations / AI, in-app updates. - **AI commit messages** — suggest subject + body from staged changes via diff --git a/ROADMAP.md b/ROADMAP.md index e80fc58..f8f1860 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -826,6 +826,21 @@ queue live, which the single-repo store never did. Verified: `tsc`, `vitest` repos and clamps, Space flips 0/3 → 1/3 with the section count following, row-visibility gate at ≥2 members). +**Workspaces — Phase 3 kick, palette entries (2026-07-02):** Workspace +management joined the command palette, closing the keyboard-first gap. A new +**Workspaces** palette group lists every workspace (Default included, active +check-marked, repo counts as meta) — it shows once a named workspace exists +and is part of the empty-query set, so switching is ⌘K → pick; rows run the +same `openWorkspace` path as the switcher menu. The Actions group gains +"New workspace…" and "Manage workspaces…"; create routes into +`WorkspaceManagerDialog` in a new create mode (`initialCreate`), and the +manager itself grew a "+ New workspace" row (creating was switcher-menu-only +before) that spawns the workspace with a placeholder name and autofocuses +the name field with the text selected — type to replace, Enter to commit. +Verified: `tsc`, `vitest` (158), `vite build`. Remaining Phase 3: +`.code-workspace` import + the Workspace Review follow-ups (tracked in +TASKS). + --- ## 1.0 — Stable (≈ 20 weeks) diff --git a/TASKS.md b/TASKS.md index dc4c76e..651bb74 100644 --- a/TASKS.md +++ b/TASKS.md @@ -440,10 +440,21 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked notes, and ⌘F stay in the per-repo Review (deliberate v1 cuts — hunk staging needs per-path `apply_patch` plumbing; cross-repo note export needs a repo-grouped feedback format). -- ☐ Workspace polish (Phase 3): command-palette entries for workspace - management (create/open/switch), `.code-workspace` import, and Workspace - Review follow-ups: hunk-level stage/discard, notes + repo-grouped feedback - export, ⌘F across member pools, per-worktree members. +- ◐ Workspace polish (Phase 3): + - ☑ Command-palette entries for workspace management (2026-07-02): a + **Workspaces** palette group — one row per workspace (Default included; + active one check-marked, others show a repo-count meta; the group appears + once a named workspace exists and is included in the empty-query groups, + so ⌘K → pick is a two-keystroke switch) running `openWorkspace`; Actions + gain "New workspace…" + "Manage workspaces…". "New workspace…" opens + `WorkspaceManagerDialog` in create mode (new `initialCreate` prop) — the + manager grew its own create path (a "+ New workspace" row in the list; + creating was switcher-menu-only before): the workspace is spawned with a + placeholder name and the name field autofocuses with the text selected. + - ☐ `.code-workspace` import + - ☐ Workspace Review follow-ups: hunk-level stage/discard, notes + + repo-grouped feedback export, ⌘F across member pools, per-worktree + members. ### Topbar - ☑ Layout + native-chrome alignment diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a7a1c93..8a59487 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -196,6 +196,10 @@ export function App() { const unstagedCount = useRepo((s) => s.unstagedDiffs.length); const stagedCount = useRepo((s) => s.stagedDiffs.length); const baselineDiffCount = useRepo((s) => s.baselineDiffs.length); + // Workspace list + active id feed the palette's Workspaces group — a + // handful of user-created entries, so subscribing whole is cheap. + const workspaces = useWorkspaces((s) => s.workspaces); + const activeWorkspaceId = useWorkspaces((s) => s.activeWorkspaceId); const [paletteOpen, setPaletteOpen] = useState(false); const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false); @@ -227,7 +231,9 @@ export function App() { const [worktreeOpen, setWorktreeOpen] = useState(false); // null = closed; otherwise the repo whose rail tile is being customized. const [iconDialog, setIconDialog] = useState<{ path: string; name: string } | null>(null); - const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false); + // false = closed; 'create' opens the manager mid-create (palette "New + // workspace…") — the dialog spawns a workspace and focuses its name field. + const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false); const [syncing, setSyncing] = useState(false); const [pulling, setPulling] = useState(false); const [pushing, setPushing] = useState(false); @@ -969,6 +975,8 @@ export function App() { { id: 'open', label: 'Open repository…', group: 'Actions', shortcut: keyHint('open-repo'), run: () => { void openViaDialog(); } }, { id: 'clone', label: 'Clone repository…', group: 'Actions', shortcut: keyHint('clone-repo'), run: () => setCloneOpen(true) }, { id: 'switch-repo', label: 'Switch repository…', group: 'Actions', shortcut: keyHint('switch-repo'), keywords: 'switch repo repository jump active picker quick open', run: () => setRepoSwitcherOpen(true) }, + { id: 'workspace-new', label: 'New workspace…', group: 'Actions', keywords: 'workspace create group repositories multi repo', run: () => setWorkspaceManagerOpen('create') }, + { id: 'workspace-manage', label: 'Manage workspaces…', group: 'Actions', keywords: 'workspace edit curate repositories add remove rename delete', run: () => setWorkspaceManagerOpen(true) }, ]; // Repo-scoped actions only make sense — and only succeed — with a repo // open, so don't surface them (the network ones would fail confusingly). @@ -1117,6 +1125,27 @@ export function App() { }, }); } + // Workspace switching — one row per workspace (Default included), shown + // only once a named workspace exists; with none, "switch" is meaningless. + const workspaceActions: PaletteAction[] = + workspaces.some((w) => w.id !== DEFAULT_WORKSPACE_ID) + ? workspaces.map((w) => { + const isActive = (activeWorkspaceId ?? DEFAULT_WORKSPACE_ID) === w.id; + const n = w.repoPaths.length; + return { + id: `workspace:${w.id}`, + label: w.id === DEFAULT_WORKSPACE_ID ? 'Default' : w.name, + group: 'Workspaces', + keywords: 'workspace open switch group', + meta: isActive ? 'active' : `${n} repo${n === 1 ? '' : 's'}`, + metaLabel: isActive + ? 'active workspace' + : `${n} repositor${n === 1 ? 'y' : 'ies'}`, + ...(isActive ? { icon: 'check' as const } : {}), + run: () => { void useWorkspaces.getState().openWorkspace(w.id); }, + }; + }) + : []; const recentActions: PaletteAction[] = recents.map((r) => ({ id: `recent:${r.path}`, label: r.name, @@ -1126,14 +1155,15 @@ export function App() { icon: 'history', run: () => { void openByPath(r.path); }, })); - return [...base, ...repoActions, ...recentActions]; + return [...base, ...repoActions, ...workspaceActions, ...recentActions]; }, [setView, selectFile, onSync, onPull, onPush, openViaDialog, openByPath, setTheme, recents, pushAllTags, showToast, meta, abortOperation, requestCommitSearch, requestDiffSearch, requestSuggestCommitMessage, requestSelectSinceBaseline, openInEditor, openInTerminal, openSettingsAt, repoActions, setRebaseDialog, setRemoteDialog, setRenameBranchDialog, baseline, setBaseline, clearBaseline, stageReviewed, commits, resetTo, unstagedCount, stagedCount, baselineDiffCount, copyDiffs, - reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab]); + reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab, + workspaces, activeWorkspaceId]); const rootStyle = { '--font-ui': FONTS.ui[uiFont], @@ -1381,7 +1411,10 @@ export function App() { )} {workspaceManagerOpen && ( - setWorkspaceManagerOpen(false)} /> + setWorkspaceManagerOpen(false)} + /> )} {!isTauri() && !meta && ( diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index edfd013..0e8dabf 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -3208,6 +3208,14 @@ textarea.clone-input { color: var(--text-dim); font-family: var(--font-mono); } +/* The create row reads as an affordance, not a workspace. */ +.ws-mgr-ws.new { + margin-top: 4px; + color: var(--text-dim); +} +.ws-mgr-ws.new:hover { + color: var(--text); +} .ws-mgr-editor { flex: 1; diff --git a/ui/src/views/Palette.tsx b/ui/src/views/Palette.tsx index 5fc4a3a..3638afc 100644 --- a/ui/src/views/Palette.tsx +++ b/ui/src/views/Palette.tsx @@ -5,7 +5,7 @@ import { match, type Match } from '../lib/fuzzy'; import { useSettings } from '../stores/settings'; /** Result groups, in the order they render in the list and the scope row. */ -export type PaletteGroup = 'Actions' | 'Branches' | 'Tags' | 'Stashes' | 'Files' | 'Commits' | 'Recent'; +export type PaletteGroup = 'Actions' | 'Branches' | 'Tags' | 'Stashes' | 'Files' | 'Commits' | 'Workspaces' | 'Recent'; export interface PaletteAction { id: string; @@ -25,7 +25,7 @@ export interface PaletteAction { run(): void; } -const GROUP_ORDER: PaletteGroup[] = ['Actions', 'Branches', 'Tags', 'Stashes', 'Files', 'Commits', 'Recent']; +const GROUP_ORDER: PaletteGroup[] = ['Actions', 'Branches', 'Tags', 'Stashes', 'Files', 'Commits', 'Workspaces', 'Recent']; const GROUP_ICON: Record = { Actions: 'command', Branches: 'branch', @@ -33,6 +33,7 @@ const GROUP_ICON: Record = { Stashes: 'history', Files: 'file', Commits: 'graph', + Workspaces: 'workspace', Recent: 'history', }; @@ -41,7 +42,7 @@ const GROUP_ICON: Record = { const CAP_PER_GROUP = 6; // per group when scope = All const CAP_SCOPED = 50; // single selected scope // Defensive upper bound across all groups. The per-group caps already keep the -// total well under this (6 groups × 6 = 36 under "All", 50 under one scope); +// total well under this (8 groups × 6 = 48 under "All", 50 under one scope); // it's belt-and-suspenders against a future group being added. const CAP_TOTAL = 80; @@ -116,8 +117,9 @@ export function CommandPalette({ actions, onClose }: Props) { if (scope !== 'All' && a.group !== scope) continue; // With no query under "All", only the cheap, always-relevant groups show; // dumping every file/commit would be noise (and slow). Type or pick a - // scope to surface them. - if (empty && scope === 'All' && a.group !== 'Actions' && a.group !== 'Recent') continue; + // scope to surface them. (Workspaces qualify: a handful of user-created + // entries, and switching is exactly the "open palette, go" gesture.) + if (empty && scope === 'All' && a.group !== 'Actions' && a.group !== 'Workspaces' && a.group !== 'Recent') continue; const m = match(query, a.label, a.keywords); if (!m) continue; const arr = byGroup.get(a.group) ?? []; diff --git a/ui/src/views/WorkspaceManagerDialog.tsx b/ui/src/views/WorkspaceManagerDialog.tsx index 93cf3cb..8f7ed8e 100644 --- a/ui/src/views/WorkspaceManagerDialog.tsx +++ b/ui/src/views/WorkspaceManagerDialog.tsx @@ -18,10 +18,15 @@ import { DEFAULT_WORKSPACE_ID, useWorkspaces } from '../stores/workspaces'; * Membership edits are durable and take effect on the next open of that * workspace; for the *active* one the rail/strip re-filter live (an added * repo opens in the background and appears, a removed one hides). + * + * `initialCreate` (palette "New workspace…") starts a create on mount: the + * workspace is spawned immediately with a placeholder name and the name + * field focused with its text selected — type to replace, Enter to commit. */ -export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { +export function WorkspaceManagerDialog({ initialCreate, onClose }: { initialCreate?: boolean; onClose: () => void }) { const workspaces = useWorkspaces((s) => s.workspaces); const activeId = useWorkspaces((s) => s.activeWorkspaceId); + const create = useWorkspaces((s) => s.create); const rename = useWorkspaces((s) => s.rename); const remove = useWorkspaces((s) => s.remove); const addRepo = useWorkspaces((s) => s.addRepo); @@ -33,6 +38,9 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { const named = workspaces.filter((w) => w.id !== DEFAULT_WORKSPACE_ID); const [selectedId, setSelectedId] = useState(activeId ?? DEFAULT_WORKSPACE_ID); + // The workspace just created here (if any) — its name field autofocuses + // with the placeholder selected so typing replaces it. + const [justCreatedId, setJustCreatedId] = useState(null); // Error from the last "add from disk" attempt (e.g. a non-repo folder). const [addError, setAddError] = useState(null); const selected = @@ -90,6 +98,25 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { setSelectedId(DEFAULT_WORKSPACE_ID); }; + // Create-then-rename: the workspace exists immediately (placeholder name, + // empty membership — the add list is right below), and the name field takes + // focus with the text selected so typing replaces it. + const createWorkspace = async () => { + const id = await create('Workspace', []); + setSelectedId(id); + setJustCreatedId(id); + }; + + // Palette "New workspace…" lands here mid-create. Ref-guarded so + // StrictMode's double-run of the mount effect can't create two. + const initialCreateRan = useRef(false); + useEffect(() => { + if (initialCreate && !initialCreateRan.current) { + initialCreateRan.current = true; + void createWorkspace(); + } + }, []); + // Add repos picked from the native folder dialog. Each pick is validated + // canonicalized through `repo_open` (membership keys on canonical paths), // and recorded in recents so it shows up with a proper name from now on. @@ -144,9 +171,14 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { onSelect={() => setSelectedId(w.id)} /> ))} - {named.length === 0 && ( -
Create workspaces from the switcher menu.
- )} + {/* Right: repos of the selected workspace */} @@ -162,6 +194,7 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { void rename(selected.id, v)} /> )} @@ -244,8 +277,10 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) { } /** Rename field that commits on blur or Enter (not per keystroke, so a rename - * isn't a burst of SQLite writes). Reset by keying on the workspace id. */ -function NameField({ initial, onCommit }: { initial: string; onCommit: (name: string) => void }) { + * isn't a burst of SQLite writes). Reset by keying on the workspace id. + * `autoFocus` (just-created workspaces) also selects the placeholder text so + * typing replaces it. */ +function NameField({ initial, onCommit, autoFocus }: { initial: string; onCommit: (name: string) => void; autoFocus?: boolean }) { const [value, setValue] = useState(initial); const commit = () => { const trimmed = value.trim(); @@ -257,6 +292,8 @@ function NameField({ initial, onCommit }: { initial: string; onCommit: (name: st className="clone-input ws-mgr-name" value={value} aria-label="Workspace name" + autoFocus={autoFocus} + onFocus={(e) => { if (autoFocus) e.currentTarget.select(); }} onChange={(e) => setValue(e.target.value)} onBlur={commit} onKeyDown={(e) => {