diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1defa66 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# VECTORA local engine configuration. Copy to .env or set in your shell. +# All values are optional; defaults are shown. + +# Loopback address and port of the local engine. +VECTORA_HOST=127.0.0.1 +VECTORA_PORT=4317 + +# Where VECTORA keeps its database, imported sources and caches. +# Default: .vectora-data inside the VECTORA folder (uploads and temp files live here too). +# VECTORA_DATA_DIR= + +# Extra allowed browser origins (comma separated). The engine only accepts +# same-origin requests plus the Vite dev server by default. +# VECTORA_ALLOWED_ORIGINS=http://localhost:5173 + +# Log level: fatal | error | warn | info | debug +VECTORA_LOG_LEVEL=info + +# Python interpreter used for Python source analysis. +# VECTORA_PYTHON=python diff --git a/.gitattributes b/.gitattributes index dfe0770..511187a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text=auto eol=lf +*.wasm binary +*.png binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fc75934 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## What changed + +## Why + +## How it was checked + +- [ ] `npm run typecheck` and `npm run lint` +- [ ] `npm test` +- [ ] `npm run build` and `npm run test:e2e` (for interface changes) + +## Notes for the reviewer diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c98a1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Type check, lint, test, build + # Windows is the platform VECTORA has been developed and tested on. + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: npm ci --no-audit --no-fund + - name: Type check + run: npm run typecheck + - name: Lint + run: npm run lint + - name: Unit and integration tests + run: npm test + - name: Production build + run: npm run build + - name: Dependency audit + run: npm audit --omit=dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6371c1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +coverage/ +.vectora-data/ +test-results/ +playwright-report/ +*.log +.env +.env.* +!.env.example +.DS_Store +Thumbs.db +*.tsbuildinfo +.bench-tmp/ +.npm-cache/ +.claude/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..1553fcb --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +cache=.npm-cache diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..191d2a8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +dist +coverage +samples +package-lock.json +*.wasm +.vectora-data +test-results +playwright-report +docs/LICENSES.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e876358 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 110, + "tabWidth": 2 +} diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..91e7578 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,92 @@ +# Technical decisions + +Decisions made while building VECTORA 0.1, with the reasoning. Newest last. + +## D1 — npm workspaces, TypeScript source exports, one `tsconfig` + +pnpm and yarn were not installed; npm workspaces need nothing extra. Packages export their TypeScript source (`"exports": "./src/index.ts"`), so there is no per-package build: `tsx` runs the engine in development, Vite and esbuild bundle for release, and one strict `tsconfig.json` type-checks everything. + +## D2 — TypeScript 5.9, not 7 + +The latest `typescript` on the registry is 7.x, the native rewrite, whose programmatic API differs from the Compiler API that the TypeScript adapter depends on (`ts.createProgram`, the type checker, custom module resolution). TypeScript is pinned to `~5.9.3`, the last line with the classic API. Revisit when the 7.x API is stable and documented. + +## D3 — `web-tree-sitter` with vendored grammar WASM files + +Native Tree-sitter bindings need a compiler toolchain on Windows. `web-tree-sitter` runs everywhere. The widely used `tree-sitter-wasms` package was tested and does **not** load with `web-tree-sitter` 0.27 (ABI mismatch); the WASM files shipped inside the official grammar packages do. Those six files are vendored in `packages/language-adapters/grammars` (MIT, versions listed in `docs/LICENSES.md`) so installation runs no native build scripts. + +## D4 — TypeScript Compiler API with the type checker for TypeScript and JavaScript + +The brief asks for precise symbol analysis and forbids assuming a call is resolved because a similarly named symbol exists. The type checker gives exactly that: an identifier becomes a relationship only when the checker ties it to a declaration in the repository. Cost: one compiler program per repository, single-threaded (see `docs/PERFORMANCE.md`). Module resolution is implemented by VECTORA against the indexed file list so the compiler never reads `node_modules`. + +## D5 — Python analysed by a helper script, not a JavaScript parser + +The brief names the Python AST. A small script using the standard-library `ast` module is run with the system interpreter in isolated mode. Trade-off: Python files are not analysed on machines without Python 3; this is detected and shown. + +## D6 — Name-based references resolved only with scope evidence + +For Python and the Tree-sitter languages there is no type information. The resolver uses, in order: same-file definitions, import bindings, and language visibility rules (same package, included header), and marks the last kind as unverified at confidence 0.7. Ambiguous names and calls on receivers of unknown type are reported as unresolved. This under-reports relationships on purpose; a wrong edge is worse than a missing one. + +## D7 — SQLite through `node:sqlite`, no ORM + +`better-sqlite3` needs a native binary; `node:sqlite` ships with Node.js 22.13 and later. It is still labelled experimental by Node.js, which is a risk recorded in `KNOWN_ISSUES.md`; the store is one file behind a small class, so swapping the driver is contained. Drizzle was not added: the schema is eight tables with JSON payloads and a handful of statements, and an ORM would add a dependency without removing code. + +## D8 — GitHub repositories downloaded as archives; no git commands + +Cloning runs git, and git in an untrusted repository can execute configured programs. Downloading the source archive through the REST API (Octokit) avoids executing anything, keeps the token in a header, and reuses the hardened ZIP path. `simple-git` was therefore not needed and was removed. For local folders the branch name is read from `.git/HEAD` as text. + +## D9 — Engine serves the web application; loopback only; host and origin checks instead of a token + +One process, one origin, so CORS is not needed in production. Because a local service can be reached by any web page in the user's browser, the engine checks `Host` (DNS rebinding) and `Origin`/`Sec-Fetch-Site` (cross-site requests) on every request. + +## D10 — Data directory inside the installation folder by default + +At the owner's request nothing is written to the system drive. The database, imported sources, uploads and the credential key default to `.vectora-data` in the VECTORA folder; tests and benchmarks use `.bench-tmp`; the npm cache for this project is `.npm-cache`. `VECTORA_DATA_DIR` overrides the location. + +## D11 — Sigma.js 3, not 4 + +Sigma 4 replaced the reducer-based API (node and edge reducers, settings) that the selection, dimming and label behaviour rely on, and its documentation was not sufficient to build against with confidence. Sigma 3.0.3 is the last release of the documented line and works with Graphology 0.26. + +## D12 — Visualisation code lives in `apps/web`, not in separate packages + +The brief suggests `visualization-2d`, `visualization-3d`, `mindmap` and `ui` packages. The three views are React components tied to the application's store, context and styling; separate packages would add build configuration without a second consumer. The layout engines, which are pure and reusable, are a package (`graph-layout`). + +## D13 — Radix primitives and hand-written components instead of generated shadcn/ui components + +shadcn/ui is a set of copied components built on Radix. Its default look (rounded cards, soft shadows) is what the design brief rules out, so the components are written directly on Radix (`Dialog`, `Switch`, `Popover`) and `cmdk`, styled with the project's tokens. + +## D14 — Custom 3D layout: anchors plus force refinement + +A plain 3D force layout does not produce a readable hierarchy. Subsystems are fixed anchors, files are simulated with `d3-force-3d` under a pull towards their anchor, and symbols are placed analytically around their file. This is deterministic, fast for large symbol counts (symbols need no simulation) and gives the centre → regions → contents structure the brief asks for. + +## D15 — Labels in 3D as one projected overlay + +drei's `Html` creates a React root per label and logged warnings when labels came and went. Labels are plain elements in one overlay, positioned by projecting node positions during rendered frames. + +## D16 — Concepts from a fixed vocabulary with evidence + +Concept grouping must be based on source evidence and kept apart from facts. Matching path segments, symbol names and imported packages against a fixed vocabulary is transparent: every membership lists exactly why. It is deliberately simple; it will miss domain concepts outside the vocabulary. + +## D17 — Question exploration is deterministic + +Questions are answered by intent detection and graph queries, so the basic application needs no external service and every statement has a citation. An optional provider adapter (chat-completions format) can rewrite the finished findings as prose; it is off by default and cannot add findings. + +## D18 — Credentials encrypted with a key file + +AES-256-GCM with a key stored in the data directory. An operating-system keystore would be stronger but needs a native module or platform commands; that is on the roadmap. The limitation is documented in `docs/SECURITY.md`. + +## D19 — Not used from the suggested stack + +- **chokidar**: no file watching in 0.1; the brief also rules out continuous background work. Re-indexing is on demand. +- **Comlink**: the two Web Workers have one message type each. +- **p-limit**: the worker pool bounds concurrency itself. +- **Drizzle, simple-git**: see D7 and D8. +- **ELK**: dagre is enough for the tree layout. +- **Tauri**: plan only, in `docs/ROADMAP.md`. + +## D20 — Browser tests use the installed Edge and start the engine without a shell + +No browser download is needed (`channel: 'msedge'`). The engine is started from the Playwright global setup with a direct process spawn, because shell-based start-up failed on a machine whose `ComSpec` variable was misconfigured. `npm run build` is a Node script for the same reason. + +## D21 — Row-id tables for graph rows + +The first schema used `WITHOUT ROWID` tables. Benchmarks showed slow bulk inserts with large JSON rows; ordinary tables with a unique index and multi-row inserts are faster. A migration rebuilds old tables. diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..70b8772 --- /dev/null +++ b/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,133 @@ +# Implementation checklist + +Status against the master directive. `[x]` done and verified, `[~]` partly done (see note), `[ ]` not done. Notes point to `KNOWN_ISSUES.md` (K) and `DECISIONS.md` (D). + +## Phase 0 — Research and architecture + +- [x] Inspect workspace and toolchain (empty repository; Node 24.14.1, Python 3.13, no pnpm) +- [x] Check current versions and compatibility of every dependency (D2, D3, D11) +- [x] Validate licences (`docs/LICENSES.md`; one package without declared licence, K29) +- [x] Architecture, design system, decisions documented + +## Phase 1 — Foundation + +- [x] Monorepo, strict TypeScript, ESLint, Prettier +- [x] Vite, Tailwind CSS 4, React 19, React Router, TanStack Query, Zustand +- [x] Typography: Zen Kaku Gothic New, Shippori Mincho, IBM Plex Mono, bundled +- [x] Light, dark and system themes with centralised tokens +- [x] Routing and application shell +- [~] shadcn/ui: Radix primitives used directly with hand-written components (D13) + +## Phase 2 — Repository ingestion + +- [x] GitHub import: public, private with token, validation, branch selection, subdirectory +- [x] ZIP import: format validation, nested root detection, structure preview, exclusions +- [x] Local folder import with folder browser, `.gitignore` and configurable exclusions +- [x] Import progress with stages +- [x] File scanning, language detection, manifest and framework detection, entry points +- [x] Bundled, labelled sample repository + +## Phase 3 — Code intelligence + +- [x] Tree-sitter (Java, Go, Rust, C, C++, C#) +- [x] TypeScript Compiler API with type checker (TypeScript, JavaScript) +- [x] Python `ast` +- [x] Language-adapter interface: parsing, symbols, relationships, locations, errors, metadata +- [x] Symbols: classes, functions, methods, interfaces, types, variables, constants, decorators, components, endpoints, models +- [x] Relationships: IMPORTS, EXPORTS, CALLS, EXTENDS, IMPLEMENTS, USES, INSTANTIATES, ROUTES_TO, READS_FROM, WRITES_TO, DEFINED_IN, BELONGS_TO, CONTAINS, DEPENDS_ON, RELATED_TO +- [x] Unresolved references reported, never guessed +- [x] Execution flows: entry points, route handlers, data-access paths, cross-subsystem calls +- [x] Graph validation, duplicate detection, normalisation, integrity checks +- [x] Unsupported files do not fail indexing; language coverage displayed +- [~] Receiver-type inference outside TypeScript (K1, K2); mounted route prefixes (K4) + +## Phase 4 — Knowledge graph + +- [x] Typed schema: 18 node types, 15 relationship types, evidence, method, confidence +- [x] Graph engine: adjacency, traversal, shortest path, cycles, impact +- [x] Persistence in SQLite +- [x] Community detection (Louvain), subsystems, concepts +- [x] Incremental indexing with cache and unchanged fast path +- [x] Search: names, partial matches, paths, qualified names, descriptions, documentation + +## Phase 5 — 2D Graph Explorer + +- [x] Sigma.js with Graphology, ForceAtlas2 in a worker, no-overlap pass +- [x] Pan, zoom, fit, minimap, node drag +- [x] Selection, multi-selection, neighbour highlighting, dimming, inspector +- [x] Filtering, focus mode, neighbour expansion, community grouping, zoom-dependent labels +- [x] Search integration +- [~] Node shapes by type in 2D (glyph, size and label instead; K17) + +## Phase 6 — 3D Atlas + +- [x] Real 3D scene (Three.js, React Three Fiber, drei) +- [x] Spatial clusters: repository centre, subsystem regions, files and symbols within, concepts and packages in layers +- [x] Orbit, pan, zoom, click, double-click, hover, search-to-focus, breadcrumb, reset, focus subsystem, neighbourhood, global view +- [x] Smooth camera transitions with reduced-motion alternative; exit from focus mode +- [x] InstancedMesh, BufferGeometry, worker layout, level of detail, label culling, on-demand rendering +- [x] WebGL context loss handling and 2D fallback +- [~] Performance verified by measurement in the browser (not measured; K14) + +## Phase 7 — Mind Map + +- [x] Hierarchy generated from the same graph +- [x] Expand, collapse, reorganise, search, focus, navigate to graph nodes, inspect source +- [x] Filter by relationship (overlay) +- [x] Export SVG and PNG + +## Phase 8 — Exploration and documentation + +- [x] Inspector: all twelve items, CodeMirror 6 preview, copy actions, focus, expand, resize, collapse +- [x] Command palette: Ctrl/Cmd+K, Escape, Enter +- [x] Question exploration with file and line evidence; says so when it cannot verify +- [x] Provider-independent adapter for optional narrative, off by default (not exercised live; K32) +- [x] About page: philosophy, how it works, languages, privacy, credits, version, licence, changelog and documentation references +- [x] Settings: appearance, graph, performance, repository, privacy — persisted and effective +- [x] Exports: JSON, GraphML, SVG, PNG, Markdown analysis, architecture document; layouts and sessions saved and restored +- [x] No source text in exports unless requested +- [x] User-facing copy free of excluded terminology (checked by a test) + +## Phase 9 — Testing + +- [x] Unit tests: scanning, ignore handling, extraction, resolution, validation, clustering, search, persistence, export +- [x] Integration tests: ZIP, GitHub (stand-in API), local import, full indexing, graph generation, incremental indexing, workspace persistence +- [x] Browser tests: landing, import, navigation, graph selection, inspection, mind map, 3D camera, search, settings, export — at desktop, tablet and mobile widths +- [x] Accessibility checks: keyboard, names and labels, focus visibility, contrast, reduced motion, landmarks, dialogs +- [x] Security tests: traversal, malicious names, oversized files, invalid archives, credential leakage +- [x] Benchmarks: 1,000 / 5,000 / 10,000 files; 50,000 symbols and 100,000 relationships +- [ ] Rendering stress tests, memory-leak checks, WebGL cleanup verification (K14) +- [ ] Live GitHub import test (K31) + +## Phase 10 — Release + +- [x] Production build (`dist/web`, `dist/engine`) verified by the browser tests +- [x] Environment configuration (`.env.example`) +- [x] Installation and development instructions +- [x] Versioning, changelog, licence, dependency licence inventory +- [x] Security notes, error handling, production logging with redaction +- [x] Dependency audit clean +- [~] Desktop packaging: plan written, not built (K33) +- [x] Screenshots and a recorded tour in the README, captured from the real application +- [x] Continuous integration workflow (type check, lint, tests, build, audit) + +## Definition of done + +| # | Requirement | Status | +|---|---|---| +| 1 | Open VECTORA | Done | +| 2 | Import an actual repository | Done (local and ZIP verified by tests; GitHub against a stand-in) | +| 3 | Index its source files | Done | +| 4 | Extract symbols and dependencies | Done | +| 5 | Persistent knowledge graph | Done | +| 6 | Hierarchical mind map | Done | +| 7 | Functional 2D graph | Done | +| 8 | Functional 3D graph | Done | +| 9 | Select nodes | Done | +| 10 | Inspect original source | Done | +| 11 | Trace relationships | Done | +| 12 | Search the repository | Done | +| 13 | Navigate between the three views | Done | +| 14 | Save and restore the workspace | Done | +| 15 | Export the graph | Done | +| 16 | Light and dark themes | Done | diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000..e4ae008 --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,59 @@ +# Known issues and limitations + +Version 0.1.0, 2026-11-12. Each item says what the limitation is and, where there is one, the workaround. + +## Analysis + +1. **Calls on values of unknown type are not resolved in Python and the tier-2 languages.** `x.method()` is resolved only when `x` is `self`/`this`, an imported module or package, or a class name. Such calls are listed as unresolved rather than guessed. TypeScript and JavaScript are not affected (the type checker is used). +2. **Inherited members are not followed** for `self.x()` / `this.x()` outside TypeScript: a method defined on a base class is reported as “member not found on enclosing type”. +3. **Data-access relationships are pattern based** (`READS_FROM`, `WRITES_TO`). They depend on recognising a model and a method name, and are always marked as derived. ORMs that hide models behind generated clients (for example a `prisma.user` accessor) are not recognised. +4. **Route prefixes from mounted routers are not applied.** `app.use('/api', router)` does not prefix the endpoints of `router`. +5. **CommonJS exports are not marked as exported.** `module.exports = …` and `exports.x = …` are analysed for calls and imports, but the symbols do not get the `exported` flag or an `EXPORTS` edge. +6. **Concepts come from a fixed vocabulary.** Domain concepts that are not in it are not found, and a file can match on a name that means something else. Every membership shows its evidence and is marked as derived. +7. **Vue and Svelte single-file components, and languages other than the nine supported, are indexed as files only.** +8. **C and C++ preprocessing is not performed.** Code inside conditional compilation is analysed as written; macros that generate declarations are not expanded. `.h` files are treated as C++ when the repository contains C++ sources. +9. **Nested functions are not separate nodes.** Calls made inside a nested function are attributed to the enclosing top-level function or method. +10. **Entry points are found by manifest fields and naming conventions**, not by build configuration. Python `if __name__ == "__main__":` blocks are not used as evidence. +11. **Go standard-library detection is by import path shape** (no dot in the first segment). + +## Performance + +12. **A change to one TypeScript or JavaScript file costs almost as much as a full index**, because the compiler program is created again (see `docs/PERFORMANCE.md`). Python and tier-2 changes are fast. +13. **TypeScript analysis uses one thread.** +14. **No rendering benchmark.** Initial render time, frame times while interacting, memory growth over long sessions and WebGL resource release were not measured. Indexing and graph operations were. +15. **Repositories above 10,000 files were not benchmarked**, and the 10,000-file run was 70% TypeScript. A much larger all-TypeScript repository may exceed the worker's 3 GB heap limit; analysis of that language then fails with a reported error and the files remain as file nodes. Workaround: import a subdirectory or add ignore patterns. +16. **Search over 50,000 nodes takes about 56 ms per query** on the test machine: a linear scan, fine for interactive use, not indexed. + +## Interface + +17. **Node shapes differ by type only in 3D.** In the 2D view types are distinguished by glyph, size and label, not by shape. +18. **“Open source location” opens the file inside VECTORA**, not in an external editor. +19. **The 2D canvas is keyboard operable but nodes are not individually exposed to assistive technology.** The Mind Map, the Search page and the inspector lists present the same content as ordinary focusable elements. +20. **PNG export captures the current view**, not the whole graph. The Mind Map exports the whole map. +21. **Screenshots and the recorded tour show the sample repository only**, and the user guide has no screenshots. They are regenerated with `npx tsx scripts/screenshots.ts --record`. +22. **A browser tab that was open during a rebuild may need a reload** to pick up new script files. + +## Security + +23. **The credential key is stored in the data directory** next to the database. It protects against disclosure of the database alone, not against someone who can read the whole data directory. An operating-system keystore is planned. +24. **Owner-only file permissions on the key file are not enforced on Windows.** +25. **The engine has no authentication** because it listens on the loopback interface only. Do not expose its port to a network. + +## Platform and dependencies + +26. **`node:sqlite` is labelled experimental by Node.js.** Its warning is suppressed at start-up. A change in a future Node.js version could require an update to `packages/graph-engine/src/store.ts`. +27. **Developed and tested on Windows 11 only**, with Node.js 24.14.1 and Microsoft Edge. macOS, Linux, Firefox and Safari were not tested. +28. **`jsdom` 31 (test dependency) declares support for Node.js 24.15 or newer**; on 24.14.1 npm prints an engine warning. The tests pass. +29. **`webgl-constants` (a transitive dependency of the 3D stack) declares no licence in its package metadata.** Flagged in `docs/LICENSES.md` for manual review. +30. **`typescript` and `web-tree-sitter` are not bundled into the engine**; `node_modules` must be present beside `dist` at run time. + +## Not verified + +31. **A live GitHub import.** GitHub access is tested against a local stand-in for the API, not against github.com. +32. **The optional narrative provider against a real service.** The adapter is implemented and off by default; it has not been exercised with a live endpoint. +33. **Desktop packaging.** Not built; `docs/ROADMAP.md` holds the plan. +34. **Symbolic-link handling in the scanner on Windows** is covered by a test that skips itself when the account may not create links. + +## Environment note for this machine + +35. The `ComSpec` environment variable on the development machine points at a MySQL folder instead of `cmd.exe`. Tools that start a shell fail with `spawn … ENOENT`. The project's own scripts avoid shells, but the variable should be corrected in the system settings. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fac4501 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 VECTORA contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..9b451de --- /dev/null +++ b/PROJECT_STATUS.md @@ -0,0 +1,72 @@ +# Project status + +**Version:** 0.1.0 +**Date:** 2026-11-12 +**Branch:** `feat/vectora` +**State:** Feature complete for the first web release. All automated checks pass. Not yet released; no desktop artifact. + +## Summary + +VECTORA is a working application: a local engine that imports and indexes repositories into a persistent knowledge graph, and a web application that explores that graph in a 2D graph, a 3D atlas and a mind map with a shared selection, an inspector showing original source, search, question exploration with citations, a dependency explorer, exports and settings. + +## Verification on 2026-11-12 + +| Check | Command | Result | +|---|---|---| +| Types | `npm run typecheck` | No errors | +| Lint | `npm run lint` | No errors | +| Unit and integration | `npm test` | 79 passed, 0 failed | +| Coverage | `npm run test:coverage` | 83.6% lines, 80.5% statements, 65.5% branches | +| Browser journeys | `npm run build` then `npm run test:e2e` | 18 passed, 0 failed (desktop, tablet, mobile) | +| Production build | `npm run build` | `dist/web`, `dist/engine` produced and exercised by the browser tests | +| Dependency audit | `npm audit --omit=dev` | 0 vulnerabilities | +| Benchmarks | `npm run bench` | 1,000 / 5,000 / 10,000 files and a 50,000-symbol graph measured; see `docs/PERFORMANCE.md` | + +Environment: Windows 11, Node.js 24.14.1, Python 3.13.2, Microsoft Edge. + +## What works + +Everything marked done in `IMPLEMENTATION_CHECKLIST.md`, including all sixteen items of the definition of done. + +## What is incomplete + +| Item | State | +|---|---| +| Rendering benchmarks, memory-leak and WebGL cleanup checks | Not done | +| Live test against github.com | Not done; a stand-in API is used in tests | +| Optional narrative provider with a real service | Implemented, not exercised | +| Desktop packaging (Tauri 2) | Plan only | +| Receiver-type inference outside TypeScript, mounted route prefixes, 2D node shapes | Not done | +| macOS, Linux, other browsers | Not tested | + +Details and workarounds: `KNOWN_ISSUES.md`. + +## Release artifacts + +Produced by `npm run build` (not committed): + +- `dist/web/` — the web application +- `dist/engine/server.mjs`, `dist/engine/analyze-worker.mjs`, `dist/engine/grammars/`, `dist/engine/python/` — the engine + +Run with `npm start`. `node_modules` must be present. There is no installer and no desktop build. + +## Next actions + +1. Correct the `ComSpec` environment variable on the development machine (see `KNOWN_ISSUES.md`, item 35). +2. Try a real GitHub import, public and private, and record the result. +3. Add a browser rendering benchmark (frame times, memory) for the 5,000-file synthetic repository. +4. Reuse the TypeScript compiler program between runs to make single-file re-indexing fast. +5. Add screenshots to the user guide. +6. Test on macOS and Linux, and in Firefox. +7. Decide on desktop packaging and settle the open question in the Tauri plan. +8. Merge `feat/vectora` into `main` and tag `v0.1.0`. + +## Where things are + +| Need | Location | +|---|---| +| How to run | `README.md` | +| How it is built | `docs/ARCHITECTURE.md` | +| Why it is built that way | `DECISIONS.md` | +| What is left | `IMPLEMENTATION_CHECKLIST.md`, `KNOWN_ISSUES.md`, `docs/ROADMAP.md` | +| Test and benchmark results | `docs/TESTING.md`, `docs/PERFORMANCE.md`, `docs/benchmarks/latest.json` | diff --git a/README.md b/README.md new file mode 100644 index 0000000..d00b747 --- /dev/null +++ b/README.md @@ -0,0 +1,292 @@ +
+ +# VECTORA + +### Understand what connects. + +Turn any software repository into a knowledge graph you can walk through —
+in two dimensions, in three, or as a mind map. Everything runs on your machine. + +[![CI](https://github.com/shrenixcodes/VECTORA/actions/workflows/ci.yml/badge.svg)](https://github.com/shrenixcodes/VECTORA/actions/workflows/ci.yml) +![Version](https://img.shields.io/badge/version-0.1.0-34445C) +![License](https://img.shields.io/badge/license-MIT-A18C72) +![Node](https://img.shields.io/badge/node-%E2%89%A5%2022.13-55704F) +![Local first](https://img.shields.io/badge/runs-100%25%20locally-20211F) + +
+ +A twelve-second tour: orbiting the 3D Atlas, searching for a class, flying to it, then seeing the same selection in the 2D graph and the mind map. + +Recorded from the real application on the bundled sample repository. Nothing is staged. + +
+ +
+ +## What it does + +VECTORA is a codebase intelligence platform that transforms software repositories into structured, interactive knowledge maps. It reveals how components, modules, dependencies and concepts connect, giving developers a new way to understand complex software systems. + +Point it at a **GitHub repository**, a **ZIP archive** or a **local folder**. It parses the source, works out what calls what, what imports what and where requests end up, and keeps the line of code that proves each relationship. Then you explore. + +
+ +## Three views, one graph + +Select something in one view and the other two follow. + + + + + + + + + + + + + + +
+ +### 3D Atlas + +The repository at the centre, subsystems around it, files and symbols inside their subsystem, concepts above. Orbit, fly to a node, unfold it, return to the whole. + + +The 3D Atlas showing the sample repository: a central node, three subsystem clusters and a layer of concept nodes above. +
+The Graph Explorer with the NoteService class selected: its neighbours are lit, the rest is dimmed, relationships are labelled, and the inspector shows the source. + + +### Graph Explorer + +Pan and zoom a two-dimensional map. Selecting a node lights up what it depends on and what depends on it, labels each relationship, and dims the rest. + +
+ +### Mind Map + +Subsystems, folders, files and symbols as a tree you can expand, rearrange, search and export as SVG or PNG. + + +The Mind Map expanded down to the functions of one file, with verifyToken selected and its source shown in the inspector. +
+ +
+ +## Every claim comes with evidence + +The question 'What calls verifyToken?' answered with two statements, each citing a file and line. + +- **Ask in plain words.** “What calls `verifyToken`?”, “What could be affected if I change this class?”, “Trace `GET /notes`”, “Identify circular dependencies”. Every statement in the answer cites the file and line it comes from. +- **Open any relationship** in the inspector to see the exact line of source behind it, how it was found, and with what confidence. +- **Nothing is guessed.** A reference is linked to a definition only when there is scope evidence for it. Everything else is listed as unresolved instead of being drawn. +- **Facts and interpretation are kept apart.** Groupings such as concepts are marked as derived and drawn with dashed lines. + +
+ +## More to look at + + + + + + + + + + +
+The repository overview: description, counts, an architecture summary with evidence, and language coverage. +

Overview — architecture summary, languages, entry points, subsystems

+
+The Dependency Explorer showing execution flows from API endpoints to the functions they reach. +

Dependency Explorer — packages, cycles, flows, unresolved references

+
+The 3D Atlas focused on one class, with a breadcrumb from repository to symbol and the source in the inspector. +

Focus — fly to a symbol; the breadcrumb shows where you are

+
+The landing page with the headline 'Understand what connects.' +

Light and dark themes — a quiet, editorial interface

+
+ +
+ +## Get started in three steps + +You need [Node.js](https://nodejs.org) 22.13 or newer. Python 3.8 or newer is needed only if you want Python code analysed. + +**1. Get the code and install** + +```bash +git clone https://github.com/shrenixcodes/VECTORA.git +``` + +```bash +cd VECTORA +``` + +```bash +npm install +``` + +**2. Build and start** + +```bash +npm run build +``` + +```bash +npm start +``` + +**3. Open it** + +Go to ****, choose **Explore a repository**, then **Index the sample repository**. In a few seconds you are inside “Orbit Notes”, a small multi-language sample that ships with VECTORA for exactly this purpose. + +When you are ready, import your own code from the same screen. + +> **Tip.** Press Ctrl + K (Cmd + K on macOS) anywhere in a workspace to search files, symbols, concepts and documentation. + +
+ +## Importing your own repository + +| Source | How | +|---|---| +| **GitHub** | Paste the URL, choose **Check repository**, pick a branch and optionally a subdirectory. For a private repository add an access token in the *Private repository* section — never in the URL. | +| **ZIP archive** | Drop the file. VECTORA checks it before extracting anything and lets you pick the project root. | +| **Local folder** | Type a path or choose **Browse**. The folder is read in place; nothing is copied or changed. | + +`.gitignore` files are respected. Dependencies are never installed and nothing in the repository is ever run. + +
+ +## Features + +| | | +|---|---| +| **Exploration** | 2D Graph Explorer, 3D Atlas, Mind Map, with shared selection, focus mode, filters and saved layouts | +| **Inspector** | Original source, incoming and outgoing relationships, evidence, parent module, related concepts | +| **Search** | Command palette, type filters, documentation search | +| **Questions** | Callers, dependents, impact of a change, traces, data access paths, cycles, startup sequence | +| **Dependencies** | External packages, subsystem dependencies, circular imports, execution flows, unresolved references | +| **Indexing** | Incremental; unchanged files are reused; analysis runs in worker threads | +| **Exports** | Graph as JSON or GraphML; mind map as SVG or PNG; views as PNG; analysis and architecture documents as Markdown | +| **Workspace** | Selection, filters, layouts and view are saved and restored automatically | +| **Accessibility** | Keyboard access, visible focus, reduced-motion support, colour never the only signal | + +
+ +## Supported languages + +| Language | Tier | How it is analysed | +|---|:-:|---| +| TypeScript, JavaScript | 1 | TypeScript Compiler API; references resolved by the type checker | +| Python | 1 | Standard-library `ast`; imports and scope resolved by the indexer | +| Java, Go, Rust, C, C++, C# | 2 | Tree-sitter syntax trees; imports and scope resolved by the indexer | + +Files in other languages are indexed as files and are searchable, but are not analysed at symbol level. Every repository shows its own language coverage. VECTORA does not claim to support every language. + +
+ +## Your code stays on your machine + +- Indexing, the graph database and search all run in a local engine. **No source code is uploaded.** +- The engine listens on the loopback interface only and rejects requests from other origins. +- Access tokens are stored encrypted, sent only to GitHub in a request header, and never written to logs, URLs or exports. +- Exports leave out source text unless you ask for it. +- No telemetry, no account. + +Everything VECTORA stores lives in one folder, `.vectora-data` inside the VECTORA folder by default. Set `VECTORA_DATA_DIR` to move it; see [`.env.example`](.env.example). Details are in [docs/SECURITY.md](docs/SECURITY.md). + +
+ +## How it works + +``` +repository ─► discovery ─► syntax analysis ─► relationship resolution ─► flows ─► organisation ─► knowledge graph + │ + Graph Explorer ◄──┐ ▼ + 3D Atlas ◄──┼── one graph, one selection ◄── local SQLite + Mind Map ◄──┘ +``` + +``` +apps/ + local-engine/ Fastify service: imports, indexing jobs, graph API, exports + web/ React application: landing, import, workspace, three views +packages/ + graph-schema/ Node and relationship types, validation + language-adapters/ TypeScript, Python and Tree-sitter adapters behind one interface + repository-indexer/ Scanning, resolution, organisation, worker pool + graph-engine/ Algorithms, SQLite store, exports, question exploration + graph-layout/ 3D and 2D layout engines + search/ Search index + shared/ Settings schema, API contracts +``` + +More in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +
+ +## Development + +```bash +npm run dev +``` + +starts the engine and the web application with reloading; open . + +| Command | Purpose | +|---|---| +| `npm test` | Unit and integration tests | +| `npm run test:e2e` | Browser journeys against the production build (run `npm run build` first) | +| `npm run typecheck` / `npm run lint` | Static checks | +| `npm run bench` | Performance benchmarks | +| `npx tsx scripts/screenshots.ts --record` | Re-create the screenshots and the tour in this README | + +Current state: 79 unit and integration tests and 18 browser journeys pass. On the development machine a 10,000-file synthetic repository indexed in 31 seconds with a warm file cache (54 seconds cold). Results, coverage and what is not yet tested are in [docs/TESTING.md](docs/TESTING.md) and [docs/PERFORMANCE.md](docs/PERFORMANCE.md). + +
+ +## Documentation + +| | | +|---|---| +| [User guide](docs/USER_GUIDE.md) | Importing, exploring, exporting | +| [Architecture](docs/ARCHITECTURE.md) | Components and how they interact | +| [Data model](docs/DATA_MODEL.md) | Every node and relationship type | +| [Indexing pipeline](docs/INDEXING_PIPELINE.md) | Scanning, parsing, resolution, organisation | +| [Graph engine](docs/GRAPH_ENGINE.md) | Persistence and algorithms | +| [Visualisation](docs/VISUALIZATION.md) | 2D, 3D and mind map rendering | +| [Design system](docs/DESIGN_SYSTEM.md) | Typography, colour, spacing, motion | +| [Security](docs/SECURITY.md) | Threat model and protections | +| [Performance](docs/PERFORMANCE.md) | Benchmarks and optimisation decisions | +| [Testing](docs/TESTING.md) | Commands, results, coverage | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Common problems and fixes | +| [Contributing](docs/CONTRIBUTING.md) | Setup and guidelines | +| [Roadmap](docs/ROADMAP.md) · [Changelog](docs/CHANGELOG.md) · [Licences](docs/LICENSES.md) | | + +Project records: [status](PROJECT_STATUS.md), [checklist](IMPLEMENTATION_CHECKLIST.md), [decisions](DECISIONS.md), [known issues](KNOWN_ISSUES.md). + +
+ +## Status and limits + +Version 0.1.0 is the first release. It has been developed and tested on Windows 11 with Microsoft Edge; macOS, Linux and other browsers are untested. There is no packaged desktop application yet. The full list of limitations is in [KNOWN_ISSUES.md](KNOWN_ISSUES.md). + +## License + +MIT. See [LICENSE](LICENSE). Third-party licences are listed in [docs/LICENSES.md](docs/LICENSES.md). + +
+ +
+ +**VECTORA**
+Understand what connects. + +
diff --git a/apps/local-engine/package.json b/apps/local-engine/package.json new file mode 100644 index 0000000..fc71bc7 --- /dev/null +++ b/apps/local-engine/package.json @@ -0,0 +1,31 @@ +{ + "name": "@vectora/local-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "dev": "tsx watch src/main.ts", + "start": "tsx src/main.ts" + }, + "dependencies": { + "@fastify/cors": "^11.4.1", + "@fastify/multipart": "^10.1.8", + "@fastify/static": "^10.1.10", + "@vectora/graph-engine": "*", + "@vectora/graph-schema": "*", + "@vectora/language-adapters": "*", + "@vectora/repository-indexer": "*", + "@vectora/search": "*", + "@vectora/shared": "*", + "fastify": "^5.12.8", + "ignore": "^7.0.14", + "octokit": "^5.0.5", + "yauzl": "^3.4.0", + "zod": "^4.6.7" + }, + "devDependencies": { + "@types/yauzl": "^2.10.3", + "fflate": "^0.8.3" + } +} diff --git a/apps/local-engine/src/config.ts b/apps/local-engine/src/config.ts new file mode 100644 index 0000000..42cae3c --- /dev/null +++ b/apps/local-engine/src/config.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DEFAULT_ENGINE_PORT } from '@vectora/shared'; + +export interface EngineConfig { + host: string; + port: number; + /** Database, imported sources, uploads and temporary files all live here. */ + dataDir: string; + /** Browser origins allowed in addition to the engine's own origin. */ + allowedOrigins: string[]; + logLevel: string; + /** Built web application to serve, when present. */ + webDist: string | null; + sampleDir: string | null; + installRoot: string; + production: boolean; + /** Replaceable network transport (tests supply a stub; nothing else should). */ + fetch?: typeof fetch; +} + +/** Walks up from this file to the VECTORA installation root. */ +export function findInstallRoot(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i++) { + const pkg = path.join(dir, 'package.json'); + if (fs.existsSync(pkg)) { + try { + if ((JSON.parse(fs.readFileSync(pkg, 'utf8')) as { name?: string }).name === 'vectora') return dir; + } catch { + // keep walking + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +export function loadConfig(overrides: Partial = {}): EngineConfig { + const env = process.env; + const installRoot = overrides.installRoot ?? findInstallRoot(); + const webDist = + [path.join(installRoot, 'dist', 'web'), path.join(installRoot, 'apps', 'web', 'dist')].find((d) => + fs.existsSync(path.join(d, 'index.html')), + ) ?? null; + const sampleDir = + [ + path.join(installRoot, 'samples', 'sample-repo'), + path.join(installRoot, 'dist', 'samples', 'sample-repo'), + ].find((d) => fs.existsSync(d)) ?? null; + const port = Number(env.VECTORA_PORT ?? DEFAULT_ENGINE_PORT); + return { + host: env.VECTORA_HOST ?? '127.0.0.1', + port: Number.isFinite(port) ? port : DEFAULT_ENGINE_PORT, + // Kept inside the installation folder by default so nothing is written to the system drive. + dataDir: path.resolve(env.VECTORA_DATA_DIR ?? path.join(installRoot, '.vectora-data')), + allowedOrigins: (env.VECTORA_ALLOWED_ORIGINS ?? 'http://localhost:5173,http://127.0.0.1:5173') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + logLevel: env.VECTORA_LOG_LEVEL ?? 'info', + webDist, + sampleDir, + installRoot, + production: env.NODE_ENV === 'production', + ...overrides, + }; +} diff --git a/apps/local-engine/src/github.ts b/apps/local-engine/src/github.ts new file mode 100644 index 0000000..decf9eb --- /dev/null +++ b/apps/local-engine/src/github.ts @@ -0,0 +1,162 @@ +/** + * GitHub access through the REST API. Repositories are downloaded as source + * archives, so no git commands or repository hooks ever run. Access tokens + * travel only in the Authorization header. + */ +import fs from 'node:fs'; +import { Octokit } from 'octokit'; + +export interface GitHubRef { + owner: string; + repo: string; + branch: string | null; + subdirectory: string | null; +} + +export class GitHubError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + } +} + +const NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/; + +/** Accepts https URLs, `git@github.com:owner/repo.git` and `owner/repo`. */ +export function parseGitHubUrl(input: string): GitHubRef { + const raw = input.trim(); + if (!raw) throw new GitHubError('Enter a GitHub repository URL', 400); + if (/^[a-z]+:\/\/[^/\s]*@/i.test(raw)) + throw new GitHubError('Do not put credentials in the URL. Use the access token field instead.', 400); + let rest: string; + const ssh = /^git@github\.com:(.+)$/i.exec(raw); + if (ssh) rest = ssh[1]!; + else if (/^https?:\/\//i.test(raw)) { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new GitHubError('That is not a valid URL', 400); + } + if (!/^(www\.)?github\.com$/i.test(url.hostname)) + throw new GitHubError('Only github.com repositories are supported', 400); + rest = url.pathname.replace(/^\/+/, ''); + } else rest = raw.replace(/^github\.com\//i, ''); + const parts = rest.replace(/\/+$/, '').split('/').filter(Boolean); + const owner = parts[0]; + const repo = parts[1]?.replace(/\.git$/i, ''); + if (!owner || !repo || !NAME.test(owner) || !NAME.test(repo)) + throw new GitHubError('Expected a repository in the form owner/name', 400); + let branch: string | null = null; + let subdirectory: string | null = null; + if ((parts[2] === 'tree' || parts[2] === 'blob') && parts[3]) { + // Branch names may contain slashes; the first segment is taken and can be corrected in the form. + branch = decodeURIComponent(parts[3]); + if (parts.length > 4) subdirectory = parts.slice(4).map(decodeURIComponent).join('/'); + } + return { owner, repo, branch, subdirectory }; +} + +export interface GitHubRepoInfo { + owner: string; + repo: string; + fullName: string; + description: string | null; + defaultBranch: string; + branches: string[]; + private: boolean; + sizeKb: number; + htmlUrl: string; +} + +const MAX_REPO_KB = 1024 * 1024; // 1 GB as reported by GitHub + +export class GitHubClient { + private octokit: Octokit; + + constructor(opts: { token?: string | null; fetch?: typeof fetch } = {}) { + this.octokit = new Octokit({ + auth: opts.token || undefined, + userAgent: 'vectora-local-engine', + request: opts.fetch ? { fetch: opts.fetch } : undefined, + retry: { enabled: false }, + throttle: { enabled: false }, + log: { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined }, + }); + } + + private wrap(err: unknown, hasToken: boolean): GitHubError { + const status = (err as { status?: number }).status ?? 502; + if (status === 404) + return new GitHubError( + hasToken + ? 'Repository not found, or the credential has no access to it' + : 'Repository not found. If it is private, add an access token.', + 404, + ); + if (status === 401) return new GitHubError('GitHub rejected the access token', 401); + if (status === 403 || status === 429) + return new GitHubError( + 'GitHub refused the request (rate limit or insufficient permission). An access token raises the limit.', + 403, + ); + return new GitHubError(`GitHub could not be reached (${status})`, 502); + } + + async describe(ref: Pick, hasToken = false): Promise { + try { + const { data } = await this.octokit.rest.repos.get({ owner: ref.owner, repo: ref.repo }); + let branches: string[] = []; + try { + const res = await this.octokit.rest.repos.listBranches({ + owner: ref.owner, + repo: ref.repo, + per_page: 100, + }); + branches = res.data.map((b) => b.name); + } catch { + branches = []; + } + if (!branches.includes(data.default_branch)) branches.unshift(data.default_branch); + return { + owner: data.owner.login, + repo: data.name, + fullName: data.full_name, + description: data.description, + defaultBranch: data.default_branch, + branches, + private: data.private, + sizeKb: data.size, + htmlUrl: data.html_url, + }; + } catch (err) { + throw this.wrap(err, hasToken); + } + } + + /** Downloads the source archive of a branch to `destFile`. */ + async downloadArchive( + ref: Pick, + branch: string, + destFile: string, + hasToken = false, + ): Promise { + const info = await this.describe(ref, hasToken); + if (info.sizeKb > MAX_REPO_KB) + throw new GitHubError('Repository is larger than the 1 GB import limit', 413); + try { + const res = await this.octokit.request('GET /repos/{owner}/{repo}/zipball/{ref}', { + owner: ref.owner, + repo: ref.repo, + ref: branch, + }); + const buf = Buffer.from(res.data as ArrayBuffer); + fs.writeFileSync(destFile, buf); + return buf.length; + } catch (err) { + throw this.wrap(err, hasToken); + } + } +} diff --git a/apps/local-engine/src/main.ts b/apps/local-engine/src/main.ts new file mode 100644 index 0000000..162c0f7 --- /dev/null +++ b/apps/local-engine/src/main.ts @@ -0,0 +1,23 @@ +import { loadConfig } from './config'; +import { buildServer } from './server'; + +const config = loadConfig(); +const { app } = await buildServer(config); + +const shutdown = async (signal: string) => { + app.log.info(`${signal} received, shutting down`); + await app.close(); + process.exit(0); +}; +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); + +try { + await app.listen({ host: config.host, port: config.port }); + app.log.info( + `VECTORA engine ready at http://${config.host}:${config.port} (data: ${config.dataDir}${config.webDist ? ', serving web application' : ''})`, + ); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/apps/local-engine/src/provider.ts b/apps/local-engine/src/provider.ts new file mode 100644 index 0000000..558bda4 --- /dev/null +++ b/apps/local-engine/src/provider.ts @@ -0,0 +1,76 @@ +/** + * Optional narrative provider. + * + * Disabled by default. When the user enables it and supplies an endpoint that + * speaks the widely used "chat completions" HTTP format, the statements that + * were already retrieved from the graph are sent to it to be rewritten as + * prose. Only those statements (file paths, symbol names, short evidence + * lines) are sent — never whole files — and the provider cannot add facts: + * the evidence list shown to the user always comes from the graph. + */ +import type { ExplorationAnswer } from '@vectora/shared'; + +export interface ProviderConfig { + endpoint: string; + model: string; + apiKey: string | null; + fetch?: typeof fetch; +} + +export interface NarrativeProvider { + summarize(answer: ExplorationAnswer): Promise; +} + +export function createChatCompletionsProvider(cfg: ProviderConfig): NarrativeProvider { + return { + async summarize(answer) { + const url = new URL(cfg.endpoint); + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + throw new Error('The provider endpoint must use HTTPS'); + const facts = answer.statements + .slice(0, 60) + .map( + (s, i) => + `${i + 1}. ${s.verified ? '[verified]' : '[derived]'} ${s.text}${s.evidence[0] ? ` (${s.evidence[0].path}:${s.evidence[0].startLine})` : ''}`, + ) + .join('\n'); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30_000); + try { + const res = await (cfg.fetch ?? fetch)(url, { + method: 'POST', + signal: controller.signal, + headers: { + 'content-type': 'application/json', + ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: cfg.model, + temperature: 0, + messages: [ + { + role: 'system', + content: + 'Rewrite the numbered findings about a software repository as a short, plain explanation. Use only the findings. Keep file references. Say clearly which points are derived rather than verified. Do not add anything that is not in the findings.', + }, + { + role: 'user', + content: `Question: ${answer.question}\n\nFindings:\n${facts}\n\nKnown limits:\n${answer.caveats.join('\n') || 'none'}`, + }, + ], + }), + }); + if (!res.ok) throw new Error(`provider returned ${res.status}`); + const data = (await res.json()) as { choices?: { message?: { content?: string } }[] }; + const text = data.choices?.[0]?.message?.content?.trim(); + if (!text) throw new Error('provider returned no text'); + return text.slice(0, 6000); + } finally { + clearTimeout(timer); + } + }, + }; +} + +export const summarizeWithProvider = (cfg: ProviderConfig, answer: ExplorationAnswer): Promise => + createChatCompletionsProvider(cfg).summarize(answer); diff --git a/apps/local-engine/src/security.ts b/apps/local-engine/src/security.ts new file mode 100644 index 0000000..b576ae5 --- /dev/null +++ b/apps/local-engine/src/security.ts @@ -0,0 +1,356 @@ +/** + * Security helpers: credential encryption at rest, safe archive extraction + * and path containment checks. + */ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import ignore from 'ignore'; +import yauzl from 'yauzl'; +import { isBinaryPath } from '@vectora/language-adapters'; + +// --------------------------------------------------------------------------- +// Credentials: AES-256-GCM with a key file kept in the data directory. +// --------------------------------------------------------------------------- + +export class CredentialVault { + private key: Buffer; + + constructor(dataDir: string) { + const file = path.join(dataDir, 'vault.key'); + if (fs.existsSync(file)) { + this.key = Buffer.from(fs.readFileSync(file, 'utf8').trim(), 'base64'); + if (this.key.length !== 32) throw new Error('Credential key file is corrupt'); + } else { + fs.mkdirSync(dataDir, { recursive: true }); + this.key = randomBytes(32); + fs.writeFileSync(file, this.key.toString('base64'), { mode: 0o600 }); + } + } + + encrypt(plain: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const data = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return [ + 'v1', + iv.toString('base64'), + cipher.getAuthTag().toString('base64'), + data.toString('base64'), + ].join(':'); + } + + decrypt(stored: string): string { + const [version, iv, tag, data] = stored.split(':'); + if (version !== 'v1' || !iv || !tag || !data) throw new Error('Unsupported credential format'); + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8'); + } +} + +// --------------------------------------------------------------------------- +// Path containment +// --------------------------------------------------------------------------- + +/** Resolves `rel` under `root` and guarantees the result does not escape it. */ +export function resolveInside(root: string, rel: string): string | null { + if (rel.includes('\0')) return null; + const base = path.resolve(root); + const target = path.resolve(base, rel); + const relative = path.relative(base, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return target; +} + +// --------------------------------------------------------------------------- +// ZIP handling +// --------------------------------------------------------------------------- + +export interface ZipLimits { + maxEntries: number; + maxTotalBytes: number; + maxFileBytes: number; + /** Uncompressed / compressed ratio above which an entry is rejected as a decompression bomb. */ + maxRatio: number; +} + +export const DEFAULT_ZIP_LIMITS: ZipLimits = { + maxEntries: 100_000, + maxTotalBytes: 2 * 1024 * 1024 * 1024, + maxFileBytes: 8 * 1024 * 1024, + maxRatio: 200, +}; + +export class ArchiveError extends Error { + constructor( + message: string, + public code: 'invalid-archive' | 'too-many-entries' | 'too-large' | 'empty', + ) { + super(message); + } +} + +export interface ZipEntryInfo { + path: string; + size: number; + directory: boolean; +} + +/** Why an archive entry is not safe to extract, or null when it is. */ +export function unsafeEntryReason(name: string): string | null { + if (!name || name.includes('\0')) return 'empty or NUL in name'; + const n = name.replace(/\\/g, '/'); + if (n.startsWith('/') || /^[a-zA-Z]:/.test(n)) return 'absolute path'; + const parts = n.split('/').filter((p) => p !== ''); + if (parts.some((p) => p === '..')) return 'parent directory traversal'; + // eslint-disable-next-line no-control-regex + if (parts.some((p) => /[<>:"|?*\u0000-\u001f]/.test(p))) return 'characters not allowed in file names'; + if (parts.some((p) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(p))) + return 'reserved device name'; + if (parts.some((p) => p.length > 255) || n.length > 1024) return 'name too long'; + if (parts.some((p) => /[. ]$/.test(p) && p !== '.')) return 'trailing dot or space'; + return null; +} + +const isSymlinkEntry = (entry: yauzl.Entry): boolean => + ((entry.externalFileAttributes >>> 16) & 0o170000) === 0o120000; + +function openZip(file: string): Promise { + return new Promise((resolve, reject) => { + // strictFileNames rejects backslashes; decodeStrings + validateEntrySizes are yauzl defaults. + yauzl.open(file, { lazyEntries: true, autoClose: true, validateEntrySizes: true }, (err, zip) => { + if (err || !zip) + reject( + new ArchiveError( + `Not a readable ZIP archive: ${err?.message ?? 'unknown error'}`, + 'invalid-archive', + ), + ); + else resolve(zip); + }); + }); +} + +export function isZipFile(file: string): boolean { + try { + const fd = fs.openSync(file, 'r'); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + // Local file header, or end-of-central-directory for an empty archive. + return ( + buf[0] === 0x50 && buf[1] === 0x4b && ((buf[2] === 3 && buf[3] === 4) || (buf[2] === 5 && buf[3] === 6)) + ); + } catch { + return false; + } +} + +export interface ZipInspection { + entries: number; + files: number; + totalBytes: number; + rejected: { path: string; reason: string }[]; + /** Candidate project roots inside the archive ('' is the archive root). */ + roots: { path: string; files: number; markers: string[] }[]; + tree: ZipEntryInfo[]; +} + +const ROOT_MARKERS = new Set([ + 'package.json', + 'pyproject.toml', + 'setup.py', + 'requirements.txt', + 'go.mod', + 'cargo.toml', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'cmakelists.txt', + 'makefile', + '.gitignore', + 'readme.md', + 'tsconfig.json', +]); + +/** Lists an archive without extracting it and suggests project roots. */ +export async function inspectZip( + file: string, + limits: ZipLimits = DEFAULT_ZIP_LIMITS, +): Promise { + if (!isZipFile(file)) throw new ArchiveError('The file is not a ZIP archive', 'invalid-archive'); + const zip = await openZip(file); + const result: ZipInspection = { entries: 0, files: 0, totalBytes: 0, rejected: [], roots: [], tree: [] }; + const dirFiles = new Map(); + const dirMarkers = new Map>(); + const top = new Map(); + await new Promise((resolve, reject) => { + zip.on('error', (e) => + reject(new ArchiveError(`Archive is damaged: ${(e as Error).message}`, 'invalid-archive')), + ); + zip.on('end', resolve); + zip.on('entry', (entry: yauzl.Entry) => { + result.entries++; + if (result.entries > limits.maxEntries) { + zip.close(); + return reject( + new ArchiveError(`Archive has more than ${limits.maxEntries} entries`, 'too-many-entries'), + ); + } + const name = entry.fileName.replace(/\\/g, '/'); + const directory = name.endsWith('/'); + const reason = unsafeEntryReason(name) ?? (isSymlinkEntry(entry) ? 'symbolic link' : null); + if (reason) { + if (result.rejected.length < 50) result.rejected.push({ path: name.slice(0, 200), reason }); + } else if (!directory) { + result.files++; + result.totalBytes += entry.uncompressedSize; + if (result.totalBytes > limits.maxTotalBytes) { + zip.close(); + return reject( + new ArchiveError('Archive is larger than the allowed uncompressed size', 'too-large'), + ); + } + const parts = name.split('/'); + const base = parts[parts.length - 1]!.toLowerCase(); + for (let d = 0; d < Math.min(parts.length, 4); d++) { + const dir = parts.slice(0, d).join('/'); + dirFiles.set(dir, (dirFiles.get(dir) ?? 0) + 1); + } + const dir = parts.slice(0, -1).join('/'); + if (ROOT_MARKERS.has(base) && parts.length <= 4) + (dirMarkers.get(dir) ?? dirMarkers.set(dir, new Set()).get(dir)!).add(parts[parts.length - 1]!); + // Two-level preview of the structure. + const key = parts.slice(0, 2).join('/'); + if (top.size < 300 && !top.has(key)) + top.set(key, { + path: key, + size: parts.length <= 2 ? entry.uncompressedSize : 0, + directory: parts.length > 2, + }); + if (parts.length > 1 && !top.has(parts[0]!)) + top.set(parts[0]!, { path: parts[0]!, size: 0, directory: true }); + } + zip.readEntry(); + }); + zip.readEntry(); + }); + if (result.files === 0) throw new ArchiveError('The archive contains no files', 'empty'); + // A single wrapping directory (as produced by GitHub and most tools) is the natural root. + const topDirs = [...dirFiles.keys()].filter((d) => d && !d.includes('/')); + const rootFiles = dirFiles.get('') ?? 0; + const nested = topDirs.length === 1 && dirFiles.get(topDirs[0]!) === rootFiles ? topDirs[0]! : ''; + const candidates = new Set([nested, ...dirMarkers.keys()]); + result.roots = [...candidates] + .map((p) => ({ path: p, files: dirFiles.get(p) ?? 0, markers: [...(dirMarkers.get(p) ?? [])].sort() })) + .filter((r) => r.files > 0) + .sort((a, b) => + a.path === nested + ? -1 + : b.path === nested + ? 1 + : b.markers.length - a.markers.length || a.path.length - b.path.length, + ) + .slice(0, 12); + result.tree = [...top.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); + return result; +} + +export interface ExtractOptions { + /** Only extract entries under this archive path; it is stripped from the output paths. */ + root?: string; + ignorePatterns?: string[]; + limits?: Partial; +} + +export interface ExtractResult { + files: number; + bytes: number; + skipped: { unsafe: number; symlinks: number; ignored: number; binary: number; oversized: number }; +} + +/** + * Extracts an archive into `destDir`. Entries that would escape the + * destination, symbolic links, ignored paths, binary files and oversized + * files are skipped and counted — they are never written to disk. + */ +export async function extractZip( + file: string, + destDir: string, + opts: ExtractOptions = {}, +): Promise { + if (!isZipFile(file)) throw new ArchiveError('The file is not a ZIP archive', 'invalid-archive'); + const limits = { ...DEFAULT_ZIP_LIMITS, ...opts.limits }; + const root = (opts.root ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const ig = ignore().add(['node_modules/', '.git/', ...(opts.ignorePatterns ?? [])]); + const dest = path.resolve(destDir); + fs.mkdirSync(dest, { recursive: true }); + const zip = await openZip(file); + const result: ExtractResult = { + files: 0, + bytes: 0, + skipped: { unsafe: 0, symlinks: 0, ignored: 0, binary: 0, oversized: 0 }, + }; + let entries = 0; + await new Promise((resolve, reject) => { + const fail = (e: Error) => { + zip.close(); + reject(e); + }; + zip.on('error', (e) => + reject(new ArchiveError(`Archive is damaged: ${(e as Error).message}`, 'invalid-archive')), + ); + zip.on('end', resolve); + zip.on('entry', (entry: yauzl.Entry) => { + if (++entries > limits.maxEntries) + return fail( + new ArchiveError(`Archive has more than ${limits.maxEntries} entries`, 'too-many-entries'), + ); + const name = entry.fileName.replace(/\\/g, '/'); + const next = () => zip.readEntry(); + if (name.endsWith('/')) return next(); + if (unsafeEntryReason(name)) return (result.skipped.unsafe++, next()); + if (isSymlinkEntry(entry)) return (result.skipped.symlinks++, next()); + if (root && !name.startsWith(`${root}/`)) return next(); + const rel = root ? name.slice(root.length + 1) : name; + if (!rel) return next(); + if (ig.ignores(rel)) return (result.skipped.ignored++, next()); + if (isBinaryPath(rel)) return (result.skipped.binary++, next()); + const tooBig = entry.uncompressedSize > limits.maxFileBytes; + const bomb = + entry.compressedSize > 0 && + entry.uncompressedSize / entry.compressedSize > limits.maxRatio && + entry.uncompressedSize > 1024 * 1024; + if (tooBig || bomb) return (result.skipped.oversized++, next()); + if (result.bytes + entry.uncompressedSize > limits.maxTotalBytes) + return fail(new ArchiveError('Archive is larger than the allowed uncompressed size', 'too-large')); + const target = resolveInside(dest, rel); + if (!target) return (result.skipped.unsafe++, next()); + zip.openReadStream(entry, (err, stream) => { + if (err || !stream) + return fail( + new ArchiveError(`Archive entry could not be read: ${err?.message ?? ''}`, 'invalid-archive'), + ); + fs.mkdirSync(path.dirname(target), { recursive: true }); + // `wx`: never overwrite; an entry listed twice cannot replace an earlier one. + pipeline(stream, fs.createWriteStream(target, { flags: 'wx', mode: 0o644 })) + .then(() => { + result.files++; + result.bytes += entry.uncompressedSize; + next(); + }) + .catch((e: NodeJS.ErrnoException) => + e.code === 'EEXIST' + ? next() + : fail(new ArchiveError(`Extraction failed: ${e.message}`, 'invalid-archive')), + ); + }); + }); + zip.readEntry(); + }); + if (result.files === 0) + throw new ArchiveError('No source files could be extracted from the archive', 'empty'); + return result; +} diff --git a/apps/local-engine/src/server.ts b/apps/local-engine/src/server.ts new file mode 100644 index 0000000..a27f695 --- /dev/null +++ b/apps/local-engine/src/server.ts @@ -0,0 +1,329 @@ +/** + * HTTP API of the local engine. Listens on the loopback interface only, + * rejects cross-origin and DNS-rebinding requests, and never logs request + * bodies or credentials. + */ +import fs from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import cors from '@fastify/cors'; +import multipart from '@fastify/multipart'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; +import { z } from 'zod'; +import { NODE_TYPES } from '@vectora/graph-schema'; +import { SUPPORTED_LANGUAGES, findPython } from '@vectora/language-adapters'; +import { APP_VERSION, type SessionState, redactSecrets } from '@vectora/shared'; +import type { EngineConfig } from './config'; +import { GitHubError } from './github'; +import { Engine, EngineError } from './service'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); +const MAX_UPLOAD_BYTES = 512 * 1024 * 1024; + +const hostName = (header: string | undefined): string => { + if (!header) return ''; + const h = header.trim().toLowerCase(); + return h.startsWith('[') ? h.slice(0, h.indexOf(']') + 1) : h.split(':')[0]!; +}; + +export async function buildServer(config: EngineConfig): Promise<{ app: FastifyInstance; engine: Engine }> { + const app = Fastify({ + bodyLimit: 2 * 1024 * 1024, + logger: + config.logLevel === 'silent' + ? false + : { + level: config.logLevel, + // Request bodies are never logged; headers that can carry secrets are removed. + redact: { + paths: ['req.headers.authorization', 'req.headers.cookie', 'req.headers["x-api-key"]'], + remove: true, + }, + serializers: { + req: (req) => ({ + method: req.method, + url: redactSecrets(req.url.split('?')[0] ?? ''), + id: req.id, + }), + // Stack traces are kept out of release logs. + err: (err: Error) => ({ + type: err.name, + message: redactSecrets(err.message), + stack: config.production ? '' : redactSecrets(err.stack ?? ''), + }), + }, + }, + }); + const engine = new Engine(config, { + info: (m) => app.log.info(redactSecrets(m)), + warn: (m) => app.log.warn(redactSecrets(m)), + error: (m) => app.log.error(redactSecrets(m)), + }); + app.addHook('onClose', async () => engine.close()); + + const allowedOrigins = new Set(config.allowedOrigins); + await app.register(cors, { + origin: (origin, cb) => cb(null, !origin || allowedOrigins.has(origin)), + methods: ['GET', 'POST', 'PUT', 'DELETE'], + credentials: false, + }); + await app.register(multipart, { limits: { fileSize: MAX_UPLOAD_BYTES, files: 1, fields: 4 } }); + + app.addHook('onRequest', async (req, reply) => { + // DNS-rebinding protection: the Host header must name the loopback interface. + const host = hostName(req.headers.host); + if (!LOOPBACK_HOSTS.has(host) && host !== config.host) + return reply.code(403).send({ error: 'Forbidden host' }); + // Cross-site request protection: browsers send Origin on cross-origin and state-changing requests. + const origin = req.headers.origin; + if (origin) { + let originHost: string; + try { + originHost = new URL(origin).host; + } catch { + return reply.code(403).send({ error: 'Forbidden origin' }); + } + if (originHost !== req.headers.host && !allowedOrigins.has(origin)) + return reply.code(403).send({ error: 'Forbidden origin' }); + } + if ( + req.method !== 'GET' && + req.method !== 'HEAD' && + req.method !== 'OPTIONS' && + req.headers['sec-fetch-site'] === 'cross-site' + ) + return reply.code(403).send({ error: 'Forbidden origin' }); + }); + app.addHook('onSend', async (_req, reply) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('Referrer-Policy', 'no-referrer'); + reply.header('X-Frame-Options', 'DENY'); + reply.header('Cross-Origin-Resource-Policy', 'same-origin'); + reply.header( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ); + }); + + app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown }, req, reply) => { + if (err instanceof EngineError || err instanceof GitHubError) + return reply.code(err.status).send({ error: err.message }); + if (err instanceof z.ZodError) + return reply + .code(400) + .send({ + error: 'Invalid request', + details: err.issues.map((i) => `${i.path.join('.')}: ${i.message}`), + }); + if (err.statusCode && err.statusCode < 500) + return reply.code(err.statusCode).send({ error: err.message }); + req.log.error({ err }, 'request failed'); + return reply.code(500).send({ error: 'Internal error' }); + }); + + const idParams = z.object({ id: z.string().uuid() }); + + // ---- meta --------------------------------------------------------------- + app.get('/api/health', async () => ({ + status: 'ok', + version: APP_VERSION, + node: process.version, + python: !!findPython(process.env.VECTORA_PYTHON), + sampleAvailable: !!config.sampleDir, + languages: SUPPORTED_LANGUAGES.map((l) => ({ + id: l.id, + name: l.name, + tier: l.tier, + extensions: l.extensions, + })), + })); + + // ---- repositories ------------------------------------------------------- + app.get('/api/repositories', async () => engine.listRepositories()); + app.get('/api/repositories/:id', async (req) => engine.getRepository(idParams.parse(req.params).id)); + app.delete('/api/repositories/:id', async (req, reply) => { + engine.deleteRepository(idParams.parse(req.params).id); + return reply.code(204).send(); + }); + app.post('/api/repositories/:id/reindex', async (req) => { + const body = z.object({ full: z.boolean().optional() }).parse(req.body ?? {}); + return engine.reindex(idParams.parse(req.params).id, body.full ?? false); + }); + + // ---- imports ------------------------------------------------------------ + app.post('/api/import/local', async (req) => { + const body = z + .object({ path: z.string().min(1).max(1024), name: z.string().max(120).optional() }) + .parse(req.body); + return engine.importLocal(body.path, body.name); + }); + app.post('/api/import/sample', async () => engine.importSample()); + app.get('/api/fs/list', async (req) => + engine.listDirectory(z.object({ path: z.string().max(1024).optional() }).parse(req.query).path), + ); + + app.post('/api/import/zip/inspect', async (req) => { + const part = await req.file(); + if (!part) throw new EngineError('No file was uploaded', 400); + const { id, file } = engine.newUploadPath(); + await pipeline(part.file, fs.createWriteStream(file, { flags: 'wx' })); + if (part.file.truncated) { + fs.rmSync(file, { force: true }); + throw new EngineError('The archive is larger than the 512 MB upload limit', 413); + } + return engine.registerUpload(id, file, part.filename ?? 'archive.zip'); + }); + app.post('/api/import/zip', async (req) => { + const body = z + .object({ + uploadId: z.string().uuid(), + root: z.string().max(512).optional(), + name: z.string().max(120).optional(), + }) + .parse(req.body); + return engine.importZip(body.uploadId, body.root, body.name); + }); + + const githubBody = z.object({ + url: z.string().min(1).max(500), + branch: z.string().max(250).nullish(), + subdirectory: z.string().max(500).nullish(), + token: z.string().max(400).nullish(), + rememberToken: z.boolean().optional(), + }); + app.post('/api/import/github/validate', async (req) => { + const body = githubBody.pick({ url: true, token: true }).parse(req.body); + return engine.validateGitHub(body.url, body.token); + }); + app.post('/api/import/github', async (req) => engine.importGitHub(githubBody.parse(req.body))); + + // ---- jobs --------------------------------------------------------------- + app.get('/api/jobs', async () => engine.activeJobs()); + app.get('/api/jobs/:id', async (req) => engine.getJob(idParams.parse(req.params).id)); + + // ---- graph and analysis ------------------------------------------------- + app.get('/api/repositories/:id/graph', async (req) => { + const q = z.object({ detail: z.enum(['full', 'structure']).optional() }).parse(req.query); + return engine.getGraph(idParams.parse(req.params).id, q.detail ?? 'full'); + }); + app.get('/api/repositories/:id/members', async (req) => { + const q = z.object({ path: z.string().min(1).max(1024) }).parse(req.query); + return engine.getFileMembers(idParams.parse(req.params).id, q.path); + }); + app.get('/api/repositories/:id/overview', async (req) => engine.getOverview(idParams.parse(req.params).id)); + app.get('/api/repositories/:id/analysis', async (req) => engine.getAnalysis(idParams.parse(req.params).id)); + app.get('/api/repositories/:id/source', async (req) => { + const q = z + .object({ + path: z.string().min(1).max(1024), + start: z.coerce.number().int().min(1).optional(), + end: z.coerce.number().int().min(1).optional(), + }) + .parse(req.query); + return engine.getSource(idParams.parse(req.params).id, q.path, q.start, q.end); + }); + app.get('/api/repositories/:id/docs', async (req) => engine.listDocs(idParams.parse(req.params).id)); + app.get('/api/repositories/:id/search', async (req) => { + const q = z + .object({ + q: z.string().max(200), + types: z.string().max(400).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .parse(req.query); + const types = q.types?.split(',').filter((t) => (NODE_TYPES as readonly string[]).includes(t)); + return engine.search(idParams.parse(req.params).id, q.q, types?.length ? types : undefined, q.limit); + }); + app.post('/api/repositories/:id/ask', async (req) => { + const body = z.object({ question: z.string().min(2).max(500) }).parse(req.body); + return engine.ask(idParams.parse(req.params).id, body.question); + }); + app.get('/api/repositories/:id/export/:format', async (req, reply) => { + const p = z + .object({ id: z.string().uuid(), format: z.enum(['json', 'graphml', 'markdown', 'architecture']) }) + .parse(req.params); + const q = z.object({ includeSource: z.enum(['true', 'false']).optional() }).parse(req.query); + const out = engine.export(p.id, p.format, q.includeSource === 'true'); + return reply + .header('Content-Type', `${out.contentType}; charset=utf-8`) + .header('Content-Disposition', `attachment; filename="${out.fileName}"`) + .send(out.body); + }); + + // ---- sessions ----------------------------------------------------------- + const sessionBody = z.object({ + view: z.string().max(40), + selectedIds: z.array(z.string().max(1200)).max(500), + focusId: z.string().max(1200).nullable(), + expandedIds: z.array(z.string().max(1200)).max(5000), + hiddenNodeTypes: z.array(z.string().max(40)).max(40), + hiddenEdgeTypes: z.array(z.string().max(40)).max(40), + layouts: z.record(z.string(), z.unknown()), + savedAt: z.string().optional(), + }); + app.get( + '/api/repositories/:id/session', + async (req) => engine.getSession(idParams.parse(req.params).id) ?? null, + ); + app.put('/api/repositories/:id/session', { bodyLimit: 16 * 1024 * 1024 }, async (req, reply) => { + engine.saveSession(idParams.parse(req.params).id, sessionBody.parse(req.body) as unknown as SessionState); + return reply.code(204).send(); + }); + + // ---- settings, credentials, local data --------------------------------- + app.get('/api/settings', async () => engine.getSettings()); + app.put('/api/settings', async (req) => engine.updateSettings(req.body)); + app.get('/api/credentials', async () => engine.listCredentials()); + const credParams = z.object({ name: z.enum(['github', 'exploration-provider']) }); + app.put('/api/credentials/:name', async (req, reply) => { + engine.setCredential( + credParams.parse(req.params).name, + z.object({ value: z.string().min(1).max(4096) }).parse(req.body).value, + ); + return reply.code(204).send(); + }); + app.delete('/api/credentials/:name', async (req, reply) => { + engine.deleteCredential(credParams.parse(req.params).name); + return reply.code(204).send(); + }); + app.get('/api/data', async () => engine.dataInfo()); + app.delete('/api/data', async (_req, reply) => { + engine.deleteAllData(); + return reply.code(204).send(); + }); + app.delete('/api/cache', async (_req, reply) => { + engine.clearCache(); + return reply.code(204).send(); + }); + + // ---- web application ---------------------------------------------------- + if (config.webDist) { + await app.register(fastifyStatic, { + root: config.webDist, + wildcard: true, + index: false, + maxAge: '1h', + immutable: false, + }); + // The application shell must never be cached: it points at content-hashed assets that change with every build. + const shell = (reply: FastifyReply) => + reply.sendFile('index.html', { cacheControl: false }).header('Cache-Control', 'no-cache'); + app.get('/', (_req, reply) => shell(reply)); + app.setNotFoundHandler((req, reply) => { + // Client-side routes fall back to the application shell; missing assets and API paths are real 404s. + const path = req.url.split('?')[0] ?? ''; + if ( + req.method === 'GET' && + !path.startsWith('/api/') && + !path.startsWith('/assets/') && + !/\.[a-z0-9]{2,5}$/i.test(path) + ) + return shell(reply); + return reply.code(404).send({ error: 'Not found' }); + }); + } else { + app.setNotFoundHandler((_req, reply) => reply.code(404).send({ error: 'Not found' })); + } + + return { app, engine }; +} diff --git a/apps/local-engine/src/service.ts b/apps/local-engine/src/service.ts new file mode 100644 index 0000000..1987774 --- /dev/null +++ b/apps/local-engine/src/service.ts @@ -0,0 +1,885 @@ +/** + * Engine service: repository imports, indexing jobs, graph queries, exports + * and local data management. The HTTP layer in `server.ts` is a thin wrapper. + */ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + type GraphIndex, + type RepositoryAnalysis, + Store, + type StoredRepository, + buildIndex, + buildOverview, + explore, + exportArchitectureDoc, + exportGraphML, + exportJson, + exportMarkdownAnalysis, +} from '@vectora/graph-engine'; +import { + type GraphEdge, + type GraphNode, + type KnowledgeGraph, + STRUCTURAL_TYPES, + normalizePath, +} from '@vectora/graph-schema'; +import type { FileFacts } from '@vectora/language-adapters'; +import { indexRepository } from '@vectora/repository-indexer'; +import { SearchIndex, searchDocuments } from '@vectora/search'; +import { + type ExplorationAnswer, + INDEX_STAGES, + INDEX_STAGE_LABELS, + type IndexStage, + type JobProgress, + type RepositoryOverview, + type RepositoryRecord, + type SessionState, + type Settings, + type SourceResponse, + mergeSettings, + redactSecrets, +} from '@vectora/shared'; +import type { EngineConfig } from './config'; +import { GitHubClient, type GitHubRef, type GitHubRepoInfo, parseGitHubUrl } from './github'; +import { summarizeWithProvider } from './provider'; +import { + ArchiveError, + CredentialVault, + type ZipInspection, + extractZip, + inspectZip, + resolveInside, +} from './security'; + +export class EngineError extends Error { + constructor( + message: string, + public status = 400, + ) { + super(message); + } +} + +interface LoadedRepo { + graph: KnowledgeGraph; + index: GraphIndex; + search: SearchIndex; + analysis: RepositoryAnalysis | null; + docs: { path: string; content: string }[] | null; +} + +const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +const toRecord = (r: StoredRepository): RepositoryRecord => { + // The absolute path of managed copies is an implementation detail; local paths are shown as the origin already. + const { rootPath: _rootPath, managedSource: _managed, ...rest } = r; + return rest; +}; + +export class Engine { + readonly store: Store; + readonly vault: CredentialVault; + private jobs = new Map(); + private queue: Promise = Promise.resolve(); + private loaded = new Map(); + private uploads = new Map< + string, + { file: string; name: string; inspection: ZipInspection; createdAt: number } + >(); + readonly sourcesDir: string; + readonly uploadsDir: string; + + constructor( + readonly config: EngineConfig, + private log: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + } = console, + ) { + fs.mkdirSync(config.dataDir, { recursive: true }); + this.sourcesDir = path.join(config.dataDir, 'sources'); + this.uploadsDir = path.join(config.dataDir, 'uploads'); + fs.mkdirSync(this.sourcesDir, { recursive: true }); + fs.mkdirSync(this.uploadsDir, { recursive: true }); + this.store = new Store(path.join(config.dataDir, 'vectora.db')); + this.vault = new CredentialVault(config.dataDir); + // Jobs do not survive a restart: anything left mid-index is marked as failed. + for (const r of this.store.listRepositories()) + if (r.status === 'indexing' || r.status === 'pending') + this.store.updateRepository(r.id, { + status: 'failed', + error: 'Indexing was interrupted. Re-index to continue.', + }); + this.cleanUploads(0); + } + + close(): void { + this.store.close(); + } + + // ---- repositories ------------------------------------------------------- + + listRepositories(): RepositoryRecord[] { + return this.store.listRepositories().map(toRecord); + } + + private repo(id: string): StoredRepository { + const r = this.store.getRepository(id); + if (!r) throw new EngineError('Repository not found', 404); + return r; + } + + getRepository(id: string): RepositoryRecord { + return toRecord(this.repo(id)); + } + + deleteRepository(id: string): void { + const r = this.repo(id); + this.store.deleteRepository(id); + this.loaded.delete(id); + if (r.managedSource) this.removeManagedDir(r.rootPath); + } + + /** Deletes a directory only when it lies inside VECTORA's own sources folder. */ + private removeManagedDir(dir: string): void { + const rel = path.relative(this.sourcesDir, path.resolve(dir)); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return; + const top = path.join(this.sourcesDir, rel.split(path.sep)[0]!); + fs.rmSync(top, { recursive: true, force: true }); + } + + private createRepository( + fields: Pick< + StoredRepository, + 'name' | 'sourceKind' | 'origin' | 'branch' | 'subdirectory' | 'rootPath' | 'managedSource' + > & { id?: string }, + ): StoredRepository { + const repo: StoredRepository = { + id: fields.id ?? randomUUID(), + name: fields.name, + sourceKind: fields.sourceKind, + origin: fields.origin, + branch: fields.branch, + subdirectory: fields.subdirectory, + rootPath: fields.rootPath, + managedSource: fields.managedSource, + status: 'pending', + createdAt: new Date().toISOString(), + lastIndexedAt: null, + fileCount: 0, + symbolCount: 0, + relationshipCount: 0, + error: null, + }; + this.store.insertRepository(repo); + return repo; + } + + // ---- jobs --------------------------------------------------------------- + + getJob(id: string): JobProgress { + const j = this.jobs.get(id); + if (!j) throw new EngineError('Job not found', 404); + return j; + } + + activeJobs(): JobProgress[] { + return [...this.jobs.values()].filter((j) => j.status === 'running'); + } + + private newJob(repositoryId: string | null): JobProgress { + const job: JobProgress = { + id: randomUUID(), + repositoryId, + status: 'running', + stage: 'acquire', + stageIndex: 0, + message: 'Waiting to start', + processed: 0, + total: 0, + startedAt: new Date().toISOString(), + finishedAt: null, + error: null, + summary: null, + }; + this.jobs.set(job.id, job); + // Keep the job table small. + if (this.jobs.size > 50) + for (const [id, j] of this.jobs) + if (j.status !== 'running' && this.jobs.size > 40) this.jobs.delete(id); + return job; + } + + private setStage(job: JobProgress, stage: IndexStage, message?: string, processed = 0, total = 0): void { + job.stage = stage; + job.stageIndex = INDEX_STAGES.indexOf(stage); + job.message = message ?? INDEX_STAGE_LABELS[stage]; + job.processed = processed; + job.total = total; + } + + /** Runs jobs one at a time so indexing never competes with itself for memory. */ + private enqueue(job: JobProgress, work: () => Promise): void { + this.queue = this.queue.then(async () => { + try { + await work(); + job.status = 'completed'; + } catch (err) { + const message = redactSecrets(err instanceof Error ? err.message : String(err)); + job.status = 'failed'; + job.error = message; + if (job.repositoryId && this.store.getRepository(job.repositoryId)) + this.store.updateRepository(job.repositoryId, { status: 'failed', error: message }); + this.log.error(`Job ${job.id} failed: ${message}`); + } finally { + job.finishedAt = new Date().toISOString(); + } + }); + } + + /** Resolves when all queued jobs have finished (used by tests and shutdown). */ + idle(): Promise { + return this.queue; + } + + private async runIndex(repo: StoredRepository, job: JobProgress, forceFull = false): Promise { + const settings = this.store.getSettings(); + if (!fs.existsSync(repo.rootPath) || !fs.statSync(repo.rootPath).isDirectory()) + throw new EngineError('The repository folder no longer exists on this machine', 410); + this.store.updateRepository(repo.id, { status: 'indexing', error: null }); + const useCache = + settings.performance.indexingCache && settings.repository.incrementalIndexing && !forceFull; + const previous = useCache + ? (this.store.loadFacts(repo.id) as Map) + : undefined; + const before = useCache ? this.store.getAnalysis(repo.id) : null; + const result = await indexRepository({ + previousFingerprint: (repo.status !== 'failed' && before?.fingerprint) || '', + repositoryId: repo.id, + repositoryName: repo.name, + rootDir: repo.rootPath, + ignorePatterns: settings.repository.ignorePatterns, + maxFileSizeBytes: settings.repository.maxFileSizeKb * 1024, + maxFiles: settings.performance.maxFiles, + disabledLanguages: settings.repository.disabledLanguages, + concurrency: settings.performance.workerConcurrency, + previous: previous && previous.size ? previous : undefined, + pythonCommand: process.env.VECTORA_PYTHON, + onProgress: (stage, message, processed, total) => this.setStage(job, stage, message, processed, total), + }); + if ('unchanged' in result) { + // Nothing changed on disk: the stored graph is still exact, so it is kept as it is. + this.setStage(job, 'persist', 'No changes since the last index'); + this.store.updateRepository(repo.id, { + status: 'ready', + lastIndexedAt: new Date().toISOString(), + error: null, + }); + job.summary = before + ? { + ...before.summary, + analyzedFiles: 0, + reusedFiles: before.summary.analyzedFiles + before.summary.reusedFiles, + durationMs: result.durationMs, + incremental: true, + } + : null; + job.message = 'Workspace ready'; + this.log.info(`Checked ${repo.name}: no changes (${result.durationMs} ms)`); + return; + } + this.setStage(job, 'persist'); + this.store.saveGraph(repo.id, result.graph); + this.store.setAnalysis(repo.id, result.analysis); + if (settings.performance.indexingCache) this.store.saveFacts(repo.id, result.facts); + else this.store.clearFacts(repo.id); + this.store.updateRepository(repo.id, { + status: 'ready', + lastIndexedAt: result.analysis.generatedAt, + fileCount: result.graph.stats.fileCount, + symbolCount: result.graph.stats.symbolCount, + relationshipCount: result.graph.stats.edgeCount, + error: null, + }); + this.loaded.delete(repo.id); + job.summary = result.analysis.summary; + job.message = 'Workspace ready'; + this.log.info( + `Indexed ${repo.name}: ${result.graph.stats.nodeCount} nodes, ${result.graph.stats.edgeCount} relationships in ${result.analysis.summary.durationMs} ms`, + ); + } + + reindex(id: string, full = false): JobProgress { + const repo = this.repo(id); + if (this.activeJobs().some((j) => j.repositoryId === id)) + throw new EngineError('This repository is already being indexed', 409); + const job = this.newJob(id); + this.enqueue(job, () => this.runIndex(repo, job, full)); + return job; + } + + // ---- imports ------------------------------------------------------------ + + importLocal(dirPath: string, name?: string, kind: 'local' | 'sample' = 'local'): JobProgress { + const abs = path.resolve(dirPath); + let stat: fs.Stats; + try { + stat = fs.statSync(abs); + } catch { + throw new EngineError('That folder does not exist', 404); + } + if (!stat.isDirectory()) throw new EngineError('The path is not a folder', 400); + const inside = path.relative(this.config.dataDir, abs); + if (!inside.startsWith('..') && !path.isAbsolute(inside)) + throw new EngineError("VECTORA's own data folder cannot be imported", 400); + const repo = this.createRepository({ + name: (name?.trim() || path.basename(abs) || 'repository').slice(0, 120), + sourceKind: kind, + origin: kind === 'sample' ? 'Bundled sample repository' : abs, + branch: readGitBranch(abs), + subdirectory: null, + rootPath: abs, + managedSource: false, + }); + const job = this.newJob(repo.id); + this.enqueue(job, async () => { + this.setStage(job, 'acquire', 'Reading local folder'); + await this.runIndex(repo, job); + }); + return job; + } + + importSample(): JobProgress { + if (!this.config.sampleDir) + throw new EngineError('The sample repository is not part of this installation', 404); + return this.importLocal(this.config.sampleDir, 'Orbit Notes (sample)', 'sample'); + } + + private cleanUploads(maxAgeMs: number): void { + try { + for (const f of fs.readdirSync(this.uploadsDir)) { + const file = path.join(this.uploadsDir, f); + if (maxAgeMs === 0 || Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) + fs.rmSync(file, { force: true }); + } + } catch { + // nothing to clean + } + for (const [id, u] of this.uploads) + if (maxAgeMs === 0 || Date.now() - u.createdAt > maxAgeMs) this.uploads.delete(id); + } + + newUploadPath(): { id: string; file: string } { + this.cleanUploads(60 * 60 * 1000); + const id = randomUUID(); + return { id, file: path.join(this.uploadsDir, `${id}.zip`) }; + } + + async registerUpload( + id: string, + file: string, + originalName: string, + ): Promise<{ uploadId: string; fileName: string } & ZipInspection> { + try { + const inspection = await inspectZip(file); + // Only the base name is kept; a client-supplied path is never used. + const name = + path + .basename(originalName.replace(/\\/g, '/')) + .replace(/[^\w .()-]/g, '_') + .slice(0, 120) || 'archive.zip'; + this.uploads.set(id, { file, name, inspection, createdAt: Date.now() }); + return { uploadId: id, fileName: name, ...inspection }; + } catch (err) { + fs.rmSync(file, { force: true }); + if (err instanceof ArchiveError) + throw new EngineError( + err.message, + err.code === 'too-large' || err.code === 'too-many-entries' ? 413 : 400, + ); + throw err; + } + } + + importZip(uploadId: string, root: string | undefined, name?: string): JobProgress { + const upload = this.uploads.get(uploadId); + if (!upload) throw new EngineError('The uploaded archive has expired. Upload it again.', 404); + const chosen = normalizePath(root ?? upload.inspection.roots[0]?.path ?? ''); + if ( + chosen && + !upload.inspection.roots.some((r) => r.path === chosen) && + !upload.inspection.tree.some( + (t) => t.directory && (t.path === chosen || t.path.startsWith(`${chosen}/`)), + ) + ) + throw new EngineError('That folder does not exist in the archive', 400); + const id = randomUUID(); + const dest = path.join(this.sourcesDir, id); + const repo = this.createRepository({ + id, + name: (name?.trim() || (chosen ? chosen.split('/').pop()! : upload.name.replace(/\.zip$/i, ''))).slice( + 0, + 120, + ), + sourceKind: 'zip', + origin: upload.name, + branch: null, + subdirectory: chosen || null, + rootPath: dest, + managedSource: true, + }); + const job = this.newJob(repo.id); + this.enqueue(job, async () => { + this.setStage(job, 'acquire', 'Extracting archive'); + try { + const settings = this.store.getSettings(); + const r = await extractZip(upload.file, dest, { + root: chosen, + ignorePatterns: settings.repository.ignorePatterns, + limits: { maxFileBytes: settings.repository.maxFileSizeKb * 1024 }, + }); + this.setStage(job, 'acquire', `Extracted ${r.files} files`, r.files, r.files); + } finally { + fs.rmSync(upload.file, { force: true }); + this.uploads.delete(uploadId); + } + await this.runIndex(repo, job); + }); + return job; + } + + private githubToken(explicit?: string | null): string | null { + if (explicit?.trim()) return explicit.trim(); + const stored = this.store.getCredential('github'); + if (!stored) return null; + try { + return this.vault.decrypt(stored); + } catch { + return null; + } + } + + async validateGitHub( + url: string, + token?: string | null, + ): Promise { + const ref = parseGitHubUrl(url); + const tok = this.githubToken(token); + const client = new GitHubClient({ token: tok, fetch: this.config.fetch }); + const info = await client.describe(ref, !!tok); + return { ...info, requested: ref, usingStoredCredential: !token?.trim() && !!tok }; + } + + importGitHub(input: { + url: string; + branch?: string | null; + subdirectory?: string | null; + token?: string | null; + rememberToken?: boolean; + }): JobProgress { + const ref = parseGitHubUrl(input.url); + const token = this.githubToken(input.token); + if (input.rememberToken && input.token?.trim()) this.setCredential('github', input.token.trim()); + const sub = normalizePath(input.subdirectory ?? ref.subdirectory ?? ''); + if (sub.split('/').some((p) => p === '..')) throw new EngineError('Invalid subdirectory', 400); + const id = randomUUID(); + const dest = path.join(this.sourcesDir, id); + const repo = this.createRepository({ + id, + name: sub ? `${ref.repo}/${sub}` : ref.repo, + sourceKind: 'github', + origin: `https://github.com/${ref.owner}/${ref.repo}`, + branch: input.branch ?? ref.branch, + subdirectory: sub || null, + rootPath: sub ? path.join(dest, ...sub.split('/')) : dest, + managedSource: true, + }); + const job = this.newJob(repo.id); + this.enqueue(job, async () => { + const client = new GitHubClient({ token, fetch: this.config.fetch }); + this.setStage(job, 'acquire', 'Contacting GitHub'); + const info = await client.describe(ref, !!token); + const branch = input.branch ?? ref.branch ?? info.defaultBranch; + this.store.updateRepository(repo.id, { branch }); + const archive = path.join(this.uploadsDir, `${id}.zip`); + try { + this.setStage(job, 'acquire', `Downloading ${info.fullName}@${branch}`); + await client.downloadArchive(ref, branch, archive, !!token); + this.setStage(job, 'acquire', 'Extracting archive'); + const inspection = await inspectZip(archive); + const settings = this.store.getSettings(); + // GitHub archives wrap everything in one `-` directory. + await extractZip(archive, dest, { + root: inspection.roots[0]?.path ?? '', + ignorePatterns: settings.repository.ignorePatterns, + limits: { maxFileBytes: settings.repository.maxFileSizeKb * 1024 }, + }); + } finally { + fs.rmSync(archive, { force: true }); + } + if (sub && !fs.existsSync(repo.rootPath)) + throw new EngineError(`The folder "${sub}" does not exist on branch ${branch}`, 404); + await this.runIndex({ ...repo, branch }, job); + }); + return job; + } + + // ---- filesystem browsing for the local import dialog ------------------- + + listDirectory(dir?: string): { + path: string; + parent: string | null; + entries: { name: string; path: string; repository: boolean }[]; + roots: string[]; + } { + const roots: string[] = []; + if (process.platform === 'win32') { + for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZAB') + if (fs.existsSync(`${letter}:\\`)) roots.push(`${letter}:\\`); + } else roots.push('/'); + const start = dir?.trim() ? path.resolve(dir) : this.config.installRoot; + let entries: { name: string; path: string; repository: boolean }[]; + try { + entries = fs + .readdirSync(start, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith('$') && d.name !== 'System Volume Information') + .map((d) => { + const p = path.join(start, d.name); + let repository: boolean; + try { + repository = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'].some( + (m) => fs.existsSync(path.join(p, m)), + ); + } catch { + repository = false; + } + return { name: d.name, path: p, repository }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + throw new EngineError('That folder cannot be read', 403); + } + const parent = path.dirname(start); + return { path: start, parent: parent === start ? null : parent, entries, roots }; + } + + // ---- graph access ------------------------------------------------------- + + private load(id: string): LoadedRepo { + const cached = this.loaded.get(id); + if (cached) return cached; + this.repo(id); + const graph = this.store.loadGraph(id); + const entry: LoadedRepo = { + graph, + index: buildIndex(graph), + search: new SearchIndex(graph.nodes), + analysis: this.store.getAnalysis(id), + docs: null, + }; + // Memory-aware: keep at most two repositories in memory. + while (this.loaded.size >= 2) this.loaded.delete(this.loaded.keys().next().value!); + this.loaded.set(id, entry); + return entry; + } + + getGraph(id: string, detail: 'full' | 'structure' = 'full'): KnowledgeGraph & { partial: boolean } { + const { graph } = this.load(id); + if (detail === 'full') return { ...graph, partial: false }; + const keep = new Set(STRUCTURAL_TYPES); + const nodes = graph.nodes.filter((n) => keep.has(n.type)); + const ids = new Set(nodes.map((n) => n.id)); + return { + ...graph, + nodes, + edges: graph.edges.filter((e) => ids.has(e.source) && ids.has(e.target)), + partial: true, + }; + } + + /** Symbols of one file with every relationship touching them (lazy expansion). */ + getFileMembers(id: string, filePath: string): { nodes: GraphNode[]; edges: GraphEdge[] } { + const { graph, index } = this.load(id); + const p = normalizePath(filePath); + const nodes = graph.nodes.filter( + (n) => n.filePath === p && n.type !== 'File' && n.type !== 'Configuration', + ); + const edges = new Map(); + for (const n of nodes) + for (const e of [...(index.out.get(n.id) ?? []), ...(index.in.get(n.id) ?? [])]) edges.set(e.id, e); + return { nodes, edges: [...edges.values()] }; + } + + getOverview(id: string): RepositoryOverview { + const l = this.load(id); + return buildOverview(this.getRepository(id), l.graph, l.analysis, l.index); + } + + getAnalysis(id: string): RepositoryAnalysis | null { + return this.load(id).analysis; + } + + getSource(id: string, filePath: string, startLine?: number, endLine?: number): SourceResponse { + const repo = this.repo(id); + const { index } = this.load(id); + const p = normalizePath(filePath); + // Only files that are part of the index can be read. + const node = index.nodes.get(`file:${p}`); + if (!node) throw new EngineError('That file is not part of the indexed repository', 404); + const abs = resolveInside(repo.rootPath, p); + if (!abs) throw new EngineError('Invalid path', 400); + let real: string; + try { + real = fs.realpathSync(abs); + } catch { + throw new EngineError('The file no longer exists on disk. Re-index the repository.', 410); + } + const rootReal = fs.realpathSync(repo.rootPath); + const rel = path.relative(rootReal, real); + if (rel.startsWith('..') || path.isAbsolute(rel)) throw new EngineError('Invalid path', 400); + const stat = fs.statSync(real); + if (!stat.isFile()) throw new EngineError('Not a file', 400); + const truncated = stat.size > MAX_SOURCE_BYTES; + const fd = fs.openSync(real, 'r'); + const buf = Buffer.alloc(Math.min(stat.size, MAX_SOURCE_BYTES)); + fs.readSync(fd, buf, 0, buf.length, 0); + fs.closeSync(fd); + const all = buf.toString('utf8').split(/\r?\n/); + const s = Math.max(1, Math.min(startLine ?? 1, all.length)); + const e = Math.max(s, Math.min(endLine ?? all.length, all.length)); + return { + path: p, + language: node.language, + content: all.slice(s - 1, e).join('\n'), + startLine: s, + endLine: e, + totalLines: all.length, + truncated, + }; + } + + private docs(id: string): { path: string; content: string }[] { + const l = this.load(id); + if (l.docs) return l.docs; + const repo = this.repo(id); + const docs: { path: string; content: string }[] = []; + for (const n of l.graph.nodes) { + if ( + n.type !== 'File' || + !n.filePath || + !['markdown', 'restructuredtext', 'text'].includes(n.language ?? '') + ) + continue; + if (docs.length >= 300) break; + const abs = resolveInside(repo.rootPath, n.filePath); + try { + if (abs && fs.statSync(abs).size <= 512 * 1024) + docs.push({ path: n.filePath, content: fs.readFileSync(abs, 'utf8') }); + } catch { + // file vanished since indexing + } + } + l.docs = docs; + return docs; + } + + listDocs(id: string): { path: string; title: string; lines: number }[] { + return this.docs(id) + .map((d) => ({ + path: d.path, + title: /^#\s+(.+)$/m.exec(d.content)?.[1]?.trim() ?? d.path.split('/').pop()!, + lines: d.content.split('\n').length, + })) + .sort((a, b) => { + const rank = (p: string) => (/^readme/i.test(p) ? 0 : p.includes('/') ? 2 : 1); + return rank(a.path) - rank(b.path) || (a.path < b.path ? -1 : 1); + }); + } + + search(id: string, query: string, types?: string[], limit = 30) { + const l = this.load(id); + const hits = l.search.search(query, { types: types as never, limit: Math.min(limit, 100) }); + return { + query, + results: hits.map((h) => ({ + id: h.node.id, + name: h.node.name, + type: h.node.type, + filePath: h.node.filePath, + line: h.node.location?.startLine ?? null, + description: h.node.description ?? null, + score: h.score, + match: h.match, + })), + documents: searchDocuments(this.docs(id), query, 12), + }; + } + + async ask(id: string, question: string): Promise { + const l = this.load(id); + const answer = explore(question.slice(0, 500), l.index, l.analysis, (q, limit = 10) => + l.search.search(q, { limit }).map((h) => h.node), + ); + const settings = this.store.getSettings(); + if ( + settings.exploration.providerEnabled && + settings.exploration.providerEndpoint && + answer.statements.length + ) { + const stored = this.store.getCredential('exploration-provider'); + try { + answer.narrative = await summarizeWithProvider( + { + endpoint: settings.exploration.providerEndpoint, + model: settings.exploration.providerModel, + apiKey: stored ? this.vault.decrypt(stored) : null, + fetch: this.config.fetch, + }, + answer, + ); + answer.narrativeSource = new URL(settings.exploration.providerEndpoint).host; + } catch (err) { + answer.narrative = null; + answer.caveats.push( + `The optional narrative service did not respond (${redactSecrets((err as Error).message)}). The evidence above is unaffected.`, + ); + } + } + return answer; + } + + export( + id: string, + format: string, + includeSource: boolean, + ): { body: string; contentType: string; fileName: string } { + const l = this.load(id); + const repo = this.getRepository(id); + const base = repo.name.replace(/[^\w.-]+/g, '-').slice(0, 60) || 'repository'; + switch (format) { + case 'json': + return { + body: exportJson(repo, l.graph, { includeSource }), + contentType: 'application/json', + fileName: `${base}.vectora.json`, + }; + case 'graphml': + return { + body: exportGraphML(repo, l.graph, { includeSource }), + contentType: 'application/xml', + fileName: `${base}.graphml`, + }; + case 'markdown': + return { + body: exportMarkdownAnalysis(this.getOverview(id), l.graph, l.analysis), + contentType: 'text/markdown', + fileName: `${base}-analysis.md`, + }; + case 'architecture': + return { + body: exportArchitectureDoc(this.getOverview(id), l.graph, l.analysis), + contentType: 'text/markdown', + fileName: `${base}-architecture.md`, + }; + default: + throw new EngineError('Unknown export format', 400); + } + } + + // ---- sessions, settings, credentials, data ----------------------------- + + getSession(id: string): SessionState | null { + this.repo(id); + return this.store.getSession(id); + } + + saveSession(id: string, session: SessionState): void { + this.repo(id); + this.store.saveSession(id, { ...session, savedAt: new Date().toISOString() }); + } + + getSettings(): Settings { + return this.store.getSettings(); + } + + updateSettings(patch: unknown): Settings { + const next = mergeSettings(this.store.getSettings(), patch); + this.store.saveSettings(next); + return next; + } + + setCredential(name: string, value: string): void { + if (!/^[a-z][a-z0-9-]{1,40}$/.test(name)) throw new EngineError('Invalid credential name', 400); + if (!value || value.length > 4096) throw new EngineError('Invalid credential value', 400); + this.store.setCredential(name, this.vault.encrypt(value)); + } + + /** Names and dates only — stored values are never returned. */ + listCredentials(): { name: string; updatedAt: string }[] { + return this.store.listCredentials(); + } + + deleteCredential(name: string): void { + this.store.deleteCredential(name); + } + + clearCache(): void { + this.store.clearFacts(); + } + + dataInfo(): { + dataDir: string; + databaseBytes: number; + sourcesBytes: number; + repositories: number; + credentials: number; + } { + const size = (p: string): number => { + try { + const st = fs.statSync(p); + if (st.isFile()) return st.size; + return fs.readdirSync(p).reduce((a, f) => a + size(path.join(p, f)), 0); + } catch { + return 0; + } + }; + const db = ['vectora.db', 'vectora.db-wal', 'vectora.db-shm'].reduce( + (a, f) => a + size(path.join(this.config.dataDir, f)), + 0, + ); + return { + dataDir: this.config.dataDir, + databaseBytes: db, + sourcesBytes: size(this.sourcesDir), + repositories: this.store.listRepositories().length, + credentials: this.store.listCredentials().length, + }; + } + + deleteAllData(): void { + if (this.activeJobs().length) + throw new EngineError('Wait for indexing to finish before deleting data', 409); + this.store.deleteAllData(false); + this.loaded.clear(); + this.jobs.clear(); + for (const dir of [this.sourcesDir, this.uploadsDir]) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + } + this.uploads.clear(); + } +} + +/** Reads the checked-out branch from `.git/HEAD` without running git. */ +export function readGitBranch(dir: string): string | null { + try { + const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8').trim(); + const m = /^ref:\s+refs\/heads\/(.+)$/.exec(head); + return m ? m[1]! : head.slice(0, 12); + } catch { + return null; + } +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f7dfe3b --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + VECTORA — Understand what connects. + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..033147c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,62 @@ +{ + "name": "@vectora/web", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@dagrejs/dagre": "^3.1.3", + "@fontsource/ibm-plex-mono": "^5.0.0", + "@fontsource/shippori-mincho": "^5.0.0", + "@fontsource/zen-kaku-gothic-new": "^5.0.0", + "@lezer/highlight": "^1.0.0", + "@react-three/drei": "^10.7.9", + "@react-three/fiber": "^9.8.2", + "@tanstack/react-query": "^5.107.1", + "@vectora/graph-layout": "*", + "@vectora/graph-schema": "*", + "@vectora/search": "*", + "@vectora/shared": "*", + "@xyflow/react": "^12.12.1", + "cmdk": "^1.1.1", + "codemirror": "^6.0.2", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-layout-noverlap": "^0.4.2", + "html-to-image": "^1.11.13", + "lucide-react": "^1.72.0", + "radix-ui": "^1.4.0", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "react-markdown": "^10.0.0", + "react-router": "^8.5.1", + "sigma": "^3.0.3", + "three": "^0.187.0", + "zustand": "^5.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.5", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.187.0", + "@vitejs/plugin-react": "^6.3.0", + "tailwindcss": "^4.3.5", + "vite": "^8.3.8" + } +} diff --git a/apps/web/public/mark.svg b/apps/web/public/mark.svg new file mode 100644 index 0000000..5bed6b5 --- /dev/null +++ b/apps/web/public/mark.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..2defbde --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,65 @@ +import { Suspense, lazy } from 'react'; +import { BrowserRouter, Link, Route, Routes } from 'react-router'; +import { SitePage } from './components/Site'; +import { Empty, Loading } from './components/ui'; +import { About } from './pages/About'; +import { ImportPage } from './pages/ImportPage'; +import { Landing } from './pages/Landing'; +import { Dependencies } from './workspace/Dependencies'; +import { Documentation, SourceView } from './workspace/Documentation'; +import { Overview } from './workspace/Overview'; +import { SearchView } from './workspace/SearchView'; +import { SettingsView } from './workspace/SettingsView'; +import { WorkspaceLayout } from './workspace/WorkspaceLayout'; + +// The three renderers are loaded on demand: each pulls in a sizeable graphics library. +const GraphExplorer = lazy(() => + import('./workspace/GraphExplorer').then((m) => ({ default: m.GraphExplorer })), +); +const Atlas = lazy(() => import('./workspace/Atlas').then((m) => ({ default: m.Atlas }))); +const MindMap = lazy(() => import('./workspace/MindMap').then((m) => ({ default: m.MindMap }))); + +const view = (el: React.ReactNode) => }>{el}; + +export function App() { + return ( + + + } /> + } /> + } /> + + + + } + /> + }> + } /> + )} /> + )} /> + )} /> + } /> + } /> + } /> + } /> + } /> + + + +

+ Return to the start +

+
+ + } + /> +
+
+ ); +} diff --git a/apps/web/src/components/CodePreview.tsx b/apps/web/src/components/CodePreview.tsx new file mode 100644 index 0000000..5fabc36 --- /dev/null +++ b/apps/web/src/components/CodePreview.tsx @@ -0,0 +1,13 @@ +import { Suspense, lazy } from 'react'; +import type { CodePreviewProps } from './CodePreviewImpl'; + +// The editor and its language grammars are loaded when the first source preview is shown. +const Impl = lazy(() => import('./CodePreviewImpl').then((m) => ({ default: m.CodePreview }))); + +export function CodePreview(props: CodePreviewProps) { + return ( + Loading source…

}> + +
+ ); +} diff --git a/apps/web/src/components/CodePreviewImpl.tsx b/apps/web/src/components/CodePreviewImpl.tsx new file mode 100644 index 0000000..5af049e --- /dev/null +++ b/apps/web/src/components/CodePreviewImpl.tsx @@ -0,0 +1,131 @@ +import { cpp } from '@codemirror/lang-cpp'; +import { go } from '@codemirror/lang-go'; +import { java } from '@codemirror/lang-java'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { markdown } from '@codemirror/lang-markdown'; +import { python } from '@codemirror/lang-python'; +import { rust } from '@codemirror/lang-rust'; +import { HighlightStyle, type LanguageSupport, syntaxHighlighting } from '@codemirror/language'; +import { EditorState, type Extension, RangeSetBuilder } from '@codemirror/state'; +import { Decoration, EditorView, lineNumbers } from '@codemirror/view'; +import { tags as t } from '@lezer/highlight'; +import { useEffect, useRef } from 'react'; + +// Syntax colours come from the design tokens, so the preview follows the theme. +const highlight = HighlightStyle.define([ + { + tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.definitionKeyword, t.moduleKeyword], + color: 'var(--indigo)', + }, + { tag: [t.string, t.special(t.string), t.regexp], color: 'var(--clay)' }, + { + tag: [t.comment, t.lineComment, t.blockComment, t.docComment], + color: 'var(--text-muted)', + fontStyle: 'italic', + }, + { tag: [t.number, t.bool, t.null, t.atom], color: 'var(--warning)' }, + { tag: [t.typeName, t.className, t.namespace], color: 'var(--text)', fontWeight: '500' }, + { + tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], + color: 'var(--text)', + }, + { tag: [t.propertyName, t.variableName], color: 'var(--text-secondary)' }, + { tag: [t.punctuation, t.bracket, t.operator], color: 'var(--text-muted)' }, + { tag: t.heading, fontWeight: '500' }, + { tag: t.invalid, color: 'var(--error)' }, +]); + +function languageFor(language: string | null, path: string): LanguageSupport | null { + switch (language) { + case 'typescript': + return javascript({ typescript: true, jsx: /\.tsx$/.test(path) }); + case 'javascript': + return javascript({ jsx: true }); + case 'python': + return python(); + case 'java': + case 'csharp': + return java(); + case 'go': + return go(); + case 'rust': + return rust(); + case 'c': + case 'cpp': + return cpp(); + case 'json': + return json(); + case 'markdown': + return markdown(); + default: + return null; + } +} + +const rangeMark = Decoration.line({ class: 'cm-vectora-range' }); + +export interface CodePreviewProps { + content: string; + language: string | null; + path: string; + /** Line number of the first line of `content` in the original file. */ + firstLine?: number; + /** Lines (original numbering) to mark as the selected range. */ + highlight?: { start: number; end: number } | null; + maxHeight?: number | string; + scrollToLine?: number; +} + +/** Read-only source preview. Source text is rendered as text only — never interpreted as markup. */ +export function CodePreview({ + content, + language, + path, + firstLine = 1, + highlight: range, + maxHeight = 320, + scrollToLine, +}: CodePreviewProps) { + const host = useRef(null); + useEffect(() => { + if (!host.current) return; + const lang = languageFor(language, path); + const extensions: Extension[] = [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.contentAttributes.of({ 'aria-label': `Source of ${path}`, tabindex: '0' }), + lineNumbers({ formatNumber: (n) => String(n + firstLine - 1) }), + syntaxHighlighting(highlight), + EditorView.theme({ + '&': { maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }, + '.cm-scroller': { overflow: 'auto' }, + }), + ]; + if (lang) extensions.push(lang); + if (range) { + extensions.push( + EditorView.decorations.of((view) => { + const b = new RangeSetBuilder(); + const from = Math.max(1, range.start - firstLine + 1); + const to = Math.min(view.state.doc.lines, range.end - firstLine + 1); + for (let l = from; l <= to; l++) + b.add(view.state.doc.line(l).from, view.state.doc.line(l).from, rangeMark); + return b.finish(); + }), + ); + } + const view = new EditorView({ + state: EditorState.create({ doc: content, extensions }), + parent: host.current, + }); + if (scrollToLine) { + const l = Math.min(view.state.doc.lines, Math.max(1, scrollToLine - firstLine + 1)); + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.doc.line(l).from, { y: 'start', yMargin: 48 }), + }); + } + return () => view.destroy(); + }, [content, language, path, firstLine, range?.start, range?.end, maxHeight, scrollToLine]); // eslint-disable-line react-hooks/exhaustive-deps + return
; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..7a27117 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query'; +import { Command } from 'cmdk'; +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; +import { api } from '@/lib/api'; +import { useWorkspace } from '@/lib/store'; +import { useWs } from '@/workspace/context'; +import { TypeMark } from './ui'; + +const VIEWS: { view: Parameters['openView']>[0]; label: string }[] = [ + { view: 'overview', label: 'Overview' }, + { view: 'mindmap', label: 'Mind Map' }, + { view: 'graph', label: 'Graph Explorer' }, + { view: 'atlas', label: '3D Atlas' }, + { view: 'dependencies', label: 'Dependency Explorer' }, + { view: 'search', label: 'Search' }, + { view: 'docs', label: 'Documentation' }, + { view: 'settings', label: 'Settings' }, +]; + +const itemClass = + 'flex cursor-pointer items-baseline gap-3 border-l-2 border-transparent px-5 py-2 text-[0.875rem] data-[selected=true]:border-ink data-[selected=true]:bg-surface'; + +/** Global search (Ctrl/Cmd + K). Enter opens the selected result and highlights it in the graph. */ +export function CommandPalette() { + const { repo, reveal, openView, settings } = useWs(); + const open = useWorkspace((s) => s.paletteOpen); + const setOpen = useWorkspace((s) => s.setPaletteOpen); + const location = useLocation(); + const [input, setInput] = useState(''); + const [query, setQuery] = useState(''); + useEffect(() => { + const t = setTimeout(() => setQuery(input.trim()), 140); + return () => clearTimeout(t); + }, [input]); + useEffect(() => { + if (!open) setInput(''); + }, [open]); + const { data, isFetching } = useQuery({ + queryKey: ['search', repo.id, query], + queryFn: () => api.search(repo.id, query), + enabled: open && query.length >= 2, + staleTime: 30_000, + placeholderData: (p) => p, + }); + + const current = location.pathname.split('/')[3] ?? 'overview'; + const openNode = (id: string) => { + setOpen(false); + reveal(id); + // Stay in the current exploration view; otherwise open the default one. + if (!['graph', 'atlas', 'mindmap'].includes(current)) openView(settings.graph.defaultMode); + }; + const results = query.length >= 2 ? (data?.results ?? []) : []; + const documents = query.length >= 2 ? (data?.documents ?? []) : []; + const views = VIEWS.filter((v) => !query || v.label.toLowerCase().includes(query.toLowerCase())); + // Results arrive after typing, so the highlighted item is set explicitly: the first result is + // always the one Enter opens. + const first = + results[0]?.id ?? + (documents[0] + ? `doc:${documents[0].path}:${documents[0].line}` + : views[0] + ? `view:${views[0].view}` + : ''); + const [selected, setSelected] = useState(''); + useEffect(() => setSelected(first), [first]); + + return ( + +
+ + Esc +
+ + {query.length >= 2 && !isFetching ? ( + + Nothing in this repository matches “{query}”. Try a file path or part of a symbol name. + + ) : null} + {results.length ? ( + + {results.map((r) => ( + openNode(r.id)} + className={itemClass} + data-testid="palette-result" + > + + {r.name} + {r.filePath ? ( + + {r.filePath} + {r.line && r.type !== 'File' ? `:${r.line}` : ''} + + ) : r.description ? ( + {r.description} + ) : null} + + + + ))} + + ) : null} + {documents.length ? ( + + {documents.map((d) => ( + { + setOpen(false); + openView('docs', `path=${encodeURIComponent(d.path)}`); + }} + className={itemClass} + > + + {d.snippet} + + {d.path}:{d.line} + {d.heading ? ` — ${d.heading}` : ''} + + + + ))} + + ) : null} + {views.length ? ( + + {views.map((v) => ( + { + setOpen(false); + openView(v.view); + }} + className={itemClass} + > + {v.label} + + ))} + + ) : null} + +

