Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
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);
Expand Down
10 changes: 5 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ function MainApp() {
setActiveWorkspaceId,
addWorkspace,
addWorkspaceFromPath,
addWorkspacesFromPaths,
addCloneAgent,
addWorktreeAgent,
connectWorkspace,
Expand Down Expand Up @@ -1466,14 +1467,15 @@ function MainApp() {

const {
handleAddWorkspace,
handleAddWorkspaceFromPath,
handleAddWorkspacesFromPaths,
handleAddAgent,
handleAddWorktreeAgent,
handleAddCloneAgent,
} = useWorkspaceActions({
isCompact,
addWorkspace,
addWorkspaceFromPath,
addWorkspacesFromPaths,
setActiveThreadId,
setActiveTab,
exitDiffView,
Expand All @@ -1493,11 +1495,9 @@ function MainApp() {
if (uniquePaths.length === 0) {
return;
}
uniquePaths.forEach((path) => {
void handleAddWorkspaceFromPath(path);
});
void handleAddWorkspacesFromPaths(uniquePaths);
},
[handleAddWorkspaceFromPath],
[handleAddWorkspacesFromPaths],
);

const {
Expand Down
2 changes: 1 addition & 1 deletion src/features/app/components/SidebarHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<FolderPlus aria-hidden />
Expand Down
1 change: 1 addition & 0 deletions src/features/app/hooks/useWorkspaceActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
25 changes: 25 additions & 0 deletions src/features/app/hooks/useWorkspaceActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type Params = {
isCompact: boolean;
addWorkspace: () => Promise<WorkspaceInfo | null>;
addWorkspaceFromPath: (path: string) => Promise<WorkspaceInfo | null>;
addWorkspacesFromPaths: (paths: string[]) => Promise<WorkspaceInfo | null>;
setActiveThreadId: (threadId: string | null, workspaceId: string) => void;
setActiveTab: (tab: "home" | "projects" | "codex" | "git" | "log") => void;
exitDiffView: () => void;
Expand All @@ -22,6 +23,7 @@ export function useWorkspaceActions({
isCompact,
addWorkspace,
addWorkspaceFromPath,
addWorkspacesFromPaths,
setActiveThreadId,
setActiveTab,
exitDiffView,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -129,6 +153,7 @@ export function useWorkspaceActions({

return {
handleAddWorkspace,
handleAddWorkspacesFromPaths,
handleAddWorkspaceFromPath,
handleAddAgent,
handleAddWorktreeAgent,
Expand Down
2 changes: 1 addition & 1 deletion src/features/home/components/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export function Home({
<span className="home-icon" aria-hidden>
+
</span>
Add Workspace
Add Workspaces
</button>
</div>
<div className="home-usage">
Expand Down
82 changes: 80 additions & 2 deletions src/features/workspaces/hooks/useWorkspaces.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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",
Expand Down Expand Up @@ -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" }),
);
});
});
Loading