feat(projects): support adding multiple projects at once in the directory picker

Add a multi-select mode to the "Add project directory" dialog: each
directory row gets a select toggle (checkbox icon, Space toggles the
highlighted row), and the primary action becomes "Add selected" and
registers every selected directory in one store update. Selections apply
to the currently browsed directory and reset on navigation, dialog open,
and clone-mode entry. Clone mode keeps its single-target flow.

Add addProjects() to useProjectsStore: validates, normalizes, and dedups
paths (already-added or duplicated), creates entries in a single state
update and single persist, activates the first newly added project, and
discovers icons for each entry. Mirrors addProject semantics for the
single entry.

Refs OPE-142
This commit is contained in:
Serhii Dziupin
2026-08-28 16:41:36 +02:00
committed by herjarsa
parent 6950e113f4
commit 04c37d32a5
15 changed files with 234 additions and 28 deletions
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { ProjectEntry } from "@/lib/api/types"
import type { DesktopSettings } from "@/lib/desktop"
import { useProjectsStore } from "./useProjectsStore"
import { useDirectoryStore } from "./useDirectoryStore"
describe("useProjectsStore settings synchronization", () => {
test("treats a successful empty project snapshot as authoritative", () => {
@@ -119,3 +120,53 @@ describe("useProjectsStore default model and thinking level", () => {
expect(project?.defaultVariant).toBe(undefined)
})
})
describe("useProjectsStore.addProjects", () => {
const resetProjects = () => {
useProjectsStore.setState({
projects: [],
activeProjectId: null,
manualProjectOrder: [],
})
}
test("adds multiple new projects in one update and activates the first", () => {
resetProjects()
const added = useProjectsStore.getState().addProjects(["/one", "/two", "/three"])
expect(added).toHaveLength(3)
expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two", "/three"])
expect(useProjectsStore.getState().activeProjectId).toBe(added[0].id)
expect(added[0].addedAt).toBe(added[1].addedAt)
})
test("skips already-added paths and duplicates within the batch", () => {
resetProjects()
useProjectsStore.getState().addProjects(["/one"])
const added = useProjectsStore.getState().addProjects(["/one", "/two", "/two", "/one"])
expect(added).toHaveLength(1)
expect(added[0].path).toBe("/two")
expect(useProjectsStore.getState().projects.map((p) => p.path)).toEqual(["/one", "/two"])
})
test("skips invalid paths and returns an empty array when nothing is addable", () => {
resetProjects()
const added = useProjectsStore.getState().addProjects(["", " ", 42 as unknown as string])
expect(added).toEqual([])
expect(useProjectsStore.getState().projects).toEqual([])
})
test("normalizes paths (trailing separators, backslashes, tilde expansion)", () => {
resetProjects()
const added = useProjectsStore.getState().addProjects(["/repo/", "C:\\repo", "~/project"])
const home = useDirectoryStore.getState().homeDirectory;
expect(added.map((p) => p.path)).toEqual(["/repo", "C:/repo", home ? `${home}/project` : "~/project"])
})
})
@@ -49,7 +49,12 @@ interface ProjectsStore {
activeProjectId: string | null;
manualProjectOrder: string[];
<<<<<<< HEAD
addProject: (path: string, options?: { label?: string; id?: string }) => Promise<ProjectEntry | null>;
=======
addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null;
addProjects: (paths: string[]) => ProjectEntry[];
>>>>>>> e7506d349 (feat(projects): support adding multiple projects at once in the directory picker)
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
@@ -653,6 +658,55 @@ export const useProjectsStore = create<ProjectsStore>()(
return entry;
},
addProjects: (paths: string[]) => {
if (isVSCodeProjectsRuntime) {
return [];
}
const current = get();
const existingPaths = new Set(current.projects.map((project) => project.path));
const now = Date.now();
const entries: ProjectEntry[] = [];
const seenPaths = new Set<string>();
for (const rawPath of paths) {
const validation = get().validateProjectPath(rawPath);
if (!validation.ok || !validation.normalizedPath) {
continue;
}
const normalizedPath = validation.normalizedPath;
if (existingPaths.has(normalizedPath) || seenPaths.has(normalizedPath)) {
continue;
}
seenPaths.add(normalizedPath);
entries.push({
id: createProjectIdFromPath(normalizedPath),
path: normalizedPath,
label: deriveProjectLabel(normalizedPath),
color: pickAutoColor([...current.projects, ...entries]),
addedAt: now,
lastOpenedAt: now,
});
}
if (entries.length === 0) {
return [];
}
const nextProjects = [...current.projects, ...entries];
set({ projects: nextProjects });
if (streamDebugEnabled()) {
console.info('[ProjectsStore] Added projects', entries);
}
// Mirror addProject: the first newly added project becomes active.
get().setActiveProject(entries[0].id);
for (const entry of entries) {
void get().discoverProjectIcon(entry.id);
}
return entries;
},
removeProject: (id: string) => {
if (isVSCodeProjectsRuntime) {
return;