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 32216b4d5..e0b254a04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -203,6 +203,7 @@ function MainApp() { setActiveWorkspaceId, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, addCloneAgent, addWorktreeAgent, connectWorkspace, @@ -1466,7 +1467,7 @@ function MainApp() { const { handleAddWorkspace, - handleAddWorkspaceFromPath, + handleAddWorkspacesFromPaths, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, @@ -1474,6 +1475,7 @@ function MainApp() { isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, setActiveThreadId, setActiveTab, exitDiffView, @@ -1493,11 +1495,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
diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx index e519a59e1..51b6c9525 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 { beforeEach, 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,13 +28,17 @@ 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(), updateWorkspaceSettings: vi.fn(), })); +beforeEach(() => { + vi.clearAllMocks(); +}); + const worktree: WorkspaceInfo = { id: "wt-1", name: "feature/old", @@ -248,3 +260,69 @@ 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(); + }); + + await act(async () => { + await result.current.addWorkspace(); + }); + + 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, 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 8c6f501eb..336879a48 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,