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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-24 09:18:05 -04:00 committed by GitHub
parent 57c4939bee
commit 21f66058ad
3 changed files with 481 additions and 15 deletions

View file

@ -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);
});
});

View file

@ -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<Response> {
try {
const body = (await request.json()) as Record<string, unknown>;
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 },
);
}
}

View file

@ -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({
<div>
<h2 className="text-base font-semibold text-foreground">Projects</h2>
{devRoot && !loading && (
<p className="mt-0.5 text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{devRoot}</code>
<span className="ml-1.5 text-muted-foreground/50">·</span>
<span className="ml-1.5">{projects.length} project{projects.length !== 1 ? "s" : ""}</span>
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] truncate max-w-[200px]">{devRoot}</code>
<button
type="button"
onClick={() => setChangeRootOpen(true)}
className="shrink-0 text-[10px] text-primary hover:text-primary/80 transition-colors font-medium"
data-testid="projects-panel-change-root"
>
Change
</button>
<span className="text-muted-foreground/50">·</span>
<span>{projects.length} project{projects.length !== 1 ? "s" : ""}</span>
</div>
)}
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => onOpenChange(false)}>
@ -484,6 +505,14 @@ export function ProjectsPanel({
<ScrollArea className="min-h-0 flex-1">
<div className="px-5 py-4">{content}</div>
</ScrollArea>
{/* Folder picker for changing dev root */}
<FolderPickerDialog
open={changeRootOpen}
onOpenChange={setChangeRootOpen}
onSelect={(path) => void handleDevRootSaved(path)}
initialPath={devRoot}
/>
</SheetContent>
</Sheet>
)
@ -943,6 +972,7 @@ export function ProjectSelectionGate() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newProjectOpen, setNewProjectOpen] = useState(false)
const [changeRootOpen, setChangeRootOpen] = useState(false)
const [filter, setFilter] = useState("")
const loadProjects = useCallback(async (root: string) => {
@ -989,19 +1019,30 @@ export function ProjectSelectionGate() {
const handleDevRootSaved = useCallback(
async (newRoot: string) => {
setDevRoot(newRoot)
setLoading(true)
setError(null)
try {
const discovered = await loadProjects(newRoot)
setProjects(discovered)
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 handleProjectCreated = useCallback(
@ -1120,6 +1161,22 @@ export function ProjectSelectionGate() {
{/* ─── Project list ─── */}
{hasProjects && (
<div className="space-y-5">
{/* Dev root + change button */}
{devRoot && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FolderRoot className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground truncate">{devRoot}</code>
<button
type="button"
onClick={() => setChangeRootOpen(true)}
className="shrink-0 text-[11px] text-primary hover:text-primary/80 transition-colors font-medium"
data-testid="gate-change-root"
>
Change
</button>
</div>
)}
{/* Filter + count */}
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground/60 tabular-nums">
@ -1240,8 +1297,31 @@ export function ProjectSelectionGate() {
)}
</div>
)}
{/* Change root for "no projects" and "no devRoot" states */}
{devRoot && !loading && sortedProjects.length === 0 && !error && (
<div className="mt-4">
<button
type="button"
onClick={() => setChangeRootOpen(true)}
className="flex items-center gap-2 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
data-testid="gate-change-root-empty"
>
<FolderOpen className="h-3.5 w-3.5" />
Change project root
</button>
</div>
)}
</div>
</div>
{/* Folder picker for changing dev root */}
<FolderPickerDialog
open={changeRootOpen}
onOpenChange={setChangeRootOpen}
onSelect={(path) => void handleDevRootSaved(path)}
initialPath={devRoot}
/>
</div>
)
}