From 375ba177fedd96d6a4a8c8facb1279720e90a9e2 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:29:27 -0500 Subject: [PATCH 1/6] chore: add bulk workspace add tracking docs --- PLAN.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ SPEC.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 PLAN.md create mode 100644 SPEC.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..1cd7afe68 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,54 @@ +# Bulk Add Workspaces — Implementation Plan + +- Issue: Dimillian/CodexMonitor#383 +- Branch: `feat/383-bulk-add-workspaces` + +## Decisions + +- Activation after bulk add: activate the **first newly-added** workspace only. +- Summary: show a summary dialog **only when** something is skipped or fails. + +## Goal + +Add multi-select / bulk add of workspaces so users can add many projects in one operation (and make drag/drop use the same pipeline without focus bouncing). + +## Milestones + +### 1) Issue + branch + +- [x] Create upstream issue (#383) +- [x] Create feature branch + +### 2) Multi-select picker wrapper + +- [ ] Add `pickWorkspacePaths()` (multi-select directory picker) in `src/services/tauri.ts` +- [ ] Add unit tests for the new wrapper + +### 3) Bulk add pipeline (shared by picker + drag/drop) + +- [ ] Implement a bulk add function in `src/features/workspaces/hooks/useWorkspaces.ts` + - [ ] Normalize + dedupe selected paths + - [ ] Skip existing workspace paths + - [ ] Skip invalid/non-directories + - [ ] Add sequentially via existing `add_workspace` IPC + - [ ] Activate only the first successful add + - [ ] Aggregate and show summary only when skipped/failed +- [ ] Add tests for bulk add behavior + +### 4) Wire UI + polish + +- [ ] Update `addWorkspace()` to use multi-select picker +- [ ] Route drag/drop additions through the bulk add pipeline (avoid focus bounce) +- [ ] Update any UI copy if needed (optional: “Add Workspaces…”) + +### 5) Validate + +- [ ] `npm run lint` +- [ ] `npm run test` +- [ ] `npm run typecheck` +- [ ] If Rust touched: `cd src-tauri && cargo test` + +## Notes + +- `PLAN.md` and `SPEC.md` are tracking docs for this branch and must not be present in the final PR diff. + diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 000000000..6c230fa32 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,49 @@ +# Bulk Add Workspaces — Spec + +## Problem + +Adding many projects is tedious because the workspace picker only supports selecting a single folder. Drag/drop can add multiple paths, but currently adds are fired without awaiting, which can lead to focus/active-workspace bouncing and inconsistent UX. + +## User-facing behavior + +### Multi-select picker + +- “Add Workspace…” opens a directory picker that supports selecting multiple folders. +- When the picker returns multiple selections: + - Add all valid folders that are not already present. + - Activate only the **first newly-added** workspace. + - If any selections are skipped (duplicate/invalid) or fail to add, show one summary dialog. + +### Drag/drop + +- Drag/drop of multiple folder paths uses the same bulk-add pipeline as the picker. +- Drop handling should avoid activating each added workspace (activate first only). + +## Dedupe rules + +- Normalize paths for comparison: + - trim whitespace + - replace `\\` with `/` + - strip trailing `/` (and `\\`) +- A selection is considered “already added” if its normalized path matches an existing workspace’s normalized `path`. + +## Error handling + +- Bulk add is best-effort: + - continue adding remaining selections even if one fails + - failures appear in the summary (and may be visible in Debug panel via existing logging) + +## Non-goals (for this PR) + +- Progress UI / per-workspace status while adding many workspaces. +- Backend/API changes (the existing `add_workspace` IPC is called N times). +- Automatic grouping / tagging during bulk add. + +## Acceptance criteria + +- Multi-select folder picker adds multiple workspaces in one operation. +- Only the first newly-added workspace becomes active after a bulk add. +- Existing workspaces are not duplicated. +- Drag/drop uses the same pipeline and does not bounce focus across newly-added workspaces. +- Summary dialog is shown only when something is skipped or fails. + From 7ba09a1de9bdc5a15ceeb2c76233ad2d52b5bc1f Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:31:12 -0500 Subject: [PATCH 2/6] feat: add multi-select workspace picker helper --- src/services/tauri.test.ts | 27 +++++++++++++++++++++++++++ src/services/tauri.ts | 8 ++++++++ 2 files changed, 35 insertions(+) diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index a9c25bdfb..16693158d 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; import * as notification from "@tauri-apps/plugin-notification"; import { addWorkspace, @@ -36,6 +37,7 @@ import { tailscaleDaemonStatus, tailscaleDaemonStop, tailscaleStatus, + pickWorkspacePaths, writeGlobalAgentsMd, writeGlobalCodexConfigToml, writeAgentMd, @@ -45,6 +47,10 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + vi.mock("@tauri-apps/plugin-notification", () => ({ isPermissionGranted: vi.fn(), requestPermission: vi.fn(), @@ -75,6 +81,27 @@ describe("tauri invoke wrappers", () => { }); }); + it("returns an empty list when workspace picker is cancelled", async () => { + const openMock = vi.mocked(open); + openMock.mockResolvedValueOnce(null); + + await expect(pickWorkspacePaths()).resolves.toEqual([]); + }); + + it("wraps a single workspace selection in an array", async () => { + const openMock = vi.mocked(open); + openMock.mockResolvedValueOnce("/tmp/project"); + + await expect(pickWorkspacePaths()).resolves.toEqual(["/tmp/project"]); + }); + + it("returns multiple workspace selections as-is", async () => { + const openMock = vi.mocked(open); + openMock.mockResolvedValueOnce(["/tmp/one", "/tmp/two"]); + + await expect(pickWorkspacePaths()).resolves.toEqual(["/tmp/one", "/tmp/two"]); + }); + it("maps workspace_id to workspaceId for git status", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 1d54e0f27..7a4f8573d 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -47,6 +47,14 @@ export async function pickWorkspacePath(): Promise { return selection; } +export async function pickWorkspacePaths(): Promise { + const selection = await open({ directory: true, multiple: true }); + if (!selection) { + return []; + } + return Array.isArray(selection) ? selection : [selection]; +} + export async function pickImageFiles(): Promise { const selection = await open({ multiple: true, From b790f5af7673fa09e421146b9b7e119da338b169 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:39:10 -0500 Subject: [PATCH 3/6] feat: support bulk add workspaces --- .../workspaces/hooks/useWorkspaces.test.tsx | 78 +++++++++- .../workspaces/hooks/useWorkspaces.ts | 134 +++++++++++++++++- 2 files changed, 204 insertions(+), 8 deletions(-) diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx index e519a59e1..7482bcd66 100644 --- a/src/features/workspaces/hooks/useWorkspaces.test.tsx +++ b/src/features/workspaces/hooks/useWorkspaces.test.tsx @@ -1,16 +1,24 @@ // @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { message } from "@tauri-apps/plugin-dialog"; import type { WorkspaceInfo } from "../../../types"; import { addWorkspace, + isWorkspacePathDir, listWorkspaces, + pickWorkspacePaths, renameWorktree, renameWorktreeUpstream, updateWorkspaceSettings, } from "../../../services/tauri"; import { useWorkspaces } from "./useWorkspaces"; +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: vi.fn(), + message: vi.fn(), +})); + vi.mock("../../../services/tauri", () => ({ listWorkspaces: vi.fn(), renameWorktree: vi.fn(), @@ -20,7 +28,7 @@ vi.mock("../../../services/tauri", () => ({ addWorktree: vi.fn(), connectWorkspace: vi.fn(), isWorkspacePathDir: vi.fn(), - pickWorkspacePath: vi.fn(), + pickWorkspacePaths: vi.fn(), removeWorkspace: vi.fn(), removeWorktree: vi.fn(), updateWorkspaceCodexBin: vi.fn(), @@ -248,3 +256,71 @@ describe("useWorkspaces.addWorkspaceFromPath", () => { expect(result.current.activeWorkspaceId).toBe("workspace-1"); }); }); + +describe("useWorkspaces.addWorkspace (bulk)", () => { + it("adds multiple workspaces and activates the first", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const pickWorkspacePathsMock = vi.mocked(pickWorkspacePaths); + const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir); + const addWorkspaceMock = vi.mocked(addWorkspace); + const messageMock = vi.mocked(message); + + listWorkspacesMock.mockResolvedValue([]); + pickWorkspacePathsMock.mockResolvedValue(["/tmp/ws-1", "/tmp/ws-2"]); + isWorkspacePathDirMock.mockResolvedValue(true); + addWorkspaceMock + .mockResolvedValueOnce({ ...workspaceOne, id: "added-1", path: "/tmp/ws-1" }) + .mockResolvedValueOnce({ ...workspaceTwo, id: "added-2", path: "/tmp/ws-2" }); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + let added: WorkspaceInfo | null = null; + await act(async () => { + added = await result.current.addWorkspace(); + }); + + expect(added?.id).toBe("added-1"); + expect(addWorkspaceMock).toHaveBeenCalledTimes(2); + expect(addWorkspaceMock).toHaveBeenCalledWith("/tmp/ws-1", null); + expect(addWorkspaceMock).toHaveBeenCalledWith("/tmp/ws-2", null); + expect(result.current.workspaces).toHaveLength(2); + expect(result.current.activeWorkspaceId).toBe("added-1"); + expect(messageMock).not.toHaveBeenCalled(); + }); + + it("shows a summary when some selections are skipped or fail", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const pickWorkspacePathsMock = vi.mocked(pickWorkspacePaths); + const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir); + const addWorkspaceMock = vi.mocked(addWorkspace); + const messageMock = vi.mocked(message); + + listWorkspacesMock.mockResolvedValue([workspaceOne]); + pickWorkspacePathsMock.mockResolvedValue([workspaceOne.path, workspaceTwo.path]); + isWorkspacePathDirMock.mockResolvedValue(true); + addWorkspaceMock.mockResolvedValue(workspaceTwo); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await result.current.addWorkspace(); + }); + + expect(addWorkspaceMock).toHaveBeenCalledTimes(1); + expect(addWorkspaceMock).toHaveBeenCalledWith(workspaceTwo.path, null); + expect(messageMock).toHaveBeenCalledTimes(1); + const [summary, options] = messageMock.mock.calls[0]; + expect(String(summary)).toContain("Skipped 1 already added workspace"); + expect(options).toEqual( + expect.objectContaining({ title: "Some workspaces were skipped", kind: "warning" }), + ); + }); +}); diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index b3ea84387..e90850a78 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -15,7 +15,7 @@ import { connectWorkspace as connectWorkspaceService, isWorkspacePathDir as isWorkspacePathDirService, listWorkspaces, - pickWorkspacePath, + pickWorkspacePaths, removeWorkspace as removeWorkspaceService, removeWorktree as removeWorktreeService, renameWorktree as renameWorktreeService, @@ -74,6 +74,10 @@ function createGroupId() { return `${Date.now()}-${Math.floor(Math.random() * GROUP_ID_RANDOM_MODULUS)}`; } +function normalizeWorkspacePathKey(value: string) { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + export function useWorkspaces(options: UseWorkspacesOptions = {}) { const [workspaces, setWorkspaces] = useState([]); const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); @@ -220,11 +224,12 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { ); const addWorkspaceFromPath = useCallback( - async (path: string) => { + async (path: string, options?: { activate?: boolean }) => { const selection = path.trim(); if (!selection) { return null; } + const shouldActivate = options?.activate !== false; onDebug?.({ id: `${Date.now()}-client-add-workspace`, timestamp: Date.now(), @@ -235,7 +240,9 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { try { const workspace = await addWorkspaceService(selection, defaultCodexBin ?? null); setWorkspaces((prev) => [...prev, workspace]); - setActiveWorkspaceId(workspace.id); + if (shouldActivate) { + setActiveWorkspaceId(workspace.id); + } Sentry.metrics.count("workspace_added", 1, { attributes: { workspace_id: workspace.id, @@ -257,13 +264,125 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { [defaultCodexBin, onDebug], ); + const addWorkspacesFromPaths = useCallback( + async (paths: string[]) => { + const existingPaths = new Set( + workspaces.map((entry) => normalizeWorkspacePathKey(entry.path)), + ); + const skippedExisting: string[] = []; + const skippedInvalid: string[] = []; + const failures: { path: string; message: string }[] = []; + const added: WorkspaceInfo[] = []; + + const seenSelections = new Set(); + const selections = paths + .map((path) => path.trim()) + .filter(Boolean) + .filter((path) => { + const key = normalizeWorkspacePathKey(path); + if (seenSelections.has(key)) { + return false; + } + seenSelections.add(key); + return true; + }); + + for (const selection of selections) { + const key = normalizeWorkspacePathKey(selection); + if (existingPaths.has(key)) { + skippedExisting.push(selection); + continue; + } + + let isDir = false; + try { + isDir = await isWorkspacePathDirService(selection); + } catch (error) { + failures.push({ + path: selection, + message: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (!isDir) { + skippedInvalid.push(selection); + continue; + } + + try { + const workspace = await addWorkspaceFromPath(selection, { + activate: added.length === 0, + }); + if (workspace) { + added.push(workspace); + existingPaths.add(key); + } + } catch (error) { + failures.push({ + path: selection, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + const hasIssues = + skippedExisting.length > 0 || skippedInvalid.length > 0 || failures.length > 0; + if (hasIssues) { + const lines: string[] = []; + lines.push(`Added ${added.length} workspace${added.length === 1 ? "" : "s"}.`); + if (skippedExisting.length > 0) { + lines.push( + `Skipped ${skippedExisting.length} already added workspace${ + skippedExisting.length === 1 ? "" : "s" + }.`, + ); + } + if (skippedInvalid.length > 0) { + lines.push( + `Skipped ${skippedInvalid.length} invalid path${ + skippedInvalid.length === 1 ? "" : "s" + } (not a folder).`, + ); + } + if (failures.length > 0) { + lines.push( + `Failed to add ${failures.length} workspace${ + failures.length === 1 ? "" : "s" + }.`, + ); + const details = failures + .slice(0, 3) + .map(({ path, message }) => `- ${path}: ${message}`); + if (failures.length > 3) { + details.push(`- …and ${failures.length - 3} more`); + } + lines.push(""); + lines.push("Failures:"); + lines.push(...details); + } + + const summary = lines.join("\n"); + const title = + failures.length > 0 ? "Some workspaces failed to add" : "Some workspaces were skipped"; + void message(summary, { + title, + kind: failures.length > 0 ? "error" : "warning", + }); + } + + return added[0] ?? null; + }, + [addWorkspaceFromPath, workspaces], + ); + const addWorkspace = useCallback(async () => { - const selection = await pickWorkspacePath(); - if (!selection) { + const selection = await pickWorkspacePaths(); + if (selection.length === 0) { return null; } - return addWorkspaceFromPath(selection); - }, [addWorkspaceFromPath]); + return addWorkspacesFromPaths(selection); + }, [addWorkspacesFromPaths]); const filterWorkspacePaths = useCallback(async (paths: string[]) => { const trimmed = paths.map((path) => path.trim()).filter(Boolean); @@ -867,6 +986,7 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { setActiveWorkspaceId, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, filterWorkspacePaths, addCloneAgent, addWorktreeAgent, From f794a9c1c82bcbf1aa508e1ef68c61451abba149 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:42:09 -0500 Subject: [PATCH 4/6] feat: wire bulk workspace add for picker and drop --- src-tauri/src/menu.rs | 2 +- src/App.tsx | 10 ++++---- src/features/app/components/SidebarHeader.tsx | 2 +- .../app/hooks/useWorkspaceActions.test.tsx | 1 + src/features/app/hooks/useWorkspaceActions.ts | 25 +++++++++++++++++++ src/features/home/components/Home.tsx | 2 +- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 5df3badc1..03664e9e7 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -94,7 +94,7 @@ pub(crate) fn build_menu( let new_clone_agent_item = MenuItemBuilder::with_id("file_new_clone_agent", "New Clone Agent").build(handle)?; let add_workspace_item = - MenuItemBuilder::with_id("file_add_workspace", "Add Workspace...").build(handle)?; + MenuItemBuilder::with_id("file_add_workspace", "Add Workspaces...").build(handle)?; registry.register("file_new_agent", &new_agent_item); registry.register("file_new_worktree_agent", &new_worktree_agent_item); diff --git a/src/App.tsx b/src/App.tsx index 98eaf16d5..ae065a23f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -203,6 +203,7 @@ function MainApp() { setActiveWorkspaceId, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, addCloneAgent, addWorktreeAgent, connectWorkspace, @@ -1465,7 +1466,7 @@ function MainApp() { const { handleAddWorkspace, - handleAddWorkspaceFromPath, + handleAddWorkspacesFromPaths, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, @@ -1473,6 +1474,7 @@ function MainApp() { isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, setActiveThreadId, setActiveTab, exitDiffView, @@ -1492,11 +1494,9 @@ function MainApp() { if (uniquePaths.length === 0) { return; } - uniquePaths.forEach((path) => { - void handleAddWorkspaceFromPath(path); - }); + void handleAddWorkspacesFromPaths(uniquePaths); }, - [handleAddWorkspaceFromPath], + [handleAddWorkspacesFromPaths], ); const { diff --git a/src/features/app/components/SidebarHeader.tsx b/src/features/app/components/SidebarHeader.tsx index f547ab13b..058989611 100644 --- a/src/features/app/components/SidebarHeader.tsx +++ b/src/features/app/components/SidebarHeader.tsx @@ -60,7 +60,7 @@ export function SidebarHeader({ className="sidebar-title-add" onClick={onAddWorkspace} data-tauri-drag-region="false" - aria-label="Add workspace" + aria-label="Add workspaces" type="button" > diff --git a/src/features/app/hooks/useWorkspaceActions.test.tsx b/src/features/app/hooks/useWorkspaceActions.test.tsx index f9eb7c25e..ba6caaf7f 100644 --- a/src/features/app/hooks/useWorkspaceActions.test.tsx +++ b/src/features/app/hooks/useWorkspaceActions.test.tsx @@ -35,6 +35,7 @@ describe("useWorkspaceActions telemetry", () => { isCompact: false, addWorkspace: vi.fn(async () => null), addWorkspaceFromPath: vi.fn(async () => null), + addWorkspacesFromPaths: vi.fn(async () => null), setActiveThreadId, setActiveTab: vi.fn(), exitDiffView: vi.fn(), diff --git a/src/features/app/hooks/useWorkspaceActions.ts b/src/features/app/hooks/useWorkspaceActions.ts index bc7be9e15..c7951953b 100644 --- a/src/features/app/hooks/useWorkspaceActions.ts +++ b/src/features/app/hooks/useWorkspaceActions.ts @@ -7,6 +7,7 @@ type Params = { isCompact: boolean; addWorkspace: () => Promise; addWorkspaceFromPath: (path: string) => Promise; + addWorkspacesFromPaths: (paths: string[]) => Promise; setActiveThreadId: (threadId: string | null, workspaceId: string) => void; setActiveTab: (tab: "home" | "projects" | "codex" | "git" | "log") => void; exitDiffView: () => void; @@ -22,6 +23,7 @@ export function useWorkspaceActions({ isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, setActiveThreadId, setActiveTab, exitDiffView, @@ -61,6 +63,28 @@ export function useWorkspaceActions({ } }, [addWorkspace, handleWorkspaceAdded, onDebug]); + const handleAddWorkspacesFromPaths = useCallback( + async (paths: string[]) => { + try { + const workspace = await addWorkspacesFromPaths(paths); + if (workspace) { + handleWorkspaceAdded(workspace); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onDebug({ + id: `${Date.now()}-client-add-workspace-error`, + timestamp: Date.now(), + source: "error", + label: "workspace/add error", + payload: message, + }); + alert(`Failed to add workspaces.\n\n${message}`); + } + }, + [addWorkspacesFromPaths, handleWorkspaceAdded, onDebug], + ); + const handleAddWorkspaceFromPath = useCallback( async (path: string) => { try { @@ -129,6 +153,7 @@ export function useWorkspaceActions({ return { handleAddWorkspace, + handleAddWorkspacesFromPaths, handleAddWorkspaceFromPath, handleAddAgent, handleAddWorktreeAgent, diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index a3c56b75f..b6c552ca8 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -256,7 +256,7 @@ export function Home({ + - Add Workspace + Add Workspaces
From e509436aeed47cc4d05f25350ed5e1d2fdaf3bed Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:50:02 -0500 Subject: [PATCH 5/6] test: fix bulk add workspace hook tests --- src/features/workspaces/hooks/useWorkspaces.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx index 7482bcd66..51b6c9525 100644 --- a/src/features/workspaces/hooks/useWorkspaces.test.tsx +++ b/src/features/workspaces/hooks/useWorkspaces.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { message } from "@tauri-apps/plugin-dialog"; import type { WorkspaceInfo } from "../../../types"; import { @@ -35,6 +35,10 @@ vi.mock("../../../services/tauri", () => ({ updateWorkspaceSettings: vi.fn(), })); +beforeEach(() => { + vi.clearAllMocks(); +}); + const worktree: WorkspaceInfo = { id: "wt-1", name: "feature/old", @@ -278,12 +282,10 @@ describe("useWorkspaces.addWorkspace (bulk)", () => { await Promise.resolve(); }); - let added: WorkspaceInfo | null = null; await act(async () => { - added = await result.current.addWorkspace(); + await result.current.addWorkspace(); }); - expect(added?.id).toBe("added-1"); expect(addWorkspaceMock).toHaveBeenCalledTimes(2); expect(addWorkspaceMock).toHaveBeenCalledWith("/tmp/ws-1", null); expect(addWorkspaceMock).toHaveBeenCalledWith("/tmp/ws-2", null); From 6eafd111f5fa4711bc2e31040a8ec4361f17bf95 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Mon, 9 Feb 2026 15:50:53 -0500 Subject: [PATCH 6/6] chore: remove tracking docs before PR --- PLAN.md | 54 ------------------------------------------------------ SPEC.md | 49 ------------------------------------------------- 2 files changed, 103 deletions(-) delete mode 100644 PLAN.md delete mode 100644 SPEC.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 1cd7afe68..000000000 --- a/PLAN.md +++ /dev/null @@ -1,54 +0,0 @@ -# Bulk Add Workspaces — Implementation Plan - -- Issue: Dimillian/CodexMonitor#383 -- Branch: `feat/383-bulk-add-workspaces` - -## Decisions - -- Activation after bulk add: activate the **first newly-added** workspace only. -- Summary: show a summary dialog **only when** something is skipped or fails. - -## Goal - -Add multi-select / bulk add of workspaces so users can add many projects in one operation (and make drag/drop use the same pipeline without focus bouncing). - -## Milestones - -### 1) Issue + branch - -- [x] Create upstream issue (#383) -- [x] Create feature branch - -### 2) Multi-select picker wrapper - -- [ ] Add `pickWorkspacePaths()` (multi-select directory picker) in `src/services/tauri.ts` -- [ ] Add unit tests for the new wrapper - -### 3) Bulk add pipeline (shared by picker + drag/drop) - -- [ ] Implement a bulk add function in `src/features/workspaces/hooks/useWorkspaces.ts` - - [ ] Normalize + dedupe selected paths - - [ ] Skip existing workspace paths - - [ ] Skip invalid/non-directories - - [ ] Add sequentially via existing `add_workspace` IPC - - [ ] Activate only the first successful add - - [ ] Aggregate and show summary only when skipped/failed -- [ ] Add tests for bulk add behavior - -### 4) Wire UI + polish - -- [ ] Update `addWorkspace()` to use multi-select picker -- [ ] Route drag/drop additions through the bulk add pipeline (avoid focus bounce) -- [ ] Update any UI copy if needed (optional: “Add Workspaces…”) - -### 5) Validate - -- [ ] `npm run lint` -- [ ] `npm run test` -- [ ] `npm run typecheck` -- [ ] If Rust touched: `cd src-tauri && cargo test` - -## Notes - -- `PLAN.md` and `SPEC.md` are tracking docs for this branch and must not be present in the final PR diff. - diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 6c230fa32..000000000 --- a/SPEC.md +++ /dev/null @@ -1,49 +0,0 @@ -# Bulk Add Workspaces — Spec - -## Problem - -Adding many projects is tedious because the workspace picker only supports selecting a single folder. Drag/drop can add multiple paths, but currently adds are fired without awaiting, which can lead to focus/active-workspace bouncing and inconsistent UX. - -## User-facing behavior - -### Multi-select picker - -- “Add Workspace…” opens a directory picker that supports selecting multiple folders. -- When the picker returns multiple selections: - - Add all valid folders that are not already present. - - Activate only the **first newly-added** workspace. - - If any selections are skipped (duplicate/invalid) or fail to add, show one summary dialog. - -### Drag/drop - -- Drag/drop of multiple folder paths uses the same bulk-add pipeline as the picker. -- Drop handling should avoid activating each added workspace (activate first only). - -## Dedupe rules - -- Normalize paths for comparison: - - trim whitespace - - replace `\\` with `/` - - strip trailing `/` (and `\\`) -- A selection is considered “already added” if its normalized path matches an existing workspace’s normalized `path`. - -## Error handling - -- Bulk add is best-effort: - - continue adding remaining selections even if one fails - - failures appear in the summary (and may be visible in Debug panel via existing logging) - -## Non-goals (for this PR) - -- Progress UI / per-workspace status while adding many workspaces. -- Backend/API changes (the existing `add_workspace` IPC is called N times). -- Automatic grouping / tagging during bulk add. - -## Acceptance criteria - -- Multi-select folder picker adds multiple workspaces in one operation. -- Only the first newly-added workspace becomes active after a bulk add. -- Existing workspaces are not duplicated. -- Drag/drop uses the same pipeline and does not bounce focus across newly-added workspaces. -- Summary dialog is shown only when something is skipped or fails. -