diff --git a/README.md b/README.md index fde92e4..eb0426b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ > coding agents, and a **local-first visual map** for humans. No LLM in the engine, > no network, your code never leaves your machine. +

+ OpenVisio viewer — the Atlas view of a large codebase, with files and symbols as a constellation linked by amber import and call edges +
+ The viewer's Atlas view — every file and symbol as a constellation linked by imports, definitions, and calls (here: a 93K-file repo). +

+ OpenVisio parses any repository with tree-sitter into a symbol + import graph, ranks it with PageRank, and serves it two ways: @@ -68,8 +74,11 @@ openvisio view ../other # …or any other local repo ``` The viewer ships in the `openvisio-viewer` package: the same React/Three.js -Atlas + City views, as a self-contained static bundle. Toggle between them, click -to focus a file, and re-index any other local repo from the path box. Nothing +Atlas + City views, as a self-contained static bundle. It opens on the **Atlas** +by default; switch to **City** with the view toggle (top-right). Click **Index** +to point it at any local repo — browse the filesystem in the built-in folder +picker (git repos are flagged) or type a path — and a staged progress loader runs +while the deterministic engine indexes. Click any node to focus it. Nothing leaves your machine. **Watch your agent think.** `view` defaults to the spotlight port (7077), so it diff --git a/core/package.json b/core/package.json index bed01a3..8418620 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@openvisio/core", - "version": "0.1.0", + "version": "0.2.0", "description": "Deterministic, local-first code-graph engine for OpenVisio: tree-sitter symbol/import extraction, PageRank ranking, token-budgeted skeletons and slices. No LLM, no network.", "type": "module", "license": "MIT", diff --git a/core/src/build.ts b/core/src/build.ts index e0169e1..1eecd5a 100644 --- a/core/src/build.ts +++ b/core/src/build.ts @@ -35,6 +35,47 @@ interface AliasRule { targets: string[] } +/** + * Parse a tsconfig/jsconfig body into alias rules. `dirRel` is the config's own + * directory, repo-relative ('' for the root) — it's prefixed onto `baseUrl` so + * the rule targets (e.g. `src/*`) resolve repo-relative even for a config that + * lives in a sub-package (e.g. `viewer/tsconfig.json` → baseUrl `viewer`). This + * is what makes `@/…` imports resolve across a monorepo with no root tsconfig. + */ +function parseTsAliases(raw: string, dirRel: string): TsAliases | null { + let config: any + try { + config = parseJsonc(raw) + } catch { + return null + } + const co = config?.compilerOptions ?? {} + const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.' + const baseRel = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '') + let baseUrl = posix.join(dirRel || '.', baseRel === '.' ? '' : baseRel) + if (baseUrl === '.') baseUrl = '' + const rules: AliasRule[] = [] + const paths = co.paths + if (paths && typeof paths === 'object') { + for (const key of Object.keys(paths)) { + const star = key.indexOf('*') + const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter( + (t: unknown): t is string => typeof t === 'string', + ) + if (targets.length === 0) continue + if (star === -1) { + rules.push({ prefix: key, suffix: '\0exact', targets }) + } else { + rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets }) + } + } + } + const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter( + (e: unknown): e is string => typeof e === 'string', + ) + return { baseUrl, rules, excludes } +} + function loadTsAliases(absRoot: string): TsAliases { const empty: TsAliases = { baseUrl: '', rules: [], excludes: [] } for (const name of ['tsconfig.json', 'jsconfig.json']) { @@ -44,35 +85,7 @@ function loadTsAliases(absRoot: string): TsAliases { } catch { continue } - let config: any - try { - config = parseJsonc(raw) - } catch { - return empty - } - const co = config?.compilerOptions ?? {} - const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.' - const baseUrl = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '') - const rules: AliasRule[] = [] - const paths = co.paths - if (paths && typeof paths === 'object') { - for (const key of Object.keys(paths)) { - const star = key.indexOf('*') - const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter( - (t: unknown): t is string => typeof t === 'string', - ) - if (targets.length === 0) continue - if (star === -1) { - rules.push({ prefix: key, suffix: '\0exact', targets }) - } else { - rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets }) - } - } - } - const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter( - (e: unknown): e is string => typeof e === 'string', - ) - return { baseUrl: baseUrl === '.' ? '' : baseUrl, rules, excludes } + return parseTsAliases(raw, '') ?? empty } return empty } @@ -225,10 +238,35 @@ export async function assembleGraph( console.error(`[build] resolving imports across ${files.length} files...`) const bySet = new Set(fileIdByPath.keys()) - const aliases = ctx.aliases ?? loadTsAliases(absRoot) + + // Per-package alias resolution. A monorepo can have many tsconfig/jsconfig + // files (one per sub-package, often with no root config at all), each mapping + // `@/*` to its own `src/*`. Discover them all from the scanned set and resolve + // every file's imports against its NEAREST enclosing config — otherwise all + // `@/…` imports go unresolved and the import graph collapses to relative-only. + const aliasEntries: { dir: string; aliases: TsAliases }[] = [] + for (const sf of scanned) { + const base = posix.basename(sf.relPath) + if (base !== 'tsconfig.json' && base !== 'jsconfig.json') continue + const dirRaw = posix.dirname(sf.relPath) + const dir = dirRaw === '.' ? '' : dirRaw + const parsed = parseTsAliases(sf.content, dir) + if (parsed) aliasEntries.push({ dir, aliases: parsed }) + } + // Longest dir first so the nearest config wins the prefix match. + aliasEntries.sort((a, b) => b.dir.length - a.dir.length) + const rootAliases = ctx.aliases ?? loadTsAliases(absRoot) + const aliasesFor = (relPath: string): TsAliases => { + for (const e of aliasEntries) { + if (e.dir === '' || relPath === e.dir || relPath.startsWith(e.dir + '/')) return e.aliases + } + return rootAliases + } + const edgeWeights = new Map() for (const file of files) { const raws = rawImportsByFile.get(file.id) ?? [] + const aliases = aliasesFor(file.path) for (const raw of raws) { const targetPath = resolveImport(file, raw.specifier, bySet, aliases) if (targetPath == null) continue @@ -357,6 +395,22 @@ export interface IndexChanges { changed: string[] } +// Persistent-cache schema version. The LMDB cache stores parse results (keyed by +// content SHA) and file IDs (keyed by path). Those are only valid for the engine +// that wrote them — when the parser's output shape changes (new symbol fields, +// import/call extraction, id allocation), a same-SHA entry from an OLDER engine +// would be served verbatim, silently dropping imports/edges or aliasing symbols +// to the wrong file. BUMP THIS whenever parse output or id allocation changes; +// the Indexer wipes a cache whose stored version doesn't match. +export const CACHE_VERSION = 2 +const CACHE_VERSION_KEY = 'meta:cacheVersion' + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + function encodeU32(n: number): Uint8Array { const buf = new Uint8Array(4) const dv = new DataView(buf.buffer) @@ -390,10 +444,27 @@ export class Indexer { if (this.dbPath && !this.store) { const { LmdbStore } = await import('./stores/lmdb.js') this.store = new LmdbStore(this.dbPath) + this.ensureCacheVersion(this.store) } return (await this.run()).graph } + /** + * Wipe the cache if it was written by a different engine version. A same-SHA + * parse result (or persisted file id) from an older engine is NOT safe to + * reuse — it can drop import/call edges or alias symbols to the wrong file — + * so on a version mismatch we clear everything and start the cache fresh. + */ + private ensureCacheVersion(store: Store): void { + const want = new TextEncoder().encode(String(CACHE_VERSION)) + const have = store.get(CACHE_VERSION_KEY) + if (!have || !bytesEqual(have, want)) { + store.clear() + store.set(CACHE_VERSION_KEY, want) + store.sync() + } + } + async reindex(): Promise<{ graph: CodeGraph; changes: IndexChanges }> { return this.run() } @@ -414,8 +485,25 @@ export class Indexer { if (this.store && this.idByPath.size === 0) { for (const sf of scanned) { const idBuf = this.store.get(`id:${sf.relPath}`) - if (idBuf) this.idByPath.set(sf.relPath, decodeU32(idBuf)) + // Only a well-formed 4-byte id is trustworthy; a truncated/corrupt + // value from a legacy cache must never reach decodeU32 (it throws) or + // seed a bogus id. + if (idBuf && idBuf.length === 4) this.idByPath.set(sf.relPath, decodeU32(idBuf)) + } + // Defensive: a corrupt/legacy store can hand back the SAME id for two + // distinct paths, which would alias one file's symbols onto another file + // (e.g. a function anchored to a tsconfig). If the restored ids aren't + // unique, discard them all and reallocate fresh in scan order. + const seen = new Set() + let collision = false + for (const id of this.idByPath.values()) { + if (seen.has(id)) { + collision = true + break + } + seen.add(id) } + if (collision) this.idByPath.clear() } const changes: IndexChanges = { added: [], removed: [], changed: [] } diff --git a/core/src/store.ts b/core/src/store.ts index d4aff7c..52e0d8f 100644 --- a/core/src/store.ts +++ b/core/src/store.ts @@ -2,6 +2,8 @@ export interface Store { get(key: string): Uint8Array | null set(key: string, value: Uint8Array): void delete(key: string): void + /** Drop every entry — used to invalidate a cache built by an older engine. */ + clear(): void sync(): void close(): void } diff --git a/core/src/stores/lmdb.ts b/core/src/stores/lmdb.ts index de8f511..fcbf32f 100644 --- a/core/src/stores/lmdb.ts +++ b/core/src/stores/lmdb.ts @@ -25,6 +25,10 @@ export class LmdbStore implements Store { this.db.removeSync(key) } + clear(): void { + this.db.clearSync() + } + sync(): void { this.db.transactionSync(() => {}) } diff --git a/docs/images/viewer.png b/docs/images/viewer.png index 7a8ecc5..d5085fc 100644 Binary files a/docs/images/viewer.png and b/docs/images/viewer.png differ diff --git a/mcp/README.md b/mcp/README.md index 823ee6e..126f1fd 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -40,6 +40,12 @@ network, your code never leaves your machine.** > [viewer](https://github.com/syntaxpriest/openvisio-oss) is for the human. They > share the same index. +

