diff --git a/gitnexus-mcp/package-lock.json b/gitnexus-mcp/package-lock.json index 41fd58235..9798a4729 100644 --- a/gitnexus-mcp/package-lock.json +++ b/gitnexus-mcp/package-lock.json @@ -1,17 +1,16 @@ { "name": "gitnexus-mcp", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus-mcp", - "version": "0.1.0", + "version": "0.1.1", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "chalk": "^5.3.0", - "commander": "^12.0.0", - "ora": "^8.0.0", + "uuid": "^13.0.0", "ws": "^8.16.0" }, "bin": { @@ -19,6 +18,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", + "@types/uuid": "^10.0.0", "@types/ws": "^8.5.10", "tsx": "^4.0.0", "typescript": "^5.4.0" @@ -530,6 +530,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -586,18 +593,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -660,54 +655,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -821,12 +768,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1087,18 +1028,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1246,36 +1175,12 @@ "node": ">= 0.10" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1303,34 +1208,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1386,18 +1263,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1455,44 +1320,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1601,22 +1428,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1783,18 +1594,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1804,50 +1603,6 @@ "node": ">= 0.8" } }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1921,6 +1676,19 @@ "node": ">= 0.8" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/gitnexus/TODO.md b/gitnexus/TODO.md index 0842fc03d..4ffce6f43 100644 --- a/gitnexus/TODO.md +++ b/gitnexus/TODO.md @@ -1,151 +1,372 @@ -# GitNexus V2 Strategic Roadmap: The "Semantic Understanding" Upgrade +# GitNexus V2: Semantic Code Intelligence Roadmap -> **Context for Models:** This document contains a comprehensive analysis of a competitor tool ("Noodlbox") and a detailed roadmap for integrating its best features into GitNexus. GitNexus is a browser-native (WASM) code analysis tool using KuzuDB and Tree-sitter. This roadmap aims to elevate GitNexus from a "Code Graph" to a "Semantic Code Understanding" platform. +> **Last Updated:** January 2026 +> **Vision:** Transform GitNexus from a "Code Graph" into a "Semantic Code Understanding" platform that rivals and surpasses tools like Noodlbox and DeepWiki. --- -## 1. Competitive Analysis: GitNexus vs. Noodlbox +## Executive Summary -### Core Architecture -| Feature | GitNexus (Current) | Noodlbox (Competitor) | Advantage | -|---------|-------------------|-----------------------|-----------| -| **Runtime** | **100% Browser (WASM)**. Zero setup. Privacy-first. | **CLI + Local Server**. Requires installation/daemon. | **GitNexus** (UX/Privacy) | -| **Storage** | **KuzuDB (WASM)**. Graph + Vector. | **LanceDB**. Graph + Vector. | Tied (Both robust) | -| **Extraction** | Tree-sitter (WASM). | Tree-sitter. | Tied | -| **Integration** | Custom MCP Bridge (Browser ↔ Local). | Direct CLI MCP Server. | Noodlbox (Simpler setup) | +### Current State +- ✅ Tree-sitter parsing → AST extraction +- ✅ KuzuDB (WASM) → Graph + Vector storage +- ✅ Hybrid search (BM25 + Semantic) +- ✅ LangChain Agent with tools (search, cypher, grep, read, blastRadius, highlight) +- ✅ MCP integration for external AI tools -### Conceptual Model (The Gap) -| Feature | GitNexus | Noodlbox | Analysis | -|---------|----------|----------|----------| -| **Grouping** | File-based (Folders). | **Communities**. Algorithmic clustering of related code. | **Noodlbox**. Reveals logical architecture vs physical. | -| **Flows** | Raw `CALLS` edges. | **Processes**. Named execution paths (Entry → End). | **Noodlbox**. "How it works" vs "What calls what". | -| **Search** | Hybrid (BM25 + Vec). | Context Search. | Tied, but Noodlbox returns Processes. | -| **Impact** | Node Blast Radius. | **Git Diff Impact**. Unstaged/Staged analysis. | **Noodlbox**. Much better DevEx. | -| **UX** | Raw IDs/Filenames. | **Labels**. Human-readable names ("Auth System"). | **Noodlbox**. Cognitive load reduction. | +### Target State +- 🎯 **Communities:** Auto-detected code clusters (Leiden algorithm) +- 🎯 **Processes:** Named execution flows with ordered steps +- 🎯 **Hierarchical Navigation:** Codebase → Community → Process → Symbol +- 🎯 **Auto-Documentation:** Generate ARCHITECTURE/ docs from graph +- 🎯 **Incremental Updates:** File watch + delta graph updates +- 🎯 **Git Diff Impact:** Pre-commit blast radius on uncommitted changes --- -## 2. Strategic Goal: "The Graph of Meaning" -We need to move GitNexus beyond just mapping *files and functions* to mapping *concepts and flows*. -**Shift:** `Files -> Communities`, `Functions -> Processes`, `Edges -> Flows`. +## Competitive Analysis + +### vs Noodlbox +| Feature | Noodlbox | GitNexus (Current) | GitNexus (Planned) | +|---------|----------|-------------------|-------------------| +| Runtime | CLI + Server | Browser (WASM) ✅ | Browser (WASM) ✅ | +| Communities | Leiden clusters ✅ | ❌ | ✅ Planned | +| Processes | Named flows ✅ | ❌ | ✅ Planned | +| Git Diff Impact | ✅ | ❌ | ✅ Planned | +| Privacy | Local server | 100% Browser ✅ | 100% Browser ✅ | + +### vs OpenDeepWiki +| Feature | OpenDeepWiki | GitNexus (Planned) | +|---------|--------------|-------------------| +| Structure Discovery | LLM guesses from files | Leiden from actual relationships ✅ | +| Process Understanding | None (file-by-file) | Static analysis traces ✅ | +| Grounding | File references only | Graph edges + files ✅ | +| "What breaks if X changes" | Cannot answer | blastRadius ✅ | + +**Our Advantage:** Real graph-based understanding vs LLM inference. --- -## 3. Implementation Roadmap - -### 🔥 PHASE 1: The Semantic Layer (High Priority) -*Transform the raw KuzuDB graph into high-level concepts.* - -#### 1.1 Communities (Code Clusters) -**Concept:** Use modularity optimization to group tightly coupled symbols into "Communities" (e.g., "Auth System", "Payment Processing") regardless of folder structure. -* **Algorithm:** Implement **Leiden Algorithm** (superior to Louvain for refined clusters) or **Louvain** in WASM (or pure JS if graph small enough). -* **Trigger:** Run post-ingestion. -* **Schema Update:** - * New Node: `Community { id, label, cohesionScore }` - * New Edge: `MEMBER_OF` (Symbol -> Community) -* **Metrics:** Calculate **Cohesion Score** (internal edges / total edges) to rate cluster quality (0-1). -* **Key Symbols:** Identify diverse "Entry Points" (called from outside) and "Central Hubs" (high internal degree). - -#### 1.2 Processes (Execution Flows) -**Concept:** Trace and name execution paths often hidden in call graphs. -* **Detection Heuristic:** - 1. Find **Entry Points**: Functions called by Frameworks (e.g., `handleRequest`) or with 0 internal callers. - 2. Trace forward `CALLS` edges (limit depth/branching). - 3. Group linear paths. -* **Schema Update:** - * New Node: `Process { id, label, process_type: 'intra'|'cross' }` - * New Edge: `STEP_IN_PROCESS { step_number }` (Symbol -> Process) -* **Process Labeling:** Heuristic naming (e.g., `[EntryFn]_[Action]`). - -#### 1.3 Git Diff Impact Detection -**Concept:** Analyze *work-in-progress* code, not just the committed graph. -* **MCP Tool:** `detect_impact(scope: 'unstaged' | 'staged')` -* **Workflow:** - 1. MCP Client reads `git diff`. - 2. Parse diff to find changed symbols (e.g., `src/auth.ts: function login`). - 3. Query Graph for `potentially_affected` (Downstream callers). - 4. **Crucial:** Map affected symbols to **Processes** and **Communities**. - 5. Output: "Changing `login` affects 'Checkout Flow' and 'User Onboarding Process'". +## Phase-Wise Implementation Plan --- -### 🟡 PHASE 2: Cognitive UX (Medium Priority) -*Make the complex graph human-readable.* +## PHASE 1: Community Detection (Leiden Algorithm) +**Goal:** Group related code into named clusters. -#### 2.1 Smart Labeling -**Concept:** Replacing IDs with meaningful names. -* **Generator:** Simple LLM pass or rule-based heuristic. - * Input: Top file paths in Community (e.g., `src/auth/*`, `src/login/*`). - * Output: Label "Authentication". -* **Storage:** `.gitnexus/labels.json`. -* **Usage:** UI & MCP tools display "Auth System" instead of "Community #12". +### 1.1 Research & Setup +- [ ] Research JS/WASM implementations of Leiden algorithm + - Options: `graphology-communities-louvain`, custom WASM port + - Constraint: Must run in browser +- [ ] Benchmark on sample codebases (100, 1K, 10K nodes) -#### 2.2 Architecture Generation (`generate_map`) -**Concept:** Auto-generated documentation that stays up to date. -* **MCP Tool:** `gitnexus_generate_architecture` -* **Output:** `ARCHITECTURE/` folder. - * `README.md`: Mermaid Diagram of Communities + Cross-community data flow. - * `{process_name}.md`: Sequence diagrams of key processes. -* **Value:** Instant "Onboarding Docs" for any repo. +### 1.2 Schema Updates +- [ ] Add `Community` node table to KuzuDB schema: + ```typescript + interface Community { + id: string; // "comm_a7f3x2" + label: string; // "Authentication" (heuristic or LLM) + cohesion: number; // 0.0 - 1.0 + symbolCount: number; // Count of symbols in community + } + ``` +- [ ] Add `MEMBER_OF` relationship type to `CodeRelation`: + ```typescript + // Symbol -> Community + { type: 'MEMBER_OF', source: symbolId, target: communityId } + ``` -#### 2.3 Centrality & Importance -**Concept:** Not all nodes are equal. -* **Algorithm:** **PageRank** or **Betweenness Centrality**. -* **Use Case:** Search results ranking. When searching "Auth", show the `AuthService` class before a random utility function used by it. +### 1.3 Ingestion Pipeline Update +- [ ] Create `community-processor.ts` in `src/core/ingestion/` +- [ ] Add Phase 6 to pipeline (after heritage processing): + ```typescript + // pipeline.ts + await processCommunities(graph, onProgress); + ``` +- [ ] Implement Leiden on the CALLS + IMPORTS adjacency matrix +- [ ] Generate heuristic labels (folder name majority) + +### 1.4 Agent Integration +- [ ] Add `listCommunities` tool or resource +- [ ] Update system prompt to teach agent about communities +- [ ] Update `search` tool to return community context + +### 1.5 UI Updates +- [ ] Color nodes by community in graph visualization +- [ ] Add community filter/legend panel + +**Estimated Effort:** 2-3 weeks --- -### 🟢 PHASE 3: Agentic Workflows (Low Priority) -*Standardized "Thinking Patterns" for the LLM.* +## PHASE 2: Process Detection (Execution Flows) +**Goal:** Trace and name execution paths. -#### 3.1 "Skills" (Structured Prompts) -Define rigid workflows for the Agent to prevent "wandering": -* **Exploration Skill:** `Read Map -> Pick Community -> List Key Processes -> Drill Down`. -* **Debugging Skill:** `Error Msg -> Search Context -> Trace Backwards (Callers) -> Check Recent Changes`. -* **Refactoring Skill:** `Select Symbol -> Blast Radius -> Dependencies (In/Out) -> Plan Split`. +### 2.1 Schema Updates +- [ ] Add `Process` node table to KuzuDB schema: + ```typescript + interface Process { + id: string; // "proc_login_flow" + label: string; // "User Login Flow" + type: 'intra_community' | 'cross_community'; + stepCount: number; + } + ``` +- [ ] Add `STEP_IN_PROCESS` relationship type: + ```typescript + // Symbol -> Process (with step property) + { type: 'STEP_IN_PROCESS', source: symbolId, target: processId, step: number } + ``` -#### 3.2 Hooks -* **Session Hook:** auto-inject `database-schema` and high-level `codebase-map` at chat start. -* **Search Hook:** Intercept `grep` in IDE to attach semantic context ("This match is part of 'Payment Process'"). +### 2.2 Process Detection Algorithm +- [ ] Create `process-processor.ts` in `src/core/ingestion/` +- [ ] Implement entry point detection: + ```typescript + // Functions with no internal callers + MATCH (f:Function) + WHERE NOT (:Function)-[:CALLS]->(f) + RETURN f + ``` +- [ ] Implement forward tracing (BFS/DFS from entry points) +- [ ] Limit depth (e.g., 10) and branching (e.g., 3) +- [ ] Deduplicate overlapping paths +- [ ] Label processes (heuristic: `{entry}_to_{terminal}`) + +### 2.3 Community Integration +- [ ] Track which communities each process touches +- [ ] Mark `type` as `cross_community` if > 1 community + +### 2.4 Agent Integration +- [ ] Add `listProcesses(communityId?)` tool +- [ ] Add `traceProcess(processId)` tool +- [ ] Update system prompt with process navigation + +### 2.5 UI Updates +- [ ] Visualize processes as highlighted paths +- [ ] Add process list panel +- [ ] Click process → animate the flow + +**Estimated Effort:** 2 weeks --- -## 4. Technical Specs & Schema Changes +## PHASE 3: Smart Labeling (LLM Enhancement) +**Goal:** Human-readable names for Communities and Processes. -### Proposed KuzuDB Schema V2 +### 3.1 Heuristic Labeling (Default) +- [ ] Community: Most common folder prefix +- [ ] Process: `{entryFunction}_to_{terminalFunction}` +### 3.2 LLM Labeling (Optional Enhancement) +- [ ] Create `labeling-service.ts` +- [ ] Batch communities/processes for LLM naming +- [ ] Prompt template: + ``` + Given these functions: login, validateToken, checkExpiry, refreshSession + All in folder: src/auth/ + Generate a 2-3 word label for this code cluster. + ``` +- [ ] Store both `heuristicLabel` and `llmLabel` +- [ ] Use `llmLabel` if available, else `heuristicLabel` + +### 3.3 Labels File Export +- [ ] Generate `.gitnexus/labels.json` on demand +- [ ] Format matching Noodlbox for familiarity + +**Estimated Effort:** 1 week + +--- + +## PHASE 4: Architecture Documentation Generation +**Goal:** Auto-generate project documentation from graph. + +### 4.1 Documentation Structure +- [ ] Output: `ARCHITECTURE/` folder + ``` + ARCHITECTURE/ + ├── README.md # Overview + Mermaid diagram + ├── communities/ + │ ├── authentication.md # Community detail + │ └── payments.md + └── processes/ + ├── user-login-flow.md # Process trace + └── checkout-flow.md + ``` + +### 4.2 Implementation +- [ ] Create `generate-docs.ts` in `src/core/docs/` +- [ ] README.md generation: + - Codebase stats (files, symbols, communities, processes) + - Mermaid diagram of community relationships + - List of key processes +- [ ] Community doc generation: + - Key symbols (highest centrality) + - Entry points + - Processes in this community +- [ ] Process doc generation: + - Ordered step list with file paths + - Mermaid sequence diagram + - Cross-community markers + +### 4.3 MCP Tool +- [ ] Add `generateArchitecture` tool to MCP +- [ ] Returns generated markdown (or writes to files) + +**Estimated Effort:** 2 weeks + +--- + +## PHASE 5: Git Diff Impact Detection +**Goal:** Pre-commit blast radius analysis. + +### 5.1 MCP Server Updates +- [ ] Add `detectImpact` tool to `gitnexus-mcp` +- [ ] Parameters: + ```typescript + interface DetectImpactParams { + scope: 'unstaged' | 'staged' | 'all' | 'compare'; + baseRef?: string; // For 'compare' scope + } + ``` + +### 5.2 Implementation +- [ ] Run `git diff` (MCP server side) +- [ ] Parse diff to extract changed file paths + line ranges +- [ ] Map changes to symbols in graph +- [ ] Run `blastRadius` on each changed symbol +- [ ] Aggregate results by: + - Changed symbols + - Impacted processes + - Affected communities + - Risk level (low/medium/high) + +### 5.3 Response Format ```typescript -// Nodes -interface Community { - id: string; // "comm_1" - label: string; // "Authentication" - cohesion: number; // 0.85 +interface ImpactResult { + changedSymbols: { name: string; file: string; changeType: 'added' | 'modified' | 'deleted' }[]; + impactedProcesses: { id: string; label: string; affectedSteps: number[] }[]; + affectedCommunities: string[]; + riskLevel: 'low' | 'medium' | 'high'; } - -interface Process { - id: string; // "proc_login_flow" - label: string; // "Login Flow" - type: 'intra_community' | 'cross_community'; -} - -// Relationships -// MEMBER_OF: Symbol -> Community -// STEP_IN_PROCESS: Symbol -> Process (property: step_index) ``` -### Algorithm Reference -* **Leiden Algorithm:** - * *Input:* Adjacency matrix of `CALLS` + `EXTENDS` edges. - * *Output:* Partition assignment (NodeID -> CommunityID). - * *Constraint:* Must run fast in JS/WASM. -* **Reciprocal Rank Fusion (RRF):** - * Already used for Search. Can be refined to weight "Key Symbols" (Centrality) higher. +**Estimated Effort:** 1-2 weeks --- -## 5. Summary of Work to Be Done -1. **Ingestion:** Add `CommunityDetection` and `ProcessTracing` post-processors. -2. **Schema:** Add `Community` and `Process` tables. -3. **MCP:** Add `detect_impact` tool accepting git diffs. -4. **UI:** Visualize Communities (colored clusters) and Processes (animated paths). -5. **Docs:** Implement `generate_map` to export findings to Markdown. +## PHASE 6: Incremental Updates (File Watch) +**Goal:** Real-time graph updates on file changes. + +### 6.1 File Watching +- [ ] Integrate file system watcher in MCP server +- [ ] Detect: added, modified, deleted files +- [ ] Debounce rapid changes (e.g., 500ms) + +### 6.2 Incremental Parsing +- [ ] Hash-based cache: file path → content hash → AST +- [ ] On change: re-parse only changed file +- [ ] Compute delta: added/removed nodes and edges + +### 6.3 Graph Patching +- [ ] Add `patchGraph` method to KuzuDB adapter +- [ ] Operations: ADD_NODE, REMOVE_NODE, ADD_EDGE, REMOVE_EDGE +- [ ] Update affected communities (optional: re-run Leiden locally) +- [ ] Update affected processes (re-trace from changed symbols) + +### 6.4 Re-embedding +- [ ] Re-embed changed symbols +- [ ] Re-embed 1-hop neighbors (context changed) + +**Estimated Effort:** 2-3 weeks + +--- + +## PHASE 7: Agent Prompt Refinement +**Goal:** Teach agent to use hierarchical navigation. + +### 7.1 System Prompt Updates +- [ ] Add "Hierarchical Navigation Protocol": + ``` + 1. Query codebase map (communities overview) + 2. Identify relevant community + 3. List processes in that community + 4. Trace specific process + 5. Read code for specific steps + ``` +- [ ] Add tool descriptions for new tools + +### 7.2 Skills (Optional) +- [ ] Create structured prompts for common tasks: + - Exploration: "How does X work?" + - Debugging: "Why is X failing?" + - Refactoring: "What breaks if I change X?" + - Documentation: "Generate docs for X" + +**Estimated Effort:** 1 week + +--- + +## Implementation Priority Order + +| Phase | Name | Priority | Effort | Dependency | +|-------|------|----------|--------|------------| +| 1 | Communities (Leiden) | 🔴 Critical | 2-3 weeks | None | +| 2 | Processes | 🔴 Critical | 2 weeks | Phase 1 | +| 3 | Smart Labeling | 🟡 Medium | 1 week | Phase 1, 2 | +| 4 | Documentation | 🟡 Medium | 2 weeks | Phase 1, 2 | +| 5 | Git Diff Impact | 🟡 Medium | 1-2 weeks | None (uses existing blastRadius) | +| 6 | Incremental Updates | 🟢 Nice-to-have | 2-3 weeks | None | +| 7 | Agent Prompt | 🟡 Medium | 1 week | Phase 1, 2 | + +**Recommended order:** 1 → 2 → 7 → 3 → 4 → 5 → 6 + +--- + +## Technical Notes + +### Leiden Algorithm Options +1. **graphology-communities-louvain** (JS, works in browser) +2. **Custom WASM port** (if performance needed) +3. **Simple Louvain** might be sufficient for V1 + +### Schema Summary (New Additions) +``` +NEW NODES: + - Community { id, label, cohesion, symbolCount } + - Process { id, label, type, stepCount } + +NEW RELATIONSHIPS (in CodeRelation): + - MEMBER_OF: Symbol → Community + - STEP_IN_PROCESS: Symbol → Process (with step property) +``` + +### Graph Visualization Color Scheme +``` +Community colors (auto-assigned): + - Auth: Blue + - Data: Green + - API: Orange + - Payment: Purple + - ... (cyclic palette) +``` + +--- + +## Success Metrics + +| Metric | Current | Target | +|--------|---------|--------| +| Agent query accuracy | ~70% | 90%+ | +| Context tokens per query | High (dump everything) | Low (hierarchical zoom) | +| Documentation quality | N/A | Comparable to DeepWiki | +| Update latency | Full re-ingest (minutes) | Incremental (seconds) | + +--- + +## Next Steps + +1. **Immediate:** Research Leiden implementations for browser +2. **Week 1-2:** Implement Phase 1 (Communities) +3. **Week 3-4:** Implement Phase 2 (Processes) +4. **Week 5:** Refine agent prompt (Phase 7) +5. **Week 6+:** Documentation generation & polish diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 69a5b8960..0bbe7d72a 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -23,6 +23,7 @@ "comlink": "^4.4.2", "d3": "^7.9.0", "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "graphology-layout-force": "^0.2.4", "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", @@ -56,6 +57,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", + "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", "vite-plugin-static-copy": "^3.1.4" @@ -5515,6 +5517,34 @@ "graphology-types": ">=0.24.0" } }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, "node_modules/graphology-layout-force": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/graphology-layout-force/-/graphology-layout-force-0.2.4.tgz", @@ -7726,6 +7756,15 @@ "ufo": "^1.6.1" } }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -7844,6 +7883,12 @@ "node": ">= 0.4" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/observable-fns": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", @@ -8009,6 +8054,15 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -9101,6 +9155,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 80e7b0189..76571053b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -24,6 +24,7 @@ "comlink": "^4.4.2", "d3": "^7.9.0", "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "graphology-layout-force": "^0.2.4", "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", @@ -57,8 +58,9 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", + "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", "vite-plugin-static-copy": "^3.1.4" } -} \ No newline at end of file +} diff --git a/gitnexus/public/wasm/c/tree-sitter-c.wasm b/gitnexus/public/wasm/c/tree-sitter-c.wasm new file mode 100644 index 000000000..ceda238dc Binary files /dev/null and b/gitnexus/public/wasm/c/tree-sitter-c.wasm differ diff --git a/gitnexus/public/wasm/cpp/tree-sitter-cpp.wasm b/gitnexus/public/wasm/cpp/tree-sitter-cpp.wasm new file mode 100644 index 000000000..2d453db9d Binary files /dev/null and b/gitnexus/public/wasm/cpp/tree-sitter-cpp.wasm differ diff --git a/gitnexus/public/wasm/csharp/tree-sitter-csharp.wasm b/gitnexus/public/wasm/csharp/tree-sitter-csharp.wasm new file mode 100644 index 000000000..5c11b4f34 Binary files /dev/null and b/gitnexus/public/wasm/csharp/tree-sitter-csharp.wasm differ diff --git a/gitnexus/public/wasm/go/tree-sitter-go.wasm b/gitnexus/public/wasm/go/tree-sitter-go.wasm new file mode 100644 index 000000000..a20aba8a9 Binary files /dev/null and b/gitnexus/public/wasm/go/tree-sitter-go.wasm differ diff --git a/gitnexus/public/wasm/java/tree-sitter-java.wasm b/gitnexus/public/wasm/java/tree-sitter-java.wasm new file mode 100644 index 000000000..45022a965 Binary files /dev/null and b/gitnexus/public/wasm/java/tree-sitter-java.wasm differ diff --git a/gitnexus/public/wasm/rust/tree-sitter-rust.wasm b/gitnexus/public/wasm/rust/tree-sitter-rust.wasm new file mode 100644 index 000000000..5b3b213d0 Binary files /dev/null and b/gitnexus/public/wasm/rust/tree-sitter-rust.wasm differ diff --git a/gitnexus/src/App.tsx b/gitnexus/src/App.tsx index c305a18a4..1288e502c 100644 --- a/gitnexus/src/App.tsx +++ b/gitnexus/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState, useEffect } from 'react'; import { AppStateProvider, useAppState } from './hooks/useAppState'; import { DropZone } from './components/DropZone'; import { LoadingOverlay } from './components/LoadingOverlay'; @@ -11,6 +11,8 @@ import { FileTreePanel } from './components/FileTreePanel'; import { CodeReferencesPanel } from './components/CodeReferencesPanel'; import { FileEntry } from './services/zip'; import { getActiveProviderConfig } from './core/llm/settings-service'; +import { ProviderConfig } from './core/llm/types'; +import { IntelligentClusteringModal } from './components/IntelligentClusteringModal'; const AppContent = () => { const { @@ -29,23 +31,68 @@ const AppContent = () => { refreshLLMSettings, initializeAgent, startEmbeddings, + startBackgroundEnrichment, embeddingStatus, codeReferences, selectedNode, isCodePanelOpen, + llmSettings, + updateLLMSettings, + runClusterEnrichment, } = useAppState(); + const [showClusteringModal, setShowClusteringModal] = useState(false); + + // Trigger clustering modal after ingestion if not seen yet + // DISABLED: Clustering is now in the upload flow + /* + useEffect(() => { + if (viewMode === 'exploring' && !llmSettings.hasSeenClusteringPrompt && !llmSettings.intelligentClustering) { + const timer = setTimeout(() => setShowClusteringModal(true), 2000); + return () => clearTimeout(timer); + } + }, [viewMode, llmSettings.hasSeenClusteringPrompt, llmSettings.intelligentClustering]); + */ + + const handleEnableClustering = useCallback(() => { + updateLLMSettings({ + intelligentClustering: true, + hasSeenClusteringPrompt: true, + useSameModelForClustering: true // Default to simple path + }); + setShowClusteringModal(false); + runClusterEnrichment().catch(console.error); + }, [updateLLMSettings, runClusterEnrichment]); + + const handleConfigureClustering = useCallback(() => { + updateLLMSettings({ hasSeenClusteringPrompt: true }); + setShowClusteringModal(false); + setSettingsPanelOpen(true); + }, [updateLLMSettings, setSettingsPanelOpen]); + + const handleSkipClustering = useCallback(() => { + updateLLMSettings({ hasSeenClusteringPrompt: true }); + setShowClusteringModal(false); + }, [updateLLMSettings]); + const graphCanvasRef = useRef(null); - const handleFileSelect = useCallback(async (file: File) => { + const handleFileSelect = useCallback(async (file: File, enableSmartClustering?: boolean) => { + console.log('📥 App.handleFileSelect - param received:', enableSmartClustering, 'provider exists:', !!getActiveProviderConfig()); const projectName = file.name.replace('.zip', ''); setProjectName(projectName); + // Set initial progress BEFORE entering loading mode to prevent black screen + setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to extract files' }); setViewMode('loading'); try { + // Prepare LLM config if clustering is enabled + const clusteringConfig = enableSmartClustering ? getActiveProviderConfig() ?? undefined : undefined; + console.log('✅ clusteringConfig:', !!clusteringConfig, clusteringConfig?.provider); + const result = await runPipeline(file, (progress) => { setProgress(progress); - }); + }, clusteringConfig || undefined); setGraph(result.graph); setFileContents(result.fileContents); @@ -67,6 +114,9 @@ const AppContent = () => { console.warn('Embeddings auto-start failed:', err); } }); + + // Start background cluster enrichment (if toggle was enabled) + startBackgroundEnrichment().catch(console.warn); } catch (error) { console.error('Pipeline error:', error); setProgress({ @@ -80,20 +130,25 @@ const AppContent = () => { setProgress(null); }, 3000); } - }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings, initializeAgent]); + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings, initializeAgent, llmSettings]); - const handleGitClone = useCallback(async (files: FileEntry[]) => { + const handleGitClone = useCallback(async (files: FileEntry[], enableSmartClustering?: boolean) => { // Extract project name from first file path (e.g., "owner-repo-123/src/..." -> "owner-repo") const firstPath = files[0]?.path || 'repository'; const projectName = firstPath.split('/')[0].replace(/-\d+$/, '') || 'repository'; setProjectName(projectName); + // Set initial progress BEFORE entering loading mode to prevent black screen + setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to process files' }); setViewMode('loading'); try { + // Prepare LLM config if clustering is enabled + const clusteringConfig = enableSmartClustering ? getActiveProviderConfig() ?? undefined : undefined; + const result = await runPipelineFromFiles(files, (progress) => { setProgress(progress); - }); + }, clusteringConfig || undefined); setGraph(result.graph); setFileContents(result.fileContents); @@ -115,6 +170,9 @@ const AppContent = () => { console.warn('Embeddings auto-start failed:', err); } }); + + // Start background cluster enrichment (if toggle was enabled) + startBackgroundEnrichment().catch(console.warn); } catch (error) { console.error('Pipeline error:', error); setProgress({ @@ -128,7 +186,7 @@ const AppContent = () => { setProgress(null); }, 3000); } - }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]); + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent, runClusterEnrichment]); const handleFocusNode = useCallback((nodeId: string) => { graphCanvasRef.current?.focusNode(nodeId); @@ -183,6 +241,14 @@ const AppContent = () => { onClose={() => setSettingsPanelOpen(false)} onSettingsSaved={handleSettingsSaved} /> + + {/* Intelligent Clustering Modal */} + ); }; diff --git a/gitnexus/src/components/DropZone.tsx b/gitnexus/src/components/DropZone.tsx index c2141f61a..0bce617a9 100644 --- a/gitnexus/src/components/DropZone.tsx +++ b/gitnexus/src/components/DropZone.tsx @@ -1,11 +1,12 @@ -import { useState, useCallback, DragEvent } from 'react'; -import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff } from 'lucide-react'; +import { useState, useCallback, DragEvent, useEffect } from 'react'; +import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Sparkles } from 'lucide-react'; import { cloneRepository, parseGitHubUrl } from '../services/git-clone'; import { FileEntry } from '../services/zip'; +import { getActiveProviderConfig } from '../core/llm/settings-service'; interface DropZoneProps { - onFileSelect: (file: File) => void; - onGitClone?: (files: FileEntry[]) => void; + onFileSelect: (file: File, enableSmartClustering?: boolean) => void; + onGitClone?: (files: FileEntry[], enableSmartClustering?: boolean) => void; } export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { @@ -17,6 +18,15 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { const [isCloning, setIsCloning] = useState(false); const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 }); const [error, setError] = useState(null); + const [enableSmartClustering, setEnableSmartClustering] = useState(false); + const [hasLLMProvider, setHasLLMProvider] = useState(false); + + // Check if LLM provider is configured + useEffect(() => { + const config = getActiveProviderConfig(); + setHasLLMProvider(!!config); + // Keep smart clustering OFF by default, user must opt-in + }, []); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); @@ -39,24 +49,25 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { if (files.length > 0) { const file = files[0]; if (file.name.endsWith('.zip')) { - onFileSelect(file); + onFileSelect(file, enableSmartClustering); } else { setError('Please drop a .zip file'); } } - }, [onFileSelect]); + }, [onFileSelect, enableSmartClustering]); const handleFileInput = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { const file = files[0]; if (file.name.endsWith('.zip')) { - onFileSelect(file); + console.log('🎯 DropZone: Calling onFileSelect with enableSmartClustering:', enableSmartClustering); + onFileSelect(file, enableSmartClustering); } else { setError('Please select a .zip file'); } } - }, [onFileSelect]); + }, [onFileSelect, enableSmartClustering]); const handleGitClone = async () => { if (!githubUrl.trim()) { @@ -76,16 +87,16 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { try { const files = await cloneRepository( - githubUrl, + githubUrl, (phase, percent) => setCloneProgress({ phase, percent }), githubToken || undefined // Pass token if provided ); // Clear token from memory after successful clone setGithubToken(''); - + if (onGitClone) { - onGitClone(files); + onGitClone(files, enableSmartClustering); } } catch (err) { console.error('Clone failed:', err); @@ -123,8 +134,8 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { className={` flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 - ${activeTab === 'zip' - ? 'bg-accent text-white shadow-md' + ${activeTab === 'zip' + ? 'bg-accent text-white shadow-md' : 'text-text-secondary hover:text-text-primary hover:bg-elevated' } `} @@ -137,8 +148,8 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { className={` flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 - ${activeTab === 'github' - ? 'bg-accent text-white shadow-md' + ${activeTab === 'github' + ? 'bg-accent text-white shadow-md' : 'text-text-secondary hover:text-text-primary hover:bg-elevated' } `} @@ -157,60 +168,98 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { {/* ZIP Upload Tab */} {activeTab === 'zip' && ( -
document.getElementById('file-input')?.click()} - > - + <> +
document.getElementById('file-input')?.click()} + > + - {/* Icon */} -
- {isDragging ? ( - - ) : ( - - )} + {/* Icon */} +
+ {isDragging ? ( + + ) : ( + + )} +
+ + {/* Text */} +