+ Enter opens the selected result. Arrow keys move through the list. +

+
+ ); +} diff --git a/apps/web/src/components/ExportDialog.tsx b/apps/web/src/components/ExportDialog.tsx new file mode 100644 index 0000000..3dd5aa3 --- /dev/null +++ b/apps/web/src/components/ExportDialog.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { api, downloadUrl } from '@/lib/api'; +import { useWs } from '@/workspace/context'; +import { Dialog, Notice } from './ui'; + +const FORMATS: { id: string; label: string; detail: string; graph: boolean }[] = [ + { + id: 'json', + label: 'Graph as JSON', + detail: 'Every node and relationship with ids, locations, extraction method and confidence.', + graph: true, + }, + { + id: 'graphml', + label: 'Graph as GraphML', + detail: 'For Gephi, yEd, Cytoscape and other graph tools.', + graph: true, + }, + { + id: 'markdown', + label: 'Repository analysis (Markdown)', + detail: 'Languages, subsystems, entry points, dependencies and unresolved references.', + graph: false, + }, + { + id: 'architecture', + label: 'Architecture documentation (Markdown)', + detail: 'Subsystem dependencies, endpoints, models, flows and circular imports.', + graph: false, + }, +]; + +export function ExportDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { + const { repo, settings } = useWs(); + const [includeSource, setIncludeSource] = useState(settings.privacy.includeSourceInExports); + useEffect(() => { + if (open) setIncludeSource(settings.privacy.includeSourceInExports); + }, [open, settings.privacy.includeSourceInExports]); + return ( + +
    + {FORMATS.map((f) => ( +
  • +
    +

    {f.label}

    +

    {f.detail}

    +
    + +
  • + ))} +
+ + {includeSource ? ( +
+ + The JSON and GraphML files will contain lines of source code from this repository. + +
+ ) : null} +

+ The Mind Map exports SVG and PNG, and the Graph Explorer and 3D Atlas export PNG, from their own + toolbars. +

+
+ ); +} diff --git a/apps/web/src/components/Inspector.tsx b/apps/web/src/components/Inspector.tsx new file mode 100644 index 0000000..29296d0 --- /dev/null +++ b/apps/web/src/components/Inspector.tsx @@ -0,0 +1,430 @@ +import { ChevronRight, X } from 'lucide-react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + EDGE_TYPE_LABELS, + type EdgeType, + type GraphEdge, + type GraphNode, + isSymbolType, +} from '@vectora/graph-schema'; +import { useSource } from '@/lib/api'; +import { fileIdOf, relationsOf } from '@/lib/graphView'; +import { useWorkspace } from '@/lib/store'; +import { useWs } from '@/workspace/context'; +import { CodePreview } from './CodePreview'; +import { IconButton, TypeMark, cx } from './ui'; + +const METHOD_LABEL: Record = { + filesystem: 'file system structure', + manifest: 'package manifest', + 'typescript-compiler': 'TypeScript compiler (syntax)', + 'typescript-checker': 'TypeScript type checker', + 'python-ast': 'Python syntax tree', + 'tree-sitter': 'Tree-sitter syntax tree', + 'scope-resolution': 'import and scope resolution', + 'name-resolution': 'name match within visible files', + pattern: 'framework pattern', + 'structural-grouping': 'repository layout', + 'keyword-evidence': 'identifier and path evidence', + aggregation: 'aggregated relationships', +}; + +function copy(text: string, done: (label: string) => void, label: string): void { + void navigator.clipboard.writeText(text).then( + () => done(label), + () => done('Copy failed'), + ); +} + +export function Inspector() { + const { adj, repo, reveal, expandFile, openView } = useWs(); + const primaryId = useWorkspace((s) => s.primaryId); + const selectedCount = useWorkspace((s) => s.selectedIds.length); + const width = useWorkspace((s) => s.inspectorWidth); + const setInspector = useWorkspace((s) => s.setInspector); + const setFocus = useWorkspace((s) => s.setFocus); + const focusId = useWorkspace((s) => s.focusId); + const [toast, setToast] = useState(null); + const toastTimer = useRef | null>(null); + const notify = useCallback((label: string) => { + setToast(label); + if (toastTimer.current) clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToast(null), 1600); + }, []); + + const node = primaryId ? adj.nodes.get(primaryId) : undefined; + const rel = useMemo(() => (node ? relationsOf(node.id, adj) : null), [node, adj]); + + // Source range: the symbol itself, or the head of a file. + const isFileLike = node?.type === 'File' || node?.type === 'Configuration'; + const loc = node?.location ?? null; + const start = loc ? (isFileLike ? 1 : Math.max(1, loc.startLine - 2)) : undefined; + const end = loc + ? isFileLike + ? Math.min(loc.endLine, 80) + : Math.min(loc.endLine + 2, loc.startLine + 120) + : undefined; + const hasSource = !!node?.filePath && !!loc && (isFileLike || isSymbolType(node.type)); + const source = useSource(repo.id, hasSource ? node!.filePath : null, start, end); + + const onResize = (e: React.PointerEvent) => { + const startX = e.clientX; + const startW = width; + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + const move = (ev: PointerEvent) => + setInspector(true, Math.max(280, Math.min(720, startW + (startX - ev.clientX)))); + const up = () => { + el.removeEventListener('pointermove', move); + el.removeEventListener('pointerup', up); + }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + }; + + if (!node || !rel) return null; + const dependencyText = () => { + const lines = [ + `${node.name} (${node.type}) — ${node.filePath ?? ''}${loc ? `:${loc.startLine}` : ''}`, + '', + 'Depends on:', + ]; + for (const e of rel.outgoing) + lines.push( + ` ${e.type} ${adj.nodes.get(e.target)?.name ?? e.target}${e.location ? ` (${e.location.path}:${e.location.startLine})` : ''}`, + ); + lines.push('', 'Used by:'); + for (const e of rel.incoming) + lines.push( + ` ${e.type} ${adj.nodes.get(e.source)?.name ?? e.source}${e.location ? ` (${e.location.path}:${e.location.startLine})` : ''}`, + ); + return lines.join('\n'); + }; + const expandRelationships = () => { + const files = new Set(); + const self = fileIdOf(node); + if (self) files.add(self); + for (const e of [...rel.incoming, ...rel.outgoing]) { + const other = adj.nodes.get(e.source === node.id ? e.target : e.source); + const fid = other ? fileIdOf(other) : null; + if (fid) files.add(fid); + } + for (const f of files) expandFile(f, true); + notify(`${files.size} file${files.size === 1 ? '' : 's'} expanded`); + }; + + return ( + + ); +} + +function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) { + return ( +
+

+ {title} + {count !== undefined ? {count} : null} +

+ {children} +
+ ); +} + +function NodeRow({ node, onClick, derived }: { node: GraphNode; onClick: () => void; derived?: boolean }) { + return ( + + ); +} + +function Relationships({ + title, + hint, + edges, + side, +}: { + title: string; + hint: string; + edges: GraphEdge[]; + side: 'source' | 'target'; +}) { + const { adj, reveal } = useWs(); + const [open, setOpen] = useState(null); + const groups = useMemo(() => { + const m = new Map(); + for (const e of edges) (m.get(e.type) ?? m.set(e.type, []).get(e.type)!).push(e); + return [...m.entries()].sort((a, b) => b[1].length - a[1].length); + }, [edges]); + return ( +
+ {edges.length === 0 ?

None resolved — {hint}.

: null} + {groups.map(([type, list]) => ( +
+

+ {EDGE_TYPE_LABELS[type]} ({list.length}) +

+
    + {list.slice(0, 30).map((e) => { + const other = adj.nodes.get(e[side]); + const isOpen = open === e.id; + return ( +
  • +
    + + + {!e.verified ? derived : null} + {other ? : null} +
    + {isOpen ? ( +
    +

    {e.evidence}

    +

    + {e.location ? `${e.location.path}:${e.location.startLine}` : 'no single location'} —{' '} + {METHOD_LABEL[e.extractionMethod] ?? e.extractionMethod},{' '} + {Math.round(e.confidence * 100)}% + {e.metadata?.occurrences ? `, ${String(e.metadata.occurrences)} occurrences` : ''} +

    +
    + ) : null} +
  • + ); + })} +
+ {list.length > 30 ?

{list.length - 30} more not shown

: null} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/Site.tsx b/apps/web/src/components/Site.tsx new file mode 100644 index 0000000..6f21903 --- /dev/null +++ b/apps/web/src/components/Site.tsx @@ -0,0 +1,229 @@ +import { Moon, Sun } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { Link, NavLink } from 'react-router'; +import { APP_VERSION } from '@vectora/shared'; +import { useSettings } from '@/lib/api'; +import { useReducedMotion, useTheme } from '@/lib/theme'; +import { type Palette, mix, paletteFor } from '@/lib/tokens'; +import { IconButton, Wordmark, cx } from './ui'; + +/** Theme for pages outside a workspace. Works even when the local engine is not running. */ +export function useSiteTheme(): { + theme: 'light' | 'dark'; + palette: Palette; + toggle: () => void; + reducedMotion: boolean; +} { + const { settings, update } = useSettings(); + const [local, setLocal] = useState<'light' | 'dark' | 'system' | undefined>(undefined); + const theme = useTheme(settings?.appearance.theme ?? local); + const reducedMotion = useReducedMotion(settings); + return { + theme, + palette: paletteFor(theme), + reducedMotion, + toggle: () => { + const next = theme === 'dark' ? 'light' : 'dark'; + if (settings) update({ appearance: { theme: next } }); + else setLocal(next); + }, + }; +} + +export function SiteHeader() { + const { theme, toggle } = useSiteTheme(); + const link = ({ isActive }: { isActive: boolean }) => + cx( + 'text-[0.875rem] no-underline transition-colors duration-200', + isActive ? 'text-ink' : 'text-ink-2 hover:text-ink', + ); + return ( +
+ + Skip to content + + + + + +
+ ); +} + +export function SiteFooter() { + return ( +
+
+
+ +

Understand what connects.

+
+ +

Version {APP_VERSION}, MIT licence

+
+
+ ); +} + +export function SitePage({ children }: { children: ReactNode }) { + return ( +
+ +
+ {children} +
+ +
+ ); +} + +/** + * Illustration of the 3D Atlas: a fixed, generated structure (a centre, six + * regions, points around them) drawn in perspective. It is a drawing of the + * idea, not data from a repository, and is captioned as such where used. + */ +export function AtlasIllustration({ className }: { className?: string }) { + const ref = useRef(null); + const { palette, reducedMotion } = useSiteTheme(); + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + // Deterministic pseudo-random numbers so the drawing is identical on every visit. + let seed = 7; + const rnd = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646; + type P = { x: number; y: number; z: number; r: number; kind: 0 | 1 | 2 }; + const points: P[] = [{ x: 0, y: 0, z: 0, r: 7, kind: 0 }]; + const links: [number, number, boolean][] = []; + const hubs: number[] = []; + for (let h = 0; h < 6; h++) { + const a = (h / 6) * Math.PI * 2 + 0.4; + const hub: P = { x: Math.cos(a) * 150, y: (rnd() - 0.5) * 110, z: Math.sin(a) * 150, r: 4.5, kind: 1 }; + hubs.push(points.length); + links.push([0, points.length, false]); + points.push(hub); + const count = 9 + Math.floor(rnd() * 9); + for (let i = 0; i < count; i++) { + const t = rnd() * Math.PI * 2; + const u = Math.acos(2 * rnd() - 1); + const d = 26 + rnd() * 42; + links.push([hubs[h]!, points.length, false]); + points.push({ + x: hub.x + Math.sin(u) * Math.cos(t) * d, + y: hub.y + Math.cos(u) * d, + z: hub.z + Math.sin(u) * Math.sin(t) * d, + r: 1.4 + rnd() * 1.4, + kind: 2, + }); + } + } + for (let i = 0; i < 14; i++) { + const a = 7 + Math.floor(rnd() * (points.length - 7)); + const b = 7 + Math.floor(rnd() * (points.length - 7)); + if (a !== b) links.push([a, b, true]); + } + + let angle = 0.6; + let raf = 0; + let visible = true; + const draw = () => { + const dpr = Math.min(2, window.devicePixelRatio || 1); + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (canvas.width !== w * dpr || canvas.height !== h * dpr) { + canvas.width = w * dpr; + canvas.height = h * dpr; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + const s = Math.min(w, h) / 470; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const proj = points.map((p) => { + const x = p.x * cos - p.z * sin; + const z = p.x * sin + p.z * cos; + const y = p.y * 0.94 - z * 0.2; + const k = 620 / (620 + z); + return { x: w / 2 + x * k * s, y: h / 2 + y * k * s, k, z }; + }); + for (const [a, b, cross] of links) { + const pa = proj[a]!; + const pb = proj[b]!; + const depth = Math.max(0, Math.min(1, ((pa.z + pb.z) / 2 + 220) / 440)); + ctx.strokeStyle = mix(cross ? palette.clay : palette.textMuted, palette.canvas, 0.35 + depth * 0.5); + ctx.lineWidth = cross ? 0.9 : 0.7; + ctx.setLineDash(cross ? [3, 4] : []); + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.stroke(); + } + ctx.setLineDash([]); + points + .map((p, i) => ({ p, q: proj[i]! })) + .sort((a, b) => b.q.z - a.q.z) + .forEach(({ p, q }) => { + const depth = Math.max(0, Math.min(1, (q.z + 220) / 440)); + const base = p.kind === 0 ? palette.text : p.kind === 1 ? palette.indigo : palette.textSecondary; + ctx.fillStyle = mix(base, palette.canvas, depth * 0.6); + const r = p.r * q.k * s * 1.25; + ctx.beginPath(); + if (p.kind === 1) ctx.rect(q.x - r, q.y - r, r * 2, r * 2); + else ctx.arc(q.x, q.y, r, 0, Math.PI * 2); + ctx.fill(); + }); + }; + const loop = () => { + angle += 0.0011; + draw(); + if (visible && !reducedMotion) raf = requestAnimationFrame(loop); + }; + draw(); + // Animate only while the drawing is on screen and motion is allowed. + const io = new IntersectionObserver(([e]) => { + visible = !!e?.isIntersecting; + cancelAnimationFrame(raf); + if (visible && !reducedMotion) raf = requestAnimationFrame(loop); + }); + io.observe(canvas); + const ro = new ResizeObserver(draw); + ro.observe(canvas); + return () => { + cancelAnimationFrame(raf); + io.disconnect(); + ro.disconnect(); + }; + }, [palette, reducedMotion]); + return ( + + ); +} diff --git a/apps/web/src/components/ViewControls.tsx b/apps/web/src/components/ViewControls.tsx new file mode 100644 index 0000000..65b84e2 --- /dev/null +++ b/apps/web/src/components/ViewControls.tsx @@ -0,0 +1,168 @@ +import { SlidersHorizontal, X } from 'lucide-react'; +import { Popover } from 'radix-ui'; +import { type ReactNode, useMemo } from 'react'; +import { + EDGE_TYPES, + EDGE_TYPE_LABELS, + NODE_TYPES, + NODE_TYPE_LABELS, + type NodeType, +} from '@vectora/graph-schema'; +import { useWorkspace } from '@/lib/store'; +import { FAMILY_GLYPH, NODE_FAMILY, nodeColor } from '@/lib/tokens'; +import { useWs } from '@/workspace/context'; +import { cx } from './ui'; + +/** Toolbar shown above every exploration view: filters, legend, focus and highlight state. */ +export function ViewToolbar({ children, status }: { children?: ReactNode; status?: ReactNode }) { + const { adj, view } = useWs(); + const focusId = useWorkspace((s) => s.focusId); + const setFocus = useWorkspace((s) => s.setFocus); + const highlight = useWorkspace((s) => s.highlight); + const setHighlight = useWorkspace((s) => s.setHighlight); + return ( +
+
+ + {children} + {focusId ? ( + + ) : null} + {highlight ? ( + + ) : null} +
+

+ {view.nodes.length.toLocaleString()} nodes, {view.edges.length.toLocaleString()} relationships + {view.collapsedSymbols + ? `, ${view.collapsedSymbols.toLocaleString()} symbols folded into their files` + : ''} + {view.truncated ? ' (limited by the node limit in Settings)' : ''} + {status ? <> — {status} : null} +

+
+ ); +} + +function FilterPopover() { + const { graph, palette } = useWs(); + const hiddenNodeTypes = useWorkspace((s) => s.hiddenNodeTypes); + const hiddenEdgeTypes = useWorkspace((s) => s.hiddenEdgeTypes); + const setHiddenNodeTypes = useWorkspace((s) => s.setHiddenNodeTypes); + const setHiddenEdgeTypes = useWorkspace((s) => s.setHiddenEdgeTypes); + const counts = useMemo(() => ({ nodes: graph.stats.nodesByType, edges: graph.stats.edgesByType }), [graph]); + const toggle = (list: string[], v: string, set: (l: string[]) => void) => + set(list.includes(v) ? list.filter((x) => x !== v) : [...list, v]); + const nodeTypes = NODE_TYPES.filter((t) => (counts.nodes[t] ?? 0) > 0 && t !== 'Repository'); + const edgeTypes = EDGE_TYPES.filter((t) => (counts.edges[t] ?? 0) > 0); + const active = hiddenNodeTypes.length + hiddenEdgeTypes.length; + return ( + + + + + +
+
+ Node types + {nodeTypes.map((t) => ( + toggle(hiddenNodeTypes, t, setHiddenNodeTypes)} + label={NODE_TYPE_LABELS[t]} + count={counts.nodes[t] ?? 0} + swatch={ + + } + /> + ))} +
+
+ Relationships + {edgeTypes.map((t) => ( + toggle(hiddenEdgeTypes, t, setHiddenEdgeTypes)} + label={EDGE_TYPE_LABELS[t]} + count={counts.edges[t] ?? 0} + /> + ))} +
+
+
+

+ + Solid line: relationship verified from source +

+

+ + Dashed or thin line: derived, not verified +

+

Each node type has its own glyph and size; names are shown as you zoom in.

+
+
+
+
+ ); +} + +function FilterRow({ + checked, + onChange, + label, + count, + swatch, +}: { + checked: boolean; + onChange: () => void; + label: string; + count: number; + swatch?: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..43b22be --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,279 @@ +import { type ComponentProps, type ReactNode, useId } from 'react'; +import { Dialog as RDialog, Switch as RSwitch } from 'radix-ui'; +import { X } from 'lucide-react'; +import type { GraphNode } from '@vectora/graph-schema'; +import { NODE_TYPE_LABELS } from '@vectora/graph-schema'; +import { FAMILY_GLYPH, NODE_FAMILY } from '@/lib/tokens'; + +export const cx = (...parts: (string | false | null | undefined)[]): string => + parts.filter(Boolean).join(' '); + +/** The brand wordmark. */ +export function Wordmark({ className }: { className?: string }) { + return ( + + VECTORA + + ); +} + +export function Dialog({ + open, + onOpenChange, + title, + description, + children, + wide, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + title: string; + description?: string; + children: ReactNode; + wide?: boolean; +}) { + return ( + + + + +
+
+ {title} + {description ? ( + {description} + ) : ( + {title} + )} +
+ + + +
+
{children}
+
+
+
+ ); +} + +export function Toggle({ + checked, + onChange, + label, + hint, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; + hint?: string; +}) { + const id = useId(); + return ( +
+ + + + +
+ ); +} + +export function SelectField({ + label, + hint, + value, + onChange, + options, +}: { + label: string; + hint?: string; + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; +}) { + const id = useId(); + return ( +
+ + +
+ ); +} + +export function NumberField({ + label, + hint, + value, + onChange, + min, + max, + step = 1, + unit, +}: { + label: string; + hint?: string; + value: number; + onChange: (v: number) => void; + min: number; + max: number; + step?: number; + unit?: string; +}) { + const id = useId(); + return ( +
+ + + { + const v = Number(e.target.value); + if (Number.isFinite(v) && v >= min && v <= max) onChange(v); + }} + /> + {unit ? {unit} : null} + +
+ ); +} + +/** Type marker: a glyph plus the type name, so the type never depends on colour alone. */ +export function TypeMark({ node, showLabel = true }: { node: Pick; showLabel?: boolean }) { + return ( + + + {showLabel ? ( + NODE_TYPE_LABELS[node.type] + ) : ( + {NODE_TYPE_LABELS[node.type]} + )} + + ); +} + +export function Notice({ + tone = 'info', + children, + action, +}: { + tone?: 'info' | 'error' | 'warning' | 'success'; + children: ReactNode; + action?: ReactNode; +}) { + const color = + tone === 'error' + ? 'var(--error)' + : tone === 'warning' + ? 'var(--warning)' + : tone === 'success' + ? 'var(--success)' + : 'var(--indigo)'; + const word = + tone === 'error' ? 'Error' : tone === 'warning' ? 'Note' : tone === 'success' ? 'Done' : 'Info'; + return ( +
+

+ + {word}. + {' '} + {children} +

+ {action} +
+ ); +} + +export function Empty({ title, children }: { title: string; children?: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export function Loading({ label = 'Loading' }: { label?: string }) { + return ( +
+ {label}… +
+ ); +} + +export function SectionHeading({ + children, + aside, + id, +}: { + children: ReactNode; + aside?: ReactNode; + id?: string; +}) { + return ( +
+

+ {children} +

+ {aside ? {aside} : null} +
+ ); +} + +export function IconButton({ + label, + children, + className, + ...rest +}: { label: string; children: ReactNode } & ComponentProps<'button'>) { + return ( + + ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..27aa7dc --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,315 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { GraphEdge, GraphNode, KnowledgeGraph } from '@vectora/graph-schema'; +import type { + ExplorationAnswer, + JobProgress, + RepositoryOverview, + RepositoryRecord, + SessionState, + Settings, + SourceResponse, +} from '@vectora/shared'; + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + } +} + +async function request(url: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(url, init); + } catch { + throw new ApiError('The local engine is not reachable. Start it with “npm start” (or “npm run dev”).', 0); + } + if (res.status === 204) return undefined as T; + const isJson = res.headers.get('content-type')?.includes('application/json'); + if (!res.ok) { + const body = isJson ? ((await res.json()) as { error?: string }) : null; + throw new ApiError( + body?.error ?? + (res.status >= 500 + ? 'The local engine is not reachable or failed to answer.' + : `Request failed (${res.status})`), + res.status, + ); + } + return (isJson ? res.json() : res.text()) as Promise; +} + +const json = (method: string, body?: unknown): RequestInit => ({ + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), +}); + +export interface Health { + status: string; + version: string; + node: string; + python: boolean; + sampleAvailable: boolean; + languages: { id: string; name: string; tier: number; extensions: string[] }[]; +} +export interface GitHubInfo { + fullName: string; + description: string | null; + defaultBranch: string; + branches: string[]; + private: boolean; + sizeKb: number; + requested: { branch: string | null; subdirectory: string | null }; + usingStoredCredential: boolean; +} +export interface ZipInspection { + uploadId: string; + fileName: string; + entries: number; + files: number; + totalBytes: number; + rejected: { path: string; reason: string }[]; + roots: { path: string; files: number; markers: string[] }[]; + tree: { path: string; size: number; directory: boolean }[]; +} +export interface DirListing { + path: string; + parent: string | null; + entries: { name: string; path: string; repository: boolean }[]; + roots: string[]; +} +export interface SearchResponse { + query: string; + results: { + id: string; + name: string; + type: GraphNode['type']; + filePath: string | null; + line: number | null; + description: string | null; + score: number; + match: string; + }[]; + documents: { path: string; line: number; snippet: string; heading: string | null }[]; +} +export interface AnalysisResponse { + generatedAt: string; + flows: { id: string; name: string; kind: string; steps: string[]; reachesData: boolean }[]; + entryPoints: { path: string; nodeId: string; reason: string }[]; + declaredDependencies: { + name: string; + version: string | null; + manifest: string; + ecosystem: string; + dev: boolean; + line: number; + }[]; + unresolvedCount: number; + unresolvedByReason: Record; + unresolvedSamples: { + fromPath: string; + fromSymbol: string | null; + kind: string; + name: string; + line: number; + reason: string; + }[]; + parseIssues: { path: string; message: string; line?: number; severity: string }[]; + skipped: Record; + truncated: boolean; + validation: { errors: number; warnings: number }; + communities: number; +} +export type GraphResponse = KnowledgeGraph & { partial: boolean }; + +export const api = { + health: () => request('/api/health'), + repositories: () => request('/api/repositories'), + repository: (id: string) => request(`/api/repositories/${id}`), + deleteRepository: (id: string) => request(`/api/repositories/${id}`, { method: 'DELETE' }), + reindex: (id: string, full = false) => + request(`/api/repositories/${id}/reindex`, json('POST', { full })), + job: (id: string) => request(`/api/jobs/${id}`), + importLocal: (path: string, name?: string) => + request('/api/import/local', json('POST', { path, name })), + importSample: () => request('/api/import/sample', json('POST')), + listDir: (path?: string) => + request(`/api/fs/list${path ? `?path=${encodeURIComponent(path)}` : ''}`), + inspectZip: (file: File) => { + const form = new FormData(); + form.append('file', file); + return request('/api/import/zip/inspect', { method: 'POST', body: form }); + }, + importZip: (uploadId: string, root?: string, name?: string) => + request('/api/import/zip', json('POST', { uploadId, root, name })), + // The token travels in the request body over the loopback interface only; it is never put in a URL. + validateGitHub: (url: string, token?: string) => + request('/api/import/github/validate', json('POST', { url, token: token || undefined })), + importGitHub: (body: { + url: string; + branch?: string; + subdirectory?: string; + token?: string; + rememberToken?: boolean; + }) => request('/api/import/github', json('POST', body)), + graph: (id: string, detail: 'full' | 'structure') => + request(`/api/repositories/${id}/graph?detail=${detail}`), + members: (id: string, path: string) => + request<{ nodes: GraphNode[]; edges: GraphEdge[] }>( + `/api/repositories/${id}/members?path=${encodeURIComponent(path)}`, + ), + overview: (id: string) => request(`/api/repositories/${id}/overview`), + analysis: (id: string) => request(`/api/repositories/${id}/analysis`), + source: (id: string, path: string, start?: number, end?: number) => + request( + `/api/repositories/${id}/source?path=${encodeURIComponent(path)}${start ? `&start=${start}` : ''}${end ? `&end=${end}` : ''}`, + ), + docs: (id: string) => + request<{ path: string; title: string; lines: number }[]>(`/api/repositories/${id}/docs`), + search: (id: string, q: string, types?: string[]) => + request( + `/api/repositories/${id}/search?q=${encodeURIComponent(q)}${types?.length ? `&types=${types.join(',')}` : ''}`, + ), + ask: (id: string, question: string) => + request(`/api/repositories/${id}/ask`, json('POST', { question })), + session: (id: string) => request(`/api/repositories/${id}/session`), + saveSession: (id: string, s: SessionState) => + request(`/api/repositories/${id}/session`, json('PUT', s)), + settings: () => request('/api/settings'), + updateSettings: (patch: unknown) => request('/api/settings', json('PUT', patch)), + credentials: () => request<{ name: string; updatedAt: string }[]>('/api/credentials'), + setCredential: (name: string, value: string) => + request(`/api/credentials/${name}`, json('PUT', { value })), + deleteCredential: (name: string) => request(`/api/credentials/${name}`, { method: 'DELETE' }), + dataInfo: () => + request<{ + dataDir: string; + databaseBytes: number; + sourcesBytes: number; + repositories: number; + credentials: number; + }>('/api/data'), + deleteAllData: () => request('/api/data', { method: 'DELETE' }), + clearCache: () => request('/api/cache', { method: 'DELETE' }), + exportUrl: (id: string, format: string, includeSource: boolean) => + `/api/repositories/${id}/export/${format}${includeSource ? '?includeSource=true' : ''}`, +}; + +// ---- hooks --------------------------------------------------------------- + +export const useHealth = () => + useQuery({ queryKey: ['health'], queryFn: api.health, staleTime: 60_000, retry: 1 }); +export const useRepositories = () => useQuery({ queryKey: ['repositories'], queryFn: api.repositories }); +export const useRepository = (id: string) => + useQuery({ + queryKey: ['repository', id], + queryFn: () => api.repository(id), + refetchInterval: (q) => + q.state.data?.status === 'indexing' || q.state.data?.status === 'pending' ? 1000 : false, + }); +export const useOverview = (id: string, enabled = true) => + useQuery({ queryKey: ['overview', id], queryFn: () => api.overview(id), enabled }); +export const useAnalysis = (id: string) => + useQuery({ queryKey: ['analysis', id], queryFn: () => api.analysis(id) }); +export const useSource = (id: string, path: string | null, start?: number, end?: number) => + useQuery({ + queryKey: ['source', id, path, start, end], + queryFn: () => api.source(id, path!, start, end), + enabled: !!path, + staleTime: 5 * 60_000, + }); +export const useJob = (jobId: string | null) => + useQuery({ + queryKey: ['job', jobId], + queryFn: () => api.job(jobId!), + enabled: !!jobId, + refetchInterval: (q) => (q.state.data && q.state.data.status !== 'running' ? false : 400), + }); + +export function useSettings() { + const qc = useQueryClient(); + const query = useQuery({ queryKey: ['settings'], queryFn: api.settings, staleTime: Infinity }); + const mutation = useMutation({ + mutationFn: api.updateSettings, + onMutate: async (patch: Record>) => { + // Optimistic: settings take effect immediately and are then confirmed by the engine. + const prev = qc.getQueryData(['settings']); + if (prev) { + const next = structuredClone(prev) as unknown as Record>; + for (const [section, values] of Object.entries(patch)) + next[section] = { ...next[section], ...values }; + qc.setQueryData(['settings'], next); + } + return { prev }; + }, + onError: (_e, _p, ctx) => ctx?.prev && qc.setQueryData(['settings'], ctx.prev), + onSuccess: (data) => qc.setQueryData(['settings'], data), + }); + return { + settings: query.data ?? null, + update: mutation.mutate, + error: mutation.error ?? query.error, + saving: mutation.isPending, + }; +} + +/** + * Loads the knowledge graph. Large repositories are loaded progressively: + * the structural skeleton first, symbols of a file when it is expanded. + */ +export function useGraph(id: string, symbolCount: number, maxNodes: number, enabled = true) { + const qc = useQueryClient(); + const detail: 'full' | 'structure' = symbolCount > maxNodes ? 'structure' : 'full'; + const query = useQuery({ + queryKey: ['graph', id, detail], + queryFn: () => api.graph(id, detail), + enabled, + staleTime: Infinity, + gcTime: 10 * 60_000, + }); + const loadMembers = async (path: string): Promise => { + const current = qc.getQueryData(['graph', id, detail]); + if (!current?.partial) return; + const extra = await qc.fetchQuery({ + queryKey: ['members', id, path], + queryFn: () => api.members(id, path), + staleTime: Infinity, + }); + qc.setQueryData(['graph', id, detail], (g) => { + if (!g) return g; + const ids = new Set(g.nodes.map((n) => n.id)); + const nodes = [...g.nodes, ...extra.nodes.filter((n) => !ids.has(n.id))]; + const all = new Set(nodes.map((n) => n.id)); + const eids = new Set(g.edges.map((e) => e.id)); + const edges = [ + ...g.edges, + ...extra.edges.filter((e) => !eids.has(e.id) && all.has(e.source) && all.has(e.target)), + ]; + return { ...g, nodes, edges }; + }); + }; + return { + graph: query.data ?? null, + isLoading: query.isLoading, + error: query.error, + partial: query.data?.partial ?? false, + loadMembers, + }; +} + +export function downloadText(fileName: string, text: string, type = 'text/plain'): void { + const url = URL.createObjectURL(new Blob([text], { type })); + downloadUrl(fileName, url); + setTimeout(() => URL.revokeObjectURL(url), 2000); +} +export function downloadUrl(fileName: string, url: string): void { + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); +} diff --git a/apps/web/src/lib/graphView.ts b/apps/web/src/lib/graphView.ts new file mode 100644 index 0000000..2649247 --- /dev/null +++ b/apps/web/src/lib/graphView.ts @@ -0,0 +1,195 @@ +/** + * Derives what the visualisations show from the full knowledge graph and the + * current filters. All three views call this with the same inputs, so they + * always present the same underlying graph. + */ +import { + type EdgeType, + type GraphEdge, + type GraphNode, + type KnowledgeGraph, + type NodeType, + isSymbolType, +} from '@vectora/graph-schema'; + +export interface ViewOptions { + density: 'structure' | 'balanced' | 'full'; + expandedIds: readonly string[]; + hiddenNodeTypes: readonly string[]; + hiddenEdgeTypes: readonly string[]; + showUnverified: boolean; + focusId: string | null; + focusDepth?: number; + maxNodes: number; + /** Nodes that must stay visible (selection, highlights). */ + pinnedIds?: readonly string[]; +} + +export interface GraphView { + nodes: GraphNode[]; + edges: GraphEdge[]; + /** Symbols left out by the density setting (they appear when their file is expanded). */ + collapsedSymbols: number; + truncated: boolean; +} + +export interface Adjacency { + nodes: Map; + out: Map; + in: Map; +} + +export function buildAdjacency(graph: Pick): Adjacency { + const nodes = new Map(); + const out = new Map(); + const inn = new Map(); + for (const n of graph.nodes) nodes.set(n.id, n); + for (const e of graph.edges) { + (out.get(e.source) ?? out.set(e.source, []).get(e.source)!).push(e); + (inn.get(e.target) ?? inn.set(e.target, []).get(e.target)!).push(e); + } + return { nodes, out, in: inn }; +} + +const PROMINENT: ReadonlySet = new Set([ + 'Class', + 'DatabaseModel', + 'ApiEndpoint', + 'Component', + 'Interface', +]); +const BALANCED_LIMIT = 1200; + +export const fileIdOf = (n: GraphNode): string | null => (n.filePath ? `file:${n.filePath}` : null); + +export function deriveView( + graph: Pick, + adj: Adjacency, + o: ViewOptions, +): GraphView { + const hiddenTypes = new Set(o.hiddenNodeTypes); + const hiddenEdges = new Set(o.hiddenEdgeTypes); + const expanded = new Set(o.expandedIds); + const pinned = new Set(o.pinnedIds ?? []); + const symbolTotal = graph.nodes.reduce((a, n) => a + (isSymbolType(n.type) ? 1 : 0), 0); + + let focusSet: Set | null = null; + if (o.focusId && adj.nodes.has(o.focusId)) { + focusSet = new Set([o.focusId]); + let frontier = [o.focusId]; + for (let d = 0; d < (o.focusDepth ?? 1); d++) { + const next: string[] = []; + for (const id of frontier) + for (const e of [...(adj.out.get(id) ?? []), ...(adj.in.get(id) ?? [])]) { + if (hiddenEdges.has(e.type)) continue; + const other = e.source === id ? e.target : e.source; + if (!focusSet.has(other)) { + focusSet.add(other); + next.push(other); + } + } + frontier = next; + } + // Keep the containing file of every focused symbol for context. + for (const id of [...focusSet]) { + const f = adj.nodes.get(id); + const fid = f ? fileIdOf(f) : null; + if (fid && adj.nodes.has(fid)) focusSet.add(fid); + } + } + + let collapsedSymbols = 0; + const visible: GraphNode[] = []; + for (const n of graph.nodes) { + if (focusSet) { + if (focusSet.has(n.id)) visible.push(n); + continue; + } + if (pinned.has(n.id)) { + visible.push(n); + continue; + } + if (hiddenTypes.has(n.type)) continue; + if (isSymbolType(n.type)) { + const fid = fileIdOf(n); + const fileExpanded = !!fid && expanded.has(fid); + const show = + fileExpanded || + o.density === 'full' || + (o.density === 'balanced' && (symbolTotal <= BALANCED_LIMIT || PROMINENT.has(n.type))); + if (!show) { + collapsedSymbols++; + continue; + } + } + visible.push(n); + } + + let truncated = false; + let nodes = visible; + if (nodes.length > o.maxNodes) { + truncated = true; + // Keep structure first, then the best-connected symbols. + const degree = (id: string) => (adj.out.get(id)?.length ?? 0) + (adj.in.get(id)?.length ?? 0); + const rank = (n: GraphNode) => (pinned.has(n.id) ? 0 : isSymbolType(n.type) ? 2 : 1); + nodes = [...nodes].sort((a, b) => rank(a) - rank(b) || degree(b.id) - degree(a.id)).slice(0, o.maxNodes); + } + const ids = new Set(nodes.map((n) => n.id)); + const edges: GraphEdge[] = []; + for (const e of graph.edges) { + if (hiddenEdges.has(e.type)) continue; + if (!o.showUnverified && !e.verified) continue; + if (ids.has(e.source) && ids.has(e.target)) edges.push(e); + } + return { nodes, edges, collapsedSymbols, truncated }; +} + +/** Node ids within one hop of `id` over the given edges (the node itself included). */ +export function neighbourhood( + id: string, + edges: readonly GraphEdge[], +): { nodes: Set; edges: Set } { + const nodes = new Set([id]); + const edgeIds = new Set(); + for (const e of edges) { + if (e.source === id || e.target === id) { + nodes.add(e.source); + nodes.add(e.target); + edgeIds.add(e.id); + } + } + return { nodes, edges: edgeIds }; +} + +export interface Relations { + incoming: GraphEdge[]; + outgoing: GraphEdge[]; + parent: GraphNode | null; + children: GraphNode[]; + concepts: GraphNode[]; + subsystem: GraphNode | null; +} + +const HIERARCHY: ReadonlySet = new Set(['CONTAINS', 'DEFINED_IN', 'BELONGS_TO']); + +export function relationsOf(id: string, adj: Adjacency): Relations { + const node = adj.nodes.get(id); + const out = adj.out.get(id) ?? []; + const inn = adj.in.get(id) ?? []; + const parentEdge = inn.find((e) => e.type === 'CONTAINS'); + const fileNode = node && isSymbolType(node.type) ? adj.nodes.get(fileIdOf(node) ?? '') : node; + const conceptIds = (fileNode?.metadata.concepts as string[] | undefined) ?? []; + const subsystemId = + (node?.metadata.subsystem as string | undefined) ?? (fileNode?.metadata.subsystem as string | undefined); + return { + incoming: inn.filter((e) => !HIERARCHY.has(e.type)), + outgoing: out.filter((e) => !HIERARCHY.has(e.type)), + parent: parentEdge ? (adj.nodes.get(parentEdge.source) ?? null) : null, + children: out + .filter((e) => e.type === 'CONTAINS') + .map((e) => adj.nodes.get(e.target)) + .filter((n): n is GraphNode => !!n), + concepts: conceptIds.map((c) => adj.nodes.get(c)).filter((n): n is GraphNode => !!n), + subsystem: subsystemId ? (adj.nodes.get(subsystemId) ?? null) : null, + }; +} diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts new file mode 100644 index 0000000..50abe11 --- /dev/null +++ b/apps/web/src/lib/store.ts @@ -0,0 +1,132 @@ +/** + * Workspace state shared by all three exploration views: selection, focus, + * expansion and filters live here so that selecting a node in one view shows + * it selected in the others. + */ +import { create } from 'zustand'; +import type { SessionState } from '@vectora/shared'; + +export interface Highlight { + label: string; + nodeIds: string[]; + edgeIds: string[]; +} + +interface WorkspaceState { + repoId: string | null; + selectedIds: string[]; + /** Most recently selected node — the one shown in the inspector. */ + primaryId: string | null; + hoverId: string | null; + /** Focus mode: only this node and its neighbourhood are shown. */ + focusId: string | null; + /** Files whose symbols are shown. */ + expandedIds: string[]; + hiddenNodeTypes: string[]; + hiddenEdgeTypes: string[]; + highlight: Highlight | null; + inspectorOpen: boolean; + inspectorWidth: number; + paletteOpen: boolean; + layouts: SessionState['layouts']; + /** Bumped whenever a view should re-centre on the primary selection. */ + revealTick: number; + sessionLoaded: boolean; + + select: (id: string, opts?: { additive?: boolean; reveal?: boolean }) => void; + clearSelection: () => void; + setHover: (id: string | null) => void; + setFocus: (id: string | null) => void; + toggleExpanded: (id: string, force?: boolean) => void; + setHiddenNodeTypes: (t: string[]) => void; + setHiddenEdgeTypes: (t: string[]) => void; + setHighlight: (h: Highlight | null) => void; + setInspector: (open: boolean, width?: number) => void; + setPaletteOpen: (open: boolean) => void; + setLayout: (key: K, value: SessionState['layouts'][K]) => void; + hydrate: (repoId: string, session: SessionState | null, defaults: { hiddenEdgeTypes: string[] }) => void; + snapshot: (view: string) => SessionState; +} + +export const DEFAULT_HIDDEN_NODE_TYPES = ['Directory', 'Module', 'Package', 'Configuration']; + +export const useWorkspace = create((set, get) => ({ + repoId: null, + selectedIds: [], + primaryId: null, + hoverId: null, + focusId: null, + expandedIds: [], + hiddenNodeTypes: DEFAULT_HIDDEN_NODE_TYPES, + hiddenEdgeTypes: ['DEFINED_IN', 'EXPORTS'], + highlight: null, + inspectorOpen: true, + inspectorWidth: 380, + paletteOpen: false, + layouts: {}, + revealTick: 0, + sessionLoaded: false, + + select: (id, opts) => + set((s) => { + if (opts?.additive) { + const has = s.selectedIds.includes(id); + const selectedIds = has ? s.selectedIds.filter((x) => x !== id) : [...s.selectedIds, id]; + return { + selectedIds, + primaryId: has ? (selectedIds[selectedIds.length - 1] ?? null) : id, + inspectorOpen: true, + }; + } + return { + selectedIds: [id], + primaryId: id, + inspectorOpen: true, + revealTick: opts?.reveal ? s.revealTick + 1 : s.revealTick, + }; + }), + clearSelection: () => set({ selectedIds: [], primaryId: null }), + setHover: (hoverId) => set((s) => (s.hoverId === hoverId ? s : { hoverId })), + setFocus: (focusId) => set((s) => ({ focusId, revealTick: s.revealTick + 1 })), + toggleExpanded: (id, force) => + set((s) => { + const has = s.expandedIds.includes(id); + const want = force ?? !has; + if (want === has) return s; + return { expandedIds: want ? [...s.expandedIds, id] : s.expandedIds.filter((x) => x !== id) }; + }), + setHiddenNodeTypes: (hiddenNodeTypes) => set({ hiddenNodeTypes }), + setHiddenEdgeTypes: (hiddenEdgeTypes) => set({ hiddenEdgeTypes }), + setHighlight: (highlight) => set({ highlight }), + setInspector: (inspectorOpen, width) => + set((s) => ({ inspectorOpen, inspectorWidth: width ?? s.inspectorWidth })), + setPaletteOpen: (paletteOpen) => set({ paletteOpen }), + setLayout: (key, value) => set((s) => ({ layouts: { ...s.layouts, [key]: value } })), + hydrate: (repoId, session, defaults) => + set({ + repoId, + selectedIds: session?.selectedIds ?? [], + primaryId: session?.selectedIds?.[session.selectedIds.length - 1] ?? null, + focusId: session?.focusId ?? null, + expandedIds: session?.expandedIds ?? [], + hiddenNodeTypes: session?.hiddenNodeTypes ?? DEFAULT_HIDDEN_NODE_TYPES, + hiddenEdgeTypes: session?.hiddenEdgeTypes ?? defaults.hiddenEdgeTypes, + layouts: session?.layouts ?? {}, + highlight: null, + hoverId: null, + sessionLoaded: true, + }), + snapshot: (view) => { + const s = get(); + return { + view, + selectedIds: s.selectedIds.slice(0, 200), + focusId: s.focusId, + expandedIds: s.expandedIds.slice(0, 2000), + hiddenNodeTypes: s.hiddenNodeTypes, + hiddenEdgeTypes: s.hiddenEdgeTypes, + layouts: s.layouts, + savedAt: new Date().toISOString(), + }; + }, +})); diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts new file mode 100644 index 0000000..15e0b50 --- /dev/null +++ b/apps/web/src/lib/theme.ts @@ -0,0 +1,48 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import type { Settings } from '@vectora/shared'; +import { type Palette, paletteFor } from './tokens'; + +const media = (q: string) => ({ + subscribe: (cb: () => void) => { + const m = window.matchMedia(q); + m.addEventListener('change', cb); + return () => m.removeEventListener('change', cb); + }, + get: () => window.matchMedia(q).matches, +}); +const darkMedia = media('(prefers-color-scheme: dark)'); +const motionMedia = media('(prefers-reduced-motion: reduce)'); + +export type ThemeSetting = 'light' | 'dark' | 'system'; + +/** Resolves the theme setting against the system preference and applies it to the document. */ +export function useTheme(setting: ThemeSetting | undefined): 'light' | 'dark' { + const systemDark = useSyncExternalStore(darkMedia.subscribe, darkMedia.get, () => false); + const stored = ( + typeof localStorage !== 'undefined' ? localStorage.getItem('vectora-theme') : null + ) as ThemeSetting | null; + const effective = setting ?? stored ?? 'system'; + const theme = effective === 'system' ? (systemDark ? 'dark' : 'light') : effective; + useEffect(() => { + document.documentElement.dataset.theme = theme; + if (setting) localStorage.setItem('vectora-theme', setting); + }, [theme, setting]); + return theme; +} + +/** True when motion should be reduced, from settings or the system preference. */ +export function useReducedMotion(settings: Settings | null): boolean { + const system = useSyncExternalStore(motionMedia.subscribe, motionMedia.get, () => false); + const pref = settings?.performance.reducedMotion ?? 'system'; + const reduced = + pref === 'on' || settings?.graph.animationIntensity === 'none' || (pref === 'system' && system); + useEffect(() => { + const el = document.documentElement; + if (pref === 'on' || settings?.graph.animationIntensity === 'none') el.dataset.motion = 'reduced'; + else if (pref === 'off') el.dataset.motion = 'full'; + else delete el.dataset.motion; + }, [pref, settings?.graph.animationIntensity]); + return reduced; +} + +export const usePalette = (theme: 'light' | 'dark'): Palette => paletteFor(theme); diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts new file mode 100644 index 0000000..b3a4907 --- /dev/null +++ b/apps/web/src/lib/tokens.ts @@ -0,0 +1,181 @@ +/** + * Design tokens — the single source of truth for colour. `styles.css` mirrors + * these values as CSS custom properties (a unit test keeps the two in step); + * WebGL renderers read them from here because they cannot use CSS variables. + */ +import type { EdgeType, NodeType } from '@vectora/graph-schema'; + +export interface Palette { + canvas: string; + surface: string; + elevated: string; + text: string; + textSecondary: string; + textMuted: string; + line: string; + indigo: string; + clay: string; + ash: string; + success: string; + warning: string; + error: string; + selection: string; + relationActive: string; + relationUnverified: string; +} + +export const LIGHT: Palette = { + canvas: '#F8F7F2', + surface: '#F3F1EA', // Washi + elevated: '#FBFAF6', + text: '#20211F', // Sumi + textSecondary: '#5F5D55', + textMuted: '#8C897F', // Stone + line: '#DCD8CE', + indigo: '#34445C', + clay: '#A18C72', + ash: '#B8B3A7', + success: '#55704F', + warning: '#7D5C1B', + error: '#964436', + selection: '#34445C', + relationActive: '#34445C', + relationUnverified: '#A18C72', +}; + +export const DARK: Palette = { + canvas: '#171816', + surface: '#20211F', + elevated: '#272824', + text: '#ECEAE2', + textSecondary: '#A7A499', + textMuted: '#77766D', + line: '#373832', + indigo: '#8292AD', + clay: '#B39A7A', + ash: '#5C5B54', + success: '#8FAE8A', + warning: '#C9A566', + error: '#D08C7C', + selection: '#8292AD', + relationActive: '#8292AD', + relationUnverified: '#B39A7A', +}; + +export const paletteFor = (theme: 'light' | 'dark'): Palette => (theme === 'dark' ? DARK : LIGHT); + +/** Visual family of a node type. Colour is always paired with shape (3D), size and a text label. */ +export type NodeFamily = + 'structure' | 'file' | 'type' | 'callable' | 'data' | 'endpoint' | 'concept' | 'external' | 'value'; + +export const NODE_FAMILY: Record = { + Repository: 'structure', + Subsystem: 'structure', + Module: 'structure', + Directory: 'structure', + File: 'file', + Configuration: 'file', + Package: 'external', + Class: 'type', + Interface: 'type', + Type: 'type', + Component: 'type', + Function: 'callable', + Method: 'callable', + Variable: 'value', + Constant: 'value', + ApiEndpoint: 'endpoint', + DatabaseModel: 'data', + Concept: 'concept', +}; + +export const FAMILY_LABEL: Record = { + structure: 'Structure', + file: 'Files', + type: 'Types and classes', + callable: 'Functions and methods', + data: 'Database models', + endpoint: 'API endpoints', + concept: 'Concepts', + external: 'External packages', + value: 'Variables and constants', +}; + +/** Single-character glyph shown next to labels so type never depends on colour alone. */ +export const FAMILY_GLYPH: Record = { + structure: '◼', + file: '▫', + type: '◆', + callable: '●', + data: '▤', + endpoint: '▶', + concept: '◎', + external: '○', + value: '·', +}; + +export function familyColor(family: NodeFamily, p: Palette): string { + switch (family) { + case 'structure': + return p.text; + case 'file': + return p.textMuted; + case 'type': + return p.indigo; + case 'callable': + return p.textSecondary; + case 'data': + return p.clay; + case 'endpoint': + return p.warning; + case 'concept': + return p.success; + case 'external': + return p.ash; + case 'value': + return p.ash; + } +} + +export const nodeColor = (type: NodeType, p: Palette): string => familyColor(NODE_FAMILY[type], p); + +/** Relative node size by type: repository largest, functions smallest. */ +export const NODE_SIZE: Record = { + Repository: 10, + Subsystem: 7, + Concept: 6, + Module: 4.5, + Directory: 3.5, + Package: 3.5, + File: 3.2, + Configuration: 2.8, + Class: 2.8, + DatabaseModel: 2.8, + Component: 2.6, + Interface: 2.4, + ApiEndpoint: 2.4, + Type: 2, + Function: 1.8, + Method: 1.6, + Constant: 1.3, + Variable: 1.3, +}; + +/** Relationship types whose direction expresses a dependency (drawn with arrows). */ +export const STRUCTURE_EDGES: readonly EdgeType[] = ['CONTAINS', 'DEFINED_IN', 'BELONGS_TO', 'EXPORTS']; + +export const hexToRgb = (hex: string): [number, number, number] => { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}; +export const withAlpha = (hex: string, a: number): string => { + const [r, g, b] = hexToRgb(hex); + return `rgba(${r},${g},${b},${a})`; +}; +/** Blends `hex` towards `bg` — used to dim unrelated nodes without transparency sorting issues. */ +export const mix = (hex: string, bg: string, t: number): string => { + const a = hexToRgb(hex); + const b = hexToRgb(bg); + const c = a.map((v, i) => Math.round(v + (b[i]! - v) * t)); + return `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..ddfd3f0 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,22 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: (count, err) => count < 1 && (err as { status?: number }).status !== 404, + }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/web/src/pages/About.tsx b/apps/web/src/pages/About.tsx new file mode 100644 index 0000000..686ae7b --- /dev/null +++ b/apps/web/src/pages/About.tsx @@ -0,0 +1,234 @@ +import type { ReactNode } from 'react'; +import { APP_VERSION } from '@vectora/shared'; +import { SitePage } from '@/components/Site'; +import { useHealth } from '@/lib/api'; + +const CREDITS: [string, string, string][] = [ + ['TypeScript', 'Compiler API used for TypeScript and JavaScript analysis', 'Apache-2.0'], + ['Tree-sitter', 'Parsing for Java, Go, Rust, C, C++ and C#', 'MIT'], + ['Python', 'Standard-library ast module for Python analysis', 'PSF'], + ['SQLite', 'Local graph database (built into Node.js)', 'Public domain'], + ['Fastify', 'Local engine HTTP server', 'MIT'], + ['React and Vite', 'Application framework and build tool', 'MIT'], + ['Sigma.js and Graphology', 'Two-dimensional graph rendering and graph algorithms', 'MIT'], + ['Three.js, React Three Fiber and drei', 'Three-dimensional rendering', 'MIT'], + ['d3-force-3d', 'Force simulation for the 3D layout', 'MIT'], + ['React Flow and dagre', 'Mind map canvas and hierarchical layout', 'MIT'], + ['CodeMirror 6', 'Source preview', 'MIT'], + ['Radix UI, cmdk and Lucide', 'Accessible dialogs, command palette and icons', 'MIT / ISC'], + ['Tailwind CSS', 'Styling', 'MIT'], + [ + 'Zen Kaku Gothic New, Shippori Mincho, IBM Plex Mono', + 'Typefaces, bundled with the application', + 'SIL OFL 1.1', + ], +]; + +function Block({ id, title, children }: { id: string; title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +export function About() { + const health = useHealth(); + return ( + +
+
+
+

VECTORA

+

+ Understand what connects. +

+

+ VECTORA is a codebase intelligence platform that transforms software repositories into + structured, interactive knowledge maps. It reveals how components, modules, dependencies and + concepts connect, giving developers a new way to understand complex software systems. +

+
+
+ + +

+ Software is understood through its connections: what calls what, what depends on what, where a + request ends up. Those connections are spread across hundreds of files, and reading the files in + order does not reveal them. +

+

VECTORA is built on three commitments.

+

+ Evidence first. Every relationship carries the file, the + line and the method that found it. A reference that cannot be tied to a definition is reported as + unresolved. It is never drawn as if it were known. +

+

+ Local by default. Analysis, storage and search run on + your own machine. The application works with no account and no hosted service. +

+

+ Restraint. The interface follows the Japanese ideas of + kanso, simplicity, and ma, the meaningful interval. Space, type and a small palette do the work; + decoration does not. +

+
+ + +

+ A repository goes through a fixed pipeline: discovery of files and manifests, syntax analysis by a + language adapter, resolution of references across files, tracing of execution flows, grouping into + subsystems and concepts, and generation of a validated knowledge graph that is stored in a local + database. +

+

+ The Graph Explorer, the 3D Atlas and the Mind Map all read that one graph and share one selection. + Verified structure and derived interpretation are kept apart throughout, and drawn differently. +

+
+ + +

+ Symbol-level analysis is available for the languages below. Other files are indexed as files only, + and each repository shows its own language coverage. +

+ + + + + + + + + + {(health.data?.languages ?? FALLBACK_LANGUAGES).map((l) => ( + + + + + + ))} + +
LanguageTierTechnique
{l.name}{l.tier} + {l.id === 'typescript' || l.id === 'javascript' + ? 'TypeScript Compiler API with type-checked resolution' + : l.id === 'python' + ? 'Python ast module with import and scope resolution' + : 'Tree-sitter syntax tree with import and scope resolution'} +
+

+ Tier 1 languages have the most precise relationship resolution. VECTORA does not claim to support + every language. +

+
+ + +
    +
  • Source code is read and analysed locally. It is not uploaded.
  • +
  • Repository scripts are never executed and repository dependencies are never installed.
  • +
  • + Access tokens are stored encrypted on your machine, sent only to GitHub in a request header, and + never written to logs, URLs or exports. +
  • +
  • + Exports contain names, paths and line numbers. Lines of source are included only when you ask + for them. +
  • +
  • + The one optional external service, a narrative summary for answers, is off by default and + receives only the findings already shown on screen. +
  • +
  • All stored data can be deleted from Settings.
  • +
+
+ + +

VECTORA stands on open-source work. With thanks to the people who maintain it.

+ + + + + + + + + + {CREDITS.map(([name, use, lic]) => ( + + + + + + ))} + +
ProjectUsed forLicence
{name}{use}{lic}
+

+ The full inventory of dependency licences is generated into docs/LICENSES.md in the + VECTORA folder. +

+
+ + +
+
Version
+
{APP_VERSION}
+
Local engine
+
+ {health.data + ? `running, version ${health.data.version} on Node.js ${health.data.node}` + : health.isLoading + ? 'checking' + : 'not reachable'} +
+
Licence
+
MIT. See the LICENSE file in the VECTORA folder.
+
+
+ + +

+ The documentation ships with the application, in the docs folder: +

+
    + {[ + 'USER_GUIDE.md', + 'ARCHITECTURE.md', + 'DATA_MODEL.md', + 'INDEXING_PIPELINE.md', + 'VISUALIZATION.md', + 'SECURITY.md', + 'PERFORMANCE.md', + 'TROUBLESHOOTING.md', + ].map((f) => ( +
  • docs/{f}
  • + ))} +
+

+ Changes between versions are recorded in docs/CHANGELOG.md. +

+
+
+
+ ); +} + +const FALLBACK_LANGUAGES = [ + { id: 'typescript', name: 'TypeScript', tier: 1 }, + { id: 'javascript', name: 'JavaScript', tier: 1 }, + { id: 'python', name: 'Python', tier: 1 }, + { id: 'java', name: 'Java', tier: 2 }, + { id: 'go', name: 'Go', tier: 2 }, + { id: 'rust', name: 'Rust', tier: 2 }, + { id: 'c', name: 'C', tier: 2 }, + { id: 'cpp', name: 'C++', tier: 2 }, + { id: 'csharp', name: 'C#', tier: 2 }, +]; diff --git a/apps/web/src/pages/ImportPage.tsx b/apps/web/src/pages/ImportPage.tsx new file mode 100644 index 0000000..000f92b --- /dev/null +++ b/apps/web/src/pages/ImportPage.tsx @@ -0,0 +1,600 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Folder, FolderGit2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { formatBytes } from '@vectora/shared'; +import { SitePage } from '@/components/Site'; +import { Dialog, Notice, cx } from '@/components/ui'; +import { + ApiError, + type GitHubInfo, + type ZipInspection, + api, + useHealth, + useJob, + useRepositories, +} from '@/lib/api'; +import { IndexingProgress } from '@/workspace/WorkspaceLayout'; + +type Method = 'github' | 'zip' | 'local'; + +export function ImportPage() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const health = useHealth(); + const repos = useRepositories(); + const [method, setMethod] = useState('github'); + const [jobId, setJobId] = useState(null); + const [error, setError] = useState(null); + const job = useJob(jobId).data; + + useEffect(() => { + if (!job || job.status === 'running') return; + void qc.invalidateQueries({ queryKey: ['repositories'] }); + if (job.status === 'completed' && job.repositoryId) navigate(`/w/${job.repositoryId}`); + else if (job.status === 'failed') { + setError(job.error ?? 'Indexing failed'); + setJobId(null); + } + }, [job, navigate, qc]); + + const start = async (fn: () => Promise<{ id: string }>) => { + setError(null); + try { + setJobId((await fn()).id); + } catch (e) { + setError((e as Error).message); + } + }; + const offline = health.error && (health.error as ApiError).status === 0; + + if (jobId) + return ( + +
+ +
+
+ ); + + return ( + +
+
+

+ Explore a repository +

+

+ The source is analysed on this machine. Nothing in the repository is installed or run. +

+ + {offline ? ( +
+ + The local engine is not running, so repositories cannot be imported. Start it with{' '} + npm start in the VECTORA folder and reload this page. + +
+ ) : null} + {error ? ( +
+ setError(null)}> + Dismiss + + } + > + {error} + +
+ ) : null} + +
+ {( + [ + ['github', 'GitHub repository'], + ['zip', 'ZIP archive'], + ['local', 'Local folder'], + ] as const + ).map(([id, label]) => ( + + ))} +
+
+ {method === 'github' ? ( + + ) : method === 'zip' ? ( + + ) : ( + + )} +
+
+ + +
+
+ ); +} + +type Start = (fn: () => Promise<{ id: string }>) => Promise; + +function GitHubForm({ onStart }: { onStart: Start }) { + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [remember, setRemember] = useState(false); + const [info, setInfo] = useState(null); + const [branch, setBranch] = useState(''); + const [subdir, setSubdir] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const validate = async () => { + setBusy(true); + setError(null); + setInfo(null); + try { + const i = await api.validateGitHub(url, token); + setInfo(i); + setBranch( + i.requested.branch && i.branches.includes(i.requested.branch) ? i.requested.branch : i.defaultBranch, + ); + setSubdir(i.requested.subdirectory ?? ''); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + return ( +
{ + e.preventDefault(); + if (info) + void onStart(() => + api.importGitHub({ + url, + branch, + subdirectory: subdir || undefined, + token: token || undefined, + rememberToken: remember, + }), + ); + else void validate(); + }} + className="grid gap-5" + > +
+ + { + setUrl(e.target.value); + setInfo(null); + }} + required + autoComplete="off" + spellCheck={false} + /> +
+
+ Private repository +
+ + setToken(e.target.value)} + /> +

+ A fine-grained token with read-only access to repository contents is enough. It is sent to + api.github.com in a request header and never placed in a URL or a log. +

+ +
+
+ {error ? {error} : null} + {info ? ( +
+
+

+ {info.fullName} {info.private ? private : null} +

+ {info.description ?

{info.description}

: null} +

+ about {formatBytes(info.sizeKb * 1024)} + {info.usingStoredCredential ? ', using the stored access token' : ''} +

+
+
+ + +
+
+ ) : null} +
+ +
+
+ ); +} + +function ZipForm({ onStart }: { onStart: Start }) { + const input = useRef(null); + const [inspection, setInspection] = useState(null); + const [root, setRoot] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [over, setOver] = useState(false); + const upload = async (file: File | undefined) => { + if (!file) return; + setError(null); + setInspection(null); + if (!/\.zip$/i.test(file.name)) return setError('Choose a file that ends in .zip.'); + setBusy(true); + try { + const r = await api.inspectZip(file); + setInspection(r); + setRoot(r.roots[0]?.path ?? ''); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + return ( +
+
{ + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + void upload(e.dataTransfer.files[0]); + }} + className={cx( + 'border border-dashed px-6 py-10 text-center transition-colors duration-200', + over ? 'border-indigo bg-surface' : 'border-ash', + )} + > +

Drop a ZIP archive here

+

up to 512 MB. The archive is checked before anything is extracted.

+ void upload(e.target.files?.[0])} + /> + +
+ {error ? {error} : null} + {inspection ? ( +
+
+

{inspection.fileName}

+

+ {inspection.files.toLocaleString()} files, {formatBytes(inspection.totalBytes)} uncompressed +

+
+ {inspection.rejected.length ? ( + + {inspection.rejected.length} entr{inspection.rejected.length === 1 ? 'y' : 'ies'} will be + skipped as unsafe ({[...new Set(inspection.rejected.map((r) => r.reason))].join(', ')}). + + ) : null} +
+ Project root + {inspection.roots.map((r) => ( + + ))} +
+
+ Detected structure +
    + {inspection.tree.slice(0, 200).map((t) => ( +
  • + {t.path.split('/').pop()} + {t.directory ? '/' : ''} +
  • + ))} +
+
+
+ +
+
+ ) : null} +

+ Ignored folders such as node_modules, binary files, symbolic links and entries that point outside the + archive are never extracted. +

+
+ ); +} + +function LocalForm({ onStart }: { onStart: Start }) { + const [path, setPath] = useState(''); + const [browse, setBrowse] = useState(false); + return ( +
{ + e.preventDefault(); + if (path.trim()) void onStart(() => api.importLocal(path.trim())); + }} + > +
+ +
+ setPath(e.target.value)} + autoComplete="off" + spellCheck={false} + /> + +
+

+ The folder is read in place by the local engine. .gitignore files and the ignore patterns from + Settings are respected; nothing is copied or changed. +

+
+
+ +
+ + + ); +} + +function FolderBrowser({ + open, + onOpenChange, + initial, + onPick, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + initial: string; + onPick: (p: string) => void; +}) { + const [dir, setDir] = useState(initial || undefined); + const list = useQuery({ + queryKey: ['fs', dir], + queryFn: () => api.listDir(dir), + enabled: open, + retry: false, + }); + return ( + +
+ {list.data?.roots.map((r) => ( + + ))} + {list.data?.parent ? ( + + ) : null} +
+

{list.data?.path ?? dir ?? ''}

+ {list.error ? ( +
+ setDir(undefined)}> + Start over + + } + > + {(list.error as Error).message} + +
+ ) : null} +
    + {list.data?.entries.length === 0 ?
  • No folders here.
  • : null} + {list.data?.entries.map((e) => ( +
  • + +
  • + ))} +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/pages/Landing.tsx b/apps/web/src/pages/Landing.tsx new file mode 100644 index 0000000..41f018c --- /dev/null +++ b/apps/web/src/pages/Landing.tsx @@ -0,0 +1,259 @@ +import { Link } from 'react-router'; +import { AtlasIllustration, SitePage } from '@/components/Site'; + +const STEPS = [ + { + mark: '一', + title: 'Import', + body: 'Point VECTORA at a GitHub repository, a ZIP archive or a folder on your machine. Ignore rules are respected and nothing in the repository is ever run.', + }, + { + mark: '二', + title: 'Index', + body: 'Source files are parsed into syntax trees. Classes, functions, imports, routes and data models are extracted with the file and line they came from.', + }, + { + mark: '三', + title: 'Resolve', + body: 'References are tied to definitions only when there is scope evidence for it. What cannot be resolved is reported, not guessed.', + }, + { + mark: '四', + title: 'Explore', + body: 'The result is one knowledge graph, shown three ways. Select a node anywhere and the other views follow.', + }, +]; + +const VIEWS = [ + { + name: 'Graph Explorer', + dimension: 'Two dimensions', + body: 'Pan and zoom through files and symbols. Selecting a node highlights what it depends on and what depends on it, and dims the rest.', + }, + { + name: '3D Atlas', + dimension: 'Three dimensions', + body: 'The repository at the centre, subsystems as regions around it, files and symbols inside their regions. Fly to a node, unfold it, return to the whole.', + }, + { + name: 'Mind Map', + dimension: 'Hierarchy', + body: 'Subsystems, folders, files and symbols as a tree you can expand, rearrange and export. Concepts sit beside the structure they were found in.', + }, +]; + +const LANGUAGES: [string, string][] = [ + ['TypeScript and JavaScript', 'type-checked symbol resolution'], + ['Python', 'syntax-tree analysis'], + ['Java, Go, Rust, C, C++, C#', 'syntax-tree analysis'], +]; + +export function Landing() { + return ( + + {/* Hero — text held to a narrow left column, the drawing bleeding off the right edge */} +
+ +
+

+ Understand what connects. +

+

+ Explore the hidden architecture of software. Transform repositories into structured, interactive + knowledge maps. +

+
+ + Explore a repository + + + Learn about VECTORA + +
+
+
+ +
+ Illustration of the 3D Atlas: a repository, its subsystems and their contents. +
+
+
+ +
+ + {/* Product visualisation */} +
+

+ A repository is a set of relationships. Reading files one by one hides them. +

+
+

+ VECTORA reads the source, records which function calls which, which class extends which, which + route leads to which handler, and keeps the line of code that proves it. +

+

+ Every relationship in the graph can be opened to show its evidence. Groupings that are + interpretations, such as concepts, are marked as derived and drawn differently from verified + structure. +

+
+
+ + {/* How it works — a real sequence, so it is numbered */} +
+
+

+ How it works +

+
    + {STEPS.map((s, i) => ( +
  1. + + Step {i + 1}: +

    {s.title}

    +

    {s.body}

    +
  2. + ))} +
+
+
+ + {/* Exploration capabilities */} +
+
+

+ One graph, three ways to read it +

+
    + {VIEWS.map((v) => ( +
  • +

    {v.dimension}

    +
    +

    {v.name}

    +

    {v.body}

    +
    +
  • + ))} +
  • + Around them: an inspector with the original source, search across files, symbols and + documentation, a dependency explorer with circular imports and unresolved references, and + questions answered with file and line citations. +
  • +
+
+
+ + {/* Supported source formats */} +
+
+
+

+ Supported sources +

+
+ {[ + [ + 'GitHub repository', + 'Public repositories by URL. Private repositories with an access token you provide; it is stored encrypted on your machine.', + ], + [ + 'ZIP archive', + 'Checked before extraction. Nested project folders are detected and you choose the root.', + ], + [ + 'Local folder', + 'Read in place through the local engine. .gitignore and your own exclusion rules apply.', + ], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+
+

Languages analysed

+
    + {LANGUAGES.map(([k, v]) => ( +
  • +

    {k}

    +

    {v}

    +
  • + ))} +
+

+ Files in other languages are listed and searchable but not analysed at symbol level. Language + coverage is shown for every repository. +

+
+
+
+ + {/* Privacy */} +
+
+

+ Your code stays on your machine +

+
    +
  • + Indexing, the graph database and search all run in a local engine. No source code is uploaded. +
  • +
  • Repository dependencies are never installed and repository scripts are never executed.
  • +
  • + The only network requests are the ones you start: downloading a repository from GitHub, and an + optional narrative service that is off by default. +
  • +
  • + Exports leave out source text unless you ask for it, and everything can be deleted from + Settings. +
  • +
+
+
+ + {/* About */} +
+

+ About +

+
+

+ VECTORA is a codebase intelligence platform that transforms software repositories into structured, + interactive knowledge maps. It reveals how components, modules, dependencies and concepts connect, + giving developers a new way to understand complex software systems. +

+

+ Philosophy, supported languages, credits and licence +

+
+
+
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..5820ee0 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,530 @@ +/* Latin subsets only: the three faces are bundled, nothing is fetched from a font service. + The few Japanese glyphs used as marks fall back to the system mincho / gothic. */ +@import '@fontsource/zen-kaku-gothic-new/latin-400.css'; +@import '@fontsource/zen-kaku-gothic-new/latin-500.css'; +@import '@fontsource/zen-kaku-gothic-new/latin-700.css'; +@import '@fontsource/zen-kaku-gothic-new/latin-ext-400.css'; +@import '@fontsource/shippori-mincho/latin-400.css'; +@import '@fontsource/shippori-mincho/latin-600.css'; +@import '@fontsource/shippori-mincho/latin-ext-400.css'; +@import '@fontsource/ibm-plex-mono/latin-400.css'; +@import '@fontsource/ibm-plex-mono/latin-500.css'; +@import 'tailwindcss'; + +/* --------------------------------------------------------------------------- + Tokens. Values mirror src/lib/tokens.ts (checked by a unit test). + --------------------------------------------------------------------------- */ +:root { + --canvas: #f8f7f2; + --surface: #f3f1ea; + --elevated: #fbfaf6; + --text: #20211f; + --text-secondary: #5f5d55; + --text-muted: #8c897f; + --line: #dcd8ce; + --indigo: #34445c; + --clay: #a18c72; + --ash: #b8b3a7; + --success: #55704f; + --warning: #7d5c1b; + --error: #964436; + --selection: #34445c; + --relation-active: #34445c; + --relation-unverified: #a18c72; + --on-accent: #f8f7f2; + --duration-ui: 200ms; + --duration-panel: 380ms; + --ease: cubic-bezier(0.2, 0, 0, 1); + color-scheme: light; +} + +:root[data-theme='dark'] { + --canvas: #171816; + --surface: #20211f; + --elevated: #272824; + --text: #eceae2; + --text-secondary: #a7a499; + --text-muted: #77766d; + --line: #373832; + --indigo: #8292ad; + --clay: #b39a7a; + --ash: #5c5b54; + --success: #8fae8a; + --warning: #c9a566; + --error: #d08c7c; + --selection: #8292ad; + --relation-active: #8292ad; + --relation-unverified: #b39a7a; + --on-accent: #171816; + color-scheme: dark; +} + +:root[data-motion='reduced'] { + --duration-ui: 0ms; + --duration-panel: 0ms; +} + +/* Reduced motion. Kept outside the cascade layers so it overrides the token values above. */ +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion='full']) { + --duration-ui: 0ms; + --duration-panel: 0ms; + } + :root:not([data-motion='full']) *, + :root:not([data-motion='full']) *::before, + :root:not([data-motion='full']) *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +} +:root[data-motion='reduced'] *, +:root[data-motion='reduced'] *::before, +:root[data-motion='reduced'] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0ms !important; +} + +@theme inline { + --color-canvas: var(--canvas); + --color-surface: var(--surface); + --color-elevated: var(--elevated); + --color-ink: var(--text); + --color-ink-2: var(--text-secondary); + --color-ink-3: var(--text-muted); + --color-line: var(--line); + --color-indigo: var(--indigo); + --color-clay: var(--clay); + --color-ash: var(--ash); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--error); + --color-on-accent: var(--on-accent); + --font-sans: 'Zen Kaku Gothic New', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', system-ui, sans-serif; + --font-serif: 'Shippori Mincho', 'Hiragino Mincho ProN', 'Yu Mincho', serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + /* Type scale */ + --text-meta: 0.75rem; + --text-label: 0.8125rem; + --text-body: 0.9375rem; + --text-section: 1.375rem; + --text-page: clamp(1.875rem, 1.4rem + 1.6vw, 2.5rem); + --text-display: clamp(2.75rem, 1.6rem + 4.2vw, 4rem); + --radius-sm: 2px; + --radius-md: 3px; +} + +@layer base { + html { + background: var(--canvas); + color: var(--text); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + body { + margin: 0; + min-height: 100dvh; + font-size: var(--text-body); + font-feature-settings: 'palt' 1; + } + #root { + min-height: 100dvh; + } + h1, + h2, + h3, + h4 { + font-weight: 500; + line-height: 1.25; + margin: 0; + text-wrap: balance; + } + p { + margin: 0; + text-wrap: pretty; + } + a { + color: inherit; + text-decoration-color: var(--line); + text-underline-offset: 0.22em; + text-decoration-thickness: 1px; + transition: + text-decoration-color var(--duration-ui) var(--ease), + color var(--duration-ui) var(--ease); + } + a:hover { + text-decoration-color: currentColor; + } + code, + kbd, + pre, + samp { + font-family: var(--font-mono); + font-size: 0.86em; + } + ::selection { + background: color-mix(in srgb, var(--indigo) 24%, transparent); + } + :focus-visible { + outline: 2px solid var(--indigo); + outline-offset: 2px; + } + * { + scrollbar-width: thin; + scrollbar-color: var(--line) transparent; + } +} + +@layer components { + .font-editorial { + font-family: var(--font-serif); + font-weight: 400; + letter-spacing: 0.01em; + } + .meta { + font-family: var(--font-mono); + font-size: var(--text-meta); + color: var(--text-muted); + letter-spacing: 0; + } + .label { + font-size: var(--text-label); + color: var(--text-secondary); + } + .rule { + border: 0; + border-top: 1px solid var(--line); + margin: 0; + } + /* Vertical brand spine — Japanese vertical setting used as the signature element. */ + .spine { + writing-mode: vertical-rl; + text-orientation: mixed; + letter-spacing: 0.42em; + font-size: var(--text-meta); + color: var(--text-muted); + } + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 36px; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + font-size: var(--text-label); + font-weight: 500; + line-height: 1; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: + background var(--duration-ui) var(--ease), + border-color var(--duration-ui) var(--ease), + color var(--duration-ui) var(--ease); + } + .btn:hover:not(:disabled) { + border-color: var(--text-muted); + background: color-mix(in srgb, var(--text) 4%, transparent); + } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .btn-primary { + background: var(--text); + border-color: var(--text); + color: var(--canvas); + } + .btn-primary:hover:not(:disabled) { + background: var(--indigo); + border-color: var(--indigo); + color: var(--on-accent); + } + .btn-quiet { + border-color: transparent; + padding: 0 8px; + color: var(--text-secondary); + } + .btn-sm { + min-height: 28px; + padding: 0 10px; + font-size: var(--text-meta); + } + .btn-icon { + min-height: 32px; + width: 32px; + padding: 0; + } + .btn[aria-pressed='true'], + .btn[data-active='true'] { + border-color: var(--text); + background: color-mix(in srgb, var(--text) 6%, transparent); + } + .field { + width: 100%; + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--elevated); + color: var(--text); + font: inherit; + font-size: var(--text-label); + transition: border-color var(--duration-ui) var(--ease); + } + .field::placeholder { + color: var(--text-muted); + } + .field:hover { + border-color: var(--ash); + } + .field:focus-visible { + outline: none; + border-color: var(--indigo); + box-shadow: 0 0 0 1px var(--indigo); + } + textarea.field { + font-family: var(--font-mono); + font-size: var(--text-meta); + line-height: 1.7; + resize: vertical; + } + .panel { + background: var(--surface); + border: 1px solid var(--line); + } + .tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 1px 8px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + font-size: var(--text-meta); + color: var(--text-secondary); + line-height: 1.7; + white-space: nowrap; + } + .tag-derived { + border-style: dashed; + border-color: var(--relation-unverified); + color: var(--text-secondary); + } + .link-row { + display: flex; + width: 100%; + align-items: baseline; + gap: 12px; + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--line); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + text-decoration: none; + transition: + padding-left var(--duration-ui) var(--ease), + color var(--duration-ui) var(--ease); + } + .link-row:hover { + padding-left: 6px; + color: var(--indigo); + } + .prose-doc { + max-width: 68ch; + line-height: 1.75; + } + .prose-doc h1, + .prose-doc h2, + .prose-doc h3 { + font-family: var(--font-serif); + margin: 1.8em 0 0.6em; + } + .prose-doc h1 { + font-size: 1.75rem; + } + .prose-doc h2 { + font-size: 1.3rem; + } + .prose-doc h3 { + font-size: 1.1rem; + } + .prose-doc p, + .prose-doc ul, + .prose-doc ol, + .prose-doc pre, + .prose-doc table, + .prose-doc blockquote { + margin: 0 0 1em; + } + .prose-doc ul, + .prose-doc ol { + padding-left: 1.3em; + } + .prose-doc pre { + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--line); + overflow-x: auto; + font-size: var(--text-meta); + line-height: 1.7; + } + .prose-doc :not(pre) > code { + padding: 0.1em 0.35em; + background: var(--surface); + border: 1px solid var(--line); + } + .prose-doc blockquote { + padding-left: 16px; + border-left: 2px solid var(--clay); + color: var(--text-secondary); + } + .prose-doc table { + border-collapse: collapse; + font-size: var(--text-label); + display: block; + overflow-x: auto; + } + .prose-doc th, + .prose-doc td { + padding: 6px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + } + .prose-doc img { + max-width: 100%; + } + .data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-label); + } + .data-table th { + font-weight: 500; + color: var(--text-muted); + text-align: left; + padding: 6px 12px 6px 0; + border-bottom: 1px solid var(--line); + white-space: nowrap; + } + .data-table td { + padding: 7px 12px 7px 0; + border-bottom: 1px solid var(--line); + vertical-align: baseline; + } + .data-table .num { + text-align: right; + font-family: var(--font-mono); + font-size: var(--text-meta); + font-variant-numeric: tabular-nums; + } + .skip-link { + position: absolute; + left: 8px; + top: -48px; + z-index: 100; + padding: 8px 12px; + background: var(--text); + color: var(--canvas); + } + .skip-link:focus { + top: 8px; + } +} + +/* Overlays */ +@keyframes overlay-in { + from { + opacity: 0; + } +} +@keyframes panel-in { + from { + opacity: 0; + transform: translateY(6px); + } +} +.overlay { + position: fixed; + inset: 0; + z-index: 50; + background: color-mix(in srgb, var(--text) 22%, transparent); + animation: overlay-in var(--duration-ui) var(--ease); +} +:root[data-theme='dark'] .overlay { + background: rgba(0, 0, 0, 0.55); +} +.dialog { + position: fixed; + z-index: 51; + left: 50%; + top: 14vh; + width: min(640px, calc(100vw - 32px)); + max-height: 72vh; + transform: translateX(-50%); + display: flex; + flex-direction: column; + background: var(--elevated); + border: 1px solid var(--line); + box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.45); + animation: panel-in var(--duration-panel) var(--ease); +} + +/* Library overrides: React Flow and CodeMirror follow the tokens. */ +.react-flow { + --xy-background-color: transparent; + --xy-edge-stroke: var(--ash); + --xy-edge-stroke-selected: var(--indigo); + --xy-controls-button-background-color: var(--elevated); + --xy-controls-button-background-color-hover: var(--surface); + --xy-controls-button-color: var(--text); + --xy-controls-button-border-color: var(--line); + --xy-minimap-background-color: var(--surface); + --xy-minimap-mask-background-color: color-mix(in srgb, var(--canvas) 70%, transparent); + --xy-attribution-background-color: transparent; + font-family: var(--font-sans); +} +.react-flow__controls { + box-shadow: none; + border: 1px solid var(--line); +} +.react-flow__attribution a { + color: var(--text-muted); +} +.cm-editor { + font-size: 12.5px; + background: transparent; +} +.cm-editor .cm-scroller { + font-family: var(--font-mono); + line-height: 1.65; +} +.cm-editor .cm-gutters { + background: transparent; + color: var(--text-muted); + border-right: 1px solid var(--line); +} +.cm-editor.cm-focused { + outline: none; +} +.cm-editor .cm-activeLine, +.cm-editor .cm-activeLineGutter { + background: transparent; +} +.cm-editor .cm-vectora-range { + background: color-mix(in srgb, var(--indigo) 9%, transparent); +} +.sigma-container canvas { + outline: none; +} diff --git a/apps/web/src/theme-init.ts b/apps/web/src/theme-init.ts new file mode 100644 index 0000000..182fd32 --- /dev/null +++ b/apps/web/src/theme-init.ts @@ -0,0 +1,5 @@ +// Applies the stored theme before the application renders, so there is no flash of the wrong theme. +const stored = localStorage.getItem('vectora-theme'); +const dark = + stored === 'dark' || (stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); +document.documentElement.dataset.theme = dark ? 'dark' : 'light'; diff --git a/apps/web/src/workers/layout3d.worker.ts b/apps/web/src/workers/layout3d.worker.ts new file mode 100644 index 0000000..6cd9197 --- /dev/null +++ b/apps/web/src/workers/layout3d.worker.ts @@ -0,0 +1,23 @@ +/// +import { type LayoutEdge, type LayoutNode, layout3d } from '@vectora/graph-layout'; + +export interface Layout3DRequest { + requestId: number; + nodes: LayoutNode[]; + edges: LayoutEdge[]; + quality: number; +} +export interface Layout3DResponse { + requestId: number; + positions: Float32Array; + radius: number; +} + +self.onmessage = (e: MessageEvent) => { + const { requestId, nodes, edges, quality } = e.data; + const result = layout3d(nodes, edges, { quality }); + (self as unknown as Worker).postMessage( + { requestId, positions: result.positions, radius: result.radius } satisfies Layout3DResponse, + [result.positions.buffer], + ); +}; diff --git a/apps/web/src/workspace/Atlas.tsx b/apps/web/src/workspace/Atlas.tsx new file mode 100644 index 0000000..bd3cea1 --- /dev/null +++ b/apps/web/src/workspace/Atlas.tsx @@ -0,0 +1,899 @@ +import { OrbitControls } from '@react-three/drei'; +import { Canvas, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'; +import { ChevronRight, Globe, Image as ImageIcon, RotateCcw } from 'lucide-react'; +import { + type MutableRefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import * as THREE from 'three'; +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; +import type { LayoutNode, LayoutRole } from '@vectora/graph-layout'; +import { type GraphEdge, type GraphNode, NODE_TYPE_LABELS, isSymbolType } from '@vectora/graph-schema'; +import { ViewToolbar } from '@/components/ViewControls'; +import { Loading } from '@/components/ui'; +import { downloadUrl } from '@/lib/api'; +import { fileIdOf, neighbourhood } from '@/lib/graphView'; +import { useWorkspace } from '@/lib/store'; +import { + FAMILY_GLYPH, + NODE_FAMILY, + NODE_SIZE, + type NodeFamily, + type Palette, + mix, + nodeColor, +} from '@/lib/tokens'; +import type { Layout3DRequest, Layout3DResponse } from '@/workers/layout3d.worker'; +import { useWs } from './context'; + +type Quality = 'low' | 'balanced' | 'high'; + +interface AtlasApi { + reset: () => void; + focusOn: (id: string) => void; + capture: () => string | null; +} + +const roleOf = (n: GraphNode): LayoutRole => { + switch (n.type) { + case 'Repository': + return 'repository'; + case 'Subsystem': + return 'subsystem'; + case 'Concept': + return 'concept'; + case 'Package': + return 'package'; + case 'Directory': + case 'Module': + return 'container'; + case 'File': + case 'Configuration': + return 'file'; + default: + return 'symbol'; + } +}; + +function webglAvailable(): boolean { + try { + const c = document.createElement('canvas'); + return !!(c.getContext('webgl2') ?? c.getContext('webgl')); + } catch { + return false; + } +} + +/** Runs the 3D layout in a Web Worker and returns positions aligned with `nodes`. */ +function useLayout3d( + nodes: GraphNode[], + edges: GraphEdge[], + parentOf: (n: GraphNode) => string | null, + quality: Quality, +) { + const [result, setResult] = useState<{ positions: Float32Array; radius: number; ids: string[] } | null>( + null, + ); + const workerRef = useRef(null); + const requestId = useRef(0); + useEffect(() => { + const worker = new Worker(new URL('../workers/layout3d.worker.ts', import.meta.url), { type: 'module' }); + workerRef.current = worker; + return () => worker.terminate(); + }, []); + useEffect(() => { + const worker = workerRef.current; + if (!worker) return; + const id = ++requestId.current; + const ids = nodes.map((n) => n.id); + const layoutNodes: LayoutNode[] = nodes.map((n) => ({ + id: n.id, + role: roleOf(n), + subsystem: (n.metadata.subsystem as string | undefined) ?? null, + parent: parentOf(n), + })); + const layoutEdges = edges + .filter((e) => e.type === 'IMPORTS' || e.type === 'CALLS' || e.type === 'USES' || e.type === 'EXTENDS') + .map((e) => ({ source: e.source, target: e.target })); + worker.onmessage = (e: MessageEvent) => { + if (e.data.requestId === requestId.current) + setResult({ positions: e.data.positions, radius: e.data.radius, ids }); + }; + worker.postMessage({ + requestId: id, + nodes: layoutNodes, + edges: layoutEdges, + quality: quality === 'low' ? 0.2 : quality === 'high' ? 1 : 0.6, + } satisfies Layout3DRequest); + }, [nodes, edges, parentOf, quality]); + return result; +} + +const FAMILIES: NodeFamily[] = [ + 'structure', + 'file', + 'type', + 'callable', + 'data', + 'endpoint', + 'concept', + 'external', + 'value', +]; + +function FamilyGeometry({ family, quality }: { family: NodeFamily; quality: Quality }) { + const d = quality === 'low' ? 0 : 1; + switch (family) { + case 'structure': + return ; + case 'file': + return ; + case 'type': + return ; + case 'data': + return ; + case 'endpoint': + return ; + case 'concept': + return ; + case 'external': + return ; + default: + return ; + } +} + +interface SceneProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + positions: Float32Array; + radius: number; + palette: Palette; + quality: Quality; + labelBudget: number; + reducedMotion: boolean; + focusBehavior: 'neighbourhood' | 'node' | 'none'; + apiRef: MutableRefObject; + onExpand: (id: string) => void; + onLabels: (items: LabelItem[]) => void; + labelEls: MutableRefObject>; +} + +interface LabelItem { + id: string; + text: string; + glyph: string; + major: boolean; + root: boolean; + on: boolean; + dim: boolean; + detail: string | null; +} + +function AtlasScene({ + nodes, + edges, + positions, + radius, + palette, + quality, + labelBudget, + reducedMotion, + focusBehavior, + apiRef, + onExpand, + onLabels, + labelEls, +}: SceneProps) { + const { camera, invalidate, gl, scene } = useThree(); + const controls = useThree((s) => s.controls) as OrbitControlsImpl | null; + const selectedIds = useWorkspace((s) => s.selectedIds); + const primaryId = useWorkspace((s) => s.primaryId); + const hoverId = useWorkspace((s) => s.hoverId); + const highlight = useWorkspace((s) => s.highlight); + const revealTick = useWorkspace((s) => s.revealTick); + const [far, setFar] = useState(false); + const [nearLabels, setNearLabels] = useState([]); + + const indexOf = useMemo(() => new Map(nodes.map((n, i) => [n.id, i])), [nodes]); + const pos = useCallback( + (i: number) => new THREE.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]), + [positions], + ); + const byFamily = useMemo(() => { + const m = new Map(); + nodes.forEach((n, i) => { + const f = NODE_FAMILY[n.type]; + (m.get(f) ?? m.set(f, []).get(f)!).push(i); + }); + return m; + }, [nodes]); + + // ---- which nodes and edges are emphasised ------------------------------ + const active = useMemo(() => { + const centre = hoverId ?? primaryId; + if (centre && indexOf.has(centre)) { + const nb = neighbourhood(centre, edges); + for (const id of selectedIds) nb.nodes.add(id); + return nb; + } + if (highlight) return { nodes: new Set(highlight.nodeIds), edges: new Set(highlight.edgeIds) }; + return null; + }, [hoverId, primaryId, selectedIds, highlight, edges, indexOf]); + + // ---- instanced nodes ---------------------------------------------------- + const meshes = useRef(new Map()); + useLayoutEffect(() => { + const m4 = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const color = new THREE.Color(); + const selected = new Set(selectedIds); + for (const [family, list] of byFamily) { + const mesh = meshes.current.get(family); + if (!mesh) continue; + list.forEach((ni, k) => { + const n = nodes[ni]!; + const emphasised = selected.has(n.id) || n.id === hoverId; + const s = NODE_SIZE[n.type] * 0.42 * (emphasised ? 1.5 : 1); + m4.compose(pos(ni), q, new THREE.Vector3(s, s, s)); + mesh.setMatrixAt(k, m4); + const base = emphasised ? palette.selection : nodeColor(n.type, palette); + color.set(active && !active.nodes.has(n.id) && !emphasised ? mix(base, palette.canvas, 0.72) : base); + mesh.setColorAt(k, color); + }); + mesh.count = list.length; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + mesh.computeBoundingSphere(); + } + invalidate(); + }, [byFamily, nodes, pos, palette, selectedIds, hoverId, active, invalidate]); + + // ---- edges: two buffered line sets (verified solid, derived dashed) ------ + const edgeSets = useMemo(() => { + const build = (list: GraphEdge[]) => { + // Cross-file relationships first, so symbol-level detail can be cut with a draw range when far away. + const coarse: GraphEdge[] = []; + const fine: GraphEdge[] = []; + for (const e of list) { + const a = nodes[indexOf.get(e.source)!]!; + const b = nodes[indexOf.get(e.target)!]!; + if ( + (isSymbolType(a.type) && isSymbolType(b.type) && a.filePath === b.filePath) || + e.type === 'CONTAINS' + ) + fine.push(e); + else coarse.push(e); + } + const ordered = [...coarse, ...fine]; + const geometry = new THREE.BufferGeometry(); + const p = new Float32Array(ordered.length * 6); + ordered.forEach((e, k) => { + const a = indexOf.get(e.source)! * 3; + const b = indexOf.get(e.target)! * 3; + p.set( + [ + positions[a]!, + positions[a + 1]!, + positions[a + 2]!, + positions[b]!, + positions[b + 1]!, + positions[b + 2]!, + ], + k * 6, + ); + }); + geometry.setAttribute('position', new THREE.BufferAttribute(p, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(ordered.length * 6), 3)); + return { geometry, ordered, coarseCount: coarse.length }; + }; + const usable = edges.filter((e) => indexOf.has(e.source) && indexOf.has(e.target)); + return { + verified: build(usable.filter((e) => e.verified)), + derived: build(usable.filter((e) => !e.verified)), + }; + }, [edges, nodes, indexOf, positions]); + useEffect( + () => () => { + edgeSets.verified.geometry.dispose(); + edgeSets.derived.geometry.dispose(); + }, + [edgeSets], + ); + const derivedLine = useRef(null); + useLayoutEffect(() => { + const c = new THREE.Color(); + for (const [set, baseHex] of [ + [edgeSets.verified, palette.textMuted], + [edgeSets.derived, palette.relationUnverified], + ] as const) { + const attr = set.geometry.getAttribute('color') as THREE.BufferAttribute; + set.ordered.forEach((e, k) => { + const on = active?.edges.has(e.id); + c.set( + on + ? e.verified + ? palette.relationActive + : palette.relationUnverified + : mix(baseHex, palette.canvas, active ? 0.88 : e.type === 'CONTAINS' || !e.verified ? 0.72 : 0.5), + ); + attr.setXYZ(k * 2, c.r, c.g, c.b); + attr.setXYZ(k * 2 + 1, c.r, c.g, c.b); + }); + attr.needsUpdate = true; + // Level of detail: from far away only cross-file relationships are drawn. + set.geometry.setDrawRange(0, (far && !active ? set.coarseCount : set.ordered.length) * 2); + } + derivedLine.current?.computeLineDistances(); + invalidate(); + }, [edgeSets, palette, active, far, invalidate]); + + // ---- camera transitions ------------------------------------------------- + const anim = useRef<{ + fromP: THREE.Vector3; + fromT: THREE.Vector3; + toP: THREE.Vector3; + toT: THREE.Vector3; + start: number; + duration: number; + } | null>(null); + const flyTo = useCallback( + (toP: THREE.Vector3, toT: THREE.Vector3) => { + const fromT = controls ? controls.target.clone() : new THREE.Vector3(); + anim.current = { + fromP: camera.position.clone(), + fromT, + toP, + toT, + start: performance.now(), + duration: reducedMotion ? 0 : 820, + }; + invalidate(); + }, + [camera, controls, invalidate, reducedMotion], + ); + useFrame(() => { + const a = anim.current; + if (!a) return; + const t = a.duration === 0 ? 1 : Math.min(1, (performance.now() - a.start) / a.duration); + const k = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + camera.position.lerpVectors(a.fromP, a.toP, k); + if (controls) { + controls.target.lerpVectors(a.fromT, a.toT, k); + controls.update(); + } else camera.lookAt(a.toT); + if (t >= 1) anim.current = null; + else invalidate(); + }); + const home = useCallback( + () => flyTo(new THREE.Vector3(0, radius * 1.25, radius * 3.3), new THREE.Vector3(0, 0, 0)), + [flyTo, radius], + ); + const focusOn = useCallback( + (id: string) => { + const i = indexOf.get(id); + if (i === undefined) return; + const n = nodes[i]!; + const target = pos(i); + const dist = + n.type === 'Repository' + ? radius * 2.6 + : n.type === 'Subsystem' + ? radius * 1.3 + : n.type === 'Concept' || n.type === 'Package' + ? radius * 0.7 + : isSymbolType(n.type) + ? 34 + : 46; + // Look at the node from outside the structure, keeping the current side when the node is central. + const dir = + target.lengthSq() > 4 ? target.clone().normalize() : camera.position.clone().sub(target).normalize(); + // Step to the side and up, so the node is not seen in line with its parent and the centre. + const side = new THREE.Vector3(0, 1, 0).cross(dir); + if (side.lengthSq() > 0.01) dir.addScaledVector(side.normalize(), 0.75); + dir.y += 0.45; + flyTo(target.clone().add(dir.normalize().multiplyScalar(dist)), target); + }, + [indexOf, nodes, pos, radius, camera, flyTo], + ); + useEffect(() => { + apiRef.current = { + reset: home, + focusOn, + capture: () => { + gl.render(scene, camera); + return gl.domElement.toDataURL('image/png'); + }, + }; + return () => { + apiRef.current = null; + }; + }, [apiRef, home, focusOn, gl, scene, camera]); + // Frame the whole structure whenever a new layout arrives. + const framed = useRef(null); + useEffect(() => { + // Runs again once the controls exist, so the first frame is drawn from the home position. + const key = `${radius}:${controls ? 1 : 0}`; + if (framed.current === key) return; + framed.current = key; + camera.position.set(0, radius * 1.25, radius * 3.3); + controls?.target.set(0, 0, 0); + controls?.update(); + invalidate(); + }, [radius, camera, controls, invalidate]); + // With on-demand rendering nothing is drawn until a frame is requested: ask for one whenever the + // scene content is (re)created, and once more after the browser has laid the canvas out. + useEffect(() => { + invalidate(); + const t = setTimeout(() => invalidate(), 150); + return () => clearTimeout(t); + }, [controls, positions, invalidate]); + useEffect(() => { + if (revealTick === 0 || focusBehavior === 'none') return; + const s = useWorkspace.getState(); + const id = s.primaryId ?? s.focusId; + if (id) focusOn(id); + else home(); + }, [revealTick]); // eslint-disable-line react-hooks/exhaustive-deps + + // ---- level of detail and label culling, recomputed when the camera rests -- + const updateDetail = useCallback(() => { + const d = camera.position.length(); + setFar(d > radius * 2.1 && edges.length > 600); + if (labelBudget === 0) return setNearLabels([]); + const scored: [number, string][] = []; + const v = new THREE.Vector3(); + const frustum = new THREE.Frustum().setFromProjectionMatrix( + new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + ); + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]!; + if (n.type === 'Repository' || n.type === 'Subsystem' || n.type === 'Concept') continue; + v.set(positions[i * 3]!, positions[i * 3 + 1]!, positions[i * 3 + 2]!); + if (!frustum.containsPoint(v)) continue; + // Larger nodes earn a label from further away. + scored.push([v.distanceTo(camera.position) / (NODE_SIZE[n.type] + 1), n.id]); + } + scored.sort((a, b) => a[0] - b[0]); + const limit = (isSymbol: boolean) => (isSymbol ? 18 : 60); + setNearLabels( + scored + .filter( + ([s, id]) => + s * (NODE_SIZE[nodes[indexOf.get(id)!]!.type] + 1) < + limit(isSymbolType(nodes[indexOf.get(id)!]!.type)) * 1.4, + ) + .slice(0, labelBudget) + .map(([, id]) => id), + ); + }, [camera, radius, edges.length, labelBudget, nodes, positions, indexOf]); + useEffect(() => { + const t = setTimeout(updateDetail, 80); + return () => clearTimeout(t); + }, [updateDetail]); + + // ---- pointer interaction ------------------------------------------------ + const idAt = (family: NodeFamily, instanceId: number | undefined) => + instanceId === undefined ? null : (nodes[byFamily.get(family)?.[instanceId] ?? -1]?.id ?? null); + const handlers = (family: NodeFamily) => ({ + onClick: (e: ThreeEvent) => { + e.stopPropagation(); + const id = idAt(family, e.instanceId); + if (!id) return; + const additive = e.nativeEvent.ctrlKey || e.nativeEvent.metaKey || e.nativeEvent.shiftKey; + useWorkspace.getState().select(id, { additive }); + if (!additive && focusBehavior !== 'none') focusOn(id); + }, + onDoubleClick: (e: ThreeEvent) => { + e.stopPropagation(); + const id = idAt(family, e.instanceId); + if (id) onExpand(id); + }, + ...(nodes.length <= 20000 + ? { + onPointerMove: (e: ThreeEvent) => { + e.stopPropagation(); + useWorkspace.getState().setHover(idAt(family, e.instanceId)); + gl.domElement.style.cursor = 'pointer'; + }, + onPointerOut: () => { + useWorkspace.getState().setHover(null); + gl.domElement.style.cursor = ''; + }, + } + : {}), + }); + + // ---- labels ------------------------------------------------------------- + const labels = useMemo(() => { + const ids = new Set(); + for (const n of nodes) + if (n.type === 'Repository' || n.type === 'Subsystem' || n.type === 'Concept') ids.add(n.id); + for (const id of selectedIds) ids.add(id); + if (hoverId) ids.add(hoverId); + if (active) [...active.nodes].slice(0, 28).forEach((id) => ids.add(id)); + for (const id of nearLabels) ids.add(id); + return [...ids].filter((id) => indexOf.has(id)); + }, [nodes, selectedIds, hoverId, active, nearLabels, indexOf]); + + // Labels are plain DOM elements in an overlay owned by the parent. The parent renders them; + // this scene only moves them, once per rendered frame, by projecting their node to the screen. + const labelItems = useMemo( + () => + labels.map((id) => { + const n = nodes[indexOf.get(id)!]!; + const major = n.type === 'Repository' || n.type === 'Subsystem' || n.type === 'Concept'; + const on = id === hoverId || selectedIds.includes(id); + return { + id, + text: n.name, + glyph: FAMILY_GLYPH[NODE_FAMILY[n.type]], + major, + root: n.type === 'Repository', + on, + dim: !!active && !active.nodes.has(id) && !on, + detail: id === hoverId && !major ? NODE_TYPE_LABELS[n.type] : null, + }; + }), + [labels, nodes, indexOf, hoverId, selectedIds, active], + ); + useEffect(() => { + onLabels(labelItems); + invalidate(); + const t = setTimeout(() => invalidate(), 40); + return () => clearTimeout(t); + }, [labelItems, onLabels, invalidate]); + const projected = useMemo(() => new THREE.Vector3(), []); + useFrame(({ size }) => { + for (const item of labelItems) { + const el = labelEls.current.get(item.id); + const i = indexOf.get(item.id); + if (!el || i === undefined) continue; + projected + .set( + positions[i * 3]!, + positions[i * 3 + 1]! + NODE_SIZE[nodes[i]!.type] * 0.42 + 0.6, + positions[i * 3 + 2]!, + ) + .project(camera); + // Behind the camera or outside the view: hide instead of drawing at a mirrored position. + if (projected.z > 1 || Math.abs(projected.x) > 1.1 || Math.abs(projected.y) > 1.1) { + el.style.visibility = 'hidden'; + continue; + } + el.style.visibility = 'visible'; + el.style.transform = `translate(${((projected.x + 1) / 2) * size.width}px, ${((1 - projected.y) / 2) * size.height}px) translate(-50%, -120%)`; + } + }); + + return ( + <> + + {FAMILIES.map((family) => { + const count = byFamily.get(family)?.length ?? 0; + if (!count) return null; + return ( + { + if (m) meshes.current.set(family, m); + else meshes.current.delete(family); + }} + args={[undefined, undefined, count]} + frustumCulled={false} + {...handlers(family)} + > + + + + ); + })} + + + + + + + + ); +} + +export function Atlas() { + const { view, adj, palette, settings, reducedMotion, expandFile, openView, theme } = useWs(); + const primaryId = useWorkspace((s) => s.primaryId); + const focusId = useWorkspace((s) => s.focusId); + const [gl, setGl] = useState<'ok' | 'unsupported' | 'lost'>(() => + webglAvailable() ? 'ok' : 'unsupported', + ); + const [attempt, setAttempt] = useState(0); + const apiRef = useRef(null); + const [labelItems, setLabelItems] = useState([]); + const labelEls = useRef(new Map()); + const quality = settings.performance.renderingQuality; + + const parentOf = useCallback( + (n: GraphNode): string | null => { + if (!isSymbolType(n.type)) return null; + const parentEdge = (adj.in.get(n.id) ?? []).find((e) => e.type === 'CONTAINS'); + return parentEdge?.source ?? fileIdOf(n); + }, + [adj], + ); + const layout = useLayout3d(view.nodes, view.edges, parentOf, quality); + const current = + layout && + layout.ids.length === view.nodes.length && + layout.ids[0] === view.nodes[0]?.id && + layout.ids[layout.ids.length - 1] === view.nodes[view.nodes.length - 1]?.id + ? layout + : null; + + // The scene keeps showing the previous arrangement until the next one is ready, so changing a + // filter or entering focus mode never blanks the view or tears down the renderer. + const [shown, setShown] = useState<{ + nodes: GraphNode[]; + edges: GraphEdge[]; + positions: Float32Array; + radius: number; + } | null>(null); + useEffect(() => { + if (current) + setShown({ + nodes: view.nodes, + edges: view.edges, + positions: current.positions, + radius: current.radius, + }); + }, [current]); // eslint-disable-line react-hooks/exhaustive-deps + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + + const onExpand = useCallback( + (id: string) => { + const n = adj.nodes.get(id); + if (!n) return; + if (n.type === 'File' || n.type === 'Configuration') return expandFile(id); + if (n.type === 'Subsystem' || n.type === 'Concept') { + // Unfold the files of a region. + (adj.in.get(id) ?? []) + .filter((e) => e.type === 'BELONGS_TO') + .slice(0, 60) + .forEach((e) => expandFile(e.source, true)); + return; + } + const fid = fileIdOf(n); + if (fid) expandFile(fid, true); + }, + [adj, expandFile], + ); + + const primary = primaryId ? adj.nodes.get(primaryId) : undefined; + const crumbs = useMemo(() => { + const out: GraphNode[] = []; + const repo = adj.nodes.get('repo:root'); + if (repo) out.push(repo); + if (!primary || primary.type === 'Repository') return out; + const sub = adj.nodes.get((primary.metadata.subsystem as string | undefined) ?? ''); + if (sub && sub.id !== primary.id) out.push(sub); + const file = isSymbolType(primary.type) ? adj.nodes.get(fileIdOf(primary) ?? '') : undefined; + if (file) out.push(file); + const parent = isSymbolType(primary.type) + ? adj.nodes.get((adj.in.get(primary.id) ?? []).find((e) => e.type === 'CONTAINS')?.source ?? '') + : undefined; + if (parent && isSymbolType(parent.type)) out.push(parent); + out.push(primary); + return out; + }, [primary, adj]); + const subsystemId = + (primary?.metadata.subsystem as string | undefined) ?? + (primary?.type === 'Subsystem' ? primary.id : undefined); + const labelBudget = quality === 'low' ? 0 : { low: 6, medium: 16, high: 40 }[settings.graph.labelDensity]; + + if (gl !== 'ok') + return ( +
+

+ {gl === 'lost' ? 'The 3D view lost its graphics context' : '3D rendering is not available here'} +

+

+ {gl === 'lost' + ? 'The browser reclaimed the graphics memory used by the Atlas. Your selection and filters are kept.' + : 'This browser or device does not provide WebGL. The same graph is available in two dimensions.'} +

+
+ + {gl === 'lost' ? ( + + ) : null} +
+
+ ); + + return ( +
+ + + {subsystemId ? ( + + ) : null} + {primary && focusId !== primary.id ? ( + + ) : null} + + + + {shown ? ( + { + const canvas = renderer.domElement; + canvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault(); + // Disposing the renderer on the way out also reports a lost context; only a loss + // while the view is on screen is a real one. + if (alive.current && canvas.isConnected) setGl('lost'); + }); + }} + onPointerMissed={(e) => { + if (e.type === 'click') useWorkspace.getState().clearSelection(); + }} + aria-label="3D knowledge atlas. Drag to orbit, right-drag to pan, scroll to zoom. Use Search to move to a node." + role="application" + > + + + + + + + ) : ( + + )} + {shown ? ( + + ) : null} + + {primary ? null : ( +

+ Drag to orbit, right-drag to pan, scroll to zoom, double-click to unfold +

+ )} +
+ ); +} diff --git a/apps/web/src/workspace/Dependencies.tsx b/apps/web/src/workspace/Dependencies.tsx new file mode 100644 index 0000000..ddb9119 --- /dev/null +++ b/apps/web/src/workspace/Dependencies.tsx @@ -0,0 +1,401 @@ +import { useMemo, useState } from 'react'; +import { EDGE_TYPE_LABELS } from '@vectora/graph-schema'; +import { Empty, SectionHeading, cx } from '@/components/ui'; +import { useAnalysis } from '@/lib/api'; +import { useWorkspace } from '@/lib/store'; +import { useWs } from './context'; + +type Tab = 'packages' | 'subsystems' | 'cycles' | 'flows' | 'unresolved'; + +/** Finds groups of files that import each other (strongly connected components, Tarjan). */ +function importCycles(nodes: string[], out: Map): string[][] { + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const on = new Set(); + const result: string[][] = []; + let counter = 0; + for (const root of nodes) { + if (index.has(root)) continue; + const work: { id: string; i: number }[] = [{ id: root, i: 0 }]; + index.set(root, counter); + low.set(root, counter++); + stack.push(root); + on.add(root); + while (work.length) { + const f = work[work.length - 1]!; + const next = out.get(f.id) ?? []; + if (f.i < next.length) { + const w = next[f.i++]!; + if (!index.has(w)) { + index.set(w, counter); + low.set(w, counter++); + stack.push(w); + on.add(w); + work.push({ id: w, i: 0 }); + } else if (on.has(w)) low.set(f.id, Math.min(low.get(f.id)!, index.get(w)!)); + } else { + work.pop(); + const parent = work[work.length - 1]; + if (parent) low.set(parent.id, Math.min(low.get(parent.id)!, low.get(f.id)!)); + if (low.get(f.id) === index.get(f.id)) { + const comp: string[] = []; + let w: string; + do { + w = stack.pop()!; + on.delete(w); + comp.push(w); + } while (w !== f.id); + if (comp.length > 1) result.push(comp); + } + } + } + } + return result.sort((a, b) => b.length - a.length); +} + +export function Dependencies() { + const { repo, graph, adj, reveal, openView, settings } = useWs(); + const { data: analysis } = useAnalysis(repo.id); + const setHighlight = useWorkspace((s) => s.setHighlight); + const [tab, setTab] = useState('packages'); + const [filter, setFilter] = useState(''); + + const packages = useMemo( + () => + graph.nodes + .filter((n) => n.type === 'Package') + .map((p) => { + const importers = (adj.in.get(p.id) ?? []).filter((e) => e.type === 'IMPORTS'); + const declaredBy = (adj.in.get(p.id) ?? []).filter((e) => e.type === 'DEPENDS_ON'); + return { node: p, importers, declaredBy, files: new Set(importers.map((e) => e.source)).size }; + }) + .sort((a, b) => b.files - a.files || (a.node.name < b.node.name ? -1 : 1)), + [graph, adj], + ); + const subsystemDeps = useMemo( + () => + graph.edges + .filter((e) => e.type === 'DEPENDS_ON' && e.source.startsWith('sub:')) + .sort((a, b) => Number(b.metadata?.count ?? 0) - Number(a.metadata?.count ?? 0)), + [graph], + ); + const cycles = useMemo(() => { + const out = new Map(); + const files = new Set(); + for (const e of graph.edges) { + if (e.type !== 'IMPORTS' || !e.target.startsWith('file:') || e.source === e.target) continue; + (out.get(e.source) ?? out.set(e.source, []).get(e.source)!).push(e.target); + files.add(e.source); + files.add(e.target); + } + return importCycles([...files], out); + }, [graph]); + + const showIn = (label: string, nodeIds: string[], edgeIds: string[] = []) => { + setHighlight({ label, nodeIds, edgeIds }); + useWorkspace.getState().clearSelection(); + openView(settings.graph.defaultMode === 'mindmap' ? 'graph' : settings.graph.defaultMode); + }; + const q = filter.trim().toLowerCase(); + const tabs: [Tab, string, number][] = [ + ['packages', 'External packages', packages.length], + ['subsystems', 'Between subsystems', subsystemDeps.length], + ['cycles', 'Circular imports', cycles.length], + ['flows', 'Execution flows', analysis?.flows.length ?? 0], + ['unresolved', 'Unresolved references', analysis?.unresolvedCount ?? 0], + ]; + + return ( +
+
+

Dependency Explorer

+
+ {tabs.map(([id, label, count]) => ( + + ))} +
+
+
+ {tab === 'packages' ? ( + packages.length === 0 ? ( + + No manifest declares a dependency and no source file imports one. + + ) : ( +
+ setFilter(e.target.value)} + /> + + + + + + + + + + + {packages + .filter((p) => !q || p.node.name.toLowerCase().includes(q)) + .slice(0, 400) + .map((p) => ( + + + + + + + + ))} + +
PackageVersionDeclared inImporting files +
+ + {p.node.metadata.dev ? dev : null} + {(p.node.metadata.version as string | null) ?? ''} + {p.declaredBy.length ? ( + p.declaredBy.map((e) => `${e.location?.path}:${e.location?.startLine}`).join(', ') + ) : ( + not declared in a manifest + )} + {p.files || not imported} + {p.files ? ( + + ) : null} +
+
+ ) + ) : null} + + {tab === 'subsystems' ? ( + subsystemDeps.length === 0 ? ( + + Either the repository has a single subsystem or no relationship crosses a subsystem boundary. + + ) : ( +
    + {subsystemDeps.map((e) => ( +
  • +
    +

    + {' '} + depends on{' '} + +

    + + {Object.entries((e.metadata?.kinds as Record) ?? {}) + .map(([k, v]) => `${v} ${EDGE_TYPE_LABELS[k as keyof typeof EDGE_TYPE_LABELS]}`) + .join(', ')} + +
    +

    {e.evidence}

    +
  • + ))} +
+ ) + ) : null} + + {tab === 'cycles' ? ( + cycles.length === 0 ? ( + + No group of files imports itself through resolved import relationships. + + ) : ( +
    + {cycles.map((c, i) => ( +
  • +
    +

    {c.length} files import each other

    + +
    +
      + {c.slice(0, 12).map((id) => ( +
    • + +
    • + ))} + {c.length > 12 ?
    • {c.length - 12} more
    • : null} +
    +
  • + ))} +
+ ) + ) : null} + + {tab === 'flows' ? ( + !analysis?.flows.length ? ( + + No API endpoint or entry point with resolved outgoing calls was found. + + ) : ( +
+

+ Flows follow route and call relationships from an endpoint or entry point. They are derived + from static structure; callbacks and runtime wiring are not included. +

+
    + {analysis.flows.map((f) => ( +
  • +
    +

    + {f.name}{' '} + {f.reachesData ? reaches data access : null} +

    + +
    +
      + {f.steps.map((s, i) => ( +
    1. + {i > 0 ? ( + + ) : null} + +
    2. + ))} +
    +
  • + ))} +
+
+ ) + ) : null} + + {tab === 'unresolved' ? ( + !analysis?.unresolvedCount ? ( + + Every reference the analysers found was tied to a definition. + + ) : ( +
+

+ These references were found in the source but could not be tied to a definition with scope + evidence. They are reported here and are not part of the graph. +

+ By reason +
    + {Object.entries(analysis.unresolvedByReason) + .sort((a, b) => b[1] - a[1]) + .map(([reason, count]) => ( +
  • + {reason} + {count.toLocaleString()} +
  • + ))} +
+ Samples + + + + + + + + + + + {analysis.unresolvedSamples.slice(0, 300).map((u, i) => ( + + + + + + + ))} + +
ReferenceKindLocationReason
{u.name} + {EDGE_TYPE_LABELS[u.kind as keyof typeof EDGE_TYPE_LABELS] ?? u.kind} + + + {u.reason}
+
+ ) + ) : null} +
+
+ ); +} diff --git a/apps/web/src/workspace/Documentation.tsx b/apps/web/src/workspace/Documentation.tsx new file mode 100644 index 0000000..44706ef --- /dev/null +++ b/apps/web/src/workspace/Documentation.tsx @@ -0,0 +1,171 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import Markdown from 'react-markdown'; +import { useSearchParams } from 'react-router'; +import { CodePreview } from '@/components/CodePreview'; +import { Empty, Loading, cx } from '@/components/ui'; +import { api, useSource } from '@/lib/api'; +import { useWs } from './context'; + +/** Documentation files found in the repository, plus documents generated from the graph. */ +export function Documentation() { + const { repo } = useWs(); + const [params, setParams] = useSearchParams(); + const docs = useQuery({ queryKey: ['docs', repo.id], queryFn: () => api.docs(repo.id) }); + const generated = params.get('generated'); + const path = params.get('path'); + useEffect(() => { + if (!path && !generated && docs.data) + setParams(docs.data[0] ? { path: docs.data[0].path } : { generated: 'architecture' }, { + replace: true, + }); + }, [path, generated, docs.data, setParams]); + const source = useSource(repo.id, path); + const gen = useQuery({ + queryKey: ['generated-doc', repo.id, generated], + queryFn: async () => (await fetch(api.exportUrl(repo.id, generated!, false))).text(), + enabled: !!generated, + }); + const content = generated ? gen.data : source.data?.content; + const isMarkdown = !!generated || /\.(md|mdx|markdown)$/i.test(path ?? ''); + + return ( +
+ +
+ {(generated ? gen.isLoading : source.isLoading) ? ( + + ) : (generated ? gen.error : source.error) ? ( + + {((generated ? gen.error : source.error) as Error).message} + + ) : content === undefined ? null : isMarkdown ? ( +
+ {generated ? ( +

+ Generated from the knowledge graph. Download it from Export in the header. +

+ ) : ( +

{path}

+ )} + {/* react-markdown renders to React elements and ignores raw HTML, so repository text cannot inject markup. */} + (/^(https?:|mailto:|#)/i.test(url) ? url : '')} + components={{ + a: ({ href, children }) => + href ? ( + + {children} + + ) : ( + {children} + ), + img: ({ alt }) => [image: {alt || 'no description'}], + }} + > + {content} + +
+ ) : ( +
+

{path}

+ +
+ )} + {!generated && source.data?.truncated ? ( +

The file is larger than the preview limit and was shortened.

+ ) : null} +
+
+ ); +} + +/** Full-file source viewer, opened from “Open source location”. */ +export function SourceView() { + const { repo, adj, reveal, openView, settings } = useWs(); + const [params] = useSearchParams(); + const path = params.get('path'); + const line = Number(params.get('line') ?? 1) || 1; + const { data, isLoading, error } = useSource(repo.id, path); + if (!path) return Open a source location from the inspector.; + if (isLoading) return ; + if (error || !data) + return {(error as Error | null)?.message}; + const fileId = `file:${data.path}`; + return ( +
+
+
+

{data.path}

+

+ {data.totalLines.toLocaleString()} lines{data.language ? `, ${data.language}` : ''} + {data.truncated ? ', shortened to the preview limit' : ''} — line {line} +

+
+ {adj.nodes.has(fileId) ? ( + + ) : null} +
+
+ +
+
+ ); +} diff --git a/apps/web/src/workspace/GraphExplorer.tsx b/apps/web/src/workspace/GraphExplorer.tsx new file mode 100644 index 0000000..384fb99 --- /dev/null +++ b/apps/web/src/workspace/GraphExplorer.tsx @@ -0,0 +1,603 @@ +import Graph from 'graphology'; +import forceAtlas2 from 'graphology-layout-forceatlas2'; +import FA2Layout from 'graphology-layout-forceatlas2/worker'; +import noverlap from 'graphology-layout-noverlap'; +import { Image as ImageIcon, Maximize, RotateCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import Sigma from 'sigma'; +import { seed2d } from '@vectora/graph-layout'; +import { EDGE_TYPE_LABELS, type GraphNode, isSymbolType } from '@vectora/graph-schema'; +import { ViewToolbar } from '@/components/ViewControls'; +import { downloadUrl } from '@/lib/api'; +import { fileIdOf, neighbourhood } from '@/lib/graphView'; +import { useWorkspace } from '@/lib/store'; +import { + FAMILY_GLYPH, + NODE_FAMILY, + NODE_SIZE, + STRUCTURE_EDGES, + type Palette, + mix, + nodeColor, +} from '@/lib/tokens'; +import { useWs } from './context'; + +type Grouping = 'subsystem' | 'community'; + +interface RenderState { + selected: Set; + primary: string | null; + hover: string | null; + activeNodes: Set | null; + activeEdges: Set | null; + palette: Palette; +} + +const LABEL_THRESHOLD = { low: 11, medium: 7.5, high: 4.5 } as const; +const LABEL_DENSITY = { low: 0.4, medium: 0.9, high: 1.8 } as const; + +export function GraphExplorer() { + const { view, adj, palette, settings, reducedMotion, expandFile, theme } = useWs(); + const selectedIds = useWorkspace((s) => s.selectedIds); + const primaryId = useWorkspace((s) => s.primaryId); + const hoverId = useWorkspace((s) => s.hoverId); + const highlight = useWorkspace((s) => s.highlight); + const revealTick = useWorkspace((s) => s.revealTick); + const container = useRef(null); + const minimap = useRef(null); + const sigmaRef = useRef(null); + const graphRef = useRef(null); + const layoutRef = useRef<{ stop: () => void } | null>(null); + const stateRef = useRef({ + selected: new Set(), + primary: null, + hover: null, + activeNodes: null, + activeEdges: null, + palette, + }); + const viewRef = useRef(view); + viewRef.current = view; + const [grouping, setGrouping] = useState('subsystem'); + const [arranging, setArranging] = useState(false); + const [failed, setFailed] = useState(null); + const groupingRef = useRef(grouping); + groupingRef.current = grouping; + + const saveLayout = useCallback(() => { + const g = graphRef.current; + if (!g) return; + const out: Record = { ...(useWorkspace.getState().layouts.graph ?? {}) }; + g.forEachNode((id, a) => { + out[id] = [Math.round(a.x * 100) / 100, Math.round(a.y * 100) / 100]; + }); + const entries = Object.entries(out); + useWorkspace + .getState() + .setLayout('graph', entries.length > 20000 ? Object.fromEntries(entries.slice(-20000)) : out); + }, []); + + const runLayout = useCallback( + (fromScratch: boolean) => { + const g = graphRef.current; + if (!g || g.order === 0) return; + layoutRef.current?.stop(); + if (fromScratch) { + const seeds = seed2d( + viewRef.current.nodes.map((n) => ({ + id: n.id, + group: + groupingRef.current === 'community' + ? ((n.metadata.community as number | undefined) ?? null) + : ((n.metadata.subsystem as string | undefined) ?? n.type), + })), + ); + g.forEachNode((id) => { + const p = seeds.get(id); + if (p) g.mergeNodeAttributes(id, { x: p[0], y: p[1] }); + }); + } + const fa2 = { + ...forceAtlas2.inferSettings(g), + barnesHutOptimize: g.order > 1200, + gravity: 0.5, + scalingRatio: 8, + slowDown: 6 + Math.log(g.order + 1), + strongGravityMode: false, + }; + const finish = () => { + noverlap.assign(g, { maxIterations: g.order > 3000 ? 15 : 60, settings: { margin: 2, ratio: 1.1 } }); + setArranging(false); + saveLayout(); + }; + if (reducedMotion) { + // No animated settling: compute the arrangement in one step. + forceAtlas2.assign(g, { iterations: g.order > 3000 ? 40 : g.order > 800 ? 100 : 220, settings: fa2 }); + finish(); + return; + } + const layout = new FA2Layout(g, { settings: fa2 }); + layout.start(); + setArranging(true); + const base = settings.graph.animationIntensity === 'standard' ? 4200 : 2400; + const timer = setTimeout(() => stop(), base * (g.order > 3000 ? 1.8 : 1)); + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + clearTimeout(timer); + layout.kill(); + if (graphRef.current === g) finish(); + }; + layoutRef.current = { stop }; + }, + [reducedMotion, settings.graph.animationIntensity, saveLayout], + ); + + // ---- create the renderer once ------------------------------------------ + useEffect(() => { + if (!container.current) return; + const g = new Graph({ multi: true, type: 'directed' }); + graphRef.current = g; + let sigma: Sigma; + try { + sigma = new Sigma(g, container.current, { + allowInvalidContainer: true, + defaultEdgeType: 'arrow', + renderEdgeLabels: true, + zIndex: true, + labelFont: '"Zen Kaku Gothic New", system-ui, sans-serif', + labelSize: 12, + labelWeight: '400', + edgeLabelFont: '"IBM Plex Mono", monospace', + edgeLabelSize: 10, + minCameraRatio: 0.015, + maxCameraRatio: 6, + stagePadding: 60, + nodeReducer: (id, data) => { + const s = stateRef.current; + const res = { ...data }; + const isSelected = s.selected.has(id); + if (s.activeNodes && !s.activeNodes.has(id) && !isSelected) { + res.color = mix(data.color as string, s.palette.canvas, 0.8); + res.label = ''; + res.zIndex = 0; + } else if (s.activeNodes) { + res.zIndex = 1; + if (s.activeNodes.size <= 60) res.forceLabel = true; + } + if (isSelected || id === s.hover) { + res.highlighted = true; + res.forceLabel = true; + res.zIndex = 2; + res.size = (data.size as number) * 1.35; + } + return res; + }, + edgeReducer: (id, data) => { + const s = stateRef.current; + const res = { ...data }; + if (s.activeEdges) { + if (s.activeEdges.has(id)) { + res.color = data.verified ? s.palette.relationActive : s.palette.relationUnverified; + res.size = data.verified ? 1.8 : 1; + res.label = `${data.relation as string}${data.verified ? '' : ' (derived)'}`; + res.forceLabel = s.activeEdges.size <= 40; + res.zIndex = 2; + } else { + res.color = mix(data.color as string, s.palette.canvas, 0.75); + res.label = ''; + res.zIndex = 0; + } + } else res.label = ''; + return res; + }, + // Hover label drawn with theme colours instead of the library's white box. + defaultDrawNodeHover: (ctx, data, st) => { + const p = stateRef.current.palette; + const size = st.labelSize; + ctx.font = `${st.labelWeight} ${size}px ${st.labelFont}`; + const label = typeof data.label === 'string' ? data.label : ''; + const w = label ? ctx.measureText(label).width + 12 : 0; + ctx.beginPath(); + ctx.arc(data.x, data.y, data.size + 3, 0, Math.PI * 2); + ctx.strokeStyle = p.selection; + ctx.lineWidth = 1.5; + ctx.stroke(); + if (!label) return; + ctx.fillStyle = p.elevated; + ctx.strokeStyle = p.line; + ctx.lineWidth = 1; + const x = data.x + data.size + 6; + ctx.fillRect(x, data.y - size / 2 - 4, w, size + 8); + ctx.strokeRect(x, data.y - size / 2 - 4, w, size + 8); + ctx.fillStyle = p.text; + ctx.fillText(label, x + 6, data.y + size / 3); + }, + }); + } catch (err) { + setFailed((err as Error).message); + return; + } + sigmaRef.current = sigma; + const ws = useWorkspace.getState; + + sigma.on('clickNode', (e) => { + const ev = e.event.original as MouseEvent; + ws().select(e.node, { additive: ev.ctrlKey || ev.metaKey || ev.shiftKey }); + }); + sigma.on('doubleClickNode', (e) => { + e.preventSigmaDefault(); + expandRef.current(e.node); + }); + sigma.on('enterNode', (e) => { + ws().setHover(e.node); + if (container.current) container.current.style.cursor = 'pointer'; + }); + sigma.on('leaveNode', () => { + ws().setHover(null); + if (container.current) container.current.style.cursor = ''; + }); + sigma.on('clickStage', () => ws().clearSelection()); + + // Dragging nodes to rearrange the layout. + let dragged: string | null = null; + let moved = false; + sigma.on('downNode', (e) => { + dragged = e.node; + moved = false; + if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox()); + }); + sigma.getMouseCaptor().on('mousemovebody', (e) => { + if (!dragged) return; + moved = true; + layoutRef.current?.stop(); + const pos = sigma.viewportToGraph(e); + g.mergeNodeAttributes(dragged, { x: pos.x, y: pos.y }); + e.preventSigmaDefault(); + e.original.preventDefault(); + e.original.stopPropagation(); + }); + const release = () => { + if (dragged && moved) saveLayout(); + dragged = null; + }; + sigma.getMouseCaptor().on('mouseup', release); + + // Minimap, redrawn at most every 120 ms. + let pending = false; + const drawMinimap = () => { + pending = false; + const c = minimap.current; + if (!c || g.order === 0) return; + const ctx = c.getContext('2d'); + if (!ctx) return; + const p = stateRef.current.palette; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + g.forEachNode((_, a) => { + if (a.x < minX) minX = a.x; + if (a.x > maxX) maxX = a.x; + if (a.y < minY) minY = a.y; + if (a.y > maxY) maxY = a.y; + }); + const pad = 8; + const sx = (c.width - pad * 2) / Math.max(1e-6, maxX - minX); + const sy = (c.height - pad * 2) / Math.max(1e-6, maxY - minY); + const k = Math.min(sx, sy); + const ox = (c.width - (maxX - minX) * k) / 2; + const oy = (c.height - (maxY - minY) * k) / 2; + const map = (x: number, y: number): [number, number] => [ + ox + (x - minX) * k, + c.height - (oy + (y - minY) * k), + ]; + ctx.clearRect(0, 0, c.width, c.height); + const step = Math.max(1, Math.floor(g.order / 2500)); + let i = 0; + g.forEachNode((id, a) => { + if (i++ % step) return; + const [x, y] = map(a.x, a.y); + ctx.fillStyle = stateRef.current.selected.has(id) ? p.selection : p.textMuted; + const r = stateRef.current.selected.has(id) ? 2.5 : 1; + ctx.fillRect(x - r / 2, y - r / 2, r, r); + }); + const dims = sigma.getDimensions(); + const a = sigma.viewportToGraph({ x: 0, y: 0 }); + const b = sigma.viewportToGraph({ x: dims.width, y: dims.height }); + const [x1, y1] = map(a.x, a.y); + const [x2, y2] = map(b.x, b.y); + ctx.strokeStyle = p.selection; + ctx.lineWidth = 1; + ctx.strokeRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)); + (c as HTMLCanvasElement & { _unmap?: (px: number, py: number) => { x: number; y: number } })._unmap = ( + px, + py, + ) => ({ x: minX + (px - ox) / k, y: minY + (c.height - py - oy) / k }); + }; + sigma.on('afterRender', () => { + if (pending) return; + pending = true; + setTimeout(drawMinimap, 120); + }); + + return () => { + layoutRef.current?.stop(); + sigma.kill(); + sigmaRef.current = null; + graphRef.current = null; + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + /** Double-click: a file unfolds its symbols; any other node unfolds the files around it. */ + const expandAround = (id: string) => { + const n = adj.nodes.get(id); + if (!n) return; + if (n.type === 'File' || n.type === 'Configuration') return expandFile(id); + const files = new Set(); + const own = fileIdOf(n); + if (own) files.add(own); + for (const e of [...(adj.out.get(id) ?? []), ...(adj.in.get(id) ?? [])]) { + const other = adj.nodes.get(e.source === id ? e.target : e.source); + if (!other) continue; + if (other.type === 'File') files.add(other.id); + else if (isSymbolType(other.type)) { + const fid = fileIdOf(other); + if (fid) files.add(fid); + } + } + [...files].slice(0, 40).forEach((f) => expandFile(f, true)); + }; + const expandRef = useRef(expandAround); + expandRef.current = expandAround; + + // ---- sync graph data ---------------------------------------------------- + useEffect(() => { + const g = graphRef.current; + const sigma = sigmaRef.current; + if (!g || !sigma) return; + const saved = useWorkspace.getState().layouts.graph ?? {}; + const wanted = new Set(view.nodes.map((n) => n.id)); + g.forEachNode((id) => { + if (!wanted.has(id)) g.dropNode(id); + }); + const fresh: GraphNode[] = []; + const scale = view.nodes.length > 4000 ? 0.75 : view.nodes.length > 1500 ? 0.9 : 1.15; + for (const n of view.nodes) { + const attrs = { + label: `${FAMILY_GLYPH[NODE_FAMILY[n.type]]} ${n.name}`, + size: NODE_SIZE[n.type] * scale + 1.2, + color: nodeColor(n.type, palette), + nodeType: n.type, + }; + if (g.hasNode(n.id)) g.mergeNodeAttributes(n.id, attrs); + else { + const s = saved[n.id]; + if (!s) fresh.push(n); + g.addNode(n.id, { ...attrs, x: s?.[0] ?? 0, y: s?.[1] ?? 0 }); + } + } + // New nodes start next to something they belong to, so expansion grows outwards from the parent. + const firstRun = fresh.length === view.nodes.length; + if (fresh.length && !firstRun) { + fresh.forEach((n, i) => { + const anchor = [fileIdOf(n), n.metadata.subsystem as string | undefined].find( + (a) => a && a !== n.id && g.hasNode(a) && !fresh.some((f) => f.id === a), + ); + const a = anchor ? g.getNodeAttributes(anchor) : { x: 0, y: 0 }; + const t = i * 2.399963; + const r = 2 + Math.sqrt(i + 1) * 1.6; + g.mergeNodeAttributes(n.id, { x: a.x + Math.cos(t) * r, y: a.y + Math.sin(t) * r }); + }); + } + g.clearEdges(); + for (const e of view.edges) { + if (!g.hasNode(e.source) || !g.hasNode(e.target)) continue; + const structural = STRUCTURE_EDGES.includes(e.type); + g.addEdgeWithKey(e.id, e.source, e.target, { + size: !e.verified ? 0.5 : structural ? 0.6 : 1, + color: mix( + e.verified ? palette.textMuted : palette.relationUnverified, + palette.canvas, + structural ? 0.55 : 0.3, + ), + type: structural ? 'line' : 'arrow', + relation: EDGE_TYPE_LABELS[e.type], + verified: e.verified, + weight: structural ? 2 : 1, + }); + } + sigma.setSetting('hideEdgesOnMove', view.edges.length > 5000); + sigma.setSetting('hideLabelsOnMove', view.nodes.length > 5000); + if (sigma.getCustomBBox()) sigma.setCustomBBox(null); + if (firstRun) runLayout(true); + else if (fresh.length) runLayout(false); + else sigma.refresh(); + }, [view, palette, runLayout]); + + // ---- settings and theme ------------------------------------------------- + useEffect(() => { + const sigma = sigmaRef.current; + if (!sigma) return; + sigma.setSetting('labelColor', { color: palette.text }); + sigma.setSetting('edgeLabelColor', { color: palette.textSecondary }); + sigma.setSetting('labelRenderedSizeThreshold', LABEL_THRESHOLD[settings.graph.labelDensity]); + sigma.setSetting('labelDensity', LABEL_DENSITY[settings.graph.labelDensity]); + stateRef.current.palette = palette; + sigma.refresh(); + }, [palette, theme, settings.graph.labelDensity]); + + // ---- selection, hover and highlight ------------------------------------ + useEffect(() => { + const s = stateRef.current; + s.selected = new Set(selectedIds); + s.primary = primaryId; + s.hover = hoverId; + const centre = hoverId ?? primaryId; + if (centre && graphRef.current?.hasNode(centre)) { + const nb = neighbourhood(centre, view.edges); + for (const id of selectedIds) + if (id !== centre) for (const x of neighbourhood(id, view.edges).nodes) nb.nodes.add(x); + s.activeNodes = nb.nodes; + s.activeEdges = nb.edges; + } else if (highlight) { + s.activeNodes = new Set(highlight.nodeIds); + s.activeEdges = new Set(highlight.edgeIds); + } else { + s.activeNodes = null; + s.activeEdges = null; + } + sigmaRef.current?.refresh({ skipIndexation: true }); + }, [selectedIds, primaryId, hoverId, highlight, view]); + + // ---- bring the selection into view ------------------------------------- + useEffect(() => { + const sigma = sigmaRef.current; + const id = useWorkspace.getState().primaryId ?? useWorkspace.getState().focusId; + if (!sigma || revealTick === 0) return; + const t = setTimeout(() => { + if (id && graphRef.current?.hasNode(id) && settings.graph.focusBehavior !== 'none') { + const d = sigma.getNodeDisplayData(id); + if (d) + void sigma + .getCamera() + .animate( + { x: d.x, y: d.y, ratio: Math.min(sigma.getCamera().ratio, 0.4) }, + { duration: reducedMotion ? 0 : 700 }, + ); + } else if (!id) void sigma.getCamera().animatedReset({ duration: reducedMotion ? 0 : 600 }); + }, 60); + return () => clearTimeout(t); + }, [revealTick, reducedMotion, settings.graph.focusBehavior]); + + const fit = () => { + const sigma = sigmaRef.current; + if (!sigma) return; + sigma.setCustomBBox(null); + void sigma.getCamera().animatedReset({ duration: reducedMotion ? 0 : 500 }); + }; + const exportPng = () => { + const sigma = sigmaRef.current; + if (!sigma || !container.current) return; + sigma.once('afterRender', () => { + const { width, height } = sigma.getDimensions(); + const ratio = window.devicePixelRatio || 1; + const out = document.createElement('canvas'); + out.width = width * ratio; + out.height = height * ratio; + const ctx = out.getContext('2d')!; + ctx.fillStyle = palette.canvas; + ctx.fillRect(0, 0, out.width, out.height); + container.current!.querySelectorAll('canvas').forEach((c) => { + if (c !== minimap.current) ctx.drawImage(c, 0, 0, out.width, out.height); + }); + downloadUrl('vectora-graph.png', out.toDataURL('image/png')); + }); + sigma.refresh(); + }; + const onKeyDown = (e: React.KeyboardEvent) => { + const sigma = sigmaRef.current; + if (!sigma) return; + const cam = sigma.getCamera(); + const step = 0.12 * cam.ratio; + const dur = { duration: reducedMotion ? 0 : 160 }; + if (e.key === '+' || e.key === '=') void cam.animatedZoom(dur); + else if (e.key === '-') void cam.animatedUnzoom(dur); + else if (e.key === '0') fit(); + else if (e.key === 'ArrowLeft') void cam.animate({ x: cam.x - step }, dur); + else if (e.key === 'ArrowRight') void cam.animate({ x: cam.x + step }, dur); + else if (e.key === 'ArrowUp') void cam.animate({ y: cam.y + step }, dur); + else if (e.key === 'ArrowDown') void cam.animate({ y: cam.y - step }, dur); + else if (e.key === 'n' || e.key === 'p') { + // Step through the neighbours of the selection (or all nodes) without a pointer. + const ws = useWorkspace.getState(); + const pool = ws.primaryId + ? [...neighbourhood(ws.primaryId, view.edges).nodes] + : view.nodes.map((n) => n.id); + if (!pool.length) return; + const i = pool.indexOf(ws.hoverId ?? ws.primaryId ?? ''); + ws.setHover(pool[(i + (e.key === 'n' ? 1 : -1) + pool.length) % pool.length]!); + } else if (e.key === 'Enter') { + const ws = useWorkspace.getState(); + if (ws.hoverId) ws.select(ws.hoverId, { reveal: true }); + } else return; + e.preventDefault(); + }; + + if (failed) + return ( +
+

The graph renderer could not start

+

+ WebGL is not available in this browser ({failed}). The Mind Map and Search views work without it. +

+
+ ); + + return ( +
+ + + + + + +
+ { + const sigma = sigmaRef.current; + const c = minimap.current as + (HTMLCanvasElement & { _unmap?: (px: number, py: number) => { x: number; y: number } }) | null; + if (!sigma || !c?._unmap) return; + const r = c.getBoundingClientRect(); + const gp = c._unmap( + ((e.clientX - r.left) / r.width) * c.width, + ((e.clientY - r.top) / r.height) * c.height, + ); + const framed = sigma.viewportToFramedGraph(sigma.graphToViewport(gp)); + void sigma.getCamera().animate({ x: framed.x, y: framed.y }, { duration: reducedMotion ? 0 : 400 }); + }} + /> +

+ Click to inspect, double-click to unfold, Ctrl-click to add to the selection, drag to rearrange +

+
+ ); +} diff --git a/apps/web/src/workspace/MindMap.tsx b/apps/web/src/workspace/MindMap.tsx new file mode 100644 index 0000000..67e6098 --- /dev/null +++ b/apps/web/src/workspace/MindMap.tsx @@ -0,0 +1,637 @@ +import dagre from '@dagrejs/dagre'; +import { + Background, + Controls, + type Edge, + Handle, + MiniMap, + type Node, + type NodeProps, + Position, + ReactFlow, + ReactFlowProvider, + getNodesBounds, + getViewportForBounds, + useReactFlow, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { toPng, toSvg } from 'html-to-image'; +import { ChevronRight, Image as ImageIcon, RotateCcw } from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + EDGE_TYPES, + EDGE_TYPE_LABELS, + type EdgeType, + type GraphNode, + isSymbolType, +} from '@vectora/graph-schema'; +import { ViewToolbar } from '@/components/ViewControls'; +import { cx } from '@/components/ui'; +import { downloadUrl } from '@/lib/api'; +import { type Adjacency, fileIdOf } from '@/lib/graphView'; +import { useWorkspace } from '@/lib/store'; +import { FAMILY_GLYPH, NODE_FAMILY } from '@/lib/tokens'; +import { useWs } from './context'; + +interface TreeItem { + /** Tree-unique key. A graph node can appear under several branches (e.g. a file under a subsystem and a concept). */ + key: string; + nodeId: string | null; + label: string; + node: GraphNode | null; + children: TreeItem[]; + derived?: boolean; +} + +const dirOf = (p: string): string => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''); + +/** + * Builds the conceptual hierarchy from the knowledge graph: + * repository → subsystems → folders → files → symbols, plus a branch of concepts. + */ +export function buildTree(adj: Adjacency, hiddenTypes: ReadonlySet): TreeItem | null { + const repo = adj.nodes.get('repo:root'); + if (!repo) return null; + const symbolChildren = (parentId: string, prefix: string, depth = 0): TreeItem[] => + depth > 3 + ? [] + : (adj.out.get(parentId) ?? []) + .filter((e) => e.type === 'CONTAINS') + .map((e) => adj.nodes.get(e.target)) + .filter((n): n is GraphNode => !!n && isSymbolType(n.type) && !hiddenTypes.has(n.type)) + .sort((a, b) => (a.location?.startLine ?? 0) - (b.location?.startLine ?? 0)) + .map((n) => ({ + key: `${prefix}>${n.id}`, + nodeId: n.id, + label: n.name, + node: n, + children: symbolChildren(n.id, prefix, depth + 1), + })); + const fileItem = (f: GraphNode, prefix: string): TreeItem => ({ + key: `${prefix}>${f.id}`, + nodeId: f.id, + label: f.name, + node: f, + children: symbolChildren(f.id, prefix), + }); + + const subsystems = [...adj.nodes.values()] + .filter((n) => n.type === 'Subsystem') + .sort((a, b) => Number(b.metadata.files ?? 0) - Number(a.metadata.files ?? 0)); + const subsystemItems: TreeItem[] = subsystems.map((s) => { + const base = String(s.filePath ?? ''); + const files = (adj.in.get(s.id) ?? []) + .filter((e) => e.type === 'BELONGS_TO') + .map((e) => adj.nodes.get(e.source)) + .filter((n): n is GraphNode => !!n && !hiddenTypes.has(n.type)); + // Group files by folder below the subsystem root. + const root: TreeItem = { key: s.id, nodeId: s.id, label: s.name, node: s, children: [] }; + const folders = new Map([['', root]]); + const folder = (rel: string): TreeItem => { + const existing = folders.get(rel); + if (existing) return existing; + const parent = folder(dirOf(rel)); + const full = base ? `${base}/${rel}` : rel; + const dirNode = adj.nodes.get(`dir:${full}`) ?? null; + const item: TreeItem = { + key: `${s.id}>dir:${full}`, + nodeId: dirNode?.id ?? null, + label: rel.slice(rel.lastIndexOf('/') + 1), + node: dirNode, + children: [], + }; + folders.set(rel, item); + parent.children.push(item); + return item; + }; + for (const f of files.sort((a, b) => (a.filePath! < b.filePath! ? -1 : 1))) { + const rel = + base && f.filePath!.startsWith(`${base}/`) ? f.filePath!.slice(base.length + 1) : f.filePath!; + folder(dirOf(rel)).children.push(fileItem(f, s.id)); + } + // A folder that only holds one folder is merged into it (`src/app` instead of `src` → `app`). + const squash = (item: TreeItem): TreeItem => { + item.children = item.children.map(squash); + if ( + item !== root && + item.node?.type !== 'File' && + item.children.length === 1 && + item.children[0]!.children.length && + !item.children[0]!.node?.location + ) { + const only = item.children[0]!; + return { ...only, label: `${item.label}/${only.label}` }; + } + return item; + }; + return squash(root); + }); + + const concepts = [...adj.nodes.values()].filter((n) => n.type === 'Concept'); + const conceptBranch: TreeItem | null = concepts.length + ? { + key: 'branch:concepts', + nodeId: null, + label: 'Concepts', + node: null, + derived: true, + children: concepts + .map((c) => ({ + key: c.id, + nodeId: c.id, + label: c.name, + node: c, + derived: true, + children: (adj.in.get(c.id) ?? []) + .filter((e) => e.type === 'BELONGS_TO') + .sort((a, b) => b.confidence - a.confidence) + .map((e) => adj.nodes.get(e.source)) + .filter((n): n is GraphNode => !!n) + .map((f) => fileItem(f, c.id)), + })) + .sort((a, b) => b.children.length - a.children.length), + } + : null; + return { + key: repo.id, + nodeId: repo.id, + label: repo.name, + node: repo, + children: [...subsystemItems, ...(conceptBranch ? [conceptBranch] : [])], + }; +} + +interface MindData extends Record { + item: TreeItem; + collapsed: boolean; + depth: number; + selected: boolean; + dimmed: boolean; + matched: boolean; + onToggle: (key: string) => void; +} + +const MindNode = memo(function MindNode({ data }: NodeProps>) { + const { item, collapsed, depth, selected, dimmed, matched } = data; + const count = item.children.length; + return ( +
+ + {item.node ? ( + + ) : null} + + {item.label} + + {count ? ( + + ) : null} + +
+ ); +}); + +const nodeTypes = { mind: MindNode }; +const NODE_H = 34; +const widthOf = (item: TreeItem, depth: number) => + Math.min(300, Math.max(128, item.label.length * (depth === 0 ? 10.5 : depth === 1 ? 9 : 7.6) + 84)); + +function MindMapInner() { + const { adj, graph, palette, reveal, reducedMotion } = useWs(); + const flow = useReactFlow(); + const selectedIds = useWorkspace((s) => s.selectedIds); + const hiddenNodeTypes = useWorkspace((s) => s.hiddenNodeTypes); + const revealTick = useWorkspace((s) => s.revealTick); + const layouts = useWorkspace((s) => s.layouts); + const setLayout = useWorkspace((s) => s.setLayout); + const [query, setQuery] = useState(''); + const [relation, setRelation] = useState(''); + const [drag, setDrag] = useState<{ id: string; x: number; y: number } | null>(null); + + // Folders are part of the hierarchy here even when hidden in the other views. + const hidden = useMemo( + () => + new Set(hiddenNodeTypes.filter((t) => t !== 'Directory' && t !== 'Module' && t !== 'Configuration')), + [hiddenNodeTypes], + ); + const tree = useMemo(() => buildTree(adj, hidden), [adj, hidden]); + + // Collapsed state: everything below the second level starts collapsed. + const defaultCollapsed = useMemo(() => { + const out: string[] = []; + const walk = (item: TreeItem, depth: number) => { + if (depth >= 1 && item.children.length) out.push(item.key); + item.children.forEach((c) => walk(c, depth + 1)); + }; + if (tree) walk(tree, 0); + return out; + }, [tree]); + const childKeys = useMemo(() => { + const m = new Map(); + const walk = (item: TreeItem) => { + m.set( + item.key, + item.children.map((c) => c.key), + ); + item.children.forEach(walk); + }; + if (tree) walk(tree); + return m; + }, [tree]); + const collapsedList = layouts.mindmapCollapsed ?? defaultCollapsed; + const collapsed = useMemo(() => new Set(collapsedList), [collapsedList]); + const toggle = useCallback( + (key: string) => { + const next = new Set(useWorkspace.getState().layouts.mindmapCollapsed ?? defaultCollapsed); + const expanding = next.has(key); + if (expanding) next.delete(key); + else next.add(key); + setLayout('mindmapCollapsed', [...next]); + // Keep a newly opened branch on screen: pan (and zoom out if needed) to show it with its children. + if (expanding) + setTimeout(() => { + const ids = [key, ...(childKeys.get(key) ?? []).slice(0, 60)].filter((id) => flow.getNode(id)); + void flow.fitView({ + nodes: ids.map((id) => ({ id })), + padding: 0.25, + maxZoom: flow.getZoom(), + duration: reducedMotion ? 0 : 380, + }); + }, 80); + }, + [defaultCollapsed, setLayout, childKeys, flow, reducedMotion], + ); + + // Paths from the root to every tree item, for search and reveal. + const index = useMemo(() => { + const pathTo = new Map(); + const byNode = new Map(); + const walk = (item: TreeItem, path: string[]) => { + pathTo.set(item.key, path); + if (item.nodeId) + (byNode.get(item.nodeId) ?? byNode.set(item.nodeId, []).get(item.nodeId)!).push(item.key); + item.children.forEach((c) => walk(c, [...path, item.key])); + }; + if (tree) walk(tree, []); + return { pathTo, byNode }; + }, [tree]); + + const expandTo = useCallback( + (key: string) => { + const path = index.pathTo.get(key); + if (!path) return; + const next = new Set(useWorkspace.getState().layouts.mindmapCollapsed ?? defaultCollapsed); + path.forEach((k) => next.delete(k)); + setLayout('mindmapCollapsed', [...next]); + }, + [index, defaultCollapsed, setLayout], + ); + + const matches = useMemo(() => { + const q = query.trim().toLowerCase(); + if (q.length < 2 || !tree) return [] as string[]; + const out: string[] = []; + const walk = (item: TreeItem) => { + if (item.label.toLowerCase().includes(q) || item.node?.filePath?.toLowerCase().includes(q)) + out.push(item.key); + item.children.forEach(walk); + }; + walk(tree); + return out.slice(0, 200); + }, [query, tree]); + + // ---- visible items and layout ------------------------------------------ + const { nodes, edges } = useMemo(() => { + if (!tree) return { nodes: [] as Node[], edges: [] as Edge[] }; + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'LR', nodesep: 10, ranksep: 56, marginx: 24, marginy: 24 }); + g.setDefaultEdgeLabel(() => ({})); + const visible: { item: TreeItem; depth: number; parent: string | null }[] = []; + const walk = (item: TreeItem, depth: number, parent: string | null) => { + visible.push({ item, depth, parent }); + g.setNode(item.key, { width: widthOf(item, depth), height: depth === 0 ? 42 : NODE_H }); + if (parent) g.setEdge(parent, item.key); + if (!collapsed.has(item.key)) item.children.slice(0, 250).forEach((c) => walk(c, depth + 1, item.key)); + }; + walk(tree, 0, null); + dagre.layout(g); + const selected = new Set(selectedIds); + const matchSet = new Set(matches); + const overrides = layouts.mindmap ?? {}; + const rfNodes: Node[] = visible.map(({ item, depth }) => { + const p = g.node(item.key); + const o = overrides[item.key]; + return { + id: item.key, + type: 'mind', + position: o ? { x: o[0], y: o[1] } : { x: p.x - p.width / 2, y: p.y - p.height / 2 }, + data: { + item, + collapsed: collapsed.has(item.key), + depth, + selected: !!item.nodeId && selected.has(item.nodeId), + dimmed: matchSet.size > 0 && !matchSet.has(item.key), + matched: matchSet.has(item.key), + onToggle: toggle, + }, + width: p.width, + height: p.height, + }; + }); + const rfEdges: Edge[] = visible + .filter((v) => v.parent) + .map((v) => ({ + id: `tree:${v.parent}>${v.item.key}`, + source: v.parent!, + target: v.item.key, + type: 'smoothstep', + selectable: false, + style: { stroke: palette.ash, strokeWidth: 1, strokeDasharray: v.item.derived ? '4 4' : undefined }, + })); + + // Relationship overlay: draw the chosen relationship between visible items, + // lifting each end to its nearest visible ancestor. + if (relation) { + const visibleByNode = new Map(); + for (const v of visible) + if (v.item.nodeId && !visibleByNode.has(v.item.nodeId)) visibleByNode.set(v.item.nodeId, v.item.key); + const lift = (id: string): string | null => { + let cur: GraphNode | undefined = adj.nodes.get(id); + for (let i = 0; cur && i < 6; i++) { + const k = visibleByNode.get(cur.id); + if (k) return k; + const parentEdge = (adj.in.get(cur.id) ?? []).find((e) => e.type === 'CONTAINS'); + cur = parentEdge + ? adj.nodes.get(parentEdge.source) + : cur.filePath && cur.id !== fileIdOf(cur) + ? adj.nodes.get(fileIdOf(cur)!) + : undefined; + } + return null; + }; + const seen = new Map(); + for (const e of graph.edges) { + if (e.type !== relation) continue; + const a = lift(e.source); + const b = lift(e.target); + if (!a || !b || a === b) continue; + const k = `${a}|${b}`; + const cur = seen.get(k); + if (cur) cur.count++; + else seen.set(k, { count: 1, verified: e.verified }); + if (seen.size > 400) break; + } + for (const [k, v] of seen) { + const [a, b] = k.split('|') as [string, string]; + rfEdges.push({ + id: `rel:${k}`, + source: a, + target: b, + type: 'default', + selectable: false, + label: v.count > 1 ? `${EDGE_TYPE_LABELS[relation]} ×${v.count}` : EDGE_TYPE_LABELS[relation], + labelStyle: { fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, fill: palette.textSecondary }, + labelBgStyle: { fill: palette.canvas }, + style: { + stroke: v.verified ? palette.relationActive : palette.relationUnverified, + strokeWidth: 1.2, + strokeDasharray: v.verified ? undefined : '5 4', + }, + markerEnd: { + type: 'arrowclosed' as never, + color: v.verified ? palette.relationActive : palette.relationUnverified, + width: 14, + height: 14, + }, + zIndex: 5, + }); + } + } + return { nodes: rfNodes, edges: rfEdges }; + }, [tree, collapsed, selectedIds, matches, layouts.mindmap, palette, relation, adj, graph.edges, toggle]); + + // While a branch is being dragged only its position changes; the layout is not recomputed. + const displayNodes = useMemo( + () => + drag ? nodes.map((n) => (n.id === drag.id ? { ...n, position: { x: drag.x, y: drag.y } } : n)) : nodes, + [nodes, drag], + ); + + // ---- focus: bring a node into view ------------------------------------- + const centreOn = useCallback( + (key: string) => { + expandTo(key); + setTimeout(() => { + const n = flow.getNode(key); + if (n) + void flow.setCenter(n.position.x + (n.width ?? 120) / 2, n.position.y + NODE_H / 2, { + zoom: Math.max(flow.getZoom(), 0.9), + duration: reducedMotion ? 0 : 420, + }); + }, 60); + }, + [expandTo, flow, reducedMotion], + ); + useEffect(() => { + if (revealTick === 0) return; + const id = useWorkspace.getState().primaryId; + const key = id ? index.byNode.get(id)?.[0] : null; + if (key) centreOn(key); + }, [revealTick]); // eslint-disable-line react-hooks/exhaustive-deps + + const exportImage = async (kind: 'png' | 'svg') => { + const el = document.querySelector('.react-flow__viewport'); + if (!el) return; + const bounds = getNodesBounds(flow.getNodes()); + const width = Math.min(8000, Math.max(800, bounds.width + 160)); + const height = Math.min(8000, Math.max(500, bounds.height + 160)); + const vp = getViewportForBounds(bounds, width, height, 0.2, 2, 0.05); + const options = { + backgroundColor: palette.canvas, + width, + height, + // Fonts are bundled with the application; skipping remote font inlining keeps the export offline. + skipFonts: true, + style: { + width: `${width}px`, + height: `${height}px`, + transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`, + }, + }; + const url = kind === 'png' ? await toPng(el, options) : await toSvg(el, options); + downloadUrl(`vectora-mindmap.${kind}`, url); + }; + + if (!tree) return null; + return ( +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && matches[0]) { + centreOn(matches[0]); + const item = nodes.find((n) => n.id === matches[0])?.data.item; + if (item?.nodeId) useWorkspace.getState().select(item.nodeId); + } + }} + /> + {query.trim().length >= 2 ? ( + {matches.length} found — Enter focuses the first + ) : null} + + + + + + + 600} + proOptions={{ hideAttribution: false }} + onNodeClick={(e, n) => { + const item = (n.data as MindData).item; + if (item.nodeId) + useWorkspace.getState().select(item.nodeId, { additive: e.ctrlKey || e.metaKey || e.shiftKey }); + }} + onNodeDoubleClick={(_e, n) => { + const item = (n.data as MindData).item; + if (item.children.length) toggle(item.key); + else if (item.nodeId) reveal(item.nodeId); + }} + onNodeDrag={(_e, n) => setDrag({ id: n.id, x: n.position.x, y: n.position.y })} + onNodeDragStop={(_e, n) => { + setDrag(null); + // Rearranged branches keep their position for this workspace. + setLayout('mindmap', { + ...(useWorkspace.getState().layouts.mindmap ?? {}), + [n.id]: [Math.round(n.position.x), Math.round(n.position.y)], + }); + }} + onNodesChange={() => undefined} + onPaneClick={() => useWorkspace.getState().clearSelection()} + aria-label="Hierarchical mind map of the repository" + > + + + + +
+ ); +} + +export function MindMap() { + return ( + + + + ); +} diff --git a/apps/web/src/workspace/Overview.tsx b/apps/web/src/workspace/Overview.tsx new file mode 100644 index 0000000..5a0ce84 --- /dev/null +++ b/apps/web/src/workspace/Overview.tsx @@ -0,0 +1,279 @@ +import { Empty, Loading, SectionHeading } from '@/components/ui'; +import { useOverview } from '@/lib/api'; +import { useWorkspace } from '@/lib/store'; +import { useWs } from './context'; + +export function Overview() { + const { repo, reveal, openView, settings } = useWs(); + const { data: o, isLoading, error } = useOverview(repo.id); + const setFocus = useWorkspace((s) => s.setFocus); + if (isLoading) return ; + if (error || !o) + return {(error as Error | null)?.message}; + + const show = (id: string, view: 'graph' | 'atlas' | 'mindmap' = settings.graph.defaultMode) => { + reveal(id); + openView(view); + }; + const analysed = o.languages.filter((l) => l.tier > 0); + const other = o.languages.filter((l) => l.tier === 0); + const maxFiles = Math.max(1, ...analysed.map((l) => l.files)); + + return ( +
+
+ {/* Lead column, deliberately narrow and offset */} +
+

{repo.sourceKind === 'sample' ? 'Bundled sample repository' : repo.origin}

+

{repo.name}

+ {o.description ? ( +

+ {o.description} — {o.descriptionSource} +

+ ) : ( +

No description was found in the manifests or the README.

+ )} +
+ + + +
+
+ + + +
+ Architecture summary +
    + {o.architectureSummary.map((s, i) => ( +
  • +

    + {s.statement} {!s.verified ? derived : null} +

    +

    Evidence: {s.evidence}

    +
  • + ))} +
+
+ +
+ Languages +
    + {analysed.map((l) => ( +
  • +
    + {l.language} + + {l.analyzedFiles}/{l.files} files, {l.symbols.toLocaleString()} symbols + +
    +
    +
    +
    +

    + {l.adapter ?? 'not analysed'} + {l.parseErrors ? `, ${l.parseErrors} parse notes` : ''} +

    +
  • + ))} +
+ {other.length ? ( +

+ Detected but not analysed at symbol level:{' '} + {other.map((l) => `${l.language} (${l.files})`).join(', ')}. +

+ ) : null} + {o.frameworks.length ? ( + <> +

Frameworks

+
    + {o.frameworks.map((f) => ( +
  • + {f.name} +
  • + ))} +
+ + ) : null} +
+ +
+ + Major subsystems + + + + + + + + + + + + + {o.subsystems.map((s) => ( + + + + + + + + ))} + +
SubsystemFilesSymbols + Out + + In +
+ + {s.files}{s.symbols}{s.outbound}{s.inbound}
+
+ +
+ Main entry points + {o.entryPoints.length === 0 ? ( +

No entry point could be identified from manifests or file conventions.

+ ) : null} + {o.entryPoints.map((e) => ( + + ))} + {o.concepts.length ? ( + <> +
+ Concepts +
+ {o.concepts.map((c) => ( + + ))} + + ) : null} +
+ +
+ Largest modules + + + + + + + + + + {o.largestModules.map((m) => ( + + + + + + ))} + +
FileSymbolsLines
+ + {m.symbols}{m.lines.toLocaleString()}
+
+ +
+ Dependency statistics +
+ {[ + ['Declared', o.dependencies.declared], + ['Imported', o.dependencies.imported], + ['Internal imports', o.dependencies.internalImports], + ].map(([k, v]) => ( +
+
{k}
+
{(v as number).toLocaleString()}
+
+ ))} +
+
    + {o.dependencies.externalPackages + .filter((p) => p.importers > 0) + .slice(0, 8) + .map((p) => ( +
  • + {p.name} + + {p.importers} file{p.importers === 1 ? '' : 's'} + {p.declared ? '' : ', not declared'} + +
  • + ))} +
+

+ +

+
+
+
+ ); +} diff --git a/apps/web/src/workspace/SearchView.tsx b/apps/web/src/workspace/SearchView.tsx new file mode 100644 index 0000000..ef3b541 --- /dev/null +++ b/apps/web/src/workspace/SearchView.tsx @@ -0,0 +1,309 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { NODE_TYPES, NODE_TYPE_LABELS, type NodeType } from '@vectora/graph-schema'; +import type { ExplorationAnswer } from '@vectora/shared'; +import { Notice, TypeMark, cx } from '@/components/ui'; +import { api } from '@/lib/api'; +import { useWorkspace } from '@/lib/store'; +import { useWs } from './context'; + +const EXAMPLES = [ + 'Explain authentication', + 'Identify circular dependencies', + 'Explain the application startup sequence', + 'Show the complete database access path', +]; + +export function SearchView() { + const { repo, graph, reveal, openView, settings } = useWs(); + const [params, setParams] = useSearchParams(); + const mode = params.get('mode') === 'questions' ? 'questions' : 'search'; + const [input, setInput] = useState(params.get('q') ?? ''); + const [query, setQuery] = useState(input.trim()); + const [types, setTypes] = useState([]); + const [question, setQuestion] = useState(''); + const setHighlight = useWorkspace((s) => s.setHighlight); + useEffect(() => { + const t = setTimeout(() => setQuery(input.trim()), 180); + return () => clearTimeout(t); + }, [input]); + const search = useQuery({ + queryKey: ['search', repo.id, query, types.join(',')], + queryFn: () => api.search(repo.id, query, types), + enabled: mode === 'search' && query.length >= 2, + placeholderData: (p) => p, + }); + const ask = useMutation({ mutationFn: (q: string) => api.ask(repo.id, q) }); + const answer: ExplorationAnswer | undefined = ask.data; + + const present = NODE_TYPES.filter((t) => (graph.stats.nodesByType[t] ?? 0) > 0 && t !== 'Repository'); + const open = (id: string) => { + reveal(id); + openView(settings.graph.defaultMode); + }; + const submit = (q: string) => { + const text = q.trim(); + if (text.length < 2) return; + setQuestion(text); + ask.mutate(text); + }; + + return ( +
+
+

Search

+
+ {( + [ + ['search', 'Find by name or path'], + ['questions', 'Ask about the repository'], + ] as const + ).map(([id, label]) => ( + + ))} +
+
+ + {mode === 'search' ? ( +
+
+ setInput(e.target.value)} + autoFocus + data-testid="search-input" + /> +
+ {present.map((t) => ( + + ))} +
+ {search.error ? ( +
+ {(search.error as Error).message} +
+ ) : null} + {query.length < 2 ? ( +

+ Type at least two characters. Exact names rank first, then prefixes, partial matches, paths + and descriptions. +

+ ) : search.data ? ( + <> +

+ {search.data.results.length} graph results + {search.data.documents.length + ? `, ${search.data.documents.length} documentation matches` + : ''}{' '} + for “{search.data.query}” +

+
    + {search.data.results.map((r) => ( +
  • + +
  • + ))} +
+ {search.data.results.length === 0 && search.data.documents.length === 0 ? ( +

+ Nothing matches. Check the spelling, or search for part of a file path. +

+ ) : null} + {search.data.documents.length ? ( + <> +

Documentation

+
    + {search.data.documents.map((d) => ( +
  • + +
  • + ))} +
+ + ) : null} + + ) : null} +
+
+ ) : ( +
+
+

+ Questions are answered from the knowledge graph. Every statement cites the file and line it + comes from, and anything the graph cannot establish is listed separately. +

+
{ + e.preventDefault(); + submit(new FormData(e.currentTarget).get('question') as string); + }} + > + + +
+
+ {EXAMPLES.map((e) => ( + + ))} +
+ {ask.error ? ( +
+ {(ask.error as Error).message} +
+ ) : null} + {answer ? ( +
+

{question}

+
+

{answer.title}

+ {answer.nodeIds.length ? ( + + ) : null} +
+ {answer.narrative ? ( +
+

{answer.narrative}

+

+ Summary written by the optional narrative service at {answer.narrativeSource}. The + evidence below comes from the graph. +

+
+ ) : null} +
    + {answer.statements.map((s, i) => ( +
  1. +

    + {' '} + {!s.verified ? derived : null} +

    + {s.evidence.length ? ( +

    + {s.evidence.slice(0, 4).map((ev, j) => ( + + ))} +

    + ) : null} +
  2. + ))} +
+ {answer.caveats.length ? ( +
+

Not established

+
    + {answer.caveats.map((c, i) => ( +
  • {c}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+
+ )} +
+ ); +} + +/** Renders `code` spans from statement text. The text is inserted as text nodes only. */ +function Formatted({ text }: { text: string }) { + const parts = text.split(/(`[^`]+`)/g); + return ( + <> + {parts.map((p, i) => + p.startsWith('`') && p.endsWith('`') ? ( + + {p.slice(1, -1)} + + ) : ( + {p} + ), + )} + + ); +} diff --git a/apps/web/src/workspace/SettingsView.tsx b/apps/web/src/workspace/SettingsView.tsx new file mode 100644 index 0000000..df9cbaa --- /dev/null +++ b/apps/web/src/workspace/SettingsView.tsx @@ -0,0 +1,527 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { type ReactNode, useEffect, useState } from 'react'; +import { EDGE_TYPES, EDGE_TYPE_LABELS } from '@vectora/graph-schema'; +import { DEFAULT_IGNORE_PATTERNS, type Settings, formatBytes } from '@vectora/shared'; +import { Dialog, Loading, Notice, NumberField, SelectField, Toggle } from '@/components/ui'; +import { api, useHealth, useSettings } from '@/lib/api'; +import { useWorkspace } from '@/lib/store'; + +function Group({ title, intro, children }: { title: string; intro?: string; children: ReactNode }) { + const id = `settings-${title.toLowerCase().replace(/\s+/g, '-')}`; + return ( +
+
+

+ {title} +

+ {intro ?

{intro}

: null} +
+
{children}
+
+ ); +} + +export function SettingsView() { + const { settings, update, error } = useSettings(); + const health = useHealth(); + const qc = useQueryClient(); + const data = useQuery({ queryKey: ['data-info'], queryFn: api.dataInfo }); + const credentials = useQuery({ queryKey: ['credentials'], queryFn: api.credentials }); + const [ignoreText, setIgnoreText] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [message, setMessage] = useState(null); + const [endpoint, setEndpoint] = useState(null); + const [model, setModel] = useState(null); + useEffect(() => { + if (!message) return; + const t = setTimeout(() => setMessage(null), 4000); + return () => clearTimeout(t); + }, [message]); + if (!settings) return ; + + const set = (section: K, values: Partial) => + update({ [section]: values } as Record>); + const hasCredential = (name: string) => credentials.data?.some((c) => c.name === name) ?? false; + const saveCredential = async (name: string, value: string) => { + await api.setCredential(name, value); + await qc.invalidateQueries({ queryKey: ['credentials'] }); + setMessage('Credential saved. It is stored encrypted on this machine and is never shown again.'); + }; + const removeCredential = async (name: string) => { + await api.deleteCredential(name); + await qc.invalidateQueries({ queryKey: ['credentials'] }); + setMessage('Credential removed.'); + }; + const toggleRelationship = (t: string) => { + const hidden = settings.graph.hiddenRelationships.includes(t) + ? settings.graph.hiddenRelationships.filter((x) => x !== t) + : [...settings.graph.hiddenRelationships, t]; + set('graph', { hiddenRelationships: hidden }); + // Apply to the open workspace as well, so the change is visible straight away. + const ws = useWorkspace.getState(); + if (ws.repoId) + ws.setHiddenEdgeTypes( + hidden.includes(t) + ? [...new Set([...ws.hiddenEdgeTypes, t])] + : ws.hiddenEdgeTypes.filter((x) => x !== t), + ); + }; + const ignoreValue = ignoreText ?? settings.repository.ignorePatterns.join('\n'); + + return ( +
+
+

Settings

+

Changes are saved as you make them and are kept by the local engine.

+
+ {error ? ( + {(error as Error).message} + ) : message ? ( + {message} + ) : null} +
+ + + set('appearance', { theme })} + options={[ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'Follow the system' }, + ]} + /> + + + + set('graph', { defaultMode })} + options={[ + { value: 'graph', label: 'Graph Explorer (2D)' }, + { value: 'atlas', label: '3D Atlas' }, + { value: 'mindmap', label: 'Mind Map' }, + ]} + /> + set('graph', { nodeDensity })} + options={[ + { value: 'structure', label: 'Structure' }, + { value: 'balanced', label: 'Balanced' }, + { value: 'full', label: 'Full' }, + ]} + /> + set('graph', { labelDensity })} + options={[ + { value: 'low', label: 'Few labels' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'Many labels' }, + ]} + /> + set('graph', { animationIntensity })} + options={[ + { value: 'none', label: 'None' }, + { value: 'subtle', label: 'Subtle' }, + { value: 'standard', label: 'Standard' }, + ]} + /> + set('graph', { focusBehavior })} + options={[ + { value: 'neighbourhood', label: 'Move to the node, focus shows two steps' }, + { value: 'node', label: 'Move to the node, focus shows one step' }, + { value: 'none', label: 'Do not move the camera' }, + ]} + /> + set('graph', { showUnverified })} + /> +
+ Relationship visibility +

+ Unchecked relationships are hidden by default in new workspaces and in the open one. +

+
+ {EDGE_TYPES.map((t) => ( + + ))} +
+
+
+ + + set('performance', { workerConcurrency })} + /> + set('performance', { indexingCache })} + /> + set('performance', { renderingQuality })} + options={[ + { value: 'low', label: 'Low' }, + { value: 'balanced', label: 'Balanced' }, + { value: 'high', label: 'High' }, + ]} + /> + set('performance', { reducedMotion })} + options={[ + { value: 'system', label: 'Follow the system' }, + { value: 'on', label: 'Always reduce motion' }, + { value: 'off', label: 'Allow motion' }, + ]} + /> + set('performance', { maxFiles })} + /> + set('performance', { maxGraphNodes })} + /> + + + + + +

+ One pattern per line, in .gitignore syntax. Each repository's own .gitignore files are always + respected as well. +

+