+ OpenVisio viewer — the Atlas view of a large codebase, with files and symbols linked by amber import and call edges +
+ The viewer's Atlas view (openvisio view) — every file and symbol as a constellation linked by imports, definitions, and calls. +

+ --- ## Why @@ -129,10 +135,13 @@ openvisio export [path] [--out=.openvisio/graph.json] # emit the graph package) on `127.0.0.1`, then opens your browser. City is a 3D treemap (districts = folders, buildings = files, sized by LOC, colored by language); Atlas is a file/symbol constellation linked by imports, definitions, and calls. - Toggle between them, click to focus, and re-index any other local repo from the - path box. `--no-open` just prints the URL. It defaults to the spotlight port - (7077) and acts as the highlight hub: with `view` running, start your agent - with `openvisio mcp . --spotlight` and its tool calls focus the graph live. + It opens on the Atlas by default; switch to City with the view toggle. Click + **Index** to browse the filesystem (git repos are flagged) or type a path and + index any other local repo, with a staged progress loader while it builds, then + click to focus a node. `--no-open` just prints the URL. It defaults to the + spotlight port (7077) and acts as the highlight hub: with `view` running, start + your agent with `openvisio mcp . --spotlight` and its tool calls focus the graph + live. --- diff --git a/mcp/package.json b/mcp/package.json index a2931d2..86bc669 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "openvisio", - "version": "0.1.5", + "version": "0.2.0", "description": "See any codebase as a graph. MCP server + CLI that serves coding agents (Claude Code, Codex, Cursor) a token-cheap, ranked, graph-native query surface over a local repo — agents query structure instead of crawling files. Local-first, read-only, no network.", "type": "module", "license": "MIT", @@ -45,7 +45,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "lmdb": "^3.2.6", - "openvisio-viewer": "^0.1.0", + "openvisio-viewer": "^0.2.0", "tree-sitter-wasms": "^0.1.13", "web-tree-sitter": "~0.25.10", "zod": "^4.4.3" diff --git a/mcp/src/spotlight.ts b/mcp/src/spotlight.ts index 6da48f1..81e5f3e 100644 --- a/mcp/src/spotlight.ts +++ b/mcp/src/spotlight.ts @@ -12,6 +12,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import * as fs from 'node:fs' +import * as os from 'node:os' import * as nodePath from 'node:path' /** @@ -325,6 +326,10 @@ export function startSpotlightServer( handleEmitRoute(req, res, bus) return } + if (url.startsWith('/api/fs/browse')) { + handleFsBrowseRoute(req, res, url) + return + } if (url.startsWith('/api/spotlight')) { res.writeHead(200, { ...CORS_HEADERS, @@ -521,3 +526,106 @@ function handleIndexRoute(req: IncomingMessage, res: ServerResponse, url: string res.writeHead(405, CORS_HEADERS) res.end('method not allowed') } + +interface FsDirEntry { + name: string + path: string + isGitRepo: boolean + isHidden: boolean +} + +/** Resolve raw user input to an absolute dir, expanding `~` and rooting relative + * paths at $HOME (the server cwd is an implementation detail the user can't see). */ +function resolveBrowseInput(raw: string | null): string { + const home = os.homedir() + if (!raw || raw.trim().length === 0) return home + let p = raw.trim() + if (p === '~') return home + if (p.startsWith('~/') || p.startsWith('~\\')) p = nodePath.join(home, p.slice(2)) + if (!nodePath.isAbsolute(p)) p = nodePath.join(home, p) + return nodePath.resolve(p) +} + +/** + * Local filesystem directory browser — powers the "browse" folder picker in the + * viewer's indexing dialog. Lists subdirectories only (never file contents), + * flags which folders look like git repos, and resolves `~`/relative input + * against $HOME. Read-only and local-only, consistent with the indexer's trust + * boundary (the server already reads local repos to index them). + * GET /api/fs/browse?path= → 200 { path, parent, home, separator, entries[] } + */ +function handleFsBrowseRoute(req: IncomingMessage, res: ServerResponse, url: string): void { + const json = (status: number, body: unknown) => { + res.writeHead(status, { ...CORS_HEADERS, 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) + } + if (req.method !== 'GET') { + res.writeHead(405, CORS_HEADERS) + res.end('method not allowed') + return + } + + const q = url.indexOf('?') + const params = new URLSearchParams(q === -1 ? '' : url.slice(q + 1)) + const target = resolveBrowseInput(params.get('path')) + + void (async () => { + try { + const stat = await fs.promises.stat(target) + if (!stat.isDirectory()) { + json(400, { error: `Not a directory: ${target}` }) + return + } + } catch { + json(404, { error: `Cannot access: ${target}` }) + return + } + + let dirents + try { + dirents = await fs.promises.readdir(target, { withFileTypes: true }) + } catch { + json(403, { error: `Permission denied: ${target}` }) + return + } + + const entries: FsDirEntry[] = [] + for (const d of dirents) { + // Symlinks report isDirectory() === false; resolve them so symlinked + // repos still appear as navigable folders. + let isDir = d.isDirectory() + if (d.isSymbolicLink()) { + try { + isDir = (await fs.promises.stat(nodePath.join(target, d.name))).isDirectory() + } catch { + isDir = false + } + } + if (!isDir) continue + + const full = nodePath.join(target, d.name) + let isGitRepo = false + try { + isGitRepo = (await fs.promises.stat(nodePath.join(full, '.git'))).isDirectory() + } catch { + // not a git repo (or .git is a file/worktree pointer we don't probe) + } + entries.push({ name: d.name, path: full, isGitRepo, isHidden: d.name.startsWith('.') }) + } + + // Git repos first, then alphabetical, case-insensitive. + entries.sort((a, b) => { + if (a.isGitRepo !== b.isGitRepo) return a.isGitRepo ? -1 : 1 + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) + + const parent = nodePath.dirname(target) + json(200, { + path: target, + parent: parent === target ? null : parent, + home: os.homedir(), + separator: nodePath.sep, + entries, + }) + })() +} diff --git a/package.json b/package.json index 0e7a3e2..6ce21e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openvisio", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "OpenVisio monorepo. The deterministic code-graph core, the standalone MCP CLI published to npm as `openvisio`, and a local-first viewer (Atlas + City). See any codebase as a graph; give coding agents a token-cheap, graph-native query surface.", "license": "MIT", @@ -19,7 +19,7 @@ ], "scripts": { "build": "npm run build -w @openvisio/core && npm run build -w openvisio-viewer && npm run build -w openvisio", - "typecheck": "npm run typecheck -w @openvisio/core && npm run typecheck -w openvisio", + "typecheck": "npm run build -w @openvisio/core && npm run typecheck -w @openvisio/core && npm run typecheck -w openvisio", "bench": "npm run build -w @openvisio/core && tsx bench/estimate.ts", "smoke": "npm run build && node mcp/smoke.mjs" }, diff --git a/viewer/index.html b/viewer/index.html index 835680f..b1aecbc 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -5,6 +5,12 @@ OpenVisio — graph viewer + + +
diff --git a/viewer/package.json b/viewer/package.json index 2044f88..e34b341 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -1,6 +1,6 @@ { "name": "openvisio-viewer", - "version": "0.1.0", + "version": "0.2.0", "description": "Self-contained Atlas + City graph viewer for OpenVisio — the same React/Three.js views from the app, served locally by `openvisio view`.", "license": "MIT", "type": "module", diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index c16e79c..ace7fa3 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -4,21 +4,37 @@ // `openvisio` server's /api/graph endpoint, with no account, narrator, or AI. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Building2, FolderSearch, Orbit, type LucideIcon } from 'lucide-react' import { AtlasView } from '@/components/graph/AtlasView' import { CityView } from '@/components/city/CityView' +import { IndexingDialog } from '@/components/workspace/IndexingDialog' +import { IndexingProgress } from '@/components/workspace/IndexingProgress' import { GraphResponseSchema, type GraphResponse } from '@/lib/api/types' import { cn } from '@/lib/utils' type Mode = 'city' | 'atlas' +interface ModeDef { + id: Mode + label: string + icon: LucideIcon + hint: string +} + +// Atlas first — it's the default landing view (the whole codebase at a glance). +const MODES: ModeDef[] = [ + { id: 'atlas', label: 'Atlas', icon: Orbit, hint: 'The whole codebase' }, + { id: 'city', label: 'City', icon: Building2, hint: 'Your code as a city' }, +] + export function App() { const [source, setSource] = useState(() => new URLSearchParams(location.search).get('path') ?? '') - const [input, setInput] = useState(source) const [graph, setGraph] = useState(null) - const [mode, setMode] = useState('city') + const [mode, setMode] = useState('atlas') const [focusedFileId, setFocusedFileId] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) const [agent, setAgent] = useState<'off' | 'idle' | 'live'>('off') const [agentTool, setAgentTool] = useState('') @@ -35,6 +51,7 @@ export function App() { if (!res.ok) throw new Error(body?.error ?? 'index failed (' + res.status + ')') setGraph(GraphResponseSchema.parse(body.graph)) setFocusedFileId(null) + setDialogOpen(false) // success — drop the dialog and reveal the map } catch (err) { setError(err instanceof Error ? err.message : String(err)) setGraph(null) @@ -43,9 +60,22 @@ export function App() { } }, []) - // Index whatever ?path= we booted with (or the user re-indexes via the box). + // Index whatever ?path= we booted with (or the user re-indexes via the dialog). useEffect(() => { if (source) void indexRepo(source) }, [source, indexRepo]) + // Open the index dialog by hand when there's no repo yet (first run, no ?path=). + useEffect(() => { if (!source) setDialogOpen(true) }, [source]) + + const startIndex = useCallback((repoPath: string) => { + const p = repoPath.trim() + if (!p) return + const url = new URL(location.href) + url.searchParams.set('path', p) + history.replaceState(null, '', url) + setSource(p) + if (p === source) void indexRepo(p) // same path → effect won't refire; re-run manually + }, [source, indexRepo]) + // Live agent spotlight — same server, same origin. A tool call's focus[0] // drives the focused file so the building/node lights up, exactly like the app. const byPath = useMemo(() => { @@ -76,33 +106,27 @@ export function App() { return () => es.close() }, [byPath]) - const onSubmit = (e: React.FormEvent) => { - e.preventDefault() - const p = input.trim() - if (!p) return - const url = new URL(location.href) - url.searchParams.set('path', p) - history.replaceState(null, '', url) - setSource(p) - } - const repo = graph?.repo return (
OpenVisio · viewer
-
- setInput(e.target.value)} - spellCheck={false} - placeholder="/absolute/path/to/repo" - className="min-w-0 flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 font-mono text-xs outline-none focus:border-[var(--color-accent)]" - /> - -
+ + {/* Index button — opens the folder browser / path dialog. */} + + {repo && (
{repo.file_count} files · {repo.total_loc.toLocaleString()} loc @@ -113,16 +137,30 @@ export function App() { {agent === 'live' ? '● ' + agentTool : '○ stream'} )} -
- {(['city', 'atlas'] as Mode[]).map((m) => ( - - ))} + + {/* Icon mode toggle — Atlas / City. */} +
+ {MODES.map(({ id, label, icon: Icon, hint }) => { + const isActive = id === mode + return ( + + ) + })}
@@ -134,13 +172,35 @@ export function App() { ) ) : ( -
- {error ? Error: {error} - : loading ? Indexing {source}… - : Enter a local repo path above and press Index.} +
+ {loading && !dialogOpen ? ( +
+ setLoading(false)} /> +
+ ) : error ? ( +
Error: {error}
+ ) : ( + + )}
)} + + { setDialogOpen(false); setLoading(false) }} + />
) } diff --git a/viewer/src/components/workspace/FolderBrowser.tsx b/viewer/src/components/workspace/FolderBrowser.tsx new file mode 100644 index 0000000..f73aab2 --- /dev/null +++ b/viewer/src/components/workspace/FolderBrowser.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ChevronRight, + CornerLeftUp, + Folder, + FolderGit2, + Home, + Loader2, + RefreshCw, +} from 'lucide-react' +import { browseDirectory, type FsBrowseResult, type FsDirEntry } from '@/lib/api/fs' +import { cn } from '@/lib/utils' + +export interface FolderBrowserProps { + // Directory to open on mount; falls back to the user's home directory. + initialPath?: string + // Fired whenever the user highlights a folder (single click). + onSelect?: (path: string) => void + // Fired when the user commits to a folder (double click or "Use this folder"). + onConfirm: (path: string) => void +} + +export function FolderBrowser({ initialPath, onSelect, onConfirm }: FolderBrowserProps) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selected, setSelected] = useState(null) + const abortRef = useRef(null) + + const navigate = useCallback( + (dirPath?: string) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setLoading(true) + setError(null) + setSelected(null) + browseDirectory(dirPath, { signal: controller.signal }) + .then((result) => { + setData(result) + setLoading(false) + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return + setError(err instanceof Error ? err.message : 'Failed to read directory') + setLoading(false) + }) + }, + [], + ) + + useEffect(() => { + navigate(initialPath) + return () => abortRef.current?.abort() + }, [navigate, initialPath]) + + const pick = useCallback( + (entry: FsDirEntry) => { + setSelected(entry.path) + onSelect?.(entry.path) + }, + [onSelect], + ) + + const crumbs = data ? buildCrumbs(data.path, data.separator) : [] + + // Hide hidden/dot folders (.git, .vscode, …) entirely — they're noise when + // you're picking a repo. (A repo that happens to be hidden still shows.) Then + // surface actual repos first, ordinary folders after, alphabetical within each. + const entries = useMemo(() => { + const list = (data?.entries ?? []).filter((e) => !e.isHidden || e.isGitRepo) + return list.sort((a, b) => { + if (a.isGitRepo !== b.isGitRepo) return a.isGitRepo ? -1 : 1 + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + }) + }, [data]) + + return ( +
+ {/* Toolbar: home / up / refresh + breadcrumb */} +
+ + + + +
+ {crumbs.map((c, i) => ( + + {i > 0 ? ( + + ) : null} + + + ))} +
+
+ + {/* Listing */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : data && entries.length === 0 ? ( +
+ No subfolders here. +
+ ) : ( +
    + {entries.map((entry) => { + const isSel = entry.path === selected + return ( +
  • + +
  • + ) + })} +
+ )} +
+ + {/* Footer: selected path + commit */} +
+
+ {selected ?? data?.path ?? ''} +
+ +
+
+ ) +} + +interface Crumb { + label: string + path: string +} + +// Turn an absolute path into clickable breadcrumb segments. Works for both +// POSIX ("/Users/me/x") and Windows ("C:\\Users\\me\\x") separators. +function buildCrumbs(fullPath: string, sep: string): Crumb[] { + const isPosix = sep === '/' + const parts = fullPath.split(sep).filter((p) => p.length > 0) + const crumbs: Crumb[] = isPosix ? [{ label: '/', path: '/' }] : [] + let running = '' + parts.forEach((part, i) => { + // POSIX paths are rooted at "/"; Windows paths start at the drive ("C:"). + running = isPosix ? `${running}/${part}` : i === 0 ? part : `${running}${sep}${part}` + crumbs.push({ label: part, path: running }) + }) + return crumbs +} diff --git a/viewer/src/components/workspace/IndexingDialog.tsx b/viewer/src/components/workspace/IndexingDialog.tsx new file mode 100644 index 0000000..e5bc776 --- /dev/null +++ b/viewer/src/components/workspace/IndexingDialog.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react' +import { FolderSearch, Keyboard } from 'lucide-react' +import { cn } from '@/lib/utils' +import { IndexingProgress } from './IndexingProgress' +import { FolderBrowser } from './FolderBrowser' + +export interface IndexingDialogProps { + open: boolean + initialPath?: string + busy: boolean + error?: string | null + onStart: (rootPath: string) => void + onCancel: () => void +} + +export function IndexingDialog({ open, initialPath, busy, error, onStart, onCancel }: IndexingDialogProps) { + const [path, setPath] = useState(initialPath ?? '') + // Default to the folder browser — "click index and the folder picker comes up". + const [mode, setMode] = useState<'type' | 'browse'>('browse') + + // Keep the field in sync when the dialog is (re)opened for a known repo. + useEffect(() => { + if (open) setPath(initialPath ?? '') + }, [open, initialPath]) + + if (!open) return null + + const start = () => { + if (path.trim().length === 0) return + onStart(path.trim()) + } + + return ( +
+
+ {busy ? ( + + ) : ( +
+
+
+
+ new index +
+

+ Analyze a repository. +

+

+ Point at a local repo path. The + deterministic engine indexes it (no LLM, no network) and returns the codebase graph. +

+
+ + {/* Type vs. browse toggle */} +
+ + +
+
+ + {mode === 'browse' ? ( +
+ { + setPath(p) + onStart(p) + }} + /> +

+ Folders marked repo contain a{' '} + .git directory. Double-click a repo to index it. +

+
+ ) : ( + <> + + setPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') start() + }} + spellCheck={false} + placeholder="/absolute/path/to/local/repo" + className={cn( + 'mt-1.5 block w-full rounded-md border border-[color:var(--color-border)] bg-[color:var(--color-bg-elev)] px-3 py-2 font-mono text-[12px] text-[color:var(--color-ink-8)] outline-none', + 'focus:border-[color:var(--color-ink-7)]', + )} + /> +

+ The path is read in place — nothing is copied or sent anywhere. +

+ + )} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + {/* In browse mode the FolderBrowser footer has its own + "use this folder" confirm, so skip the duplicate here. */} + {mode === 'type' ? ( + + ) : null} +
+
+ )} +
+
+ ) +} diff --git a/viewer/src/components/workspace/IndexingProgress.tsx b/viewer/src/components/workspace/IndexingProgress.tsx new file mode 100644 index 0000000..9d20717 --- /dev/null +++ b/viewer/src/components/workspace/IndexingProgress.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react' +import { Check, CircleDot, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface IndexingProgressProps { + target: string + message?: string + onCancel: () => void +} + +interface Stage { + id: string + label: string + hint: string + minSec: number +} + +// The deterministic OpenVisio engine doesn't stream progress, so these are +// believable cumulative milestones — `minSec` is roughly how many wall-clock +// seconds in we'd expect that stage to be in flight. Indexing is fast (no LLM): +// most repos finish in seconds; only very large ones reach the later stages. +const STAGES: Stage[] = [ + { id: 'scan', label: 'Walking files', hint: 'Listing files and applying exclusion rules.', minSec: 0 }, + { id: 'parse', label: 'Parsing with tree-sitter', hint: 'Extracting symbols from each source file.', minSec: 3 }, + { id: 'analyze', label: 'Resolving imports', hint: 'Tracing module relationships into edges.', minSec: 10 }, + { id: 'rank', label: 'Ranking the graph', hint: 'PageRank centrality over the import graph.', minSec: 18 }, + { id: 'layout', label: 'Computing layout', hint: 'Placing nodes for the City + Atlas views.', minSec: 28 }, +] + +const HINTS = [ + 'Indexing is deterministic and local — no LLM, no network.', + 'The same engine the MCP serves builds this graph.', + 'Large repos take a little longer; the graph is cached after.', + 'Same repo bytes always produce the same graph.', +] + +export function IndexingProgress({ target, message, onCancel }: IndexingProgressProps) { + const [elapsedMs, setElapsedMs] = useState(0) + const [hintIdx, setHintIdx] = useState(0) + + useEffect(() => { + const start = Date.now() + const tick = setInterval(() => setElapsedMs(Date.now() - start), 200) + return () => clearInterval(tick) + }, []) + + useEffect(() => { + const rotate = setInterval(() => setHintIdx((i) => (i + 1) % HINTS.length), 6000) + return () => clearInterval(rotate) + }, []) + + const elapsedSec = elapsedMs / 1000 + // Pick the highest stage whose minSec has been reached. + let currentIdx = 0 + for (let i = 0; i < STAGES.length; i++) { + if (elapsedSec >= (STAGES[i]?.minSec ?? Infinity)) currentIdx = i + } + + // Progress bar: smoothly fills toward 92% over the expected duration so + // the user always sees motion. The last 8% completes when the index + // actually returns. + const estimatedTotal = STAGES[STAGES.length - 1]?.minSec ?? 40 + const pct = Math.min(92, (elapsedSec / estimatedTotal) * 100) + + return ( +
+
+ + + +
+
+ openvisio engine +
+

