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:
parent
57c4939bee
commit
21f66058ad
3 changed files with 481 additions and 15 deletions
277
src/tests/web-switch-project.test.ts
Normal file
277
src/tests/web-switch-project.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
109
web/app/api/switch-root/route.ts
Normal file
109
web/app/api/switch-root/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue