From 21f66058ad6f256ec4970fcbf62fd2d954396693 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 09:18:05 -0400 Subject: [PATCH] feat(web): add "Change project root" button to web UI (#2355) Adds a visible control to change the devRoot directory from both the project selection gate and the slide-out projects panel, so users no longer need to hand-edit ~/.gsd/web-preferences.json. - New /api/switch-root POST endpoint: validates path (exists, is dir), persists to web-preferences.json (clearing lastActiveProject), and returns discovered projects under the new root - ProjectSelectionGate: shows current devRoot with "Change" link above the project list; also shows "Change project root" link when no projects are found under the current root - ProjectsPanel: shows "Change" link next to the devRoot path in the slide-out header - Both views use the existing FolderPickerDialog for directory browsing - 17 tests covering path validation, preference persistence, tilde expansion, and end-to-end switch scenarios Fixes #2264 Co-authored-by: Claude Opus 4.6 (1M context) --- src/tests/web-switch-project.test.ts | 277 +++++++++++++++++++++++++++ web/app/api/switch-root/route.ts | 109 +++++++++++ web/components/gsd/projects-view.tsx | 110 +++++++++-- 3 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 src/tests/web-switch-project.test.ts create mode 100644 web/app/api/switch-root/route.ts diff --git a/src/tests/web-switch-project.test.ts b/src/tests/web-switch-project.test.ts new file mode 100644 index 000000000..eae701fd0 --- /dev/null +++ b/src/tests/web-switch-project.test.ts @@ -0,0 +1,277 @@ +import test, { after, describe } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, + existsSync, statSync, +} from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Test the core validation + persistence logic used by /api/switch-root +// without pulling in the heavy bridge-service import chain. +// +// The server-side handler does: +// 1. Validate path exists and is a directory +// 2. Resolve tilde + resolve() to absolute path +// 3. Persist devRoot to web-preferences.json (clearing lastActiveProject) +// 4. Discover projects under the new root +// +// We test each concern in isolation using the same logic. +// --------------------------------------------------------------------------- + +// ── Helpers (mirrors /api/switch-root handler logic) ────────────────────── + +function expandTilde(p: string): string { + if (p === "~") return homedir(); + if (p.startsWith("~/")) return homedir() + p.slice(1); + return p; +} + +interface SwitchRootResult { + ok: boolean; + error?: string; + devRoot?: string; +} + +function validateSwitchRoot(rawDevRoot: string): SwitchRootResult { + const trimmed = rawDevRoot.trim(); + if (!trimmed) { + return { ok: false, error: "Missing devRoot in request body" }; + } + + const expanded = expandTilde(trimmed); + const resolved = resolve(expanded); + + if (!existsSync(resolved)) { + return { ok: false, error: `Path does not exist: ${resolved}` }; + } + + try { + const stat = statSync(resolved); + if (!stat.isDirectory()) { + return { ok: false, error: `Not a directory: ${resolved}` }; + } + } catch { + return { ok: false, error: `Cannot access path: ${resolved}` }; + } + + return { ok: true, devRoot: resolved }; +} + +interface WebPreferences { + devRoot?: string; + lastActiveProject?: string; +} + +function persistSwitchRoot( + prefsPath: string, + newDevRoot: string, +): WebPreferences { + let existing: WebPreferences = {}; + try { + if (existsSync(prefsPath)) { + existing = JSON.parse(readFileSync(prefsPath, "utf-8")); + } + } catch { + // Corrupt file — start fresh + } + + const prefs: WebPreferences = { + ...existing, + devRoot: newDevRoot, + lastActiveProject: undefined, + }; + + writeFileSync(prefsPath, JSON.stringify(prefs, null, 2), "utf-8"); + return prefs; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const tempRoot = mkdtempSync(join(tmpdir(), "gsd-switch-root-")); + +const rootA = join(tempRoot, "root-a"); +mkdirSync(rootA); +mkdirSync(join(rootA, "project-x")); +mkdirSync(join(rootA, "project-x", ".git")); +writeFileSync(join(rootA, "project-x", "package.json"), "{}"); +mkdirSync(join(rootA, "project-y")); + +const rootB = join(tempRoot, "root-b"); +mkdirSync(rootB); +mkdirSync(join(rootB, "project-z")); +writeFileSync(join(rootB, "project-z", "Cargo.toml"), ""); + +const filePath = join(tempRoot, "not-a-dir.txt"); +writeFileSync(filePath, "hello"); + +const prefsDir = join(tempRoot, "prefs"); +mkdirSync(prefsDir); +const prefsPath = join(prefsDir, "web-preferences.json"); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests — Path validation +// --------------------------------------------------------------------------- + +describe("switch-root: path validation", () => { + test("valid directory returns ok with resolved path", () => { + const result = validateSwitchRoot(rootA); + assert.ok(result.ok); + assert.equal(result.devRoot, rootA); + }); + + test("empty string returns error", () => { + const result = validateSwitchRoot(""); + assert.ok(!result.ok); + assert.match(result.error!, /Missing devRoot/); + }); + + test("whitespace-only string returns error", () => { + const result = validateSwitchRoot(" "); + assert.ok(!result.ok); + assert.match(result.error!, /Missing devRoot/); + }); + + test("non-existent path returns error", () => { + const result = validateSwitchRoot(join(tempRoot, "nonexistent-dir")); + assert.ok(!result.ok); + assert.match(result.error!, /does not exist/); + }); + + test("file path (not a directory) returns error", () => { + const result = validateSwitchRoot(filePath); + assert.ok(!result.ok); + assert.match(result.error!, /Not a directory/); + }); + + test("tilde path expands to home directory", () => { + const result = validateSwitchRoot("~"); + // ~ always exists as a directory (user's home) + assert.ok(result.ok, `Expected ok for ~, got error: ${result.error}`); + assert.equal(result.devRoot, homedir()); + }); + + test("resolves relative paths to absolute", () => { + // Create a relative path that's valid from cwd + const result = validateSwitchRoot(rootA); + assert.ok(result.ok); + assert.ok(result.devRoot!.startsWith("/"), "Should be absolute path"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — Preference persistence +// --------------------------------------------------------------------------- + +describe("switch-root: preference persistence", () => { + test("writes devRoot and clears lastActiveProject", () => { + writeFileSync(prefsPath, JSON.stringify({ + devRoot: rootA, + lastActiveProject: "/old/project", + }, null, 2)); + + const result = persistSwitchRoot(prefsPath, rootB); + + assert.equal(result.devRoot, rootB); + assert.equal(result.lastActiveProject, undefined); + + // Verify on-disk + const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8")); + assert.equal(onDisk.devRoot, rootB); + // undefined is not serialized to JSON + assert.ok( + !("lastActiveProject" in onDisk) || onDisk.lastActiveProject == null, + "lastActiveProject should be cleared", + ); + }); + + test("creates prefs file from scratch", () => { + const freshPath = join(prefsDir, "fresh.json"); + assert.ok(!existsSync(freshPath)); + + persistSwitchRoot(freshPath, rootA); + + assert.ok(existsSync(freshPath)); + const onDisk = JSON.parse(readFileSync(freshPath, "utf-8")); + assert.equal(onDisk.devRoot, rootA); + }); + + test("handles corrupt prefs file gracefully", () => { + writeFileSync(prefsPath, "NOT VALID JSON!!!"); + + const result = persistSwitchRoot(prefsPath, rootB); + assert.equal(result.devRoot, rootB); + + const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8")); + assert.equal(onDisk.devRoot, rootB); + }); + + test("overwrites existing devRoot", () => { + writeFileSync(prefsPath, JSON.stringify({ devRoot: rootA }, null, 2)); + + persistSwitchRoot(prefsPath, rootB); + + const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8")); + assert.equal(onDisk.devRoot, rootB); + assert.notEqual(onDisk.devRoot, rootA); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — Tilde expansion +// --------------------------------------------------------------------------- + +describe("switch-root: tilde expansion", () => { + test("~ expands to home directory", () => { + assert.equal(expandTilde("~"), homedir()); + }); + + test("~/Projects expands correctly", () => { + assert.equal(expandTilde("~/Projects"), `${homedir()}/Projects`); + }); + + test("absolute path is unchanged", () => { + assert.equal(expandTilde("/usr/local/bin"), "/usr/local/bin"); + }); + + test("relative path is unchanged", () => { + assert.equal(expandTilde("relative/path"), "relative/path"); + }); + + test("~user is not expanded (only bare ~ or ~/)", () => { + assert.equal(expandTilde("~other"), "~other"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — End-to-end switch scenario +// --------------------------------------------------------------------------- + +describe("switch-root: end-to-end scenario", () => { + test("full switch: validate + persist + verify projects change", () => { + // Start with root-a + writeFileSync(prefsPath, JSON.stringify({ + devRoot: rootA, + lastActiveProject: join(rootA, "project-x"), + }, null, 2)); + + // User requests switch to root-b + const validation = validateSwitchRoot(rootB); + assert.ok(validation.ok, `Validation should pass: ${validation.error}`); + + const prefs = persistSwitchRoot(prefsPath, validation.devRoot!); + assert.equal(prefs.devRoot, rootB); + assert.equal(prefs.lastActiveProject, undefined); + + // Verify on-disk state + const finalPrefs = JSON.parse(readFileSync(prefsPath, "utf-8")); + assert.equal(finalPrefs.devRoot, rootB); + }); +}); diff --git a/web/app/api/switch-root/route.ts b/web/app/api/switch-root/route.ts new file mode 100644 index 000000000..900023bbe --- /dev/null +++ b/web/app/api/switch-root/route.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { homedir } from "node:os"; +import { webPreferencesPath } from "../../../../src/app-paths.ts"; +import { discoverProjects } from "../../../../src/web/project-discovery-service.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Shape of persisted web preferences. */ +interface WebPreferences { + devRoot?: string; + lastActiveProject?: string; +} + +/** Expand leading `~/` to the user's home directory. */ +function expandTilde(p: string): string { + if (p === "~") return homedir(); + if (p.startsWith("~/")) return homedir() + p.slice(1); + return p; +} + +/** + * POST /api/switch-root + * + * Validates the new root path, persists it as the `devRoot` preference, + * and returns the discovered projects under the new root. + * + * Request body: { "devRoot": "/absolute/path" } + * Response: { "devRoot": "/resolved/path", "projects": [...] } + */ +export async function POST(request: Request): Promise { + try { + const body = (await request.json()) as Record; + const rawDevRoot = typeof body.devRoot === "string" ? body.devRoot.trim() : ""; + + if (!rawDevRoot) { + return Response.json( + { error: "Missing devRoot in request body" }, + { status: 400 }, + ); + } + + const expanded = expandTilde(rawDevRoot); + const resolved = resolve(expanded); + + // Validate: path must exist + if (!existsSync(resolved)) { + return Response.json( + { error: `Path does not exist: ${resolved}` }, + { status: 400 }, + ); + } + + // Validate: path must be a directory + try { + const stat = statSync(resolved); + if (!stat.isDirectory()) { + return Response.json( + { error: `Not a directory: ${resolved}` }, + { status: 400 }, + ); + } + } catch { + return Response.json( + { error: `Cannot access path: ${resolved}` }, + { status: 400 }, + ); + } + + // Read existing preferences and merge + let existing: WebPreferences = {}; + try { + if (existsSync(webPreferencesPath)) { + existing = JSON.parse(readFileSync(webPreferencesPath, "utf-8")); + } + } catch { + // Corrupt file — start fresh + } + + const prefs: WebPreferences = { + ...existing, + devRoot: resolved, + // Clear last active project since we're changing the root + lastActiveProject: undefined, + }; + + // Ensure parent directory exists + const dir = dirname(webPreferencesPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(webPreferencesPath, JSON.stringify(prefs, null, 2), "utf-8"); + + // Discover projects under the new root + const projects = discoverProjects(resolved, true); + + return Response.json({ + devRoot: resolved, + projects, + }); + } catch (err) { + return Response.json( + { error: `Failed to switch root: ${err instanceof Error ? err.message : String(err)}` }, + { status: 500 }, + ); + } +} diff --git a/web/components/gsd/projects-view.tsx b/web/components/gsd/projects-view.tsx index c9be904a8..69f0fdcd1 100644 --- a/web/components/gsd/projects-view.tsx +++ b/web/components/gsd/projects-view.tsx @@ -317,22 +317,35 @@ export function ProjectsPanel({ const handleDevRootSaved = useCallback( async (newRoot: string) => { - setDevRoot(newRoot) setLoading(true) setError(null) try { - const discovered = await loadProjects(newRoot) - setProjects(discovered) + // Validate path and persist in a single call + const res = await authFetch("/api/switch-root", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ devRoot: newRoot }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`) + } + + const data = await res.json() as { devRoot: string; projects: ProjectMetadata[] } + setDevRoot(data.devRoot) + setProjects(data.projects) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load projects") + setError(err instanceof Error ? err.message : "Failed to switch project root") } finally { setLoading(false) } }, - [loadProjects], + [], ) const [newProjectOpen, setNewProjectOpen] = useState(false) + const [changeRootOpen, setChangeRootOpen] = useState(false) const workspaceState = useGSDWorkspaceState() const handleProjectCreated = useCallback( @@ -468,11 +481,19 @@ export function ProjectsPanel({

Projects

{devRoot && !loading && ( -

- {devRoot} - · - {projects.length} project{projects.length !== 1 ? "s" : ""} -

+
+ {devRoot} + + · + {projects.length} project{projects.length !== 1 ? "s" : ""} +
)}
+ + )} + {/* Filter + count */}

@@ -1240,8 +1297,31 @@ export function ProjectSelectionGate() { )}

)} + + {/* Change root for "no projects" and "no devRoot" states */} + {devRoot && !loading && sortedProjects.length === 0 && !error && ( +
+ +
+ )} + + {/* Folder picker for changing dev root */} + void handleDevRootSaved(path)} + initialPath={devRoot} + /> ) }