+ Analyzing the codebase… +

+
+ {target} +
+
+
+
elapsed
+
+ {formatTime(elapsedMs)} +
+
+
+ + {/* Stage list */} +
    + {STAGES.map((stage, i) => { + const status: 'done' | 'active' | 'pending' = + i < currentIdx ? 'done' : i === currentIdx ? 'active' : 'pending' + return ( +
  1. + + {status === 'done' ? ( + + ) : status === 'active' ? ( + + ) : ( + + )} + + + {stage.label} + + {status === 'active' ? ( + + {stage.hint} + + ) : null} +
  2. + ) + })} +
+ + {/* Progress bar */} +
+
+
+
+
+ {message || HINTS[hintIdx]} + ~{Math.round(pct)}% +
+
+ + {/* Cancel */} +
+ +
+
+ ) +} + +function formatTime(ms: number): string { + const totalSec = Math.floor(ms / 1000) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + return `${m}:${s.toString().padStart(2, '0')}` +} diff --git a/viewer/src/lib/api/fs.ts b/viewer/src/lib/api/fs.ts new file mode 100644 index 0000000..ad05520 --- /dev/null +++ b/viewer/src/lib/api/fs.ts @@ -0,0 +1,38 @@ +// Client for the local filesystem browser (GET /api/fs/browse), served by the +// `openvisio view` spotlight server. Used by the folder picker in the indexing +// dialog so users can navigate the disk and pick a repo to index instead of +// typing an absolute path by hand. + +export interface FsDirEntry { + name: string + path: string + isGitRepo: boolean + isHidden: boolean +} + +export interface FsBrowseResult { + path: string + parent: string | null + home: string + separator: string + entries: FsDirEntry[] +} + +export async function browseDirectory( + dirPath?: string, + opts: { signal?: AbortSignal } = {}, +): Promise { + const qs = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '' + const res = await fetch(`/api/fs/browse${qs}`, { signal: opts.signal }) + if (!res.ok) { + let message = `Failed to read directory (${res.status})` + try { + const body = (await res.json()) as { error?: string } + if (body.error) message = body.error + } catch { + // non-JSON error body — keep the default message + } + throw new Error(message) + } + return (await res.json()) as FsBrowseResult +} diff --git a/viewer/src/styles.css b/viewer/src/styles.css index db7016f..e0dea29 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -94,9 +94,17 @@ --color-o-cline: #242424; --color-o-cmut: #93938d; - --font-sans: "Google Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + /* The viewer is a plain Vite SPA (no next/font), so the actual families are + loaded via the Google Fonts in index.html and named directly here — + matching the app's type system: Geist UI/display, JetBrains mono chrome, + Instrument Serif editorial. */ + --font-jetbrains: "JetBrains Mono"; + --font-geist: "Geist"; + --font-instrument: "Instrument Serif"; + + --font-sans: var(--font-geist), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: var(--font-jetbrains), ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace; - --font-display: var(--font-geist), "Geist", "Google Sans", ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-geist), "Geist", ui-sans-serif, system-ui, sans-serif; --font-serif: var(--font-instrument), "Instrument Serif", Georgia, "Times New Roman", serif; }