diff --git a/packages/api/src/__tests__/fs-browse.test.ts b/packages/api/src/__tests__/fs-browse.test.ts new file mode 100644 index 00000000..00f80cdf --- /dev/null +++ b/packages/api/src/__tests__/fs-browse.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + mkdtemp, + mkdir, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + DIRECTORY_ENTRY_LIMIT, + browseDirectories, + resolveBrowseRoots, +} from '../fs-browse'; + +const temporaryDirectories: string[] = []; + +async function makeTemporaryDirectory(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe('resolveBrowseRoots', () => { + it('returns the home directory plus normalized absolute configured roots', async () => { + const home = await makeTemporaryDirectory('codegraph-home-'); + const configured = await makeTemporaryDirectory('codegraph-root-'); + + await expect( + resolveBrowseRoots(home, ` ${configured},relative,${home} `), + ).resolves.toEqual([await realpath(home), await realpath(configured)]); + }); +}); + +describe('browseDirectories', () => { + it('returns configured roots when no path is requested', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + + await expect(browseDirectories(undefined, [root])).resolves.toEqual({ + path: null, + parent: null, + entries: [ + { + name: root.split('/').at(-1), + path: await realpath(root), + projectMarkers: [], + isSymlink: false, + }, + ], + truncated: false, + }); + }); + + it('lists only immediate child directories with sorted project markers', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + const alpha = join(root, 'alpha'); + const beta = join(root, 'beta'); + await mkdir(alpha); + await mkdir(beta); + await mkdir(join(alpha, '.git')); + await writeFile(join(alpha, 'package.json'), '{}'); + await writeFile(join(alpha, 'README.md'), 'not exposed'); + await writeFile(join(root, 'root-file.txt'), 'not exposed'); + + await expect(browseDirectories(root, [root])).resolves.toEqual({ + path: await realpath(root), + parent: null, + entries: [ + { + name: 'alpha', + path: await realpath(alpha), + projectMarkers: ['.git', 'package.json'], + isSymlink: false, + }, + { + name: 'beta', + path: await realpath(beta), + projectMarkers: [], + isSymlink: false, + }, + ], + truncated: false, + }); + }); + + it('returns the containing browse root as parent for a child directory', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + const child = join(root, 'child'); + await mkdir(child); + + const result = await browseDirectories(child, [root]); + + expect(result.path).toBe(await realpath(child)); + expect(result.parent).toBe(await realpath(root)); + }); + + it('omits hidden directories unless includeHidden is true', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + await mkdir(join(root, '.hidden')); + await mkdir(join(root, 'visible')); + + const hiddenByDefault = await browseDirectories(root, [root]); + const hiddenIncluded = await browseDirectories(root, [root], { + includeHidden: true, + }); + + expect(hiddenByDefault.entries.map((entry) => entry.name)).toEqual([ + 'visible', + ]); + expect(hiddenIncluded.entries.map((entry) => entry.name)).toEqual([ + '.hidden', + 'visible', + ]); + }); + + it('rejects lexical and encoded traversal outside a browse root with 403', async () => { + const container = await makeTemporaryDirectory('codegraph-container-'); + const root = join(container, 'root'); + const outside = join(container, 'outside'); + await mkdir(root); + await mkdir(outside); + + await expect( + browseDirectories(join(root, '..', 'outside'), [root]), + ).rejects.toMatchObject({ + status: 403, + }); + await expect( + browseDirectories(decodeURIComponent(`${root}/%2e%2e/outside`), [root]), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('rejects a symlink to a directory outside a browse root with 403', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + const outside = await makeTemporaryDirectory('codegraph-outside-'); + const link = join(root, 'outside-link'); + await symlink(outside, link, 'dir'); + + await expect(browseDirectories(link, [root])).rejects.toMatchObject({ + status: 403, + }); + }); + + it('marks in-root directory symlinks and omits out-of-root symlinks', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + const outside = await makeTemporaryDirectory('codegraph-outside-'); + const target = join(root, 'target'); + await mkdir(target); + await symlink(target, join(root, 'inside-link'), 'dir'); + await symlink(outside, join(root, 'outside-link'), 'dir'); + + const result = await browseDirectories(root, [root]); + const normalizedRoot = await realpath(root); + + expect(result.entries).toEqual([ + { + name: 'inside-link', + path: join(normalizedRoot, 'inside-link'), + projectMarkers: [], + isSymlink: true, + }, + { + name: 'target', + path: join(normalizedRoot, 'target'), + projectMarkers: [], + isSymlink: false, + }, + ]); + }); + + it('returns 404 for a nonexistent path', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + + await expect( + browseDirectories(join(root, 'missing'), [root]), + ).rejects.toMatchObject({ + status: 404, + }); + }); + + it('returns 400 for a file path and for a relative path', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + const file = join(root, 'file.txt'); + await writeFile(file, 'file'); + + await expect(browseDirectories(file, [root])).rejects.toMatchObject({ + status: 400, + }); + await expect( + browseDirectories('relative/path', [root]), + ).rejects.toMatchObject({ + status: 400, + }); + }); + + it('caps sorted entries and reports truncation', async () => { + const root = await makeTemporaryDirectory('codegraph-root-'); + await Promise.all( + Array.from({ length: DIRECTORY_ENTRY_LIMIT + 1 }, (_, index) => + mkdir(join(root, `directory-${String(index).padStart(3, '0')}`)), + ), + ); + + const result = await browseDirectories(root, [root]); + + expect(result.entries).toHaveLength(DIRECTORY_ENTRY_LIMIT); + expect(result.entries[0]?.name).toBe('directory-000'); + expect(result.entries.at(-1)?.name).toBe( + `directory-${String(DIRECTORY_ENTRY_LIMIT - 1).padStart(3, '0')}`, + ); + expect(result.truncated).toBe(true); + }); +}); diff --git a/packages/api/src/__tests__/fs-directories-route.test.ts b/packages/api/src/__tests__/fs-directories-route.test.ts new file mode 100644 index 00000000..3f1b71f2 --- /dev/null +++ b/packages/api/src/__tests__/fs-directories-route.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createFsRoutes } from '../routes/fs-directories'; + +const temporaryDirectories: string[] = []; + +async function makeTemporaryDirectory(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe('GET /api/fs/directories', () => { + it('returns a requested directory and honors includeHidden=true', async () => { + const root = await makeTemporaryDirectory('codegraph-route-root-'); + await mkdir(join(root, '.hidden')); + await mkdir(join(root, 'visible')); + const routes = createFsRoutes({ homeDirectory: root }); + const query = new URLSearchParams({ path: root, includeHidden: 'true' }); + + const response = await routes.request( + `/api/fs/directories?${query.toString()}`, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + path: string | null; + parent: string | null; + entries: Array<{ name: string }>; + truncated: boolean; + }; + expect(body.path).not.toBeNull(); + expect(body.parent).toBeNull(); + expect(body.entries.map((entry) => entry.name)).toEqual([ + '.hidden', + 'visible', + ]); + expect(body.truncated).toBe(false); + }); + + it('returns configured roots when path is absent', async () => { + const home = await makeTemporaryDirectory('codegraph-route-home-'); + const extra = await makeTemporaryDirectory('codegraph-route-extra-'); + const routes = createFsRoutes({ + homeDirectory: home, + configuredRoots: extra, + }); + + const response = await routes.request('/api/fs/directories'); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + path: string | null; + parent: string | null; + entries: Array<{ path: string }>; + truncated: boolean; + }; + expect(body.path).toBeNull(); + expect(body.parent).toBeNull(); + expect(body.entries).toHaveLength(2); + expect(body.entries.map((entry) => entry.path)).toEqual( + expect.arrayContaining([ + expect.stringContaining(home.split('/').at(-1) ?? ''), + ]), + ); + expect(body.truncated).toBe(false); + }); + + it('returns 403 for an encoded traversal outside the root', async () => { + const container = await makeTemporaryDirectory( + 'codegraph-route-container-', + ); + const root = join(container, 'root'); + const outside = join(container, 'outside'); + await mkdir(root); + await mkdir(outside); + const routes = createFsRoutes({ homeDirectory: root }); + const encodedPath = `${encodeURIComponent(root)}%2F%2E%2E%2Foutside`; + + const response = await routes.request( + `/api/fs/directories?path=${encodedPath}`, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: 'path is outside every filesystem browse root', + }); + }); + + it('does not register a mutating method', async () => { + const root = await makeTemporaryDirectory('codegraph-route-root-'); + const routes = createFsRoutes({ homeDirectory: root }); + + const response = await routes.request('/api/fs/directories', { + method: 'POST', + }); + + expect(response.status).toBe(404); + }); + + it('returns a fixed message when directory enumeration fails unexpectedly', async () => { + const root = await makeTemporaryDirectory('codegraph-route-root-'); + const routes = createFsRoutes({ + homeDirectory: root, + browse: async () => { + throw new Error('sensitive filesystem detail'); + }, + }); + + const response = await routes.request( + `/api/fs/directories?path=${encodeURIComponent(root)}`, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to browse directories.', + }); + }); +}); diff --git a/packages/api/src/fs-browse.ts b/packages/api/src/fs-browse.ts new file mode 100644 index 00000000..58991ed1 --- /dev/null +++ b/packages/api/src/fs-browse.ts @@ -0,0 +1,233 @@ +import { lstat, readdir, realpath, stat } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; +import { isInsideRoot } from './source-access'; + +export const DIRECTORY_ENTRY_LIMIT = 500; + +export const PROJECT_MARKERS = [ + '.git', + 'package.json', + 'pnpm-workspace.yaml', + 'Cargo.toml', + 'pyproject.toml', + 'go.mod', + 'setup.py', + 'composer.json', + 'Gemfile', + 'build.gradle', + 'pom.xml', +] as const; + +export type ProjectMarker = (typeof PROJECT_MARKERS)[number]; + +export interface DirectoryBrowseEntry { + name: string; + path: string; + projectMarkers: ProjectMarker[]; + isSymlink: boolean; +} + +export interface DirectoryBrowseResponse { + path: string | null; + parent: string | null; + entries: DirectoryBrowseEntry[]; + truncated: boolean; +} + +export class FsBrowseError extends Error { + public constructor( + public readonly status: 400 | 403 | 404, + message: string, + ) { + super(message); + this.name = 'FsBrowseError'; + } +} + +interface BrowseOptions { + includeHidden?: boolean; +} + +interface DirectoryCandidate { + name: string; + path: string; + markerPath: string; + isSymlink: boolean; +} + +async function normalizedDirectory(path: string): Promise { + try { + const normalized = await realpath(resolve(path)); + const metadata = await stat(normalized); + return metadata.isDirectory() ? normalized : null; + } catch { + return null; + } +} + +export async function resolveBrowseRoots( + homeDirectory: string, + configuredRoots: string | undefined, +): Promise { + const candidates = [homeDirectory]; + if (configuredRoots !== undefined) { + candidates.push( + ...configuredRoots + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry !== '' && isAbsolute(entry)), + ); + } + + const roots = new Set(); + for (const candidate of candidates) { + const normalized = await normalizedDirectory(candidate); + if (normalized !== null) roots.add(normalized); + } + return Array.from(roots); +} + +async function projectMarkers(directory: string): Promise { + const checks = await Promise.all( + PROJECT_MARKERS.map(async (marker): Promise => { + try { + await lstat(join(directory, marker)); + return marker; + } catch { + return null; + } + }), + ); + return checks.filter((marker): marker is ProjectMarker => marker !== null); +} + +async function rootEntries( + roots: readonly string[], +): Promise { + return Promise.all( + roots.map(async (root) => ({ + name: basename(root) || root, + path: root, + projectMarkers: await projectMarkers(root), + isSymlink: false, + })), + ); +} + +async function directoryCandidates( + directory: string, + roots: readonly string[], + includeHidden: boolean, +): Promise { + const directoryEntries = await readdir(directory, { withFileTypes: true }); + const candidates = await Promise.all( + directoryEntries.map(async (entry): Promise => { + if (!includeHidden && entry.name.startsWith('.')) return null; + + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + return { + name: entry.name, + path: entryPath, + markerPath: entryPath, + isSymlink: false, + }; + } + if (!entry.isSymbolicLink()) return null; + + try { + const resolvedTarget = await realpath(entryPath); + if (!roots.some((root) => isInsideRoot(resolvedTarget, root))) + return null; + if (!(await stat(resolvedTarget)).isDirectory()) return null; + return { + name: entry.name, + path: entryPath, + markerPath: resolvedTarget, + isSymlink: true, + }; + } catch { + return null; + } + }), + ); + + return candidates + .filter((entry): entry is DirectoryCandidate => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function browseDirectories( + requestedPath: string | undefined, + browseRoots: readonly string[], + options: BrowseOptions = {}, +): Promise { + const roots = ( + await Promise.all(browseRoots.map((root) => normalizedDirectory(root))) + ).filter((root): root is string => root !== null); + + if (requestedPath === undefined || requestedPath === '') { + const entries = (await rootEntries(roots)).sort((left, right) => + left.name.localeCompare(right.name), + ); + return { path: null, parent: null, entries, truncated: false }; + } + if (requestedPath.includes('\0')) { + throw new FsBrowseError(400, 'path contains an invalid character'); + } + if (!isAbsolute(requestedPath)) { + throw new FsBrowseError(400, 'path must be absolute'); + } + if (roots.length === 0) { + throw new FsBrowseError(403, 'no filesystem browse root is configured'); + } + + let normalizedPath: string; + try { + normalizedPath = await realpath(resolve(requestedPath)); + } catch { + throw new FsBrowseError(404, 'directory not found'); + } + + const containingRoot = roots + .filter((root) => isInsideRoot(normalizedPath, root)) + .sort((left, right) => right.length - left.length)[0]; + if (containingRoot === undefined) { + throw new FsBrowseError( + 403, + 'path is outside every filesystem browse root', + ); + } + + let metadata; + try { + metadata = await stat(normalizedPath); + } catch { + throw new FsBrowseError(404, 'directory not found'); + } + if (!metadata.isDirectory()) { + throw new FsBrowseError(400, 'path must identify a directory'); + } + + const candidates = await directoryCandidates( + normalizedPath, + roots, + options.includeHidden === true, + ); + const truncated = candidates.length > DIRECTORY_ENTRY_LIMIT; + const entries = await Promise.all( + candidates.slice(0, DIRECTORY_ENTRY_LIMIT).map(async (entry) => ({ + name: entry.name, + path: entry.path, + projectMarkers: await projectMarkers(entry.markerPath), + isSymlink: entry.isSymlink, + })), + ); + + return { + path: normalizedPath, + parent: normalizedPath === containingRoot ? null : dirname(normalizedPath), + entries, + truncated, + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b2f8be80..69508849 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -24,6 +24,7 @@ import { naturalRoutes } from './routes/natural'; import { sourceRoutes } from './routes/source'; import { profileRoutes } from './routes/profile'; import { analysisRoutes } from './routes/analysis'; +import { fsRoutes } from './routes/fs-directories'; // Load .env before any route module reads configuration from process.env. const loadedEnvFile = loadEnvironment(); @@ -68,6 +69,7 @@ app.route('/', naturalRoutes); app.route('/', sourceRoutes); app.route('/', profileRoutes); app.route('/', analysisRoutes); +app.route('/', fsRoutes); // Serve the built dashboard, when one is present, from the same origin as the // API. Same origin means the browser never needs a CORS allowance for it. @@ -106,6 +108,7 @@ serve({ fetch: app.fetch, port }, (info) => { console.log(` Knowledge: GET /api/knowledge/stats`); console.log(` Profile: GET /api/profile`); console.log(` Embeddings: GET /api/embeddings/status`); + console.log(` Directories: GET /api/fs/directories`); }); export { app }; diff --git a/packages/api/src/routes/fs-directories.ts b/packages/api/src/routes/fs-directories.ts new file mode 100644 index 00000000..6aebab22 --- /dev/null +++ b/packages/api/src/routes/fs-directories.ts @@ -0,0 +1,52 @@ +import { Hono } from 'hono'; +import { homedir } from 'node:os'; +import { + browseDirectories, + FsBrowseError, + resolveBrowseRoots, + type DirectoryBrowseResponse, +} from '../fs-browse'; +import { safeErrorMessage } from '../safe-error'; + +interface FsRoutesOptions { + homeDirectory?: string; + configuredRoots?: string; + browse?: typeof browseDirectories; +} + +export function createFsRoutes(options: FsRoutesOptions = {}): Hono { + const routes = new Hono(); + + routes.get('/api/fs/directories', async (c) => { + try { + const roots = await resolveBrowseRoots( + options.homeDirectory ?? homedir(), + options.configuredRoots ?? process.env['CODEGRAPH_BROWSE_ROOTS'], + ); + const response: DirectoryBrowseResponse = await ( + options.browse ?? browseDirectories + )(c.req.query('path'), roots, { + includeHidden: c.req.query('includeHidden') === 'true', + }); + return c.json(response); + } catch (error) { + if (error instanceof FsBrowseError) { + return c.json({ error: error.message }, error.status); + } + return c.json( + { + error: safeErrorMessage( + 'GET /api/fs/directories', + error, + 'Failed to browse directories.', + ), + }, + 500, + ); + } + }); + + return routes; +} + +export const fsRoutes = createFsRoutes(); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 4b107011..33fb8935 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@codegraph/types": "workspace:*", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.13", @@ -35,6 +36,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^4.3.4", + "happy-dom": "20.8.3", "tailwindcss": "^4.3.0", "tw-animate-css": "1.3.3", "typescript": "^5.9.3", diff --git a/packages/dashboard/src/components/dashboard/folder-picker.test.tsx b/packages/dashboard/src/components/dashboard/folder-picker.test.tsx new file mode 100644 index 00000000..5ac56b0f --- /dev/null +++ b/packages/dashboard/src/components/dashboard/folder-picker.test.tsx @@ -0,0 +1,368 @@ +// @vitest-environment happy-dom + +import { act, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ParseProjectDialog } from "./parse-project-dialog"; + +interface DirectoryPayload { + path: string | null; + parent: string | null; + entries: Array<{ + name: string; + path: string; + projectMarkers: string[]; + isSymlink: boolean; + }>; + truncated: boolean; +} + +interface MountedView { + container: HTMLDivElement; + root: Root; +} + +const mounted: MountedView[] = []; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function render(element: ReactElement): Promise { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + mounted.push({ container, root }); + await act(async () => root.render(element)); + return container; +} + +async function click(element: Element): Promise { + await act(async () => { + (element as HTMLElement).click(); + }); +} + +async function keyDown(element: Element, key: string): Promise { + await act(async () => { + element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); +} + +async function change(element: HTMLInputElement, value: string): Promise { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function button(name: string, scope: ParentNode = document): HTMLButtonElement { + const match = Array.from(scope.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === name, + ); + if (!(match instanceof HTMLButtonElement)) + throw new Error(`Button not found: ${name}`); + return match; +} + +function directories( + path: string | null, + entries: DirectoryPayload["entries"], + options: { parent?: string | null; truncated?: boolean } = {}, +): DirectoryPayload { + return { + path, + parent: options.parent === undefined ? null : options.parent, + entries, + truncated: options.truncated ?? false, + }; +} + +function directoryEntry( + name: string, + path: string, + projectMarkers: string[] = [], + isSymlink = false, +): DirectoryPayload["entries"][number] { + return { name, path, projectMarkers, isSymlink }; +} + +async function openIndexForm(): Promise { + const container = await render( + , + ); + await click(button("Index Project", container)); + return container; +} + +async function openPicker(): Promise { + const container = await openIndexForm(); + await click(button("Browse", container)); + await flush(); + return container; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(async () => { + vi.unstubAllGlobals(); + while (mounted.length > 0) { + const view = mounted.pop(); + if (!view) continue; + await act(async () => view.root.unmount()); + view.container.remove(); + } + document.body.innerHTML = ""; +}); + +describe("folder picker", () => { + it("opens a labelled dialog, descends with Enter, and navigates up by breadcrumb", async () => { + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const path = url.searchParams.get("path"); + if (path === "/Users/randy") { + return response( + directories( + "/Users/randy", + [directoryEntry("code", "/Users/randy/code")], + { parent: "/Users" }, + ), + ); + } + if (path === "/Users") { + return response( + directories("/Users", [directoryEntry("randy", "/Users/randy")]), + ); + } + return response(directories(null, [directoryEntry("Users", "/Users")])); + }); + vi.stubGlobal("fetch", fetcher); + + await openPicker(); + + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).toBeInstanceOf(HTMLElement); + expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy(); + await click(button("Users", dialog ?? document)); + await flush(); + expect( + Array.from((dialog ?? document).querySelectorAll("button")).some( + (candidate) => candidate.textContent?.trim() === "Root", + ), + ).toBe(false); + + const randy = button("randy", dialog ?? document); + randy.focus(); + await keyDown(randy, "Enter"); + await flush(); + + expect(document.body.textContent).toContain("/Users/randy"); + expect(document.body.textContent).toContain("code"); + await click(button("Users", dialog ?? document)); + await flush(); + expect(document.body.textContent).toContain("randy"); + }); + + it("selects the current folder, fills the path input, and closes the dialog", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + response( + directories("/work/project", [], { + parent: "/work", + }), + ), + ), + ); + + const container = await openPicker(); + await click(button("Select this folder")); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect( + (container.querySelector("#index-project-path") as HTMLInputElement) + .value, + ).toBe("/work/project"); + }); + + it("renders project markers and a symlink label on directory rows", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + response( + directories("/work", [ + directoryEntry( + "codegraph", + "/work/codegraph", + [".git", "package.json", "Cargo.toml", "pyproject.toml"], + true, + ), + ]), + ), + ), + ); + + await openPicker(); + + expect(document.body.textContent).toContain("git"); + expect(document.body.textContent).toContain("node"); + expect(document.body.textContent).toContain("cargo"); + expect(document.body.textContent).toContain("python"); + expect(document.body.textContent).toContain("symlink"); + }); + + it("refetches the current path when hidden folders are toggled", async () => { + const fetcher = vi.fn(async (_input: RequestInfo | URL) => + response(directories("/work", [])), + ); + vi.stubGlobal("fetch", fetcher); + + await openPicker(); + const toggle = document.querySelector('input[type="checkbox"]'); + expect(toggle).toBeInstanceOf(HTMLInputElement); + await click(toggle as HTMLInputElement); + await flush(); + + expect(fetcher).toHaveBeenCalledTimes(2); + expect( + new URL(String(fetcher.mock.calls[1]?.[0])).searchParams.get( + "includeHidden", + ), + ).toBe("true"); + }); + + it("announces loading and errors and exposes a retry action", async () => { + let rejectRequest: ((reason: Error) => void) | undefined; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }), + ), + ); + + const container = await openIndexForm(); + await click(button("Browse", container)); + expect( + document.querySelector('[aria-live="polite"]')?.textContent, + ).toContain("Loading"); + + await act(async () => rejectRequest?.(new Error("Permission denied"))); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "Permission denied", + ); + expect(button("Retry")).toBeInstanceOf(HTMLButtonElement); + }); + + it("renders honest empty and truncated states", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + response( + directories("/large", [], { + parent: "/", + truncated: true, + }), + ), + ), + ); + + await openPicker(); + + expect(document.body.textContent).toContain("No folders found"); + expect(document.body.textContent).toContain("Some folders are not shown"); + }); + + it("closes on Escape and returns focus to Browse", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => response(directories("/", []))), + ); + const container = await openPicker(); + const browse = button("Browse", container); + + await keyDown( + document.querySelector('[role="dialog"]') as HTMLElement, + "Escape", + ); + await flush(); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(browse); + }); +}); + +describe("recent indexed paths", () => { + it("persists a successful indexed path and selects it from the recent list", async () => { + const fetcher = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "POST") { + return response({ + success: true, + projectId: "one", + projectName: "One", + }); + } + return response(directories("/", [])); + }, + ); + vi.stubGlobal("fetch", fetcher); + const container = await openIndexForm(); + const input = container.querySelector( + "#index-project-path", + ) as HTMLInputElement; + await change(input, "/work/one"); + await click(button("Index", container)); + await flush(); + + expect( + window.localStorage.getItem("codegraph.recentProjectPaths"), + ).toContain("/work/one"); + await change(input, "/different"); + await click(button("/work/one", container)); + expect(input.value).toBe("/work/one"); + }); + + it("shows only the five most recent unique stored paths", async () => { + window.localStorage.setItem( + "codegraph.recentProjectPaths", + JSON.stringify([ + "/six", + "/five", + "/four", + "/three", + "/two", + "/one", + "/six", + ]), + ); + const container = await openIndexForm(); + + expect(button("/six", container)).toBeInstanceOf(HTMLButtonElement); + expect(button("/two", container)).toBeInstanceOf(HTMLButtonElement); + expect(container.textContent).not.toContain("/one"); + expect(container.querySelectorAll("[data-recent-path]")).toHaveLength(5); + }); +}); diff --git a/packages/dashboard/src/components/dashboard/folder-picker.tsx b/packages/dashboard/src/components/dashboard/folder-picker.tsx new file mode 100644 index 00000000..cfe5bffc --- /dev/null +++ b/packages/dashboard/src/components/dashboard/folder-picker.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from "react"; +import { ChevronRight, Folder, Link2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + loadDirectories, + projectBadgeLabels, + type DirectoryListing, + type PathCrumb, +} from "@/lib/folder-picker"; + +type PickerState = + | { status: "loading" } + | { status: "success"; listing: DirectoryListing } + | { status: "error"; message: string }; + +interface FolderPickerProps { + apiUrl: string; + open: boolean; + initialPath: string; + onOpenChange: (open: boolean) => void; + onSelect: (path: string) => void; +} + +function folderLabel(path: string): string { + const label = path + .split(/[\\/]+/) + .filter(Boolean) + .at(-1); + return label ?? (path === "/" ? "Root" : path); +} + +export function FolderPicker({ + apiUrl, + open, + initialPath, + onOpenChange, + onSelect, +}: FolderPickerProps) { + const [path, setPath] = useState(null); + const [showHidden, setShowHidden] = useState(false); + const [retryKey, setRetryKey] = useState(0); + const [state, setState] = useState({ status: "loading" }); + const [crumbs, setCrumbs] = useState([]); + + useEffect(() => { + if (!open) return; + + const controller = new AbortController(); + let active = true; + setState({ status: "loading" }); + loadDirectories(apiUrl, path, showHidden, controller.signal) + .then((listing) => { + if (!active) return; + setState({ status: "success", listing }); + setCrumbs((current) => { + if (!listing.path) return []; + const currentIndex = current.findIndex( + (crumb) => crumb.path === listing.path, + ); + const currentCrumb = { + label: folderLabel(listing.path), + path: listing.path, + }; + if (currentIndex >= 0) { + const next = current.slice(0, currentIndex + 1); + if (currentIndex === 0 && listing.parent) { + return [ + { label: folderLabel(listing.parent), path: listing.parent }, + ...next, + ]; + } + return next; + } + return listing.parent + ? [ + { label: folderLabel(listing.parent), path: listing.parent }, + currentCrumb, + ] + : [currentCrumb]; + }); + }) + .catch((error: unknown) => { + if (!active || controller.signal.aborted) return; + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Unable to load folders", + }); + }); + + return () => { + active = false; + controller.abort(); + }; + }, [apiUrl, open, path, retryKey, showHidden]); + + const listing = state.status === "success" ? state.listing : null; + + const handleOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + const nextPath = initialPath.trim() || null; + setPath(nextPath); + setCrumbs( + nextPath ? [{ label: folderLabel(nextPath), path: nextPath }] : [], + ); + setShowHidden(false); + } + onOpenChange(nextOpen); + }; + + return ( + + + + + +
+ + Choose a project folder + + + Browse directories on this machine. Project badges identify likely + code roots. + +
+ +
+
+ + +
+ + {listing?.path && ( + + {listing.path} + + )} + +
+ {state.status === "loading" && ( +
+ Loading folders... +
+ )} + + {state.status === "error" && ( +
+
+

+ Unable to load folders +

+

{state.message}

+ +
+
+ )} + + {listing && listing.entries.length === 0 && ( +
+ No folders found +
+ )} + + {listing && listing.entries.length > 0 && ( +
    + {listing.entries.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ + {listing?.truncated && ( +

+ Some folders are not shown. Choose a more specific folder to + continue. +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx b/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx index c62710d9..2e77d402 100644 --- a/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx +++ b/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx @@ -1,54 +1,66 @@ -import { useState, useCallback } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { FolderPicker } from "./folder-picker"; interface ParseProjectDialogProps { - apiUrl: string - onProjectParsed?: (project: ParsedProject) => void + apiUrl: string; + onProjectParsed?: (project: ParsedProject) => void; } export interface ParsedProject { - projectId: string - projectName: string + projectId: string; + projectName: string; } interface ParseResult { - success: boolean - projectId?: string - projectName?: string + success: boolean; + projectId?: string; + projectName?: string; stats?: { - files: number - entities: number - edges: number - errors: number - durationMs: number - } - errorMessages?: string[] - error?: string + files: number; + entities: number; + edges: number; + errors: number; + durationMs: number; + }; + errorMessages?: string[]; + error?: string; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } export function parseSuccessfulProject(value: unknown): ParsedProject { if ( - !isRecord(value) - || value.success !== true - || typeof value.projectId !== 'string' - || typeof value.projectName !== 'string' + !isRecord(value) || + value.success !== true || + typeof value.projectId !== "string" || + typeof value.projectName !== "string" ) { - throw new Error('Invalid parse response') + throw new Error("Invalid parse response"); } - return { projectId: value.projectId, projectName: value.projectName } + return { projectId: value.projectId, projectName: value.projectName }; } -function parseStats(value: unknown): ParseResult['stats'] { - if (!isRecord(value)) return undefined - const fields = ['files', 'entities', 'edges', 'errors', 'durationMs'] as const - if (fields.some((field) => typeof value[field] !== 'number' || !Number.isFinite(value[field]))) { - return undefined +function parseStats(value: unknown): ParseResult["stats"] { + if (!isRecord(value)) return undefined; + const fields = [ + "files", + "entities", + "edges", + "errors", + "durationMs", + ] as const; + if ( + fields.some( + (field) => + typeof value[field] !== "number" || !Number.isFinite(value[field]), + ) + ) { + return undefined; } return { files: value.files as number, @@ -56,120 +68,215 @@ function parseStats(value: unknown): ParseResult['stats'] { edges: value.edges as number, errors: value.errors as number, durationMs: value.durationMs as number, - } + }; } interface ParseProjectFormProps { - path: string - loading: boolean - result: ParseResult | null - onPathChange: (path: string) => void - onParse: () => void - onCancel: () => void + apiUrl?: string; + path: string; + loading: boolean; + result: ParseResult | null; + recentPaths?: string[]; + onPathChange: (path: string) => void; + onRecentPathSelect?: (path: string) => void; + onParse: () => void; + onCancel: () => void; } export function ParseProjectForm({ + apiUrl = "", path, loading, result, + recentPaths = [], onPathChange, + onRecentPathSelect = onPathChange, onParse, onCancel, }: ParseProjectFormProps) { + const [pickerOpen, setPickerOpen] = useState(false); + return ( -
- - onPathChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') onParse() - if (event.key === 'Escape') onCancel() - }} - className="h-7 w-64 text-xs" - autoFocus - /> - - - {result && ( - result.success ? ( - - {result.stats?.files} files, {result.stats?.entities} symbols ({((result.stats?.durationMs ?? 0) / 1000).toFixed(1)}s) - - ) : ( - - {result.error} - - ) +
+
+ + onPathChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") onParse(); + if (event.key === "Escape") onCancel(); + }} + className="h-7 w-64 text-xs" + autoFocus + /> + + + + {result && + (result.success ? ( + + {result.stats?.files} files, {result.stats?.entities} symbols ( + {((result.stats?.durationMs ?? 0) / 1000).toFixed(1)}s) + + ) : ( + + {result.error} + + ))} +
+ {recentPaths.length > 0 && ( +
+ Recent: + {recentPaths.map((recentPath) => ( + + ))} +
)}
- ) + ); +} + +export const RECENT_PROJECT_PATHS_STORAGE_KEY = "codegraph.recentProjectPaths"; + +export function normalizeRecentPaths(paths: readonly string[]): string[] { + return Array.from( + new Set(paths.map((entry) => entry.trim()).filter(Boolean)), + ).slice(0, 5); +} + +function readRecentPaths(): string[] { + if (typeof window === "undefined") return []; + + try { + const stored: unknown = JSON.parse( + window.localStorage.getItem(RECENT_PROJECT_PATHS_STORAGE_KEY) ?? "[]", + ); + if ( + !Array.isArray(stored) || + stored.some((entry) => typeof entry !== "string") + ) + return []; + return normalizeRecentPaths(stored as string[]); + } catch (error) { + console.warn("Unable to read recent project paths", error); + return []; + } } -export function ParseProjectDialog({ apiUrl, onProjectParsed }: ParseProjectDialogProps) { - const [open, setOpen] = useState(false) - const [path, setPath] = useState('') - const [loading, setLoading] = useState(false) - const [result, setResult] = useState(null) +function saveRecentPaths(paths: readonly string[]): void { + try { + window.localStorage.setItem( + RECENT_PROJECT_PATHS_STORAGE_KEY, + JSON.stringify(normalizeRecentPaths(paths)), + ); + } catch (error) { + console.warn("Unable to save recent project paths", error); + } +} + +export function ParseProjectDialog({ + apiUrl, + onProjectParsed, +}: ParseProjectDialogProps) { + const [open, setOpen] = useState(false); + const [path, setPath] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [recentPaths, setRecentPaths] = useState(readRecentPaths); const handleParse = useCallback(async () => { - const trimmed = path.trim() - if (!trimmed) return + const trimmed = path.trim(); + if (!trimmed) return; - setLoading(true) - setResult(null) + setLoading(true); + setResult(null); try { const res = await fetch(`${apiUrl}/api/parse/project`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: trimmed }), - }) - const data: unknown = await res.json() + }); + const data: unknown = await res.json(); // A failed index can still arrive as a well-formed body, so the payload's // own verdict matters as much as the status code. if (!res.ok || !isRecord(data) || data.error || data.success === false) { setResult({ success: false, - error: isRecord(data) && typeof data.error === 'string' - ? data.error - : isRecord(data) && Array.isArray(data.errorMessages) && typeof data.errorMessages[0] === 'string' - ? data.errorMessages[0] - : `HTTP ${res.status}`, - }) + error: + isRecord(data) && typeof data.error === "string" + ? data.error + : isRecord(data) && + Array.isArray(data.errorMessages) && + typeof data.errorMessages[0] === "string" + ? data.errorMessages[0] + : `HTTP ${res.status}`, + }); } else { - const parsedProject = parseSuccessfulProject(data) - const stats = parseStats(data.stats) + const parsedProject = parseSuccessfulProject(data); + const stats = parseStats(data.stats); const errorMessages = Array.isArray(data.errorMessages) - ? data.errorMessages.filter((message): message is string => typeof message === 'string') - : undefined - setResult({ success: true, ...parsedProject, stats, errorMessages }) - onProjectParsed?.(parsedProject) + ? data.errorMessages.filter( + (message): message is string => typeof message === "string", + ) + : undefined; + setResult({ success: true, ...parsedProject, stats, errorMessages }); + const nextRecentPaths = normalizeRecentPaths([trimmed, ...recentPaths]); + setRecentPaths(nextRecentPaths); + saveRecentPaths(nextRecentPaths); + onProjectParsed?.(parsedProject); } } catch (err) { - setResult({ success: false, error: err instanceof Error ? err.message : 'Parse failed' }) + setResult({ + success: false, + error: err instanceof Error ? err.message : "Parse failed", + }); } finally { - setLoading(false) + setLoading(false); } - }, [path, apiUrl, onProjectParsed]) + }, [path, apiUrl, onProjectParsed, recentPaths]); if (!open) { return ( @@ -181,22 +288,25 @@ export function ParseProjectDialog({ apiUrl, onProjectParsed }: ParseProjectDial > Index Project - ) + ); } const closeForm = () => { - setOpen(false) - setResult(null) - } + setOpen(false); + setResult(null); + }; return ( void handleParse()} onCancel={closeForm} /> - ) + ); } diff --git a/packages/dashboard/src/components/dashboard/recent-paths-ssr.test.tsx b/packages/dashboard/src/components/dashboard/recent-paths-ssr.test.tsx new file mode 100644 index 00000000..d10918b7 --- /dev/null +++ b/packages/dashboard/src/components/dashboard/recent-paths-ssr.test.tsx @@ -0,0 +1,15 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { ParseProjectDialog } from "./parse-project-dialog"; + +describe("recent project paths during static rendering", () => { + it("does not access browser storage or log a warning", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const html = renderToStaticMarkup(); + + expect(html).toContain("Index Project"); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/dashboard/src/components/ui/dialog.tsx b/packages/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 00000000..3f57d93c --- /dev/null +++ b/packages/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogClose = DialogPrimitive.Close; +const DialogTitle = DialogPrimitive.Title; +const DialogDescription = DialogPrimitive.Description; + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +}; diff --git a/packages/dashboard/src/lib/folder-picker.ts b/packages/dashboard/src/lib/folder-picker.ts new file mode 100644 index 00000000..ed9bfec6 --- /dev/null +++ b/packages/dashboard/src/lib/folder-picker.ts @@ -0,0 +1,144 @@ +export type ProjectMarker = + | ".git" + | "package.json" + | "pnpm-workspace.yaml" + | "Cargo.toml" + | "pyproject.toml" + | "go.mod" + | "setup.py" + | "composer.json" + | "Gemfile" + | "build.gradle" + | "pom.xml"; + +export interface DirectoryEntry { + name: string; + path: string; + projectMarkers: ProjectMarker[]; + isSymlink: boolean; +} + +export interface DirectoryListing { + path: string | null; + parent: string | null; + entries: DirectoryEntry[]; + truncated: boolean; +} + +interface FetchResponse { + ok: boolean; + status: number; + statusText: string; + json(): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const PROJECT_MARKERS: ReadonlySet = new Set([ + ".git", + "package.json", + "pnpm-workspace.yaml", + "Cargo.toml", + "pyproject.toml", + "go.mod", + "setup.py", + "composer.json", + "Gemfile", + "build.gradle", + "pom.xml", +]); + +function isProjectMarker(value: unknown): value is ProjectMarker { + return typeof value === "string" && PROJECT_MARKERS.has(value); +} + +function parseEntry(value: unknown): DirectoryEntry { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.path !== "string" || + !Array.isArray(value.projectMarkers) || + value.projectMarkers.some((marker) => !isProjectMarker(marker)) || + typeof value.isSymlink !== "boolean" + ) { + throw new Error("Invalid directory response"); + } + + return { + name: value.name, + path: value.path, + projectMarkers: value.projectMarkers as ProjectMarker[], + isSymlink: value.isSymlink, + }; +} + +const MARKER_LABELS: Record = { + ".git": "git", + "package.json": "node", + "pnpm-workspace.yaml": "node", + "Cargo.toml": "cargo", + "pyproject.toml": "python", + "go.mod": "go", + "setup.py": "python", + "composer.json": "php", + Gemfile: "ruby", + "build.gradle": "jvm", + "pom.xml": "jvm", +}; + +export function projectBadgeLabels( + markers: readonly ProjectMarker[], +): string[] { + return Array.from(new Set(markers.map((marker) => MARKER_LABELS[marker]))); +} + +export function parseDirectoryListing(value: unknown): DirectoryListing { + if ( + !isRecord(value) || + (value.path !== null && typeof value.path !== "string") || + (value.parent !== null && typeof value.parent !== "string") || + !Array.isArray(value.entries) || + typeof value.truncated !== "boolean" + ) { + throw new Error("Invalid directory response"); + } + + return { + path: value.path, + parent: value.parent, + entries: value.entries.map(parseEntry), + truncated: value.truncated, + }; +} + +export async function loadDirectories( + apiUrl: string, + path: string | null, + showHidden: boolean, + signal: AbortSignal, + fetcher: ( + input: string, + init?: RequestInit, + ) => Promise = fetch, +): Promise { + const url = new URL("/api/fs/directories", apiUrl || window.location.origin); + if (path) url.searchParams.set("path", path); + if (showHidden) url.searchParams.set("includeHidden", "true"); + + const response = await fetcher(url.href, { signal }); + if (!response.ok) { + const body: unknown = await response.json(); + if (isRecord(body) && typeof body.error === "string") + throw new Error(body.error); + const suffix = response.statusText ? ` ${response.statusText}` : ""; + throw new Error(`HTTP ${response.status}${suffix}`); + } + return parseDirectoryListing(await response.json()); +} + +export interface PathCrumb { + label: string; + path: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97b095a7..f3969daf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) apps/web: dependencies: @@ -269,7 +269,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/api: dependencies: @@ -303,7 +303,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/cli: dependencies: @@ -337,7 +337,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/codegraph-tools: dependencies: @@ -359,7 +359,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/core: dependencies: @@ -424,13 +424,16 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/dashboard: dependencies: '@codegraph/types': specifier: workspace:* version: link:../types + '@radix-ui/react-dialog': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-separator': specifier: 1.1.8 version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -492,6 +495,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.3(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)) + happy-dom: + specifier: 20.8.3 + version: 20.8.3 tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -532,7 +538,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/logger: devDependencies: @@ -541,7 +547,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/mcp-server: dependencies: @@ -581,7 +587,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/mcpb: devDependencies: @@ -611,7 +617,7 @@ importers: version: 0.27.7 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-common: dependencies: @@ -648,7 +654,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-generic: dependencies: @@ -670,7 +676,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-go: dependencies: @@ -698,7 +704,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-languages: dependencies: @@ -793,7 +799,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-markdown: dependencies: @@ -833,7 +839,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-nlp: dependencies: @@ -882,7 +888,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-python: dependencies: @@ -910,7 +916,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-rust: dependencies: @@ -938,7 +944,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/plugin-typescript: dependencies: @@ -963,7 +969,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages/types: devDependencies: @@ -972,7 +978,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3) packages: @@ -3356,9 +3362,15 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.67.0': resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4688,6 +4700,10 @@ packages: guid-typescript@1.0.9: resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -6683,6 +6699,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6735,6 +6755,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlbuilder@10.1.1: resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} engines: {node: '>=4.0'} @@ -8899,8 +8931,14 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/wrap-ansi@3.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.19 + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9964,7 +10002,7 @@ snapshots: eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) @@ -9997,7 +10035,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -10012,7 +10050,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10507,6 +10545,18 @@ snapshots: guid-typescript@1.0.9: {} + happy-dom@20.8.3: + dependencies: + '@types/node': 22.19.19 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -12824,7 +12874,7 @@ snapshots: lightningcss: 1.32.0 tsx: 4.22.3 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(happy-dom@20.8.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -12852,6 +12902,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 22.19.19 + happy-dom: 20.8.3 transitivePeerDependencies: - jiti - less @@ -12870,6 +12921,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} which-boxed-primitive@1.1.1: @@ -12948,6 +13001,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + xmlbuilder@10.1.1: {} y18n@5.0.8: {}