+ {isDragging ? 'Drop it here!' : 'Drop your codebase'} +

+

+ Drag & drop a .zip file to generate a knowledge graph +

+ + {/* Hints */} +
+ + .zip + +
- {/* Text */} -

- {isDragging ? 'Drop it here!' : 'Drop your codebase'} -

-

- Drag & drop a .zip file to generate a knowledge graph -

- - {/* Hints */} -
- - .zip - + {/* Smart Clustering Toggle - Below drop zone */} +
+
-
+ )} {/* GitHub URL Tab */} @@ -302,11 +351,11 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { {isCloning ? ( <> - {cloneProgress.phase === 'cloning' + {cloneProgress.phase === 'cloning' ? `Cloning... ${cloneProgress.percent}%` : cloneProgress.phase === 'reading' - ? 'Reading files...' - : 'Starting...' + ? 'Reading files...' + : 'Starting...' } ) : ( @@ -322,7 +371,7 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => { {isCloning && (
-
diff --git a/gitnexus/src/components/GraphCanvas.tsx b/gitnexus/src/components/GraphCanvas.tsx index 2bf4f1e68..118d386f9 100644 --- a/gitnexus/src/components/GraphCanvas.tsx +++ b/gitnexus/src/components/GraphCanvas.tsx @@ -115,7 +115,23 @@ export const GraphCanvas = forwardRef((_, ref) => { // Update Sigma graph when KnowledgeGraph changes useEffect(() => { if (!graph) return; - const sigmaGraph = knowledgeGraphToGraphology(graph); + + // Build communityMemberships map from MEMBER_OF relationships + // MEMBER_OF edges: nodeId -> communityId (stored as targetId) + const communityMemberships = new Map(); + graph.relationships.forEach(rel => { + if (rel.type === 'MEMBER_OF') { + // Find the community node to get its index + const communityNode = graph.nodes.find(n => n.id === rel.targetId && n.label === 'Community'); + if (communityNode) { + // Extract community index from id (e.g., "comm_5" -> 5) + const communityIdx = parseInt(rel.targetId.replace('comm_', ''), 10) || 0; + communityMemberships.set(rel.sourceId, communityIdx); + } + } + }); + + const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); setSigmaGraph(sigmaGraph); }, [graph, setSigmaGraph]); diff --git a/gitnexus/src/components/IntelligentClusteringModal.tsx b/gitnexus/src/components/IntelligentClusteringModal.tsx new file mode 100644 index 000000000..ec9ab92a2 --- /dev/null +++ b/gitnexus/src/components/IntelligentClusteringModal.tsx @@ -0,0 +1,140 @@ +import { Brain, Sparkles, X, Settings } from 'lucide-react'; + +interface IntelligentClusteringModalProps { + isOpen: boolean; + onClose: () => void; + onEnable: () => void; + onConfigure: () => void; +} + +export const IntelligentClusteringModal = ({ + isOpen, + onClose, + onEnable, + onConfigure +}: IntelligentClusteringModalProps) => { + if (!isOpen) return null; + + // Parent handles all state updates via onEnable/onConfigure/onClose + const handleEnable = () => { + onEnable(); + }; + + const handleSkip = () => { + onClose(); + }; + + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ + {/* Header with cool gradient background */} +
+
+ +
+ + + +
+
+ +
+
+ +

+ Upgrade to Intelligent Clustering? +

+

+ Your clusters are ready, but they could be smarter! Right now they're just named after folders. +

+
+ + {/* Body */} +
+ +
+

+ + What you get: +

+
    +
  • + + Semantic names (e.g., "Auth System" vs "utils") +
  • +
  • + + Search keywords for better agent context +
  • +
  • + + Descriptions of what the code actually does +
  • +
+
+ + {/* How it works */} +
+
+
+ +
+
+

Uses Your Configured LLM

+

+ Runs on your own API key. Very low token usage (~$0.01 for most codebases). +
+ 💡 Tip: Use a cheaper model like GPT-4o-mini in settings! +

+
+
+
+ +
+ + {/* Actions */} +
+ + +
+ +
+ +
+
+ +
+
+ ); +}; diff --git a/gitnexus/src/components/SettingsPanel.tsx b/gitnexus/src/components/SettingsPanel.tsx index 8d8874214..962bdef7c 100644 --- a/gitnexus/src/components/SettingsPanel.tsx +++ b/gitnexus/src/components/SettingsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw } from 'lucide-react'; +import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, Sparkles } from 'lucide-react'; import { loadSettings, saveSettings, @@ -534,6 +534,147 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
)} + + {/* Intelligent Clustering Settings */} +
+

+ + Intelligent Clustering (Beta) +

+ +
+
+
+ +

Generate semantic names and descriptions for code clusters

+
+ +
+ + {settings.intelligentClustering && ( +
+
+
+ +

Use the same provider configured above

+
+ +
+ + {!settings.useSameModelForClustering && ( +
+
+
+ +
+

+ Pro Tip: Use a cheaper model like GPT-4o-mini or Gemini Flash for clustering! +

+
+ + {/* Simplistic Clustering Provider Config - For now just a model name override for simplicity, + or we could duplicate the provider selector. + For key simplicity in this iteration, let's just let them override the MODEL name if using the SAME provider, + or we can add a provider dropdown. + + Actually, the simplest implementation for "separate model" is just allowing them to pick a provider/model + for clustering specifically. But that replicates a lot of UI. + + Let's stick to the plan: "Use same model as agent" vs "Use different model". + If different, show a simplified provider config (just Provider + Model + Key if needed). + + For MVP, let's just assume they want to use OpenAI/Azure/Gemini with a specific model string. + */} + +
+ + +
+ +
+ + setSettings(prev => ({ + ...prev, + clusteringProvider: { ...prev.clusteringProvider, model: e.target.value } + }))} + placeholder="e.g. gpt-4o-mini" + className="w-full px-3 py-2 bg-elevated border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted outline-none" + /> +
+ +
+ + setSettings(prev => ({ + ...prev, + clusteringProvider: { ...prev.clusteringProvider, apiKey: e.target.value } + }))} + placeholder="Leave blank to use main key if matching..." + className="w-full px-3 py-2 bg-elevated border border-border-subtle rounded-lg text-sm text-text-primary placeholder:text-text-muted outline-none" + /> +
+ +

+ Required if using a different provider than your main agent. +

+
+ )} +
+ )} +
+
+ {/* Privacy Note */}
diff --git a/gitnexus/src/components/StatusBar.tsx b/gitnexus/src/components/StatusBar.tsx index 93b6daa09..476f4ecb8 100644 --- a/gitnexus/src/components/StatusBar.tsx +++ b/gitnexus/src/components/StatusBar.tsx @@ -1,7 +1,8 @@ +import { Pause, X } from 'lucide-react'; import { useAppState } from '../hooks/useAppState'; export const StatusBar = () => { - const { graph, progress } = useAppState(); + const { graph, progress, enrichmentProgress, cancelEnrichment } = useAppState(); const nodeCount = graph?.nodes.length ?? 0; const edgeCount = graph?.relationships.length ?? 0; @@ -13,15 +14,17 @@ export const StatusBar = () => { .map(n => n.properties.language) .filter(Boolean); if (languages.length === 0) return null; - + const counts = languages.reduce((acc, lang) => { acc[lang!] = (acc[lang!] || 0) + 1; return acc; }, {} as Record); - + return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0]; })(); + const isLabeling = enrichmentProgress !== null; + return (
{/* Left - Status */} @@ -29,13 +32,31 @@ export const StatusBar = () => { {progress && progress.phase !== 'complete' ? ( <>
-
{progress.message} + ) : isLabeling ? ( + <> +
+
+
+ Labeling clusters {enrichmentProgress.current}/{enrichmentProgress.total}... + + ) : (
@@ -63,4 +84,3 @@ export const StatusBar = () => {
); }; - diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index bc7870622..15df37c54 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -2,12 +2,12 @@ export enum SupportedLanguages { JavaScript = 'javascript', TypeScript = 'typescript', Python = 'python', - // Java = 'java', - // C = 'c', - // CPlusPlus = 'cpp', - // CSharp = 'csharp', - // Go = 'go', - // Rust = 'rust', + Java = 'java', + C = 'c', + CPlusPlus = 'cpp', + CSharp = 'csharp', + Go = 'go', + Rust = 'rust', // PHP = 'php', // Ruby = 'ruby', // Swift = 'swift', diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index eb4e81c2a..1e8fa2037 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -13,7 +13,8 @@ export type NodeLabel = | 'Decorator' | 'Import' | 'Type' - | 'CodeElement'; + | 'CodeElement' + | 'Community'; export type NodeProperties = { @@ -23,6 +24,13 @@ export type NodeProperties = { endLine?: number, language?: string, isExported?: boolean, + // Community-specific properties + heuristicLabel?: string, + cohesion?: number, + symbolCount?: number, + keywords?: string[], + description?: string, + enrichedBy?: 'heuristic' | 'llm', } export type RelationshipType = @@ -36,6 +44,7 @@ export type RelationshipType = | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' + | 'MEMBER_OF' export interface GraphNode { id: string, diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 77a7ef747..d0ac9b22e 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -23,6 +23,17 @@ const FUNCTION_NODE_TYPES = new Set([ // Common async variants 'async_function_declaration', 'async_arrow_function', + // Java + 'method_declaration', + 'constructor_declaration', + // C/C++ + // 'function_definition' already included above + // Go + // 'method_declaration' already included from Java + // C# + 'local_function_statement', + // Rust + 'function_item', ]); /** diff --git a/gitnexus/src/core/ingestion/cluster-enricher.ts b/gitnexus/src/core/ingestion/cluster-enricher.ts new file mode 100644 index 000000000..51e00d618 --- /dev/null +++ b/gitnexus/src/core/ingestion/cluster-enricher.ts @@ -0,0 +1,245 @@ +/** + * Cluster Enricher + * + * LLM-based enrichment for community clusters. + * Generates semantic names, keywords, and descriptions using an LLM. + */ + +import { CommunityNode } from './community-processor'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ClusterEnrichment { + name: string; + keywords: string[]; + description: string; +} + +export interface EnrichmentResult { + enrichments: Map; + tokensUsed: number; +} + +export interface LLMClient { + generate: (prompt: string) => Promise; +} + +export interface ClusterMemberInfo { + name: string; + filePath: string; + type: string; // 'Function' | 'Class' | 'Method' | 'Interface' +} + +// ============================================================================ +// PROMPT TEMPLATE +// ============================================================================ + +const buildEnrichmentPrompt = ( + members: ClusterMemberInfo[], + heuristicLabel: string +): string => { + // Limit to first 20 members to control token usage + const limitedMembers = members.slice(0, 20); + + const memberList = limitedMembers + .map(m => `${m.name} (${m.type})`) + .join(', '); + + return `Analyze this code cluster and provide a semantic name and short description. + +Heuristic: "${heuristicLabel}" +Members: ${memberList}${members.length > 20 ? ` (+${members.length - 20} more)` : ''} + +Reply with JSON only: +{"name": "2-4 word semantic name", "description": "One sentence describing purpose"}` +}; + +// ============================================================================ +// PARSE LLM RESPONSE +// ============================================================================ + +const parseEnrichmentResponse = ( + response: string, + fallbackLabel: string +): ClusterEnrichment => { + try { + // Extract JSON from response (handles markdown code blocks) + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('No JSON found in response'); + } + + const parsed = JSON.parse(jsonMatch[0]); + + return { + name: parsed.name || fallbackLabel, + keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [], + description: parsed.description || '', + }; + } catch { + // Fallback if parsing fails + return { + name: fallbackLabel, + keywords: [], + description: '', + }; + } +}; + +// ============================================================================ +// MAIN ENRICHMENT FUNCTION +// ============================================================================ + +/** + * Enrich clusters with LLM-generated names, keywords, and descriptions + * + * @param communities - Community nodes to enrich + * @param memberMap - Map of communityId -> member info + * @param llmClient - LLM client for generation + * @param onProgress - Progress callback + */ +export const enrichClusters = async ( + communities: CommunityNode[], + memberMap: Map, + llmClient: LLMClient, + onProgress?: (current: number, total: number) => void +): Promise => { + const enrichments = new Map(); + let tokensUsed = 0; + + for (let i = 0; i < communities.length; i++) { + const community = communities[i]; + const members = memberMap.get(community.id) || []; + + onProgress?.(i + 1, communities.length); + + if (members.length === 0) { + // No members, use heuristic + enrichments.set(community.id, { + name: community.heuristicLabel, + keywords: [], + description: '', + }); + continue; + } + + try { + const prompt = buildEnrichmentPrompt(members, community.heuristicLabel); + const response = await llmClient.generate(prompt); + + // Rough token estimate + tokensUsed += prompt.length / 4 + response.length / 4; + + const enrichment = parseEnrichmentResponse(response, community.heuristicLabel); + enrichments.set(community.id, enrichment); + } catch (error) { + // On error, fallback to heuristic + console.warn(`Failed to enrich cluster ${community.id}:`, error); + enrichments.set(community.id, { + name: community.heuristicLabel, + keywords: [], + description: '', + }); + } + } + + return { enrichments, tokensUsed }; +}; + +// ============================================================================ +// BATCH ENRICHMENT (more efficient) +// ============================================================================ + +/** + * Enrich multiple clusters in a single LLM call (batch mode) + * More efficient for token usage but requires larger context window + */ +export const enrichClustersBatch = async ( + communities: CommunityNode[], + memberMap: Map, + llmClient: LLMClient, + batchSize: number = 5, + onProgress?: (current: number, total: number) => void +): Promise => { + const enrichments = new Map(); + let tokensUsed = 0; + + // Process in batches + for (let i = 0; i < communities.length; i += batchSize) { + // Report progress + onProgress?.(Math.min(i + batchSize, communities.length), communities.length); + + const batch = communities.slice(i, i + batchSize); + + const batchPrompt = batch.map((community, idx) => { + const members = memberMap.get(community.id) || []; + const limitedMembers = members.slice(0, 15); + const memberList = limitedMembers + .map(m => `${m.name} (${m.type})`) + .join(', '); + + return `Cluster ${idx + 1} (id: ${community.id}): +Heuristic: "${community.heuristicLabel}" +Members: ${memberList}`; + }).join('\n\n'); + + const prompt = `Analyze these code clusters and generate semantic names, keywords, and descriptions. + +${batchPrompt} + +Output JSON array: +[ + {"id": "comm_X", "name": "...", "keywords": [...], "description": "..."}, + ... +]`; + + try { + const response = await llmClient.generate(prompt); + tokensUsed += prompt.length / 4 + response.length / 4; + + // Parse batch response + const jsonMatch = response.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) as Array<{ + id: string; + name: string; + keywords: string[]; + description: string; + }>; + + for (const item of parsed) { + enrichments.set(item.id, { + name: item.name, + keywords: item.keywords || [], + description: item.description || '', + }); + } + } + } catch (error) { + console.warn('Batch enrichment failed, falling back to heuristics:', error); + // Fallback for this batch + for (const community of batch) { + enrichments.set(community.id, { + name: community.heuristicLabel, + keywords: [], + description: '', + }); + } + } + } + + // Fill in any missing communities + for (const community of communities) { + if (!enrichments.has(community.id)) { + enrichments.set(community.id, { + name: community.heuristicLabel, + keywords: [], + description: '', + }); + } + } + + return { enrichments, tokensUsed }; +}; diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts new file mode 100644 index 000000000..1a8901acc --- /dev/null +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -0,0 +1,354 @@ +/** + * Community Detection Processor + * + * Uses the Leiden algorithm (via graphology-communities-louvain) to detect + * communities/clusters in the code graph based on CALLS relationships. + * + * Communities represent groups of code that work together frequently, + * helping agents navigate the codebase by functional area rather than file structure. + */ + +import Graph from 'graphology'; +import louvain from 'graphology-communities-louvain'; +import { KnowledgeGraph, NodeLabel } from '../graph/types'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface CommunityNode { + id: string; + label: string; + heuristicLabel: string; + cohesion: number; + symbolCount: number; +} + +export interface CommunityMembership { + nodeId: string; + communityId: string; +} + +export interface CommunityDetectionResult { + communities: CommunityNode[]; + memberships: CommunityMembership[]; + stats: { + totalCommunities: number; + modularity: number; + nodesProcessed: number; + }; +} + +// ============================================================================ +// COMMUNITY COLORS (for visualization) +// ============================================================================ + +export const COMMUNITY_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#06b6d4', // cyan + '#3b82f6', // blue + '#8b5cf6', // violet + '#d946ef', // fuchsia + '#ec4899', // pink + '#f43f5e', // rose + '#14b8a6', // teal + '#84cc16', // lime +]; + +export const getCommunityColor = (communityIndex: number): string => { + return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]; +}; + +// ============================================================================ +// MAIN PROCESSOR +// ============================================================================ + +/** + * Detect communities in the knowledge graph using Leiden algorithm + * + * This runs AFTER all relationships (CALLS, IMPORTS, etc.) have been built. + * It uses primarily CALLS edges to cluster code that works together. + */ +export const processCommunities = async ( + knowledgeGraph: KnowledgeGraph, + onProgress?: (message: string, progress: number) => void +): Promise => { + onProgress?.('Building graph for community detection...', 0); + + // Step 1: Build a graphology graph from the knowledge graph + // We only include symbol nodes (Function, Class, Method) and CALLS edges + const graph = buildGraphologyGraph(knowledgeGraph); + + if (graph.order === 0) { + // No nodes to cluster + return { + communities: [], + memberships: [], + stats: { totalCommunities: 0, modularity: 0, nodesProcessed: 0 } + }; + } + + onProgress?.(`Running Leiden algorithm on ${graph.order} nodes...`, 30); + + // Step 2: Run Leiden (via Louvain implementation with refinement) + const details = louvain.detailed(graph, { + resolution: 1.0, // Default resolution, can be tuned + randomWalk: true, + }); + + onProgress?.(`Found ${details.count} communities...`, 60); + + // Step 3: Create community nodes with heuristic labels + const communityNodes = createCommunityNodes( + details.communities as Record, + details.count, + graph, + knowledgeGraph + ); + + onProgress?.('Creating membership edges...', 80); + + // Step 4: Create membership mappings + const memberships: CommunityMembership[] = []; + Object.entries(details.communities).forEach(([nodeId, communityNum]) => { + memberships.push({ + nodeId, + communityId: `comm_${communityNum}`, + }); + }); + + onProgress?.('Community detection complete!', 100); + + return { + communities: communityNodes, + memberships, + stats: { + totalCommunities: details.count, + modularity: details.modularity, + nodesProcessed: graph.order, + } + }; +}; + +// ============================================================================ +// HELPER: Build graphology graph from knowledge graph +// ============================================================================ + +/** + * Build a graphology graph containing only symbol nodes and CALLS edges + * This is what the Leiden algorithm will cluster + */ +const buildGraphologyGraph = (knowledgeGraph: KnowledgeGraph): Graph => { + // Use undirected graph for Leiden - it looks at edge density, not direction + const graph = new Graph({ type: 'undirected', allowSelfLoops: false }); + + // Symbol types that should be clustered + const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']); + + // Add symbol nodes + knowledgeGraph.nodes.forEach(node => { + if (symbolTypes.has(node.label)) { + graph.addNode(node.id, { + name: node.properties.name, + filePath: node.properties.filePath, + type: node.label, + }); + } + }); + + // Add CALLS edges (primary clustering signal) + // We can also include EXTENDS/IMPLEMENTS for OOP clustering + const clusteringRelTypes = new Set(['CALLS', 'EXTENDS', 'IMPLEMENTS']); + + knowledgeGraph.relationships.forEach(rel => { + if (clusteringRelTypes.has(rel.type)) { + // Only add edge if both nodes exist in our symbol graph + // Also skip self-loops (recursive calls) - not allowed in undirected graph + if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId) && rel.sourceId !== rel.targetId) { + // Avoid duplicate edges + if (!graph.hasEdge(rel.sourceId, rel.targetId)) { + graph.addEdge(rel.sourceId, rel.targetId); + } + } + } + }); + + return graph; +}; + +// ============================================================================ +// HELPER: Create community nodes with heuristic labels +// ============================================================================ + +/** + * Create Community nodes with auto-generated labels based on member file paths + */ +const createCommunityNodes = ( + communities: Record, + communityCount: number, + graph: Graph, + knowledgeGraph: KnowledgeGraph +): CommunityNode[] => { + // Group node IDs by community + const communityMembers = new Map(); + + Object.entries(communities).forEach(([nodeId, commNum]) => { + if (!communityMembers.has(commNum)) { + communityMembers.set(commNum, []); + } + communityMembers.get(commNum)!.push(nodeId); + }); + + // Build node lookup for file paths + const nodePathMap = new Map(); + knowledgeGraph.nodes.forEach(node => { + if (node.properties.filePath) { + nodePathMap.set(node.id, node.properties.filePath); + } + }); + + // Create community nodes - SKIP SINGLETONS (isolated nodes) + const communityNodes: CommunityNode[] = []; + + communityMembers.forEach((memberIds, commNum) => { + // Skip singleton communities - they're just isolated nodes + if (memberIds.length < 2) return; + + const heuristicLabel = generateHeuristicLabel(memberIds, nodePathMap, graph, commNum); + + communityNodes.push({ + id: `comm_${commNum}`, + label: heuristicLabel, + heuristicLabel, + cohesion: calculateCohesion(memberIds, graph), + symbolCount: memberIds.length, + }); + }); + + // Sort by size descending + communityNodes.sort((a, b) => b.symbolCount - a.symbolCount); + + return communityNodes; +}; + +// ============================================================================ +// HELPER: Generate heuristic label from folder patterns +// ============================================================================ + +/** + * Generate a human-readable label from the most common folder name in the community + */ +const generateHeuristicLabel = ( + memberIds: string[], + nodePathMap: Map, + graph: Graph, + commNum: number +): string => { + // Collect folder names from file paths + const folderCounts = new Map(); + + memberIds.forEach(nodeId => { + const filePath = nodePathMap.get(nodeId) || ''; + const parts = filePath.split('/').filter(Boolean); + + // Get the most specific folder (parent directory) + if (parts.length >= 2) { + const folder = parts[parts.length - 2]; + // Skip generic folder names + if (!['src', 'lib', 'core', 'utils', 'common', 'shared', 'helpers'].includes(folder.toLowerCase())) { + folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1); + } + } + }); + + // Find most common folder + let maxCount = 0; + let bestFolder = ''; + + folderCounts.forEach((count, folder) => { + if (count > maxCount) { + maxCount = count; + bestFolder = folder; + } + }); + + if (bestFolder) { + // Capitalize first letter + return bestFolder.charAt(0).toUpperCase() + bestFolder.slice(1); + } + + // Fallback: use function names to detect patterns + const names: string[] = []; + memberIds.forEach(nodeId => { + const name = graph.getNodeAttribute(nodeId, 'name'); + if (name) names.push(name); + }); + + // Look for common prefixes + if (names.length > 2) { + const commonPrefix = findCommonPrefix(names); + if (commonPrefix.length > 2) { + return commonPrefix.charAt(0).toUpperCase() + commonPrefix.slice(1); + } + } + + // Last resort: generic name with community ID for uniqueness + return `Cluster_${commNum}`; +}; + +/** + * Find common prefix among strings + */ +const findCommonPrefix = (strings: string[]): string => { + if (strings.length === 0) return ''; + + const sorted = strings.slice().sort(); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + + let i = 0; + while (i < first.length && first[i] === last[i]) { + i++; + } + + return first.substring(0, i); +}; + +// ============================================================================ +// HELPER: Calculate community cohesion +// ============================================================================ + +/** + * Calculate cohesion score (0-1) based on internal edge density + * Higher cohesion = more internal connections relative to size + */ +const calculateCohesion = (memberIds: string[], graph: Graph): number => { + if (memberIds.length <= 1) return 1.0; + + const memberSet = new Set(memberIds); + let internalEdges = 0; + + // Count edges within the community + memberIds.forEach(nodeId => { + if (graph.hasNode(nodeId)) { + graph.forEachNeighbor(nodeId, neighbor => { + if (memberSet.has(neighbor)) { + internalEdges++; + } + }); + } + }); + + // Each edge is counted twice (once from each end), so divide by 2 + internalEdges = internalEdges / 2; + + // Maximum possible internal edges for n nodes: n*(n-1)/2 + const maxPossibleEdges = (memberIds.length * (memberIds.length - 1)) / 2; + + if (maxPossibleEdges === 0) return 1.0; + + return Math.min(1.0, internalEdges / maxPossibleEdges); +}; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 1abdece66..378a3bdd1 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -117,6 +117,33 @@ export const processHeritage = async ( }); } } + + // IMPLEMENTS (Rust): impl Trait for Struct + if (captureMap['heritage.trait'] && captureMap['heritage.class']) { + const structName = captureMap['heritage.class'].text; + const traitName = captureMap['heritage.trait'].text; + + // Resolve struct and trait IDs + const structId = symbolTable.lookupExact(file.path, structName) || + symbolTable.lookupFuzzy(structName)[0]?.nodeId || + generateId('Struct', `${file.path}:${structName}`); + + const traitId = symbolTable.lookupFuzzy(traitName)[0]?.nodeId || + generateId('Trait', `${traitName}`); + + if (structId && traitId) { + const relId = generateId('IMPLEMENTS', `${structId}->${traitId}`); + + graph.addRelationship({ + id: relId, + sourceId: structId, + targetId: traitId, + type: 'IMPLEMENTS', + confidence: 1.0, + reason: 'trait-impl', + }); + } + } }); // Cleanup diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 9edc7d358..0dd9b4893 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -35,8 +35,24 @@ const resolveImportPath = ( const basePath = currentDir.join('/'); - // 3. Try extensions (prioritize .tsx for React projects) - const extensions = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']; + // 3. Try extensions for all supported languages + const extensions = [ + '', + // TypeScript/JavaScript + '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', + // Python + '.py', '/__init__.py', + // Java + '.java', + // C/C++ + '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', + // C# + '.cs', + // Go + '.go', + // Rust + '.rs', '/mod.rs' + ]; for (const ext of extensions) { const candidate = basePath + ext; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 0b5546ce9..79d53216e 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -81,10 +81,37 @@ export const processParsing = async ( let nodeLabel = 'CodeElement'; + // Core types if (captureMap['definition.function']) nodeLabel = 'Function'; else if (captureMap['definition.class']) nodeLabel = 'Class'; else if (captureMap['definition.interface']) nodeLabel = 'Interface'; else if (captureMap['definition.method']) nodeLabel = 'Method'; + // Struct types (C, C++, Go, Rust, C#) + else if (captureMap['definition.struct']) nodeLabel = 'Struct'; + // Enum types + else if (captureMap['definition.enum']) nodeLabel = 'Enum'; + // Namespace/Module (C++, C#, Rust) + else if (captureMap['definition.namespace']) nodeLabel = 'Namespace'; + else if (captureMap['definition.module']) nodeLabel = 'Module'; + // Rust-specific + else if (captureMap['definition.trait']) nodeLabel = 'Trait'; + else if (captureMap['definition.impl']) nodeLabel = 'Impl'; + else if (captureMap['definition.type']) nodeLabel = 'TypeAlias'; + else if (captureMap['definition.const']) nodeLabel = 'Const'; + else if (captureMap['definition.static']) nodeLabel = 'Static'; + // C-specific + else if (captureMap['definition.typedef']) nodeLabel = 'Typedef'; + else if (captureMap['definition.macro']) nodeLabel = 'Macro'; + else if (captureMap['definition.union']) nodeLabel = 'Union'; + // C#-specific + else if (captureMap['definition.property']) nodeLabel = 'Property'; + else if (captureMap['definition.record']) nodeLabel = 'Record'; + else if (captureMap['definition.delegate']) nodeLabel = 'Delegate'; + // Java-specific + else if (captureMap['definition.annotation']) nodeLabel = 'Annotation'; + else if (captureMap['definition.constructor']) nodeLabel = 'Constructor'; + // C++ template + else if (captureMap['definition.template']) nodeLabel = 'Template'; const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index d598c3f28..a273d1a5d 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -5,6 +5,7 @@ import { processParsing } from './parsing-processor'; import { processImports, createImportMap } from './import-processor'; import { processCalls } from './call-processor'; import { processHeritage } from './heritage-processor'; +import { processCommunities, CommunityDetectionResult } from './community-processor'; import { createSymbolTable } from './symbol-table'; import { createASTCache } from './ast-cache'; import { PipelineProgress, PipelineResult } from '../../types/pipeline'; @@ -150,7 +151,7 @@ export const runPipelineFromFiles = async ( }); await processHeritage(graph, files, astCache, symbolTable, (current, total) => { - const heritageProgress = 92 + ((current / total) * 6); + const heritageProgress = 88 + ((current / total) * 4); onProgress({ phase: 'heritage', percent: Math.round(heritageProgress), @@ -159,12 +160,62 @@ export const runPipelineFromFiles = async ( }); }); + // Phase 7: Community Detection (92-98%) + onProgress({ + phase: 'communities', + percent: 92, + message: 'Detecting code communities...', + stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount }, + }); + + const communityResult = await processCommunities(graph, (message, progress) => { + const communityProgress = 92 + (progress * 0.06); + onProgress({ + phase: 'communities', + percent: Math.round(communityProgress), + message, + stats: { filesProcessed: files.length, totalFiles: files.length, nodesCreated: graph.nodeCount }, + }); + }); + + // Log community detection results + if (import.meta.env.DEV) { + console.log(`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`); + } + + // Add community nodes to the graph + communityResult.communities.forEach(comm => { + graph.addNode({ + id: comm.id, + label: 'Community' as const, + properties: { + name: comm.label, + filePath: '', + heuristicLabel: comm.heuristicLabel, + cohesion: comm.cohesion, + symbolCount: comm.symbolCount, + } + }); + }); + + // Add MEMBER_OF relationships + communityResult.memberships.forEach(membership => { + graph.addRelationship({ + id: `${membership.nodeId}_member_of_${membership.communityId}`, + type: 'MEMBER_OF', + sourceId: membership.nodeId, + targetId: membership.communityId, + confidence: 1.0, + reason: 'leiden-algorithm', + }); + }); + - // Phase 6: Complete (100%) + // Phase 8: Complete (100%) onProgress({ phase: 'complete', percent: 100, - message: 'Graph generation complete!', + message: `Graph complete! ${communityResult.stats.totalCommunities} communities detected.`, stats: { filesProcessed: files.length, totalFiles: files.length, @@ -175,7 +226,7 @@ export const runPipelineFromFiles = async ( // Cleanup WASM memory before returning astCache.clear(); - return { graph, fileContents }; + return { graph, fileContents, communityResult }; } catch (error) { cleanup(); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 674059649..464ed1f86 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -148,9 +148,184 @@ export const PYTHON_QUERIES = ` (identifier) @heritage.extends)) @heritage `; +// Java queries - works with tree-sitter-java +export const JAVA_QUERIES = ` +; Classes, Interfaces, Enums, Annotations +(class_declaration name: (identifier) @name) @definition.class +(interface_declaration name: (identifier) @name) @definition.interface +(enum_declaration name: (identifier) @name) @definition.enum +(annotation_type_declaration name: (identifier) @name) @definition.annotation + +; Methods & Constructors +(method_declaration name: (identifier) @name) @definition.method +(constructor_declaration name: (identifier) @name) @definition.constructor + +; Imports +(import_declaration (scoped_identifier) @import.source) @import + +; Calls +(method_invocation name: (identifier) @call.name) @call +(method_invocation object: (_) name: (identifier) @call.name) @call + +; Heritage - extends class +(class_declaration name: (identifier) @heritage.class + (superclass (type_identifier) @heritage.extends)) @heritage + +; Heritage - implements interfaces +(class_declaration name: (identifier) @heritage.class + (super_interfaces (type_list (type_identifier) @heritage.implements))) @heritage.impl +`; + +// C queries - works with tree-sitter-c +export const C_QUERIES = ` +; Functions +(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function + +; Structs, Unions, Enums, Typedefs +(struct_specifier name: (type_identifier) @name) @definition.struct +(union_specifier name: (type_identifier) @name) @definition.union +(enum_specifier name: (type_identifier) @name) @definition.enum +(type_definition declarator: (type_identifier) @name) @definition.typedef + +; Macros +(preproc_function_def name: (identifier) @name) @definition.macro +(preproc_def name: (identifier) @name) @definition.macro + +; Includes +(preproc_include path: (_) @import.source) @import + +; Calls +(call_expression function: (identifier) @call.name) @call +(call_expression function: (field_expression field: (field_identifier) @call.name)) @call +`; + +// Go queries - works with tree-sitter-go +export const GO_QUERIES = ` +; Functions & Methods +(function_declaration name: (identifier) @name) @definition.function +(method_declaration name: (field_identifier) @name) @definition.method + +; Types +(type_declaration (type_spec name: (type_identifier) @name type: (struct_type))) @definition.struct +(type_declaration (type_spec name: (type_identifier) @name type: (interface_type))) @definition.interface +(type_declaration (type_spec name: (type_identifier) @name)) @definition.type + +; Imports +(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import +(import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import + +; Calls +(call_expression function: (identifier) @call.name) @call +(call_expression function: (selector_expression field: (field_identifier) @call.name)) @call +`; + +// C++ queries - works with tree-sitter-cpp +export const CPP_QUERIES = ` +; Classes, Structs, Namespaces +(class_specifier name: (type_identifier) @name) @definition.class +(struct_specifier name: (type_identifier) @name) @definition.struct +(namespace_definition name: (namespace_identifier) @name) @definition.namespace +(enum_specifier name: (type_identifier) @name) @definition.enum + +; Functions & Methods +(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function +(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method + +; Templates +(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template +(template_declaration (function_definition declarator: (function_declarator declarator: (identifier) @name))) @definition.template + +; Includes +(preproc_include path: (_) @import.source) @import + +; Calls +(call_expression function: (identifier) @call.name) @call +(call_expression function: (field_expression field: (field_identifier) @call.name)) @call +(call_expression function: (qualified_identifier name: (identifier) @call.name)) @call +(call_expression function: (template_function name: (identifier) @call.name)) @call + +; Heritage +(class_specifier name: (type_identifier) @heritage.class + (base_class_clause (type_identifier) @heritage.extends)) @heritage +(class_specifier name: (type_identifier) @heritage.class + (base_class_clause (access_specifier) (type_identifier) @heritage.extends)) @heritage +`; + +// C# queries - works with tree-sitter-c-sharp +export const CSHARP_QUERIES = ` +; Types +(class_declaration name: (identifier) @name) @definition.class +(interface_declaration name: (identifier) @name) @definition.interface +(struct_declaration name: (identifier) @name) @definition.struct +(enum_declaration name: (identifier) @name) @definition.enum +(record_declaration name: (identifier) @name) @definition.record +(delegate_declaration name: (identifier) @name) @definition.delegate + +; Namespaces +(namespace_declaration name: (identifier) @name) @definition.namespace +(namespace_declaration name: (qualified_name) @name) @definition.namespace + +; Methods & Properties +(method_declaration name: (identifier) @name) @definition.method +(local_function_statement name: (identifier) @name) @definition.function +(constructor_declaration name: (identifier) @name) @definition.constructor +(property_declaration name: (identifier) @name) @definition.property + +; Using +(using_directive (qualified_name) @import.source) @import +(using_directive (identifier) @import.source) @import + +; Calls +(invocation_expression function: (identifier) @call.name) @call +(invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call + +; Heritage +(class_declaration name: (identifier) @heritage.class + (base_list (simple_base_type (identifier) @heritage.extends))) @heritage +(class_declaration name: (identifier) @heritage.class + (base_list (simple_base_type (generic_name (identifier) @heritage.extends)))) @heritage +`; + +// Rust queries - works with tree-sitter-rust +export const RUST_QUERIES = ` +; Functions & Items +(function_item name: (identifier) @name) @definition.function +(struct_item name: (type_identifier) @name) @definition.struct +(enum_item name: (type_identifier) @name) @definition.enum +(trait_item name: (type_identifier) @name) @definition.trait +(impl_item type: (type_identifier) @name) @definition.impl +(mod_item name: (identifier) @name) @definition.module + +; Type aliases, const, static, macros +(type_item name: (type_identifier) @name) @definition.type +(const_item name: (identifier) @name) @definition.const +(static_item name: (identifier) @name) @definition.static +(macro_definition name: (identifier) @name) @definition.macro + +; Use statements +(use_declaration argument: (_) @import.source) @import + +; Calls +(call_expression function: (identifier) @call.name) @call +(call_expression function: (field_expression field: (field_identifier) @call.name)) @call +(call_expression function: (scoped_identifier name: (identifier) @call.name)) @call +(call_expression function: (generic_function function: (identifier) @call.name)) @call + +; Heritage (trait implementation) +(impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage +(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage +`; + export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES, [SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES, [SupportedLanguages.Python]: PYTHON_QUERIES, + [SupportedLanguages.Java]: JAVA_QUERIES, + [SupportedLanguages.C]: C_QUERIES, + [SupportedLanguages.Go]: GO_QUERIES, + [SupportedLanguages.CPlusPlus]: CPP_QUERIES, + [SupportedLanguages.CSharp]: CSHARP_QUERIES, + [SupportedLanguages.Rust]: RUST_QUERIES, }; \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index f61b0da71..959eb55dc 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -4,11 +4,27 @@ import { SupportedLanguages } from '../../config/supported-languages'; * Map file extension to SupportedLanguage enum */ export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => { + // TypeScript (including TSX) if (filename.endsWith('.tsx')) return SupportedLanguages.TypeScript; if (filename.endsWith('.ts')) return SupportedLanguages.TypeScript; + // JavaScript (including JSX) if (filename.endsWith('.jsx')) return SupportedLanguages.JavaScript; if (filename.endsWith('.js')) return SupportedLanguages.JavaScript; + // Python if (filename.endsWith('.py')) return SupportedLanguages.Python; + // Java + if (filename.endsWith('.java')) return SupportedLanguages.Java; + // C (source and headers) + if (filename.endsWith('.c') || filename.endsWith('.h')) return SupportedLanguages.C; + // C++ (all common extensions) + if (filename.endsWith('.cpp') || filename.endsWith('.cc') || filename.endsWith('.cxx') || + filename.endsWith('.hpp') || filename.endsWith('.hxx') || filename.endsWith('.hh')) return SupportedLanguages.CPlusPlus; + // C# + if (filename.endsWith('.cs')) return SupportedLanguages.CSharp; + // Go + if (filename.endsWith('.go')) return SupportedLanguages.Go; + // Rust + if (filename.endsWith('.rs')) return SupportedLanguages.Rust; return null; }; diff --git a/gitnexus/src/core/kuzu/csv-generator.ts b/gitnexus/src/core/kuzu/csv-generator.ts index be99f6688..0b1689893 100644 --- a/gitnexus/src/core/kuzu/csv-generator.ts +++ b/gitnexus/src/core/kuzu/csv-generator.ts @@ -196,6 +196,36 @@ const generateCodeElementCSV = ( return rows.join('\n'); }; +/** + * Generate CSV for Community nodes (from Leiden algorithm) + * Headers: id,label,heuristicLabel,keywords,description,enrichedBy,cohesion,symbolCount + */ +const generateCommunityCSV = (nodes: GraphNode[]): string => { + const headers = ['id', 'label', 'heuristicLabel', 'keywords', 'description', 'enrichedBy', 'cohesion', 'symbolCount']; + const rows: string[] = [headers.join(',')]; + + for (const node of nodes) { + if (node.label !== 'Community') continue; + + // Handle keywords array - convert to KuzuDB array format + const keywords = (node.properties as any).keywords || []; + const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`; + + rows.push([ + escapeCSVField(node.id), + escapeCSVField(node.properties.name || ''), // label is stored in name + escapeCSVField(node.properties.heuristicLabel || ''), + keywordsStr, // Array format for KuzuDB + escapeCSVField((node.properties as any).description || ''), + escapeCSVField((node.properties as any).enrichedBy || 'heuristic'), + escapeCSVNumber(node.properties.cohesion, 0), + escapeCSVNumber(node.properties.symbolCount, 0), + ].join(',')); + } + + return rows.join('\n'); +}; + // ============================================================================ // RELATIONSHIP CSV GENERATOR (Single Table) // ============================================================================ @@ -247,6 +277,7 @@ export const generateAllCSVs = ( nodeCSVs.set('Interface', generateCodeElementCSV(nodes, 'Interface', fileContents)); nodeCSVs.set('Method', generateCodeElementCSV(nodes, 'Method', fileContents)); nodeCSVs.set('CodeElement', generateCodeElementCSV(nodes, 'CodeElement', fileContents)); + nodeCSVs.set('Community', generateCommunityCSV(nodes)); // Generate single relation CSV const relCSV = generateRelationCSV(graph); diff --git a/gitnexus/src/core/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index ea994d7f8..73aa40b25 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -127,29 +127,51 @@ export const loadGraphToKuzu = async ( const [, fromId, toId, relType, confidenceStr, reason] = match; const confidence = parseFloat(confidenceStr) || 1.0; - // Extract labels from node IDs (format: Label:path:name) - const fromLabel = fromId.split(':')[0]; - const toLabel = toId.split(':')[0]; + // Extract labels from node IDs + // Community nodes have IDs like "comm_14" (no colon) + // Other nodes have IDs like "Label:path:name" + const getNodeLabel = (nodeId: string): string => { + if (nodeId.startsWith('comm_')) { + return 'Community'; + } + return nodeId.split(':')[0]; + }; + + // Reserved Cypher keywords need backtick escaping + const RESERVED_LABELS = ['Macro', 'Enum', 'Union', 'Const', 'Module', 'Struct']; + const escapeLabel = (label: string): string => { + return RESERVED_LABELS.includes(label) ? `\`${label}\`` : label; + }; + + const fromLabel = escapeLabel(getNodeLabel(fromId)); + const toLabel = escapeLabel(getNodeLabel(toId)); // INSERT with explicit node matching (including confidence and reason) const insertQuery = ` - MATCH (a:${fromLabel} {id: '${fromId.replace(/'/g, "''")}'}) - MATCH (b:${toLabel} {id: '${toId.replace(/'/g, "''")}'}) + MATCH (a:${fromLabel} {id: '${fromId.replace(/'/g, "''")}'}), + (b:${toLabel} {id: '${toId.replace(/'/g, "''")}'}) CREATE (a)-[:${REL_TABLE_NAME} {type: '${relType}', confidence: ${confidence}, reason: '${reason.replace(/'/g, "''")}'}]->(b) `; await conn.query(insertQuery); insertedRels++; - } catch { + } catch (err) { // Skip failed insertions (nodes might not exist, or relation pair not allowed by schema) skippedRels++; - if (import.meta.env.DEV) { - const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/); - if (match) { - const [, fromId, toId, relType] = match; - const fromLabel = fromId.split(':')[0]; - const toLabel = toId.split(':')[0]; - const key = `${relType}:${fromLabel}->${toLabel}`; - skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1); + const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)"/); + if (match) { + const [, fromId, toId, relType] = match; + const getNodeLabel = (nodeId: string): string => { + if (nodeId.startsWith('comm_')) return 'Community'; + return nodeId.split(':')[0]; + }; + const fromLabel = getNodeLabel(fromId); + const toLabel = getNodeLabel(toId); + const key = `${relType}:${fromLabel}->` + toLabel; + skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1); + + // Log each skipped relation + if (import.meta.env.DEV) { + console.warn(`⚠️ Skipped: ${key} | "${fromId}" → "${toId}" | ${err instanceof Error ? err.message : String(err)}`); } } } @@ -204,6 +226,9 @@ const getCopyQuery = (table: NodeTableName, path: string): string => { if (table === 'Folder') { return `COPY Folder(id, name, filePath) FROM "${path}" (HEADER=true, PARALLEL=false)`; } + if (table === 'Community') { + return `COPY Community(id, label, heuristicLabel, keywords, description, enrichedBy, cohesion, symbolCount) FROM "${path}" (HEADER=true, PARALLEL=false)`; + } // All code element tables: Function, Class, Interface, Method, CodeElement return `COPY ${table}(id, name, filePath, startLine, endLine, content) FROM "${path}" (HEADER=true, PARALLEL=false)`; }; diff --git a/gitnexus/src/core/kuzu/schema.ts b/gitnexus/src/core/kuzu/schema.ts index 0be8b8576..437383c2d 100644 --- a/gitnexus/src/core/kuzu/schema.ts +++ b/gitnexus/src/core/kuzu/schema.ts @@ -12,7 +12,12 @@ // ============================================================================ // NODE TABLE NAMES // ============================================================================ -export const NODE_TABLES = ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement'] as const; +export const NODE_TABLES = [ + 'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', + // Multi-language support + 'Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', + 'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module' +] as const; export type NodeTableName = typeof NODE_TABLES[number]; // ============================================================================ @@ -21,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number]; export const REL_TABLE_NAME = 'CodeRelation'; // Valid relation types -export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS'] as const; +export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'MEMBER_OF'] as const; export type RelType = typeof REL_TYPES[number]; // ============================================================================ @@ -105,6 +110,58 @@ CREATE NODE TABLE CodeElement ( PRIMARY KEY (id) )`; +// ============================================================================ +// COMMUNITY NODE TABLE (for Leiden algorithm clusters) +// ============================================================================ + +export const COMMUNITY_SCHEMA = ` +CREATE NODE TABLE Community ( + id STRING, + label STRING, + heuristicLabel STRING, + keywords STRING[], + description STRING, + enrichedBy STRING, + cohesion DOUBLE, + symbolCount INT32, + PRIMARY KEY (id) +)`; + +// ============================================================================ +// MULTI-LANGUAGE NODE TABLE SCHEMAS +// ============================================================================ + +// Generic code element with startLine/endLine for C, C++, Rust, Go, Java, C# +const CODE_ELEMENT_BASE = (name: string) => ` +CREATE NODE TABLE \`${name}\` ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + content STRING, + PRIMARY KEY (id) +)`; + +export const STRUCT_SCHEMA = CODE_ELEMENT_BASE('Struct'); +export const ENUM_SCHEMA = CODE_ELEMENT_BASE('Enum'); +export const MACRO_SCHEMA = CODE_ELEMENT_BASE('Macro'); +export const TYPEDEF_SCHEMA = CODE_ELEMENT_BASE('Typedef'); +export const UNION_SCHEMA = CODE_ELEMENT_BASE('Union'); +export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace'); +export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait'); +export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); +export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); +export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); +export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); +export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property'); +export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); +export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate'); +export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation'); +export const CONSTRUCTOR_SCHEMA = CODE_ELEMENT_BASE('Constructor'); +export const TEMPLATE_SCHEMA = CODE_ELEMENT_BASE('Template'); +export const MODULE_SCHEMA = CODE_ELEMENT_BASE('Module'); + // ============================================================================ // RELATION TABLE SCHEMA // Single table with 'type' property - connects all node tables @@ -119,18 +176,79 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM File TO Interface, FROM File TO Method, FROM File TO CodeElement, + FROM File TO \`Struct\`, + FROM File TO \`Enum\`, + FROM File TO \`Macro\`, + FROM File TO Typedef, + FROM File TO \`Union\`, + FROM File TO Namespace, + FROM File TO Trait, + FROM File TO Impl, + FROM File TO TypeAlias, + FROM File TO \`Const\`, + FROM File TO Static, + FROM File TO Property, + FROM File TO Record, + FROM File TO Delegate, + FROM File TO Annotation, + FROM File TO Constructor, + FROM File TO Template, + FROM File TO \`Module\`, FROM Folder TO Folder, FROM Folder TO File, FROM Function TO Function, FROM Function TO Method, FROM Function TO Class, + FROM Function TO Community, + FROM Function TO \`Macro\`, + FROM Function TO \`Struct\`, + FROM Function TO Template, + FROM Function TO \`Enum\`, + FROM Function TO Namespace, + FROM Function TO TypeAlias, FROM Class TO Method, FROM Class TO Function, FROM Class TO Class, FROM Class TO Interface, + FROM Class TO Community, + FROM Class TO Template, FROM Method TO Function, FROM Method TO Method, FROM Method TO Class, + FROM Method TO Community, + FROM Method TO Template, + FROM Method TO \`Struct\`, + FROM Template TO Template, + FROM Template TO Function, + FROM Template TO Method, + FROM Template TO Class, + FROM Template TO \`Struct\`, + FROM CodeElement TO Community, + FROM Interface TO Community, + FROM \`Struct\` TO Community, + FROM \`Struct\` TO Trait, + FROM \`Struct\` TO Function, + FROM \`Struct\` TO Method, + FROM \`Enum\` TO Community, + FROM \`Macro\` TO Community, + FROM Typedef TO Community, + FROM \`Union\` TO Community, + FROM Namespace TO Community, + FROM Trait TO Community, + FROM Impl TO Community, + FROM Impl TO Trait, + FROM TypeAlias TO Community, + FROM \`Const\` TO Community, + FROM Static TO Community, + FROM Property TO Community, + FROM Record TO Community, + FROM Delegate TO Community, + FROM Annotation TO Community, + FROM Constructor TO Community, + FROM Constructor TO Interface, + FROM Constructor TO Class, + FROM Template TO Community, + FROM \`Module\` TO Community, type STRING, confidence DOUBLE, reason STRING @@ -169,6 +287,26 @@ export const NODE_SCHEMA_QUERIES = [ INTERFACE_SCHEMA, METHOD_SCHEMA, CODE_ELEMENT_SCHEMA, + COMMUNITY_SCHEMA, + // Multi-language support + STRUCT_SCHEMA, + ENUM_SCHEMA, + MACRO_SCHEMA, + TYPEDEF_SCHEMA, + UNION_SCHEMA, + NAMESPACE_SCHEMA, + TRAIT_SCHEMA, + IMPL_SCHEMA, + TYPE_ALIAS_SCHEMA, + CONST_SCHEMA, + STATIC_SCHEMA, + PROPERTY_SCHEMA, + RECORD_SCHEMA, + DELEGATE_SCHEMA, + ANNOTATION_SCHEMA, + CONSTRUCTOR_SCHEMA, + TEMPLATE_SCHEMA, + MODULE_SCHEMA, ]; export const REL_SCHEMA_QUERIES = [ diff --git a/gitnexus/src/core/llm/types.ts b/gitnexus/src/core/llm/types.ts index c01811933..8df3114b0 100644 --- a/gitnexus/src/core/llm/types.ts +++ b/gitnexus/src/core/llm/types.ts @@ -87,6 +87,12 @@ export interface LLMSettings { gemini?: Partial>; anthropic?: Partial>; ollama?: Partial>; + + // Intelligent Clustering Settings + intelligentClustering: boolean; + hasSeenClusteringPrompt: boolean; + useSameModelForClustering: boolean; + clusteringProvider?: Partial; // Optional specific config for clustering } /** @@ -94,6 +100,9 @@ export interface LLMSettings { */ export const DEFAULT_LLM_SETTINGS: LLMSettings = { activeProvider: 'gemini', + intelligentClustering: false, + hasSeenClusteringPrompt: false, + useSameModelForClustering: true, openai: { apiKey: '', model: 'gpt-4o', diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index e6092f8b6..d5224ca4e 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -33,6 +33,12 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string => [SupportedLanguages.JavaScript]: '/wasm/javascript/tree-sitter-javascript.wasm', [SupportedLanguages.TypeScript]: '/wasm/typescript/tree-sitter-typescript.wasm', [SupportedLanguages.Python]: '/wasm/python/tree-sitter-python.wasm', + [SupportedLanguages.Java]: '/wasm/java/tree-sitter-java.wasm', + [SupportedLanguages.C]: '/wasm/c/tree-sitter-c.wasm', + [SupportedLanguages.CPlusPlus]: '/wasm/cpp/tree-sitter-cpp.wasm', + [SupportedLanguages.CSharp]: '/wasm/csharp/tree-sitter-csharp.wasm', + [SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm', + [SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm', }; return languageFileMap[language]; @@ -40,18 +46,27 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string => export const loadLanguage = async (language: SupportedLanguages, filePath?: string): Promise => { if (!parser) await loadParser(); - const wasmPath = getWasmPath(language, filePath); - // Use wasmPath as cache key to differentiate ts vs tsx if (languageCache.has(wasmPath)) { parser!.setLanguage(languageCache.get(wasmPath)!); return; } - if (!wasmPath) throw new Error(`Unsupported language: ${language}`); + if (!wasmPath) { + console.error(`❌ [Parser] No WASM path configured for language: ${language}`); + throw new Error(`Unsupported language: ${language}`); + } - const loadedLanguage = await Parser.Language.load(wasmPath); - languageCache.set(wasmPath, loadedLanguage); - parser!.setLanguage(loadedLanguage); + try { + const loadedLanguage = await Parser.Language.load(wasmPath); + languageCache.set(wasmPath, loadedLanguage); + parser!.setLanguage(loadedLanguage); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`❌ [Parser] Failed to load WASM grammar for ${language}`); + console.error(` WASM Path: ${wasmPath}`); + console.error(` Error: ${errorMessage}`); + throw new Error(`Failed to load grammar for ${language}: ${errorMessage}`); + } } diff --git a/gitnexus/src/hooks/useAppState.tsx b/gitnexus/src/hooks/useAppState.tsx index 28bffef81..bce50e94e 100644 --- a/gitnexus/src/hooks/useAppState.tsx +++ b/gitnexus/src/hooks/useAppState.tsx @@ -8,7 +8,7 @@ import type { IngestionWorkerApi } from '../workers/ingestion.worker'; import type { FileEntry } from '../services/zip'; import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types'; import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo, MessageStep } from '../core/llm/types'; -import { loadSettings, getActiveProviderConfig } from '../core/llm/settings-service'; +import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service'; import type { AgentMessage } from '../core/llm/agent'; import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants'; @@ -112,8 +112,8 @@ interface AppState { setProjectName: (name: string) => void; // Worker API (shared across app) - runPipeline: (file: File, onProgress: (p: PipelineProgress) => void) => Promise; - runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void) => Promise; + runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; + runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; runQuery: (cypher: string) => Promise; isDatabaseReady: () => Promise; @@ -123,6 +123,9 @@ interface AppState { // Embedding methods startEmbeddings: (forceDevice?: 'webgpu' | 'wasm') => Promise; + startBackgroundEnrichment: () => Promise; + cancelEnrichment: () => Promise; + enrichmentProgress: { current: number; total: number } | null; semanticSearch: (query: string, k?: number) => Promise; semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise; isEmbeddingReady: boolean; @@ -132,6 +135,7 @@ interface AppState { // LLM/Agent state llmSettings: LLMSettings; + updateLLMSettings: (updates: Partial) => void; isSettingsPanelOpen: boolean; setSettingsPanelOpen: (open: boolean) => void; isAgentReady: boolean; @@ -145,6 +149,7 @@ interface AppState { // LLM methods refreshLLMSettings: () => void; + runClusterEnrichment: () => Promise; initializeAgent: (overrideProjectName?: string) => Promise; sendChatMessage: (message: string) => Promise; clearChat: () => void; @@ -289,6 +294,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const [isCodePanelOpen, setCodePanelOpen] = useState(false); const [codeReferenceFocus, setCodeReferenceFocus] = useState(null); + // Cluster enrichment state + const [enrichmentProgress, setEnrichmentProgress] = useState<{ current: number; total: number } | null>(null); + const enrichmentCancelledRef = useRef(false); + const normalizePath = useCallback((p: string) => { return p.replace(/\\/g, '/').replace(/^\.?\//, ''); }, []); @@ -427,25 +436,27 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const runPipeline = useCallback(async ( file: File, - onProgress: (progress: PipelineProgress) => void + onProgress: (progress: PipelineProgress) => void, + clusteringConfig?: ProviderConfig ): Promise => { const api = apiRef.current; if (!api) throw new Error('Worker not initialized'); const proxiedOnProgress = Comlink.proxy(onProgress); - const serializedResult = await api.runPipeline(file, proxiedOnProgress); + const serializedResult = await api.runPipeline(file, proxiedOnProgress, clusteringConfig); return deserializePipelineResult(serializedResult, createKnowledgeGraph); }, []); const runPipelineFromFiles = useCallback(async ( files: FileEntry[], - onProgress: (progress: PipelineProgress) => void + onProgress: (progress: PipelineProgress) => void, + clusteringConfig?: ProviderConfig ): Promise => { const api = apiRef.current; if (!api) throw new Error('Worker not initialized'); const proxiedOnProgress = Comlink.proxy(onProgress); - const serializedResult = await api.runPipelineFromFiles(files, proxiedOnProgress); + const serializedResult = await api.runPipelineFromFiles(files, proxiedOnProgress, clusteringConfig); return deserializePipelineResult(serializedResult, createKnowledgeGraph); }, []); @@ -510,6 +521,63 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } }, []); + // Background cluster enrichment + const startBackgroundEnrichment = useCallback(async (): Promise => { + const api = apiRef.current; + if (!api) return; + + enrichmentCancelledRef.current = false; + + try { + const result = await api.startBackgroundEnrichment( + Comlink.proxy((current: number, total: number) => { + setEnrichmentProgress({ current, total }); + setProgress({ + phase: 'complete', + percent: 100, + message: `Labeling clusters ${current}/${total}...`, + }); + }) + ); + + setEnrichmentProgress(null); + + if (!result.skipped && result.enriched > 0) { + setProgress({ + phase: 'complete', + percent: 100, + message: 'Smart cluster labels generated!', + }); + // Clear after 3 seconds + setTimeout(() => setProgress(null), 3000); + } + } catch (err) { + console.warn('Background enrichment failed:', err); + setEnrichmentProgress(null); + } + }, []); + + // Cancel/pause enrichment + const cancelEnrichment = useCallback(async (): Promise => { + const api = apiRef.current; + if (!api) return; + + enrichmentCancelledRef.current = true; + setEnrichmentProgress(null); + + try { + await api.cancelEnrichment(); + setProgress({ + phase: 'complete', + percent: 100, + message: 'LLM labeling stopped. Using heuristic labels.', + }); + setTimeout(() => setProgress(null), 3000); + } catch (err) { + console.warn('Cancel enrichment failed:', err); + } + }, []); + const semanticSearch = useCallback(async ( query: string, k: number = 10 @@ -536,6 +604,101 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }, []); // LLM methods + const updateLLMSettings = useCallback((updates: Partial) => { + setLLMSettings(prev => { + const next = { ...prev, ...updates }; + saveSettings(next); + return next; + }); + }, []); + + const runClusterEnrichment = useCallback(async () => { + const api = apiRef.current; + if (!api) { + setAgentError('Worker not initialized'); + return; + } + + + const defaultConfig = getActiveProviderConfig(); + const configToUse = (!llmSettings.useSameModelForClustering && llmSettings.clusteringProvider?.provider) + ? { + ...defaultConfig, + provider: llmSettings.clusteringProvider.provider || defaultConfig!.provider, + model: llmSettings.clusteringProvider.model || defaultConfig!.model, + apiKey: (llmSettings.clusteringProvider as any).apiKey || (defaultConfig as any).apiKey + } as ProviderConfig + : defaultConfig; + + if (!configToUse) { + // No provider configured - open settings panel + setSettingsPanelOpen(true); + setAgentError('Please configure an LLM provider in Settings first.'); + return; + } + + + try { + setProgress({ + phase: 'enriching', + percent: 1, + message: 'Starting AI enrichment...', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: 0 } + }); + + const { enrichments } = await api.enrichCommunities( + configToUse, + Comlink.proxy((current, total) => { + setProgress(prev => prev ? ({ + ...prev, + percent: Math.min(99, 5 + Math.round((current / total) * 90)), + message: `Enriching clusters ${current}/${total}` + }) : null); + }) + ); + + // Update local graph + setGraph(prevGraph => { + if (!prevGraph) return null; + + const newNodes = prevGraph.nodes.map(n => { + if (n.label === 'Community' && enrichments[n.id]) { + const e = enrichments[n.id]; + return { + ...n, + properties: { + ...n.properties, + name: e.name, + keywords: e.keywords, + description: e.description, + enrichedBy: 'llm' as const + } + }; + } + return n; + }); + return { ...prevGraph, nodes: newNodes }; + }); + + setProgress({ + phase: 'complete', + percent: 100, + message: '✨ Smart labels generated!', + stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: 0 } + }); + + + // Clear progress after 3 seconds + setTimeout(() => setProgress(null), 3000); + + } catch (err) { + console.error(err); + const errorMsg = err instanceof Error ? err.message : String(err); + setAgentError('Clustering enrichment failed: ' + errorMsg); + setProgress(null); + } + }, [llmSettings]); + const refreshLLMSettings = useCallback(() => { setLLMSettings(loadSettings()); }, []); @@ -1039,6 +1202,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { embeddingStatus, embeddingProgress, startEmbeddings, + startBackgroundEnrichment, + cancelEnrichment, + enrichmentProgress, semanticSearch, semanticSearchWithContext, isEmbeddingReady: embeddingStatus === 'ready', @@ -1046,6 +1212,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { testArrayParams, // LLM/Agent state llmSettings, + updateLLMSettings, isSettingsPanelOpen, setSettingsPanelOpen, isAgentReady, @@ -1057,6 +1224,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { currentToolCalls, // LLM methods refreshLLMSettings, + runClusterEnrichment, initializeAgent, sendChatMessage, clearChat, diff --git a/gitnexus/src/hooks/useSettings.ts b/gitnexus/src/hooks/useSettings.ts new file mode 100644 index 000000000..a27bf313f --- /dev/null +++ b/gitnexus/src/hooks/useSettings.ts @@ -0,0 +1,10 @@ +import { useAppState } from './useAppState'; + +export const useSettings = () => { + const { llmSettings, updateLLMSettings } = useAppState(); + + return { + settings: llmSettings, + updateSettings: updateLLMSettings + }; +}; diff --git a/gitnexus/src/lib/constants.ts b/gitnexus/src/lib/constants.ts index 549ab9adf..a5c231b32 100644 --- a/gitnexus/src/lib/constants.ts +++ b/gitnexus/src/lib/constants.ts @@ -17,6 +17,7 @@ export const NODE_COLORS: Record = { Import: '#475569', // Slate darker - very muted Type: '#a78bfa', // Violet light CodeElement: '#64748b', // Slate - muted + Community: '#818cf8', // Indigo light - cluster indicator }; // Node sizes by type - clear visual hierarchy with dramatic size differences @@ -37,6 +38,27 @@ export const NODE_SIZES: Record = { Import: 1.5, // Very small - usually hidden anyway Type: 3, // Type alias - small CodeElement: 2, // Generic small + Community: 0, // Hidden by default - metadata node +}; + +// Community color palette for cluster-based coloring +export const COMMUNITY_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#06b6d4', // cyan + '#3b82f6', // blue + '#8b5cf6', // violet + '#d946ef', // fuchsia + '#ec4899', // pink + '#f43f5e', // rose + '#14b8a6', // teal + '#84cc16', // lime +]; + +export const getCommunityColor = (communityIndex: number): string => { + return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]; }; // Labels to show by default (hide imports and variables by default as they clutter) diff --git a/gitnexus/src/lib/graph-adapter.ts b/gitnexus/src/lib/graph-adapter.ts index 92a1a9d67..a190d45a2 100644 --- a/gitnexus/src/lib/graph-adapter.ts +++ b/gitnexus/src/lib/graph-adapter.ts @@ -1,6 +1,6 @@ import Graph from 'graphology'; import { KnowledgeGraph, NodeLabel } from '../core/graph/types'; -import { NODE_COLORS, NODE_SIZES } from './constants'; +import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; export interface SigmaNodeAttributes { x: number; @@ -16,6 +16,8 @@ export interface SigmaNodeAttributes { zIndex?: number; highlighted?: boolean; mass?: number; // ForceAtlas2 mass - higher = more repulsion + community?: number; // Community index from Leiden algorithm + communityColor?: string; // Color assigned by community } export interface SigmaEdgeAttributes { @@ -74,9 +76,13 @@ const getNodeMass = (nodeType: NodeLabel, nodeCount: number): number => { /** * Converts the KnowledgeGraph to a graphology Graph for Sigma.js * Folders are positioned in a wide spread, children positioned NEAR their parents + * + * @param knowledgeGraph - The knowledge graph to convert + * @param communityMemberships - Optional map of nodeId -> communityIndex for community coloring */ export const knowledgeGraphToGraphology = ( - knowledgeGraph: KnowledgeGraph + knowledgeGraph: KnowledgeGraph, + communityMemberships?: Map ): Graph => { const graph = new Graph(); const nodeCount = knowledgeGraph.nodes.length; @@ -116,6 +122,31 @@ export const knowledgeGraphToGraphology = ( // Small jitter for children around their parent const childJitter = Math.sqrt(nodeCount) * 3; + // === CLUSTER-BASED POSITIONING === + // Calculate cluster centers - each cluster gets a region of the graph + const clusterCenters = new Map(); + if (communityMemberships && communityMemberships.size > 0) { + // Find unique community IDs + const communities = new Set(communityMemberships.values()); + const communityCount = communities.size; + const clusterSpread = structuralSpread * 0.8; // Clusters spread across 80% of graph + + // Position cluster centers using golden angle for even distribution + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + let idx = 0; + communities.forEach(communityId => { + const angle = idx * goldenAngle; + const radius = clusterSpread * Math.sqrt((idx + 1) / communityCount); + clusterCenters.set(communityId, { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + }); + idx++; + }); + } + // Jitter within cluster (tighter than childJitter) + const clusterJitter = Math.sqrt(nodeCount) * 1.5; + // Store positions for parent lookup const nodePositions = new Map(); @@ -136,6 +167,7 @@ export const knowledgeGraphToGraphology = ( const baseSize = NODE_SIZES[node.label] || 8; const scaledSize = getScaledNodeSize(baseSize, nodeCount); + // Structural nodes keep their type-based color graph.addNode(node.id, { x, y, @@ -161,18 +193,28 @@ export const knowledgeGraphToGraphology = ( let x: number, y: number; - // Find parent position (parent should already be positioned!) - const parentId = childToParent.get(nodeId); - const parentPos = parentId ? nodePositions.get(parentId) : null; + // Check if this is a symbol node with a community assignment + const communityIndex = communityMemberships?.get(nodeId); + const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']); + const clusterCenter = communityIndex !== undefined ? clusterCenters.get(communityIndex) : null; - if (parentPos) { - // Position near parent with small random offset - x = parentPos.x + (Math.random() - 0.5) * childJitter; - y = parentPos.y + (Math.random() - 0.5) * childJitter; + if (clusterCenter && symbolTypes.has(node.label)) { + // CLUSTER-BASED POSITIONING: Position near cluster center with tight jitter + x = clusterCenter.x + (Math.random() - 0.5) * clusterJitter; + y = clusterCenter.y + (Math.random() - 0.5) * clusterJitter; } else { - // No parent found - position randomly but still spread out - x = (Math.random() - 0.5) * structuralSpread * 0.5; - y = (Math.random() - 0.5) * structuralSpread * 0.5; + // HIERARCHY-BASED POSITIONING: Position near parent + const parentId = childToParent.get(nodeId); + const parentPos = parentId ? nodePositions.get(parentId) : null; + + if (parentPos) { + x = parentPos.x + (Math.random() - 0.5) * childJitter; + y = parentPos.y + (Math.random() - 0.5) * childJitter; + } else { + // No parent found - position randomly but still spread out + x = (Math.random() - 0.5) * structuralSpread * 0.5; + y = (Math.random() - 0.5) * structuralSpread * 0.5; + } } nodePositions.set(nodeId, { x, y }); @@ -180,11 +222,20 @@ export const knowledgeGraphToGraphology = ( const baseSize = NODE_SIZES[node.label] || 8; const scaledSize = getScaledNodeSize(baseSize, nodeCount); + // Check if this node has a community assignment (reuse communityIndex from above) + const hasCommunity = communityIndex !== undefined; + + // Symbol nodes get colored by community if available + const usesCommunityColor = hasCommunity && symbolTypes.has(node.label); + const nodeColor = usesCommunityColor + ? getCommunityColor(communityIndex!) + : NODE_COLORS[node.label] || '#9ca3af'; + graph.addNode(nodeId, { x, y, size: scaledSize, - color: NODE_COLORS[node.label] || '#9ca3af', + color: nodeColor, label: node.properties.name, nodeType: node.label, filePath: node.properties.filePath, @@ -192,6 +243,8 @@ export const knowledgeGraphToGraphology = ( endLine: node.properties.endLine, hidden: false, mass: getNodeMass(node.label, nodeCount), + community: communityIndex, + communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined, }); }; diff --git a/gitnexus/src/repomix-output.md b/gitnexus/src/repomix-output.md new file mode 100644 index 000000000..6a96c3c1e --- /dev/null +++ b/gitnexus/src/repomix-output.md @@ -0,0 +1,13681 @@ +This file is a merged representation of the entire codebase, combining all repository files into a single document. +Generated by Repomix on: 2026-01-20 12:29:34 + +# File Summary + +## Purpose: + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + +## File Format: + +The content is organized as follows: +1. This summary section +2. Repository information +3. Repository structure +4. Multiple file entries, each consisting of: + a. A header with the file path (## File: path/to/file) + b. The full contents of the file in a code block + +## Usage Guidelines: + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + +## Notes: + +- Some files may have been excluded based on .gitignore rules and Repomix's + configuration. +- Binary files are not included in this packed representation. Please refer to + the Repository Structure section for a complete list of file paths, including + binary files. + +## Additional Information: + +For more information about Repomix, visit: https://github.com/andersonby/python-repomix + + +# Repository Structure + +``` +App.tsx +components + ActivityFeed.tsx + CodeReferencesPanel.tsx + EmbeddingStatus.tsx + FileTreePanel.tsx + GraphCanvas.tsx + Header.tsx + IntelligentClusteringModal.tsx + LoadingOverlay.tsx + MarkdownRenderer.tsx + MCPToggle.tsx + MermaidDiagram.tsx + QueryFAB.tsx + RightPanel.tsx + StatusBar.tsx + ToolCallCard.tsx + WebGPUFallbackDialog.tsx +config + ignore-service.ts + supported-languages.ts +core + embeddings + embedder.ts + embedding-pipeline.ts + index.ts + text-generator.ts + types.ts + graph + graph.ts + types.ts + ingestion + ast-cache.ts + call-processor.ts + cluster-enricher.ts + community-processor.ts + heritage-processor.ts + import-processor.ts + parsing-processor.ts + pipeline.ts + structure-processor.ts + symbol-table.ts + tree-sitter-queries.ts + utils.ts + kuzu + csv-generator.ts + kuzu-adapter.ts + schema.ts + llm + agent.ts + context-builder.ts + index.ts + settings-service.ts + mcp + mcp-client.ts + search + hybrid-search.ts + index.ts + tree-sitter + parser-loader.ts +hooks + useAppState.tsx + useSigma.ts +index.css +main.tsx +services + zip.ts +types + kuzu-wasm.d.ts + pipeline.ts +vite-env.d.ts +workers + ingestion.worker.ts +``` + +# Repository Files + + +## App.tsx + +```text +import { useCallback, useRef } from 'react'; +import { AppStateProvider, useAppState } from './hooks/useAppState'; +import { DropZone } from './components/DropZone'; +import { LoadingOverlay } from './components/LoadingOverlay'; +import { Header } from './components/Header'; +import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas'; +import { RightPanel } from './components/RightPanel'; +import { SettingsPanel } from './components/SettingsPanel'; +import { StatusBar } from './components/StatusBar'; +import { FileTreePanel } from './components/FileTreePanel'; +import { CodeReferencesPanel } from './components/CodeReferencesPanel'; +import { FileEntry } from './services/zip'; +import { getActiveProviderConfig } from './core/llm/settings-service'; + +const AppContent = () => { + const { + viewMode, + setViewMode, + setGraph, + setFileContents, + setProgress, + setProjectName, + progress, + isRightPanelOpen, + runPipeline, + runPipelineFromFiles, + isSettingsPanelOpen, + setSettingsPanelOpen, + refreshLLMSettings, + initializeAgent, + startEmbeddings, + embeddingStatus, + codeReferences, + selectedNode, + isCodePanelOpen, + } = useAppState(); + + const graphCanvasRef = useRef(null); + + const handleFileSelect = useCallback(async (file: File) => { + const projectName = file.name.replace('.zip', ''); + setProjectName(projectName); + setViewMode('loading'); + + try { + const result = await runPipeline(file, (progress) => { + setProgress(progress); + }); + + setGraph(result.graph); + setFileContents(result.fileContents); + setViewMode('exploring'); + + // Initialize (or re-initialize) the agent AFTER a repo loads so it captures + // the current codebase context (file contents + graph tools) in the worker. + if (getActiveProviderConfig()) { + initializeAgent(projectName); + } + + // Auto-start embeddings pipeline in background + // Uses WebGPU if available, falls back to WASM + startEmbeddings().catch((err) => { + // WebGPU not available - try WASM fallback silently + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + } catch (error) { + console.error('Pipeline error:', error); + setProgress({ + phase: 'error', + percent: 0, + message: 'Error processing file', + detail: error instanceof Error ? error.message : 'Unknown error', + }); + setTimeout(() => { + setViewMode('onboarding'); + setProgress(null); + }, 3000); + } + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings, initializeAgent]); + + const handleGitClone = useCallback(async (files: FileEntry[]) => { + // Extract project name from first file path (e.g., "owner-repo-123/src/..." -> "owner-repo") + const firstPath = files[0]?.path || 'repository'; + const projectName = firstPath.split('/')[0].replace(/-\d+$/, '') || 'repository'; + + setProjectName(projectName); + setViewMode('loading'); + + try { + const result = await runPipelineFromFiles(files, (progress) => { + setProgress(progress); + }); + + setGraph(result.graph); + setFileContents(result.fileContents); + setViewMode('exploring'); + + // Initialize (or re-initialize) the agent AFTER a repo loads so it captures + // the current codebase context (file contents + graph tools) in the worker. + if (getActiveProviderConfig()) { + initializeAgent(projectName); + } + + // Auto-start embeddings pipeline in background + // Uses WebGPU if available, falls back to WASM + startEmbeddings().catch((err) => { + // WebGPU not available - try WASM fallback silently + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + } catch (error) { + console.error('Pipeline error:', error); + setProgress({ + phase: 'error', + percent: 0, + message: 'Error processing repository', + detail: error instanceof Error ? error.message : 'Unknown error', + }); + setTimeout(() => { + setViewMode('onboarding'); + setProgress(null); + }, 3000); + } + }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]); + + const handleFocusNode = useCallback((nodeId: string) => { + graphCanvasRef.current?.focusNode(nodeId); + }, []); + + // Handle settings saved - refresh and reinitialize agent + // NOTE: Must be defined BEFORE any conditional returns (React hooks rule) + const handleSettingsSaved = useCallback(() => { + refreshLLMSettings(); + initializeAgent(); + }, [refreshLLMSettings, initializeAgent]); + + // Render based on view mode + if (viewMode === 'onboarding') { + return ; + } + + if (viewMode === 'loading' && progress) { + return ; + } + + // Exploring view + return ( +
+
+ +
+ {/* Left Panel - File Tree */} + + + {/* Graph area - takes remaining space */} +
+ + + {/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */} + {isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && ( +
+ +
+ )} +
+ + {/* Right Panel - Code & Chat (tabbed) */} + {isRightPanelOpen && } +
+ + + + {/* Settings Panel (modal) */} + setSettingsPanelOpen(false)} + onSettingsSaved={handleSettingsSaved} + /> +
+ ); +}; + +function App() { + return ( + + + + ); +} + +export default App; +``` + +## components/ActivityFeed.tsx + +```text +/** + * Activity Feed Component + * + * Shows real-time log of external AI agent tool calls. + * Used in RightPanel as an alternative to the Chat tab. + */ + +import { useState, useEffect, useRef } from 'react'; +import { Activity, Search, Database, Terminal, Eye, Loader2, CheckCircle, XCircle, Clock, FileText, Zap } from 'lucide-react'; +import { getMCPClient, type ActivityEvent } from '../core/mcp/mcp-client'; + +// Tool icons +const TOOL_ICONS: Record = { + context: Zap, + search: Search, + cypher: Database, + grep: Terminal, + read: FileText, + blastRadius: Activity, + highlight: Eye, +}; + +// Tool colors +const TOOL_COLORS: Record = { + context: 'text-amber-400', + search: 'text-cyan-400', + cypher: 'text-purple-400', + grep: 'text-green-400', + read: 'text-blue-400', + blastRadius: 'text-rose-400', + highlight: 'text-teal-400', +}; + +export function ActivityFeed() { + const [events, setEvents] = useState([]); + const containerRef = useRef(null); + + useEffect(() => { + const client = getMCPClient(); + + // Subscribe to activity events + const unsubscribe = client.onActivity((event) => { + setEvents(prev => { + // Keep max 100 events + const next = [...prev, event]; + if (next.length > 100) { + next.shift(); + } + return next; + }); + }); + + // Get existing events + setEvents(client.getActivityLog()); + + return () => { + unsubscribe(); + }; + }, []); + + // Auto-scroll to bottom + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [events]); + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const formatParams = (params: any): string => { + if (!params) return ''; + // Show first key-value pairs, truncated + const entries = Object.entries(params).slice(0, 2); + return entries.map(([k, v]) => { + const val = typeof v === 'string' ? v.slice(0, 30) : JSON.stringify(v).slice(0, 30); + return `${k}: ${val}${val.length >= 30 ? '...' : ''}`; + }).join(', '); + }; + + const formatResult = (event: ActivityEvent): string => { + if (event.status === 'running') return 'Running...'; + if (event.status === 'error') return `Error: ${event.error?.slice(0, 50) || 'Unknown'}`; + + // Format result based on type + if (Array.isArray(event.result)) { + return `${event.result.length} results`; + } + if (typeof event.result === 'object' && event.result) { + const keys = Object.keys(event.result); + if (keys.includes('content')) return `${event.result.content?.length || 0} chars`; + if (keys.includes('projectName')) return event.result.projectName; + return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`; + } + return String(event.result || 'Done'); + }; + + if (events.length === 0) { + return ( +
+
+ 📡 +
+

+ No Agent Activity +

+

+ When external AI agents (Cursor, Claude Code) call GitNexus tools, + their activity will appear here in real-time. +

+

+ Make sure MCP toggle is enabled in the header +

+
+ ); + } + + return ( +
+
+ {events.map((event) => { + const Icon = TOOL_ICONS[event.tool] || Activity; + const color = TOOL_COLORS[event.tool] || 'text-text-muted'; + + return ( +
+ {/* Header row */} +
+ {/* Agent color indicator */} + {event.agentColor && ( +
+ )} + + {event.tool} + {event.agentName && event.agentName !== 'Unknown' && ( + + {event.agentName} + + )} + + + {formatTime(event.timestamp)} + +
+ + {/* Params preview */} + {event.params && Object.keys(event.params).length > 0 && ( +
+ {formatParams(event.params)} +
+ )} + + {/* Status/Result */} +
+ {event.status === 'running' && ( + <> + + Running... + + )} + {event.status === 'complete' && ( + <> + + {formatResult(event)} + {event.duration && ( + {event.duration}ms + )} + + )} + {event.status === 'error' && ( + <> + + {formatResult(event)} + + )} +
+
+ ); + })} +
+
+ ); +} +``` + +## components/CodeReferencesPanel.tsx + +```text +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from 'lucide-react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { useAppState } from '../hooks/useAppState'; +import { NODE_COLORS } from '../lib/constants'; + +// Match the code theme used elsewhere in the app +const customTheme = { + ...vscDarkPlus, + 'pre[class*="language-"]': { + ...vscDarkPlus['pre[class*="language-"]'], + background: '#0a0a10', + margin: 0, + padding: '12px 0', + fontSize: '13px', + lineHeight: '1.6', + }, + 'code[class*="language-"]': { + ...vscDarkPlus['code[class*="language-"]'], + background: 'transparent', + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + }, +}; + +export interface CodeReferencesPanelProps { + onFocusNode: (nodeId: string) => void; +} + +export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => { + const { + graph, + fileContents, + selectedNode, + codeReferences, + removeCodeReference, + clearCodeReferences, + setSelectedNode, + codeReferenceFocus, + } = useAppState(); + + const [isCollapsed, setIsCollapsed] = useState(false); + const [glowRefId, setGlowRefId] = useState(null); + const panelRef = useRef(null); + const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + const refCardEls = useRef>(new Map()); + const glowTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (glowTimerRef.current) { + window.clearTimeout(glowTimerRef.current); + glowTimerRef.current = null; + } + }; + }, []); + + const [panelWidth, setPanelWidth] = useState(() => { + try { + const saved = window.localStorage.getItem('gitnexus.codePanelWidth'); + const parsed = saved ? parseInt(saved, 10) : NaN; + if (!Number.isFinite(parsed)) return 560; // increased default + return Math.max(420, Math.min(parsed, 900)); + } catch { + return 560; + } + }); + + useEffect(() => { + try { + window.localStorage.setItem('gitnexus.codePanelWidth', String(panelWidth)); + } catch { + // ignore + } + }, [panelWidth]); + + const startResize = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { startX: e.clientX, startWidth: panelWidth }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMove = (ev: MouseEvent) => { + const state = resizeRef.current; + if (!state) return; + const delta = ev.clientX - state.startX; + const next = Math.max(420, Math.min(state.startWidth + delta, 900)); + setPanelWidth(next); + }; + + const onUp = () => { + resizeRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, [panelWidth]); + + const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]); + + // When the user clicks a citation badge in chat, focus the corresponding snippet card: + // - expand the panel if collapsed + // - smooth-scroll the card into view + // - briefly glow it for discoverability + useEffect(() => { + if (!codeReferenceFocus) return; + + // Ensure panel is expanded + setIsCollapsed(false); + + const { filePath, startLine, endLine } = codeReferenceFocus; + const target = + aiReferences.find(r => + r.filePath === filePath && + r.startLine === startLine && + r.endLine === endLine + ) ?? + aiReferences.find(r => r.filePath === filePath); + + if (!target) return; + + // Double rAF: wait for collapse state + list DOM to render. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = refCardEls.current.get(target.id); + if (!el) return; + + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setGlowRefId(target.id); + + if (glowTimerRef.current) { + window.clearTimeout(glowTimerRef.current); + } + glowTimerRef.current = window.setTimeout(() => { + setGlowRefId((prev) => (prev === target.id ? null : prev)); + glowTimerRef.current = null; + }, 1200); + }); + }); + }, [codeReferenceFocus?.ts, aiReferences]); + + const refsWithSnippets = useMemo(() => { + return aiReferences.map((ref) => { + const content = fileContents.get(ref.filePath); + if (!content) { + return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0 }; + } + + const lines = content.split('\n'); + const totalLines = lines.length; + + const startLine = ref.startLine ?? 0; + const endLine = ref.endLine ?? startLine; + + const contextBefore = 3; + const contextAfter = 20; + const start = Math.max(0, startLine - contextBefore); + const end = Math.min(totalLines - 1, endLine + contextAfter); + + return { + ref, + content: lines.slice(start, end + 1).join('\n'), + start, + end, + highlightStart: Math.max(0, startLine - start), + highlightEnd: Math.max(0, endLine - start), + totalLines, + }; + }); + }, [aiReferences, fileContents]); + + const selectedFilePath = selectedNode?.properties?.filePath; + const selectedFileContent = selectedFilePath ? fileContents.get(selectedFilePath) : undefined; + const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath; + const showSelectedViewer = !!selectedNode && !!selectedFilePath; + const showCitations = aiReferences.length > 0; + + if (isCollapsed) { + return ( + + ); + } + + return ( + + ); +}; +``` + +## components/EmbeddingStatus.tsx + +```text +import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from 'lucide-react'; +import { useAppState } from '../hooks/useAppState'; +import { useState } from 'react'; +import { WebGPUFallbackDialog } from './WebGPUFallbackDialog'; + +/** + * Embedding status indicator and trigger button + * Shows in header when graph is loaded + */ +export const EmbeddingStatus = () => { + const { + embeddingStatus, + embeddingProgress, + startEmbeddings, + graph, + viewMode, + testArrayParams, + } = useAppState(); + + const [testResult, setTestResult] = useState(null); + const [showFallbackDialog, setShowFallbackDialog] = useState(false); + + // Only show when exploring a loaded graph + if (viewMode !== 'exploring' || !graph) return null; + + const nodeCount = graph.nodes.length; + + const handleStartEmbeddings = async (forceDevice?: 'webgpu' | 'wasm') => { + try { + await startEmbeddings(forceDevice); + } catch (error: any) { + // Check if it's a WebGPU not available error + if (error?.name === 'WebGPUNotAvailableError' || + error?.message?.includes('WebGPU not available')) { + setShowFallbackDialog(true); + } else { + console.error('Embedding failed:', error); + } + } + }; + + const handleUseCPU = () => { + setShowFallbackDialog(false); + handleStartEmbeddings('wasm'); + }; + + const handleSkipEmbeddings = () => { + setShowFallbackDialog(false); + // Just close - user can try again later if they want + }; + + const handleTestArrayParams = async () => { + setTestResult('Testing...'); + const result = await testArrayParams(); + if (result.success) { + setTestResult('✅ Array params WORK!'); + console.log('✅ Array params test passed!'); + } else { + setTestResult(`❌ ${result.error}`); + console.error('❌ Array params test failed:', result.error); + } + }; + + // WebGPU fallback dialog - rendered independently of state + const fallbackDialog = ( + setShowFallbackDialog(false)} + onUseCPU={handleUseCPU} + onSkip={handleSkipEmbeddings} + nodeCount={nodeCount} + /> + ); + + // Idle state - show button to start + if (embeddingStatus === 'idle') { + return ( + <> +
+ {/* Test button (dev only) */} + {import.meta.env.DEV && ( + + )} + + +
+ {fallbackDialog} + + ); + } + + // Loading model + if (embeddingStatus === 'loading') { + const downloadPercent = embeddingProgress?.modelDownloadPercent ?? 0; + return ( + <> +
+ +
+ Loading AI model... +
+
+
+
+
+ {fallbackDialog} + + ); + } + + // Embedding in progress + if (embeddingStatus === 'embedding') { + const processed = embeddingProgress?.nodesProcessed ?? 0; + const total = embeddingProgress?.totalNodes ?? 0; + const percent = embeddingProgress?.percent ?? 0; + + return ( +
+ +
+ + Embedding {processed}/{total} nodes + +
+
+
+
+
+ ); + } + + // Indexing + if (embeddingStatus === 'indexing') { + return ( +
+ + Creating vector index... +
+ ); + } + + // Ready + if (embeddingStatus === 'ready') { + return ( +
+ + Semantic Ready +
+ ); + } + + // Error + if (embeddingStatus === 'error') { + return ( + <> + + {fallbackDialog} + + ); + } + + return null; +}; +``` + +## components/FileTreePanel.tsx + +```text +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { + ChevronRight, + ChevronDown, + Folder, + FolderOpen, + FileCode, + Search, + Filter, + PanelLeftClose, + PanelLeft, + Box, + Braces, + Variable, + Hash, + Target, +} from 'lucide-react'; +import { useAppState } from '../hooks/useAppState'; +import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants'; +import { GraphNode, NodeLabel } from '../core/graph/types'; + +// Tree node structure +interface TreeNode { + id: string; + name: string; + type: 'folder' | 'file'; + path: string; + children: TreeNode[]; + graphNode?: GraphNode; +} + +// Build tree from graph nodes +const buildFileTree = (nodes: GraphNode[]): TreeNode[] => { + const root: TreeNode[] = []; + const pathMap = new Map(); + + // Filter to only folders and files + const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File'); + + // Sort by path to ensure parents come before children + fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath)); + + fileNodes.forEach(node => { + const parts = node.properties.filePath.split('/').filter(Boolean); + let currentPath = ''; + let currentLevel = root; + + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + + let existing = pathMap.get(currentPath); + + if (!existing) { + const isLastPart = index === parts.length - 1; + const isFile = isLastPart && node.label === 'File'; + + existing = { + id: isLastPart ? node.id : currentPath, + name: part, + type: isFile ? 'file' : 'folder', + path: currentPath, + children: [], + graphNode: isLastPart ? node : undefined, + }; + + pathMap.set(currentPath, existing); + currentLevel.push(existing); + } + + currentLevel = existing.children; + }); + }); + + return root; +}; + +// Tree item component +interface TreeItemProps { + node: TreeNode; + depth: number; + searchQuery: string; + onNodeClick: (node: TreeNode) => void; + expandedPaths: Set; + toggleExpanded: (path: string) => void; + selectedPath: string | null; +} + +const TreeItem = ({ + node, + depth, + searchQuery, + onNodeClick, + expandedPaths, + toggleExpanded, + selectedPath, +}: TreeItemProps) => { + const isExpanded = expandedPaths.has(node.path); + const isSelected = selectedPath === node.path; + const hasChildren = node.children.length > 0; + + // Filter children based on search + const filteredChildren = useMemo(() => { + if (!searchQuery) return node.children; + return node.children.filter(child => + child.name.toLowerCase().includes(searchQuery.toLowerCase()) || + child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase())) + ); + }, [node.children, searchQuery]); + + // Check if this node matches search + const matchesSearch = searchQuery && node.name.toLowerCase().includes(searchQuery.toLowerCase()); + + const handleClick = () => { + if (hasChildren) { + toggleExpanded(node.path); + } + onNodeClick(node); + }; + + return ( +
+ + + {/* Children */} + {isExpanded && filteredChildren.length > 0 && ( +
+ {filteredChildren.map(child => ( + + ))} +
+ )} +
+ ); +}; + +// Icon for node types +const getNodeTypeIcon = (label: NodeLabel) => { + switch (label) { + case 'Folder': return Folder; + case 'File': return FileCode; + case 'Class': return Box; + case 'Function': return Braces; + case 'Method': return Braces; + case 'Interface': return Hash; + case 'Import': return FileCode; + default: return Variable; + } +}; + +interface FileTreePanelProps { + onFocusNode: (nodeId: string) => void; +} + +export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { + const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState(); + + const [isCollapsed, setIsCollapsed] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [activeTab, setActiveTab] = useState<'files' | 'filters'>('files'); + + // Build file tree from graph + const fileTree = useMemo(() => { + if (!graph) return []; + return buildFileTree(graph.nodes); + }, [graph]); + + // Auto-expand first level on initial load + useEffect(() => { + if (fileTree.length > 0 && expandedPaths.size === 0) { + const firstLevel = new Set(fileTree.map(n => n.path)); + setExpandedPaths(firstLevel); + } + }, [fileTree.length]); // Only run when tree first loads + + // Auto-expand to selected file when selectedNode changes (e.g., from graph click) + useEffect(() => { + const path = selectedNode?.properties?.filePath; + if (!path) return; + + // Expand all parent folders leading to this file + const parts = path.split('/').filter(Boolean); + const pathsToExpand: string[] = []; + let currentPath = ''; + + // Build all parent paths (exclude the last part if it's a file) + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + pathsToExpand.push(currentPath); + } + + if (pathsToExpand.length > 0) { + setExpandedPaths(prev => { + const next = new Set(prev); + pathsToExpand.forEach(p => next.add(p)); + return next; + }); + } + }, [selectedNode?.id]); // Trigger when selected node changes + + const toggleExpanded = useCallback((path: string) => { + setExpandedPaths(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const handleNodeClick = useCallback((treeNode: TreeNode) => { + if (treeNode.graphNode) { + // Only focus if selecting a different node + const isSameNode = selectedNode?.id === treeNode.graphNode.id; + setSelectedNode(treeNode.graphNode); + openCodePanel(); + if (!isSameNode) { + onFocusNode(treeNode.graphNode.id); + } + } + }, [setSelectedNode, openCodePanel, onFocusNode, selectedNode]); + + const selectedPath = selectedNode?.properties.filePath || null; + + if (isCollapsed) { + return ( +
+ +
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + +
+ +
+ + {activeTab === 'files' && ( + <> + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 bg-elevated border border-border-subtle rounded text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent" + /> +
+
+ + {/* File tree */} +
+ {fileTree.length === 0 ? ( +
+ No files loaded +
+ ) : ( + fileTree.map(node => ( + + )) + )} +
+ + )} + + {activeTab === 'filters' && ( +
+
+

+ Node Types +

+

+ Toggle visibility of node types in the graph +

+
+ +
+ {FILTERABLE_LABELS.map((label) => { + const Icon = getNodeTypeIcon(label); + const isVisible = visibleLabels.includes(label); + + return ( + + ); + })} +
+ + {/* Edge Type Toggles */} +
+

+ Edge Types +

+

+ Toggle visibility of relationship types +

+ +
+ {ALL_EDGE_TYPES.map((edgeType) => { + const info = EDGE_INFO[edgeType]; + const isVisible = visibleEdgeTypes.includes(edgeType); + + return ( + + ); + })} +
+
+ + {/* Depth Filter */} +
+

+ + Focus Depth +

+

+ Show nodes within N hops of selection +

+ +
+ {[ + { value: null, label: 'All' }, + { value: 1, label: '1 hop' }, + { value: 2, label: '2 hops' }, + { value: 3, label: '3 hops' }, + { value: 5, label: '5 hops' }, + ].map(({ value, label }) => ( + + ))} +
+ + {depthFilter !== null && !selectedNode && ( +

+ Select a node to apply depth filter +

+ )} +
+ + {/* Legend */} +
+

+ Color Legend +

+
+ {(['Folder', 'File', 'Class', 'Function', 'Interface', 'Method'] as NodeLabel[]).map(label => ( +
+
+ {label} +
+ ))} +
+
+
+ )} + + {/* Stats footer */} + {graph && ( +
+
+ {graph.nodes.length} nodes + {graph.relationships.length} edges +
+
+ )} +
+ ); +}; +``` + +## components/GraphCanvas.tsx + +```text +import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react'; +import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from 'lucide-react'; +import { useSigma } from '../hooks/useSigma'; +import { useAppState } from '../hooks/useAppState'; +import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter'; +import { QueryFAB } from './QueryFAB'; +import Graph from 'graphology'; + +export interface GraphCanvasHandle { + focusNode: (nodeId: string) => void; +} + +export const GraphCanvas = forwardRef((_, ref) => { + const { + graph, + setSelectedNode, + selectedNode: appSelectedNode, + visibleLabels, + visibleEdgeTypes, + openCodePanel, + depthFilter, + highlightedNodeIds, + aiCitationHighlightedNodeIds, + aiToolHighlightedNodeIds, + blastRadiusNodeIds, + isAIHighlightsEnabled, + toggleAIHighlights, + animatedNodes, + } = useAppState(); + const [hoveredNodeName, setHoveredNodeName] = useState(null); + + const effectiveHighlightedNodeIds = useMemo(() => { + if (!isAIHighlightsEnabled) return highlightedNodeIds; + const next = new Set(highlightedNodeIds); + for (const id of aiCitationHighlightedNodeIds) next.add(id); + for (const id of aiToolHighlightedNodeIds) next.add(id); + // Note: blast radius nodes are handled separately with red color + return next; + }, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]); + + // Blast radius nodes (only when AI highlights enabled) + const effectiveBlastRadiusNodeIds = useMemo(() => { + if (!isAIHighlightsEnabled) return new Set(); + return blastRadiusNodeIds; + }, [blastRadiusNodeIds, isAIHighlightsEnabled]); + + // Animated nodes (only when AI highlights enabled) + const effectiveAnimatedNodes = useMemo(() => { + if (!isAIHighlightsEnabled) return new Map(); + return animatedNodes; + }, [animatedNodes, isAIHighlightsEnabled]); + + const handleNodeClick = useCallback((nodeId: string) => { + if (!graph) return; + const node = graph.nodes.find(n => n.id === nodeId); + if (node) { + setSelectedNode(node); + openCodePanel(); + } + }, [graph, setSelectedNode, openCodePanel]); + + const handleNodeHover = useCallback((nodeId: string | null) => { + if (!nodeId || !graph) { + setHoveredNodeName(null); + return; + } + const node = graph.nodes.find(n => n.id === nodeId); + if (node) { + setHoveredNodeName(node.properties.name); + } + }, [graph]); + + const handleStageClick = useCallback(() => { + setSelectedNode(null); + }, [setSelectedNode]); + + const { + containerRef, + sigmaRef, + setGraph: setSigmaGraph, + zoomIn, + zoomOut, + resetZoom, + focusNode, + isLayoutRunning, + startLayout, + stopLayout, + selectedNode: sigmaSelectedNode, + setSelectedNode: setSigmaSelectedNode, + } = useSigma({ + onNodeClick: handleNodeClick, + onNodeHover: handleNodeHover, + onStageClick: handleStageClick, + highlightedNodeIds: effectiveHighlightedNodeIds, + blastRadiusNodeIds: effectiveBlastRadiusNodeIds, + animatedNodes: effectiveAnimatedNodes, + visibleEdgeTypes, + }); + + // Expose focusNode to parent via ref + useImperativeHandle(ref, () => ({ + focusNode: (nodeId: string) => { + // Also update app state so the selection syncs properly + if (graph) { + const node = graph.nodes.find(n => n.id === nodeId); + if (node) { + setSelectedNode(node); + openCodePanel(); + } + } + focusNode(nodeId); + } + }), [focusNode, graph, setSelectedNode, openCodePanel]); + + // Update Sigma graph when KnowledgeGraph changes + useEffect(() => { + if (!graph) return; + const sigmaGraph = knowledgeGraphToGraphology(graph); + setSigmaGraph(sigmaGraph); + }, [graph, setSigmaGraph]); + + // Update node visibility when filters change + useEffect(() => { + const sigma = sigmaRef.current; + if (!sigma) return; + + const sigmaGraph = sigma.getGraph() as Graph; + if (sigmaGraph.order === 0) return; // Don't filter empty graph + + filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels); + sigma.refresh(); + }, [visibleLabels, depthFilter, appSelectedNode, sigmaRef]); + + // Sync app selected node with sigma + useEffect(() => { + if (appSelectedNode) { + setSigmaSelectedNode(appSelectedNode.id); + } else { + setSigmaSelectedNode(null); + } + }, [appSelectedNode, setSigmaSelectedNode]); + + // Focus on selected node + const handleFocusSelected = useCallback(() => { + if (appSelectedNode) { + focusNode(appSelectedNode.id); + } + }, [appSelectedNode, focusNode]); + + // Clear selection + const handleClearSelection = useCallback(() => { + setSelectedNode(null); + setSigmaSelectedNode(null); + resetZoom(); + }, [setSelectedNode, setSigmaSelectedNode, resetZoom]); + + return ( +
+ {/* Background gradient */} +
+
+
+ + {/* Sigma container */} +
+ + {/* Hovered node tooltip - only show when NOT selected */} + {hoveredNodeName && !sigmaSelectedNode && ( +
+ {hoveredNodeName} +
+ )} + + {/* Selection info bar */} + {sigmaSelectedNode && appSelectedNode && ( +
+
+ + {appSelectedNode.properties.name} + + + ({appSelectedNode.label}) + + +
+ )} + + {/* Graph Controls - Bottom Right */} +
+ + + + + {/* Divider */} +
+ + {/* Focus on selected */} + {appSelectedNode && ( + + )} + + {/* Clear selection */} + {sigmaSelectedNode && ( + + )} + + {/* Divider */} +
+ + {/* Layout control */} + +
+ + {/* Layout running indicator */} + {isLayoutRunning && ( +
+
+ Layout optimizing... +
+ )} + + {/* Query FAB */} + + + {/* AI Highlights toggle - Top Right */} +
+ +
+
+ ); +}); + +GraphCanvas.displayName = 'GraphCanvas'; +``` + +## components/Header.tsx + +```text +import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react'; +import { useAppState } from '../hooks/useAppState'; +import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; +import { GraphNode } from '../core/graph/types'; +import { EmbeddingStatus } from './EmbeddingStatus'; +import { MCPToggle } from './MCPToggle'; +import { buildCodebaseContext } from '../core/llm/context-builder'; + +// Color mapping for node types in search results +const NODE_TYPE_COLORS: Record = { + Folder: '#6366f1', + File: '#3b82f6', + Function: '#10b981', + Class: '#f59e0b', + Method: '#14b8a6', + Interface: '#ec4899', + Variable: '#64748b', + Import: '#475569', + Type: '#a78bfa', +}; + +interface HeaderProps { + onFocusNode?: (nodeId: string) => void; +} + +export const Header = ({ onFocusNode }: HeaderProps) => { + const { + projectName, + graph, + openChatPanel, + isRightPanelOpen, + rightPanelTab, + setSettingsPanelOpen, + runQuery, + semanticSearch, + setHighlightedNodeIds, + fileContents, + triggerNodeAnimation, + } = useAppState(); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const searchRef = useRef(null); + const inputRef = useRef(null); + + const nodeCount = graph?.nodes.length ?? 0; + const edgeCount = graph?.relationships.length ?? 0; + + // Search results - filter nodes by name + const searchResults = useMemo(() => { + if (!graph || !searchQuery.trim()) return []; + + const query = searchQuery.toLowerCase(); + return graph.nodes + .filter(node => node.properties.name.toLowerCase().includes(query)) + .slice(0, 10); // Limit to 10 results + }, [graph, searchQuery]); + + // Handle clicking outside to close dropdown + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setIsSearchOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Keyboard shortcut (Cmd+K / Ctrl+K) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + setIsSearchOpen(true); + } + if (e.key === 'Escape') { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + // Handle keyboard navigation in results + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isSearchOpen || searchResults.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(i => Math.min(i + 1, searchResults.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = searchResults[selectedIndex]; + if (selected) { + handleSelectNode(selected); + } + } + }; + + const handleSelectNode = (node: GraphNode) => { + // onFocusNode handles both camera focus AND selection in useSigma + onFocusNode?.(node.id); + setSearchQuery(''); + setIsSearchOpen(false); + setSelectedIndex(0); + }; + + return ( +
+ {/* Left section */} +
+ {/* Logo */} +
+
+ ◇ +
+ GitNexus +
+ + {/* Project badge */} + {projectName && ( +
+ + {projectName} +
+ )} +
+ + {/* Center - Search */} +
+
+ + { + setSearchQuery(e.target.value); + setIsSearchOpen(true); + setSelectedIndex(0); + }} + onFocus={() => setIsSearchOpen(true)} + onKeyDown={handleKeyDown} + className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted" + /> + + ⌘K + +
+ + {/* Search Results Dropdown */} + {isSearchOpen && searchQuery.trim() && ( +
+ {searchResults.length === 0 ? ( +
+ No nodes found for "{searchQuery}" +
+ ) : ( +
+ {searchResults.map((node, index) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Right section */} +
+ {/* GitHub Star Button */} + + + Star if cool + + + + + {/* Stats */} + {graph && ( +
+ {nodeCount} nodes + {edgeCount} edges +
+ )} + + {/* Embedding Status */} + + + {/* MCP Toggle for external AI agents */} + { + // Use semantic search from the app + const results = await semanticSearch(query, limit); + // Trigger pulse animation on search results + const nodeIds = results.map((r: any) => r.id).filter(Boolean); + if (nodeIds.length > 0) { + triggerNodeAnimation(nodeIds, 'pulse'); + } + return results; + }} + onCypher={async (query) => { + // Execute Cypher query + const results = await runQuery(query); + return results; + }} + onBlastRadius={async (nodeId, hops = 2) => { + // Run blast radius query + const query = ` + MATCH (start)-[*1..${hops}]-(connected) + WHERE start.id = '${nodeId}' OR start.name = '${nodeId}' + RETURN DISTINCT connected.id AS id, connected.name AS name, labels(connected) AS labels + `; + const results = await runQuery(query); + // Trigger ripple animation on blast radius results + const nodeIds = results.map((r: any) => r.id).filter(Boolean); + if (nodeIds.length > 0) { + triggerNodeAnimation(nodeIds, 'ripple'); + } + return results; + }} + onHighlight={(nodeIds) => { + // Highlight nodes in the graph + setHighlightedNodeIds(new Set(nodeIds)); + // Trigger glow animation on highlighted nodes + if (nodeIds.length > 0) { + triggerNodeAnimation(nodeIds, 'glow'); + } + }} + getContext={async () => { + // Build codebase context for external AI agents + if (!projectName) return null; + const context = await buildCodebaseContext(runQuery, projectName); + // Reshape to match MCP CodebaseContext format + return { + projectName: context.stats.projectName, + stats: { + fileCount: context.stats.fileCount, + functionCount: context.stats.functionCount, + classCount: context.stats.classCount, + interfaceCount: context.stats.interfaceCount, + methodCount: context.stats.methodCount, + }, + hotspots: context.hotspots, + folderTree: context.folderTree, + }; + }} + onGrep={async (pattern, caseSensitive = false, maxResults = 50) => { + // Grep across file contents + const results: Array<{ filePath: string; line: string; lineNumber: number; match: string }> = []; + const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi'); + + for (const [filePath, content] of fileContents.entries()) { + const lines = content.split('\n'); + for (let i = 0; i < lines.length && results.length < maxResults; i++) { + const line = lines[i]; + const match = line.match(regex); + if (match) { + results.push({ + filePath, + line: line.trim(), + lineNumber: i + 1, + match: match[0], + }); + } + } + if (results.length >= maxResults) break; + } + return results; + }} + onRead={async (filePath, startLine, endLine) => { + // Read file content + let content = fileContents.get(filePath); + + // Try normalized path if not found + if (!content) { + const normalizedPath = filePath.replace(/\\/g, '/'); + for (const [path, c] of fileContents.entries()) { + if (path.endsWith(normalizedPath) || normalizedPath.endsWith(path)) { + content = c; + break; + } + } + } + + if (!content) { + return { error: `File not found: ${filePath}` }; + } + + const lines = content.split('\n'); + const language = filePath.split('.').pop() || 'text'; + + // If line range specified, return only those lines + if (startLine !== undefined && endLine !== undefined) { + const slice = lines.slice(startLine - 1, endLine); + return { + filePath, + content: slice.join('\n'), + language, + lines: slice.length, + }; + } + + return { + filePath, + content, + language, + lines: lines.length, + }; + }} + /> + + {/* Icon buttons */} + + + + {/* AI Button */} + +
+
+ ); +}; +``` + +## components/IntelligentClusteringModal.tsx + +```text +import { Brain, Sparkles, X, Settings, Wallet } from 'lucide-react'; +import { useSettings } from '../hooks/useSettings'; +import { useAppState } from '../hooks/useAppState'; + +interface IntelligentClusteringModalProps { + isOpen: boolean; + onClose: () => void; + onEnable: () => void; + onConfigure: () => void; +} + +export const IntelligentClusteringModal = ({ + isOpen, + onClose, + onEnable, + onConfigure +}: IntelligentClusteringModalProps) => { + const { updateSettings } = useSettings(); + + if (!isOpen) return null; + + const handleEnable = () => { + updateSettings({ intelligentClustering: true, hasSeenClusteringPrompt: true }); + onEnable(); + onClose(); + }; + + const handleSkip = () => { + updateSettings({ hasSeenClusteringPrompt: true }); + onClose(); + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ + {/* Header with cool gradient background */} +
+
+ +
+ + + +
+
+ +
+
+ +

+ Upgrade to Intelligent Clustering? +

+

+ Your clusters are ready, but they could be smarter! Right now they're just named after folders. +

+
+ + {/* Body */} +
+ +
+

+ + What you get: +

+
    +
  • + + Semantic names (e.g., "Auth System" vs "utils") +
  • +
  • + + Search keywords for better agent context +
  • +
  • + + Descriptions of what the code actually does +
  • +
+
+ + {/* Cost Note */} +
+
+
+ +
+
+

Super cheap!

+

+ Costs very less tokens for the whole codebase. +
+ Pro tip: Smaller, cheaper models like GPT-4o-mini work great too! +

+
+
+
+ +
+ + {/* Actions */} +
+ + +
+ +
+ +
+
+ +
+
+ ); +}; +``` + +## components/LoadingOverlay.tsx + +```text +import { PipelineProgress } from '../types/pipeline'; + +interface LoadingOverlayProps { + progress: PipelineProgress; +} + +export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => { + return ( +
+ {/* Background gradient effects */} +
+
+
+
+ + {/* Pulsing orb */} +
+
+
+
+ + {/* Progress bar */} +
+
+
+
+
+ + {/* Status text */} +
+

+ {progress.message} + | +

+ {progress.detail && ( +

+ {progress.detail} +

+ )} +
+ + {/* Stats */} + {progress.stats && ( +
+
+ + {progress.stats.filesProcessed} / {progress.stats.totalFiles} files +
+
+ + {progress.stats.nodesCreated} nodes +
+
+ )} + + {/* Percent */} +

+ {progress.percent}% +

+
+ ); +}; +``` + +## components/MarkdownRenderer.tsx + +````text +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { MermaidDiagram } from './MermaidDiagram'; +import { ToolCallCard } from './ToolCallCard'; + +// Custom syntax theme +const customTheme = { + ...vscDarkPlus, + 'pre[class*="language-"]': { + ...vscDarkPlus['pre[class*="language-"]'], + background: '#0a0a10', + margin: 0, + padding: '16px 0', + fontSize: '13px', + lineHeight: '1.6', + }, + 'code[class*="language-"]': { + ...vscDarkPlus['code[class*="language-"]'], + background: 'transparent', + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + }, +}; + +interface MarkdownRendererProps { + content: string; + onLinkClick?: (href: string) => void; + toolCalls?: any[]; // Keep flexible for now +} + +export const MarkdownRenderer: React.FC = ({ + content, + onLinkClick, + toolCalls +}) => { + + // Helper to format text for display (convert [[links]] to markdown links) + const formatMarkdownForDisplay = (md: string) => { + // Avoid rewriting inside fenced code blocks. + const parts = md.split('```'); + for (let i = 0; i < parts.length; i += 2) { + // Pattern 1: File grounding - [[file.ext]] + parts[i] = parts[i].replace( + /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-–]\d+)?)?)\]\]/g, + (_m, inner: string) => { + const trimmed = inner.trim(); + const href = `code-ref:${encodeURIComponent(trimmed)}`; + return `[${trimmed}](${href})`; + } + ); + + // Pattern 2: Node grounding - [[Type:Name]] + parts[i] = parts[i].replace( + /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g, + (_m, nodeType: string, nodeName: string) => { + const trimmed = `${nodeType}:${nodeName.trim()}`; + const href = `node-ref:${encodeURIComponent(trimmed)}`; + return `[${trimmed}](${href})`; + } + ); + } + return parts.join('```'); + }; + + const handleLinkClick = (e: React.MouseEvent, href: string) => { + if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) { + e.preventDefault(); + onLinkClick?.(href); + } + // External links open in new tab (default behavior) + }; + + const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]); + + const markdownComponents = React.useMemo(() => ({ + a: ({ href, children, ...props }: any) => { + const hrefStr = href || ''; + + // Grounding links (Code refs & Node refs) + if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) { + const isNodeRef = hrefStr.startsWith('node-ref:'); + const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes. + + // Styles + const baseParams = "code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors"; + const colorParams = isNodeRef + ? "border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70" + : "border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70"; + + return ( + handleLinkClick(e, hrefStr)} + className={`${baseParams} ${colorParams}`} + title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`} + {...props} + > + {children} + + ); + } + + // External links + return ( + + {children} + + ); + }, + code: ({ className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !className && !match; + const codeContent = String(children).replace(/\n$/, ''); + + if (isInline) { + return {children}; + } + + const language = match ? match[1] : 'text'; + + // Render Mermaid diagrams + if (language === 'mermaid') { + return ; + } + + return ( + + {codeContent} + + ); + }, + pre: ({ children }: any) => <>{children}, + }), [onLinkClick]); // Removed handleLinkClick dependency as it is defined inside component but depends on onLinkClick + + return ( +
+ { + if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url; + // Default behavior for http/https/etc + return url; + }} + components={markdownComponents} + > + {formattedContent} + + + {/* Tool Call Cards appended at the bottom if provided */} + {toolCalls && toolCalls.length > 0 && ( +
+ {toolCalls.map(tc => ( + + ))} +
+ )} +
+ ); +}; +```` + +## components/MCPToggle.tsx + +```text +/** + * MCP Toggle Component + * + * Toggle for enabling MCP exposure to external AI agents (Cursor, Claude, etc.) + * Shows MCP config for setup and connection status. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Copy, Check, X, Sparkles, Zap, ExternalLink } from 'lucide-react'; +import { getMCPClient, type CodebaseContext } from '../core/mcp/mcp-client'; + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +interface MCPToggleProps { + onSearch?: (query: string, limit?: number) => Promise; + onCypher?: (query: string) => Promise; + onBlastRadius?: (nodeId: string, hops?: number) => Promise; + onHighlight?: (nodeIds: string[], color?: string) => void; + onGrep?: (pattern: string, caseSensitive?: boolean, maxResults?: number) => Promise; + onRead?: (filePath: string, startLine?: number, endLine?: number) => Promise; + showOnboardingTip?: boolean; + getContext?: () => Promise; +} + +const MCP_TIP_DISMISSED_KEY = 'gitnexus-mcp-tip-dismissed'; + +// MCP config that users copy to their AI agent +const MCP_CONFIG = `{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus-mcp"] + } + } +}`; + +export function MCPToggle({ + onSearch, + onCypher, + onBlastRadius, + onHighlight, + onGrep, + onRead, + showOnboardingTip = false, + getContext, +}: MCPToggleProps = {}) { + const [status, setStatus] = useState('disconnected'); + const [copied, setCopied] = useState(false); + const [showPopup, setShowPopup] = useState(false); + const popupRef = useRef(null); + const [showTip, setShowTip] = useState(false); + + const isConnected = status === 'connected'; + const isConnecting = status === 'connecting'; + + // Show tip when graph becomes ready + useEffect(() => { + if (showOnboardingTip) { + const dismissed = localStorage.getItem(MCP_TIP_DISMISSED_KEY); + if (!dismissed) { + const timer = setTimeout(() => setShowTip(true), 1500); + return () => clearTimeout(timer); + } + } + }, [showOnboardingTip]); + + // Close popup when clicking outside + useEffect(() => { + if (!showPopup) return; + const handleClickOutside = (event: MouseEvent) => { + if (popupRef.current && !popupRef.current.contains(event.target as Node)) { + setShowPopup(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showPopup]); + + const dismissTip = () => { + setShowTip(false); + localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true'); + }; + + const connect = useCallback(async () => { + const client = getMCPClient(); + setStatus('connecting'); + setShowTip(false); + + try { + await client.connect(); + + // Register tool handlers + if (onSearch) client.registerHandler('search', async (params) => onSearch(params.query, params.limit)); + if (onCypher) client.registerHandler('cypher', async (params) => onCypher(params.query)); + if (onBlastRadius) client.registerHandler('blastRadius', async (params) => onBlastRadius(params.nodeId, params.hops)); + if (onHighlight) client.registerHandler('highlight', async (params) => { onHighlight(params.nodeIds, params.color); return { highlighted: params.nodeIds.length }; }); + if (onGrep) client.registerHandler('grep', async (params) => onGrep(params.pattern, params.caseSensitive, params.maxResults)); + if (onRead) client.registerHandler('read', async (params) => onRead(params.filePath, params.startLine, params.endLine)); + if (getContext) client.registerHandler('context', async () => getContext()); + + setStatus('connected'); + setShowPopup(false); + localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true'); + + // Send context after connecting + if (getContext) { + try { + const context = await getContext(); + if (context) client.sendContext(context); + } catch (e) { + console.error('[MCP] Failed to send context:', e); + } + } + } catch { + setStatus('error'); + } + }, [onSearch, onCypher, onBlastRadius, onHighlight, onGrep, onRead, getContext]); + + const disconnect = useCallback(() => { + const client = getMCPClient(); + client.disconnect(); + setStatus('disconnected'); + }, []); + + const toggle = useCallback(() => { + if (isConnected) { + disconnect(); + } else if (!isConnecting) { + connect(); + } + }, [isConnected, isConnecting, connect, disconnect]); + + const copyConfig = () => { + navigator.clipboard.writeText(MCP_CONFIG); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + // Listen for connection changes + useEffect(() => { + const client = getMCPClient(); + const unsubscribe = client.onConnectionChange((connected) => { + setStatus(connected ? 'connected' : 'disconnected'); + if (connected) setShowPopup(false); + }); + return () => { unsubscribe(); }; + }, []); + + return ( +
+ {/* MCP Button */} + + + {/* Popup */} + {showPopup && ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Connect AI Agents

+

Cursor, Claude Code, Antigravity

+
+
+ +
+
+ + {/* Content */} +
+ {/* Step 1: Config */} +
+
+ 1 + Add to your AI agent's MCP config +
+
+
+                                    {MCP_CONFIG}
+                                
+ +
+
+ + {/* Step 2: Connect */} +
+
+ 2 + Connect browser to daemon +
+ +
+ + {/* Status message */} + {status === 'error' && ( +

+ Daemon not running. Make sure your AI agent has started gitnexus-mcp. +

+ )} + + {/* Help link */} + + Learn more + + +
+
+ )} + + {/* Onboarding Tip */} + {showTip && !isConnected && !showPopup && ( +
+ +
+
+ +
+
+

+ Connect your AI tools +

+

+ Let Cursor or Claude access GitNexus code intelligence. +

+ +
+
+
+ )} +
+ ); +} +``` + +## components/MermaidDiagram.tsx + +```text +import { useEffect, useRef, useState } from 'react'; +import mermaid from 'mermaid'; +import { AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'; + +// Initialize mermaid with dark theme +mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + themeVariables: { + primaryColor: '#06b6d4', + primaryTextColor: '#e4e4ed', + primaryBorderColor: '#1e1e2a', + lineColor: '#3b3b54', + secondaryColor: '#1e1e2a', + tertiaryColor: '#0a0a10', + background: '#0a0a10', + mainBkg: '#0f0f18', + nodeBorder: '#3b3b54', + clusterBkg: '#1e1e2a', + titleColor: '#e4e4ed', + edgeLabelBackground: '#0f0f18', + nodeTextColor: '#e4e4ed', + }, + flowchart: { + curve: 'basis', + padding: 15, + nodeSpacing: 50, + rankSpacing: 50, + }, + sequence: { + actorMargin: 50, + boxMargin: 10, + boxTextMargin: 5, + noteMargin: 10, + messageMargin: 35, + }, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontSize: 13, + suppressErrorRendering: true, // Prevent default error div appending +}); + +// Override the default error handler to prevent it from logging to UI +mermaid.parseError = (_err) => { + // Silent catch +}; + +interface MermaidDiagramProps { + code: string; +} + +export const MermaidDiagram = ({ code }: MermaidDiagramProps) => { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const [svg, setSvg] = useState(''); + + useEffect(() => { + const renderDiagram = async () => { + if (!containerRef.current) return; + + try { + // Generate unique ID for this diagram + const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Render the diagram + const { svg: renderedSvg } = await mermaid.render(id, code.trim()); + setSvg(renderedSvg); + setError(null); + } catch (err) { + // Silent catch for streaming: + // If render fails (common during partial streaming), we: + // 1. Log to console for debugging + // 2. Do NOT set error state (avoids flashing red box) + // 3. Do NOT clear existing SVG (keeps last valid state visible) + console.debug('Mermaid render skipped (incomplete):', err); + } + }; + + // Debounce rendering to prevent "jerking" during high-speed streaming + const timeoutId = setTimeout(() => { + renderDiagram(); + }, 300); + + return () => clearTimeout(timeoutId); + }, [code]); + + if (error) { + return ( +
+
+ + Diagram Error +
+
{error}
+
+ + Show source + +
+            {code}
+          
+
+
+ ); + } + + return ( +
+ {/* Backdrop for expanded view */} + {isExpanded && ( +
setIsExpanded(false)} + /> + )} + +
+ {/* Header */} +
+ + Diagram + + +
+ + {/* Diagram container */} +
+
+
+ ); +}; +``` + +## components/QueryFAB.tsx + +```text +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from 'lucide-react'; +import { useAppState } from '../hooks/useAppState'; + +const EXAMPLE_QUERIES = [ + { + label: 'All Functions', + query: `MATCH (n:Function) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`, + }, + { + label: 'All Classes', + query: `MATCH (n:Class) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`, + }, + { + label: 'All Interfaces', + query: `MATCH (n:Interface) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`, + }, + { + label: 'Function Calls', + query: `MATCH (a:File)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.id AS id, a.name AS caller, b.name AS callee LIMIT 50`, + }, + { + label: 'Import Dependencies', + query: `MATCH (a:File)-[r:CodeRelation {type: 'IMPORTS'}]->(b:File) RETURN a.id AS id, a.name AS from, b.name AS imports LIMIT 50`, + }, +]; + +export const QueryFAB = () => { + const { setHighlightedNodeIds, setQueryResult, queryResult, clearQueryHighlights, graph, runQuery, isDatabaseReady } = useAppState(); + + const [isExpanded, setIsExpanded] = useState(false); + const [query, setQuery] = useState(''); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [showExamples, setShowExamples] = useState(false); + const [showResults, setShowResults] = useState(true); + + const textareaRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (isExpanded && textareaRef.current) { + textareaRef.current.focus(); + } + }, [isExpanded]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setShowExamples(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isExpanded) { + setIsExpanded(false); + setShowExamples(false); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isExpanded]); + + const handleRunQuery = useCallback(async () => { + if (!query.trim() || isRunning) return; + + if (!graph) { + setError('No project loaded. Load a project first.'); + return; + } + + const ready = await isDatabaseReady(); + if (!ready) { + setError('Database not ready. Please wait for loading to complete.'); + return; + } + + setIsRunning(true); + setError(null); + + const startTime = performance.now(); + + try { + const rows = await runQuery(query); + const executionTime = performance.now() - startTime; + + // Extract node IDs from results - handles various formats + // 1. Array format: first element if it looks like a node ID + // 2. Object format: any field ending with 'id' (case-insensitive) + // 3. Values matching node ID pattern: Label:path:name + const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/; + + const nodeIds = rows + .flatMap(row => { + const ids: string[] = []; + + if (Array.isArray(row)) { + // Array format - check all elements for node ID patterns + row.forEach(val => { + if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) { + ids.push(val); + } + }); + } else if (typeof row === 'object' && row !== null) { + // Object format - check fields ending with 'id' and values matching patterns + Object.entries(row).forEach(([key, val]) => { + const keyLower = key.toLowerCase(); + if (typeof val === 'string') { + // Field name contains 'id' + if (keyLower.includes('id') || keyLower === 'id') { + ids.push(val); + } + // Value matches node ID pattern + else if (nodeIdPattern.test(val)) { + ids.push(val); + } + } + }); + } + + return ids; + }) + .filter(Boolean) + .filter((id, index, arr) => arr.indexOf(id) === index); + + setQueryResult({ rows, nodeIds, executionTime }); + setHighlightedNodeIds(new Set(nodeIds)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Query execution failed'); + setQueryResult(null); + setHighlightedNodeIds(new Set()); + } finally { + setIsRunning(false); + } + }, [query, isRunning, graph, isDatabaseReady, runQuery, setHighlightedNodeIds, setQueryResult]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleRunQuery(); + } + }; + + const handleSelectExample = (exampleQuery: string) => { + setQuery(exampleQuery); + setShowExamples(false); + textareaRef.current?.focus(); + }; + + const handleClose = () => { + setIsExpanded(false); + setShowExamples(false); + clearQueryHighlights(); + setError(null); + }; + + const handleClear = () => { + setQuery(''); + clearQueryHighlights(); + setError(null); + textareaRef.current?.focus(); + }; + + if (!isExpanded) { + return ( + + ); + } + + return ( +
+
+
+
+ +
+ Cypher Query +
+ +
+ +
+
+