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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user