Compare commits
96
Commits
v1.4.9
...
release/v1.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f844b00db5 | ||
|
|
26ff700e37 | ||
|
|
6388113e10 | ||
|
|
c672697012 | ||
|
|
d786e692af | ||
|
|
a6421b3b1b | ||
|
|
9f4109a33f | ||
|
|
79e1d933fa | ||
|
|
4d4756fe86 | ||
|
|
b10d25bbca | ||
|
|
a94d6ef80b | ||
|
|
1ff324ca16 | ||
|
|
9364739fb4 | ||
|
|
75635638b1 | ||
|
|
5be0537ce4 | ||
|
|
6d9ec1009e | ||
|
|
4911201664 | ||
|
|
08541e2857 | ||
|
|
d9960c62bf | ||
|
|
7c983d798f | ||
|
|
d87744fffc | ||
|
|
100858f8c8 | ||
|
|
e5dafce9f2 | ||
|
|
ab956f113c | ||
|
|
6147579e54 | ||
|
|
4a1f912aee | ||
|
|
d09078925e | ||
|
|
338cb01ee0 | ||
|
|
4450a14b98 | ||
|
|
3f0b8c1a5b | ||
|
|
bb68cc1eb0 | ||
|
|
d6debf3324 | ||
|
|
9ab92a97d0 | ||
|
|
4fde5f241b | ||
|
|
9f9bbcd744 | ||
|
|
fd67cfd5a7 | ||
|
|
3f28f7ead5 | ||
|
|
d9ba9aa998 | ||
|
|
c19e76a4a3 | ||
|
|
b75e76d44a | ||
|
|
83b5bec293 | ||
|
|
3388ae16d7 | ||
|
|
d784f591b2 | ||
|
|
0f43190543 | ||
|
|
fe87ff8f74 | ||
|
|
be2401061e | ||
|
|
b73233d232 | ||
|
|
1c8ae5eb46 | ||
|
|
b73928f732 | ||
|
|
19faf3b326 | ||
|
|
cb772b9e29 | ||
|
|
10f8815639 | ||
|
|
6ead5e5986 | ||
|
|
14791ded4a | ||
|
|
9eeb20bb04 | ||
|
|
5a7c0fdbb1 | ||
|
|
0561d24efd | ||
|
|
153262304c | ||
|
|
16cf4c503e | ||
|
|
57951a197b | ||
|
|
63fc4c795f | ||
|
|
5c4fca21c3 | ||
|
|
dd0f5eed7d | ||
|
|
e3d73a7aed | ||
|
|
255e3e79eb | ||
|
|
be4458b650 | ||
|
|
4fed097abb | ||
|
|
4fa395f4b6 | ||
|
|
52277247fe | ||
|
|
ba5de0bde4 | ||
|
|
dc86ea96dc | ||
|
|
d1adc8331a | ||
|
|
80d363f145 | ||
|
|
12be2025f1 | ||
|
|
dcf40da3a8 | ||
|
|
c617350c58 | ||
|
|
71255a096c | ||
|
|
39322e37df | ||
|
|
0e6f8f1720 | ||
|
|
113dadf945 | ||
|
|
af421cc9a3 | ||
|
|
c72890d59d | ||
|
|
be210c7c61 | ||
|
|
8d7aa94381 | ||
|
|
d90d1ba96f | ||
|
|
313b13fade | ||
|
|
b03413dcf9 | ||
|
|
9f69c43100 | ||
|
|
ddb6a704b3 | ||
|
|
e2de9271fc | ||
|
|
acf6fbdd39 | ||
|
|
bf09eab95b | ||
|
|
fd7fb5bf1f | ||
|
|
c27c9f612c | ||
|
|
9edf06bda2 | ||
|
|
01ddc3e756 |
@@ -0,0 +1,5 @@
|
||||
# Prettier initial formatting (2026-03-28)
|
||||
afcc3d1523f99c77ff67c4fd1af12334660113f6
|
||||
|
||||
# ESLint unused import removal (2026-03-28)
|
||||
1491826bc8da5436d3b1eb092d9274f2c9f028a7
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
.husky/* text eol=lf
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Setup GitNexus Web
|
||||
description: Setup Node.js 20, build gitnexus-shared, install web dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
shell: bash
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install web dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: gitnexus-web
|
||||
@@ -16,6 +16,11 @@ runs:
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
shell: bash
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ changelog:
|
||||
- dependencies
|
||||
- title: "\U0001F4DD Other Changes"
|
||||
labels:
|
||||
- "*"
|
||||
- '*'
|
||||
exclude:
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
@@ -28,15 +28,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
@@ -4,6 +4,32 @@ on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx prettier --check .
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx eslint .
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -18,12 +44,6 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
- run: npx tsc -b --noEmit
|
||||
working-directory: gitnexus-web
|
||||
|
||||
@@ -6,12 +6,12 @@ name: CI Report
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
workflows: ['CI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read # needed to list/download workflow run artifacts
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
actions: read # needed to list/download workflow run artifacts
|
||||
contents: read # needed for sparse checkout of vitest.config.ts
|
||||
pull-requests: write # needed to post sticky PR comment
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
--coverage.reportOnFailure=true
|
||||
working-directory: gitnexus
|
||||
|
||||
# gitnexus-shared already built by setup-gitnexus action above
|
||||
- name: Install gitnexus-web dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus-web
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # required for Claude to read CI results on PRs
|
||||
actions: read # required for Claude to read CI results on PRs
|
||||
steps:
|
||||
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
|
||||
- name: Resolve PR context
|
||||
|
||||
@@ -30,6 +30,10 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
@@ -63,7 +67,22 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
|
||||
if [ -z "$NOTES" ]; then
|
||||
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
|
||||
echo "fallback=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$NOTES" > /tmp/release-notes.md
|
||||
echo "fallback=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
|
||||
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
|
||||
|
||||
+3
-1
@@ -93,4 +93,6 @@ GitNexus.sln
|
||||
|
||||
.history/
|
||||
|
||||
.swarm/
|
||||
.swarm/
|
||||
|
||||
local_docs/
|
||||
+9
-16
@@ -1,33 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook (husky): typecheck + unit tests for both packages.
|
||||
# Mirrors CI checks from ci-quality.yml and ci-tests.yml.
|
||||
# Pre-commit hook: format staged files + typecheck.
|
||||
# Tests run in CI (ci-tests.yml), not here.
|
||||
# Skip with: git commit --no-verify
|
||||
#
|
||||
# CI coverage:
|
||||
# quality / typecheck → tsc --noEmit in gitnexus/
|
||||
# quality / typecheck-web → tsc -b --noEmit in gitnexus-web/
|
||||
# tests / ubuntu+coverage → vitest run in gitnexus/ (all projects)
|
||||
# e2e / chromium → playwright (requires servers — skipped)
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# 1. Format staged files with prettier via lint-staged
|
||||
echo "pre-commit: formatting staged files..."
|
||||
"$ROOT/node_modules/.bin/lint-staged" || exit 1
|
||||
|
||||
# 2. Typecheck changed packages
|
||||
WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
|
||||
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
|
||||
|
||||
if [ -n "$WEB_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
|
||||
cd "$ROOT/gitnexus-web" && npx tsc -b --noEmit
|
||||
|
||||
echo "pre-commit: running gitnexus-web unit tests..."
|
||||
npx vitest run --reporter=dot
|
||||
cd "$ROOT/gitnexus-web" && ./node_modules/.bin/tsc -b --noEmit || exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_CHANGED" ]; then
|
||||
echo "pre-commit: typechecking gitnexus..."
|
||||
cd "$ROOT/gitnexus" && npx tsc --noEmit
|
||||
|
||||
echo "pre-commit: running gitnexus unit tests (default project)..."
|
||||
npx vitest run --project default --reporter=dot
|
||||
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: all checks passed"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
dist/
|
||||
coverage/
|
||||
gitnexus/vendor/
|
||||
gitnexus/test/fixtures/
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
*.d.ts
|
||||
*.snap
|
||||
*.wasm
|
||||
*.md
|
||||
.gitnexus/
|
||||
.vercel/
|
||||
.claude-flow/
|
||||
.swarm/
|
||||
assets/
|
||||
repomix-output*
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./gitnexus-web/src/index.css"
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- version: 1.2.0 -->
|
||||
<!-- version: 1.3.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-03-22
|
||||
-->
|
||||
|
||||
Last reviewed: 2026-03-24
|
||||
Last reviewed: 2026-04-13
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
@@ -54,6 +54,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). |
|
||||
| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. |
|
||||
@@ -63,7 +64,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current symbol stats, run `npx gitnexus analyze` and inspect `.gitnexus/meta.json`.
|
||||
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
+117
-2
@@ -16,7 +16,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
|
||||
|
||||
1. **Ingestion** (`gitnexus analyze`)
|
||||
- Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`.
|
||||
- Walks the git working tree, parses supported languages via **Tree-sitter**, resolves imports/calls/inheritance, detects **communities** and **processes** (execution flows), and builds an in-memory **knowledge graph** (`gitnexus/src/core/graph/`).
|
||||
- The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below).
|
||||
- Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store.
|
||||
- The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory.
|
||||
|
||||
@@ -49,7 +49,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
|
||||
| If you are changing… | Start in… |
|
||||
|----------------------|-----------|
|
||||
| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). |
|
||||
| Parsing or graph construction | `gitnexus/src/core/ingestion/` (pipeline, processors, resolvers, type-extractors). |
|
||||
| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). |
|
||||
| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. |
|
||||
| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. |
|
||||
| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). |
|
||||
@@ -58,8 +58,123 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
|
||||
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
|
||||
|
||||
## Pipeline Phase DAG
|
||||
|
||||
The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs.
|
||||
|
||||
```
|
||||
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
|
||||
→ crossFile → mro → communities → processes
|
||||
```
|
||||
|
||||
### Phase files
|
||||
|
||||
| Phase | File | Dependencies | What it does |
|
||||
|-------|------|-------------|--------------|
|
||||
| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes |
|
||||
| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges |
|
||||
| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution |
|
||||
| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) |
|
||||
| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection |
|
||||
| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order |
|
||||
| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges |
|
||||
| `communities` | `communities.ts` | `mro` | Leiden community detection |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links |
|
||||
|
||||
### How to add a new phase
|
||||
|
||||
1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`)
|
||||
2. Define a `PipelinePhase<MyOutput>` object with `name`, `deps`, and `execute(ctx, deps)`
|
||||
3. Export it from `pipeline-phases/index.ts`
|
||||
4. Add it to the `buildPhaseList()` function in `pipeline.ts`
|
||||
|
||||
```typescript
|
||||
// pipeline-phases/my-phase.ts
|
||||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
|
||||
export interface MyPhaseOutput { /* ... */ }
|
||||
|
||||
export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
||||
name: 'myPhase',
|
||||
deps: ['parse'], // runs after parse completes
|
||||
async execute(ctx, deps) {
|
||||
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
// ... do work, write to ctx.graph ...
|
||||
return { /* typed output */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### DAG runner
|
||||
|
||||
The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives:
|
||||
- `ctx: PipelineContext` — shared graph, repoPath, progress callback
|
||||
- `deps: Map<string, PhaseResult>` — outputs from all upstream phases
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Overloaded method resolution
|
||||
|
||||
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
|
||||
disambiguate overloaded methods. Two overloads with different parameter counts
|
||||
produce distinct graph nodes: `Method:file:Class.method#1` vs
|
||||
`Method:file:Class.method#2`.
|
||||
|
||||
**Same-arity overload disambiguation:** When two overloads share the same
|
||||
parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a
|
||||
type-hash suffix `~type1,type2` is appended to produce distinct node IDs:
|
||||
`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix
|
||||
is only added when a same-arity collision is detected within a class and all
|
||||
parameters have non-null type annotations. Languages without type info (Python,
|
||||
Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures
|
||||
are intentionally excluded from type-hashing because they are declaration-only
|
||||
contracts that should collapse to the implementation body's node ID. See issue
|
||||
\#651.
|
||||
|
||||
**C++ const-qualified overload disambiguation:** Methods overloaded by const
|
||||
qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an
|
||||
`isConst` property and a `$const` ID suffix appended to the const-qualified
|
||||
variant when a non-const collision exists. The `$const` suffix appears after the
|
||||
type-hash suffix: e.g. `Method:file:Container.begin#0$const`.
|
||||
|
||||
**Generic/template type preservation in type-hash:** The type-hash suffix uses
|
||||
`rawType` (full AST text including generic/template args) rather than the
|
||||
simplified `type` from `extractSimpleTypeName`. This means C++ template overloads
|
||||
like `process(vector<int>)` vs `process(vector<string>)` produce distinct IDs:
|
||||
`~vector<int>` vs `~vector<std::string>`. Java generic overloads like
|
||||
`process(List<String>)` vs `process(List<Integer>)` are a compile error due to
|
||||
type erasure, so this gap is theoretical for Java.
|
||||
|
||||
**ID stability on first overload:** Type and const tags are collision-only. When
|
||||
a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag).
|
||||
Adding `save(String)` changes the original to `save#1~int`. This is correct for
|
||||
fresh analysis but means IDs are not stable across overload additions. Future
|
||||
incremental re-analysis should account for this.
|
||||
|
||||
**Variadic method matching:** When one side is variadic (`parameterCount`
|
||||
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
|
||||
emitted with confidence 0.7 instead of 1.0. Variadic methods like
|
||||
`foo(String... args)` may superficially match `foo(String s)` by type but
|
||||
are not guaranteed to be interchangeable across all languages (Java/Kotlin
|
||||
accept this via varargs sugar; TypeScript, C#, Rust do not).
|
||||
|
||||
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
|
||||
|
||||
| Match quality | Confidence | When |
|
||||
|---|---|---|
|
||||
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
|
||||
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
|
||||
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
|
||||
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
|
||||
|
||||
## Related docs
|
||||
|
||||
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
|
||||
- [TESTING.md](TESTING.md) — how to run tests.
|
||||
|
||||
@@ -10,6 +10,19 @@ All notable changes to GitNexus will be documented in this file.
|
||||
- Added automatic cleanup of stale KuzuDB index files
|
||||
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
|
||||
|
||||
## [1.5.3] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
|
||||
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
|
||||
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
|
||||
- Ensure import rewrites survive npm publish lifecycle
|
||||
|
||||
## [1.4.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- version: 1.2.0 -->
|
||||
<!-- version: 1.3.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-03-22
|
||||
-->
|
||||
|
||||
Last reviewed: 2026-03-24
|
||||
Last reviewed: 2026-04-13
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
@@ -41,6 +41,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
|
||||
| 2026-03-22 | 1.0.0 | Added structured header and changelog. |
|
||||
@@ -49,4 +50,206 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
|
||||
## GitNexus rules
|
||||
|
||||
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
|
||||
GitNexus MCP rules are in the `<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Migration Guide
|
||||
|
||||
## OVERRIDES → METHOD_OVERRIDES (PR #642)
|
||||
|
||||
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
|
||||
consistency with the new `METHOD_IMPLEMENTS` edge type.
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**No.** Backward compatibility is handled automatically at runtime:
|
||||
|
||||
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
|
||||
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
|
||||
edges continue to return correct results without any manual intervention.
|
||||
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
|
||||
queries that reference either will work.
|
||||
|
||||
### What happens on re-index?
|
||||
|
||||
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
|
||||
edges going forward. The old `OVERRIDES` edges are replaced as part of the
|
||||
normal full re-index.
|
||||
|
||||
### When will the legacy alias be removed?
|
||||
|
||||
The `OVERRIDES` compat alias will remain until a future major version. Removal
|
||||
will be announced in this file and in the changelog before it happens.
|
||||
@@ -19,6 +19,8 @@
|
||||
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
|
||||
</a>
|
||||
|
||||
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
|
||||
|
||||
</div>
|
||||
|
||||
**Building nervous system for agent context.**
|
||||
@@ -50,7 +52,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
@@ -59,6 +61,28 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
---
|
||||
|
||||
## Enterprise
|
||||
|
||||
GitNexus is available as an **enterprise offering** - either as a fully managed **SaaS** or a **self-hosted** deployment. Also available for **commercial use** of the OSS version with proper licensing.
|
||||
|
||||
Enterprise includes:
|
||||
- **PR Review** - automated blast radius analysis on pull requests
|
||||
- **Auto-updating Code Wiki** - always up-to-date documentation (Code Wiki is also available in OSS)
|
||||
- **Auto-reindexing** - knowledge graph stays fresh automatically
|
||||
- **Multi-repo support** - unified graph across repositories
|
||||
- **OCaml support** - additional language coverage
|
||||
- **Priority feature/language support** - request new languages or features
|
||||
|
||||
**Upcoming:**
|
||||
- Auto regression forensics
|
||||
- End-to-end test generation
|
||||
|
||||
👉 Learn more at [akonlabs.com](https://akonlabs.com)
|
||||
|
||||
💬 For commercial licensing or enterprise inquiries, ping us on [Discord](https://discord.gg/AAsRVT6fGb) or drop an email at founders@akonlabs.com
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — packages, index → graph → MCP flow, where to change code
|
||||
@@ -95,7 +119,6 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | — | — | MCP |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
@@ -147,8 +170,8 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
{
|
||||
"mcp": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"type": "local",
|
||||
"command": ["gitnexus", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,13 +188,14 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus list # List all indexed repositories
|
||||
@@ -181,11 +205,21 @@ gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate repository wiki from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <name> <repo> # Add a repo to a group
|
||||
gitnexus group remove <name> <repo> # Remove a repo from a group
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**7 tools** exposed via MCP:
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ------------------ | ----------------------------------------------------------------- | -------------- |
|
||||
@@ -196,6 +230,11 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts`| Inspect extracted contracts and cross-links | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
@@ -291,8 +330,8 @@ Or run locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/abhigyanpatwari/gitnexus.git
|
||||
cd gitnexus/gitnexus-web
|
||||
npm install
|
||||
cd gitnexus/gitnexus-shared && npm install && npm run build
|
||||
cd ../gitnexus-web && npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
# PR #626 HIGH-Priority Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
|
||||
|
||||
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
|
||||
|
||||
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Path Traversal — Validate Group Name
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
|
||||
- Test: `gitnexus/test/unit/group/storage.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for validateGroupName**
|
||||
|
||||
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
|
||||
|
||||
```typescript
|
||||
describe('validateGroupName', () => {
|
||||
it('test_validateGroupName_traversal_path_throws', () => {
|
||||
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_slash_in_name_throws', () => {
|
||||
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_empty_string_throws', () => {
|
||||
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_dash_throws', () => {
|
||||
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_underscore_throws', () => {
|
||||
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_dots_throws', () => {
|
||||
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_valid_alphanumeric_passes', () => {
|
||||
expect(() => validateGroupName('my-group_01')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_single_char_passes', () => {
|
||||
expect(() => validateGroupName('A')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_all_digits_passes', () => {
|
||||
expect(() => validateGroupName('123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupDir rejects invalid names', () => {
|
||||
it('test_getGroupDir_traversal_throws', () => {
|
||||
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_getGroupDir_valid_name_returns_path', () => {
|
||||
const dir = getGroupDir(tmpDir, 'company');
|
||||
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroupDir rejects invalid names', () => {
|
||||
it('test_createGroupDir_traversal_throws', async () => {
|
||||
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
|
||||
|
||||
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
|
||||
|
||||
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
|
||||
|
||||
```typescript
|
||||
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export function validateGroupName(name: string): void {
|
||||
if (!GROUP_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
||||
validateGroupName(groupName);
|
||||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
```
|
||||
|
||||
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
|
||||
git commit -m "fix(group): validate group name to prevent path traversal
|
||||
|
||||
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
|
||||
Called in getGroupDir (defense in depth) which covers all CLI entry
|
||||
points: create, add, remove, status, sync.
|
||||
|
||||
Addresses PR #626 review item 1 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
|
||||
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for excluded directories**
|
||||
|
||||
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_detect_skips_vendor_directory', async () => {
|
||||
writeFile('services/auth/package.json', '{}');
|
||||
writeFile('services/auth/src/index.ts', '');
|
||||
// vendor should be skipped — its contents should not create a boundary
|
||||
writeFile('vendor/some-dep/package.json', '{}');
|
||||
writeFile('vendor/some-dep/src/lib.go', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/auth');
|
||||
expect(paths).not.toContain('vendor/some-dep');
|
||||
});
|
||||
|
||||
it('test_detect_skips_target_directory', async () => {
|
||||
writeFile('services/api/go.mod', 'module api');
|
||||
writeFile('services/api/main.go', '');
|
||||
writeFile('target/classes/Main.java', '');
|
||||
writeFile('target/pom.xml', '<project/>');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('target');
|
||||
});
|
||||
|
||||
it('test_detect_skips_pycache_directory', async () => {
|
||||
writeFile('services/ml/pyproject.toml', '[project]');
|
||||
writeFile('services/ml/model.py', '');
|
||||
// __pycache__ with a marker + source files — would be detected as
|
||||
// a boundary if not excluded, since it has package.json + .py file
|
||||
writeFile('__pycache__/package.json', '{}');
|
||||
writeFile('__pycache__/cached.py', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/ml');
|
||||
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
|
||||
});
|
||||
|
||||
it('test_detect_skips_dotfile_directories_regression', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
writeFile('.hidden/package.json', '{}');
|
||||
writeFile('.hidden/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('.hidden');
|
||||
});
|
||||
|
||||
it('test_detect_does_not_skip_regular_source_directories', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
expect(boundaries).toHaveLength(1);
|
||||
expect(boundaries[0].serviceName).toBe('api');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
|
||||
|
||||
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
|
||||
|
||||
In `gitnexus/src/core/group/service-boundary-detector.ts`:
|
||||
|
||||
After `SOURCE_EXTENSIONS` (after line 51), add:
|
||||
|
||||
```typescript
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'target',
|
||||
'build',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.tox',
|
||||
'.mypy_cache',
|
||||
'.gradle',
|
||||
'.mvn',
|
||||
'out',
|
||||
'bin',
|
||||
]);
|
||||
```
|
||||
|
||||
In `walkForBoundaries`, replace line 78:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
```
|
||||
|
||||
In `hasSourceFilesInSubdirs`, replace line 130:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
|
||||
git commit -m "fix(group): add directory exclusions to service boundary detector
|
||||
|
||||
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
|
||||
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
|
||||
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
|
||||
Replaces inline node_modules check.
|
||||
|
||||
Addresses PR #626 review item 3 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
|
||||
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
|
||||
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
|
||||
|
||||
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
|
||||
|
||||
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
|
||||
|
||||
```typescript
|
||||
it('test_syncGroup_closes_only_opened_pools', async () => {
|
||||
const config = makeConfig({
|
||||
'app/backend': 'backend-repo',
|
||||
'app/frontend': 'frontend-repo',
|
||||
});
|
||||
|
||||
const closedIds: string[] = [];
|
||||
|
||||
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
|
||||
const { vi } = await import('vitest');
|
||||
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
||||
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
|
||||
if (id) closedIds.push(id);
|
||||
});
|
||||
|
||||
try {
|
||||
await syncGroup(config, {
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: groupPath.replace(/\//g, '-'),
|
||||
path: groupPath,
|
||||
repoPath: '/tmp/' + groupPath,
|
||||
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
||||
}),
|
||||
skipWrite: true,
|
||||
}).catch(() => {});
|
||||
// Regardless of extraction errors, closeLbug should be called per id
|
||||
// closeLbug should only receive specific pool ids, never undefined/empty
|
||||
for (const id of closedIds) {
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe('string');
|
||||
}
|
||||
// No blanket close (no-arg call)
|
||||
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
|
||||
expect(blanketCalls).toHaveLength(0);
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
closeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
|
||||
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
|
||||
|
||||
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
|
||||
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
|
||||
const source = fs.readFileSync(cliGroupPath, 'utf-8');
|
||||
|
||||
// closeLbug() without arguments (blanket close) must not appear.
|
||||
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
|
||||
// Match closeLbug() but not closeLbug(someArg)
|
||||
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
|
||||
expect(source).not.toMatch(blanketClosePattern);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
|
||||
|
||||
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
|
||||
|
||||
In `gitnexus/src/cli/group.ts`:
|
||||
|
||||
Remove the `closeLbug` import at line 160:
|
||||
```typescript
|
||||
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
|
||||
```
|
||||
|
||||
Replace the try/finally wrapper (lines 162-189):
|
||||
```typescript
|
||||
try {
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
|
||||
```typescript
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
|
||||
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
|
||||
|
||||
sync.ts already closes pools per-id in its finally block.
|
||||
The blanket closeLbug() in cli/group.ts tears down ALL active pools
|
||||
including unrelated ones in MCP server context.
|
||||
|
||||
Addresses PR #626 review item 4 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: gRPC Proto Regex — Brace-Depth Counter
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
|
||||
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for nested braces in proto services**
|
||||
|
||||
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
|
||||
writeFile(
|
||||
'api/gateway.proto',
|
||||
`syntax = "proto3";
|
||||
package gateway.v1;
|
||||
|
||||
import "google/api/annotations.proto";
|
||||
|
||||
service GatewayService {
|
||||
rpc GetUser (GetUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/v1/users/{user_id}"
|
||||
};
|
||||
}
|
||||
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/users"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(2);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::gateway.v1.GatewayService/CreateUser',
|
||||
'grpc::gateway.v1.GatewayService/GetUser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_multiple_services', async () => {
|
||||
writeFile(
|
||||
'api/multi.proto',
|
||||
`syntax = "proto3";
|
||||
package multi;
|
||||
|
||||
service ServiceA {
|
||||
rpc MethodA (Req) returns (Res);
|
||||
}
|
||||
|
||||
service ServiceB {
|
||||
rpc MethodB1 (Req) returns (Res);
|
||||
rpc MethodB2 (Req) returns (Res);
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::multi.ServiceA/MethodA',
|
||||
'grpc::multi.ServiceB/MethodB1',
|
||||
'grpc::multi.ServiceB/MethodB2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
|
||||
writeFile(
|
||||
'api/nested.proto',
|
||||
`syntax = "proto3";
|
||||
package nested;
|
||||
|
||||
service DeepService {
|
||||
rpc DeepMethod (Req) returns (Res) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/deep"
|
||||
body: "*"
|
||||
additional_bindings {
|
||||
get: "/v1/deep/{id}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
|
||||
});
|
||||
|
||||
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
|
||||
writeFile(
|
||||
'api/broken.proto',
|
||||
`syntax = "proto3";
|
||||
package broken;
|
||||
|
||||
service IncompleteService {
|
||||
rpc SomeMethod (Req) returns (Res);
|
||||
// Missing closing brace — EOF before depth returns to 0
|
||||
`,
|
||||
);
|
||||
|
||||
// Should not throw; incomplete service is silently skipped
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
|
||||
);
|
||||
|
||||
// The old regex would find partial match; the new parser should skip it
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the nested brace test fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
|
||||
|
||||
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
|
||||
|
||||
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
|
||||
|
||||
```typescript
|
||||
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
|
||||
const pkg = pkgMatch ? pkgMatch[1] : '';
|
||||
|
||||
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
|
||||
const rpcRe = /rpc\s+(\w+)\s*\(/g;
|
||||
let rpcMatch: RegExpExecArray | null;
|
||||
while ((rpcMatch = rpcRe.exec(body)) !== null) {
|
||||
const methodName = rpcMatch[1];
|
||||
const cid = contractId(pkg, serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
|
||||
package: pkg,
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
source: 'proto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
|
||||
|
||||
```typescript
|
||||
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
const headerRe = /service\s+(\w+)\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(content)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < content.length && depth > 0) {
|
||||
const ch = content[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
// If EOF before depth returns to 0, skip incomplete service
|
||||
if (depth !== 0) continue;
|
||||
|
||||
// body is between opening { (consumed by regex) and closing } (pos is one past it)
|
||||
const body = content.slice(bodyStart, pos - 1);
|
||||
results.push({ name: serviceName, body });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: ALL PASS (including existing regression tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
|
||||
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
|
||||
|
||||
The serviceRe regex used [^}]* which stopped at the first '}'.
|
||||
Proto services with google.api.http annotations contain nested {}
|
||||
blocks, causing methods to be missed.
|
||||
|
||||
New extractServiceBlocks() uses a brace-depth counter (init depth=1
|
||||
after opening {, scan char-by-char). Malformed protos with unclosed
|
||||
braces are silently skipped.
|
||||
|
||||
Addresses PR #626 review item 2 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run Full Test Suite
|
||||
|
||||
- [ ] **Step 1: Run all group-related tests**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 2: Run full test suite to catch regressions**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run`
|
||||
Expected: ALL PASS, 0 failures
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `cd gitnexus && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
---
|
||||
|
||||
### Task 6: CLI Integration Smoke Test
|
||||
|
||||
- [ ] **Step 1: Add CLI smoke test for path traversal**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
|
||||
|
||||
```typescript
|
||||
it('test_create_with_invalid_name_fails', () => {
|
||||
const result = runGroup(['create', '../../evil']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Invalid group name');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add test/integration/group/group-cli.test.ts
|
||||
git commit -m "test(group): add CLI smoke test for path traversal rejection
|
||||
|
||||
Verifies that 'group create ../../evil' fails with Invalid group name.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
# PR #626 HIGH-Priority Fixes Design
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
|
||||
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
|
||||
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: Path Traversal via Group Name
|
||||
|
||||
**File:** `gitnexus/src/core/group/storage.ts`
|
||||
**Risk:** A group name like `../../etc` creates directories outside the intended path.
|
||||
|
||||
### Solution
|
||||
|
||||
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
|
||||
|
||||
- Call in `createGroupDir` (primary entry point)
|
||||
- Call in `getGroupDir` (defense in depth)
|
||||
- Throw descriptive error on invalid names
|
||||
|
||||
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
|
||||
|
||||
### Why regex over path.resolve + startsWith
|
||||
|
||||
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
|
||||
- Stricter: disallows spaces, dots, Unicode edge cases
|
||||
- Simpler to reason about
|
||||
|
||||
### Tests
|
||||
|
||||
- `../../evil` throws
|
||||
- `foo/bar` throws
|
||||
- Empty string throws
|
||||
- `my-group_01` passes
|
||||
- `A` (single char) passes
|
||||
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
|
||||
|
||||
### CLI/API entry points accepting groupName
|
||||
|
||||
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
|
||||
|
||||
| Command | Entry | Calls |
|
||||
|---------|-------|-------|
|
||||
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
|
||||
| `group add` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group remove` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
|
||||
| `group status` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group sync` | `cli/group.ts` action | `getGroupDir` |
|
||||
|
||||
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
|
||||
|
||||
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
|
||||
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
|
||||
|
||||
### Solution
|
||||
|
||||
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
|
||||
|
||||
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
|
||||
2. Initialise depth to 1 immediately after the opening `{`
|
||||
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
|
||||
4. Stop when depth reaches 0 (the matching closing `}`)
|
||||
5. Return name + body pairs
|
||||
|
||||
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
|
||||
|
||||
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
|
||||
|
||||
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
|
||||
|
||||
### Tests
|
||||
|
||||
- Proto with single service, no nesting (regression)
|
||||
- Proto with `google.api.http` nested braces inside RPC options
|
||||
- Proto with multiple services
|
||||
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
|
||||
- Malformed proto with unclosed brace (graceful handling)
|
||||
|
||||
---
|
||||
|
||||
## Fix 3: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
|
||||
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
|
||||
|
||||
### Solution
|
||||
|
||||
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
|
||||
|
||||
```text
|
||||
node_modules, vendor, target, build, dist,
|
||||
__pycache__, .venv, venv, .tox, .mypy_cache,
|
||||
.gradle, .mvn, out, bin
|
||||
```
|
||||
|
||||
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
|
||||
|
||||
Apply in both:
|
||||
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
|
||||
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
|
||||
|
||||
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
|
||||
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
|
||||
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
|
||||
|
||||
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
|
||||
|
||||
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
|
||||
|
||||
### Tests
|
||||
|
||||
- Directory named `vendor/` is skipped
|
||||
- Directory named `target/` is skipped
|
||||
- Directory named `__pycache__/` is skipped
|
||||
- Regular source directories are NOT skipped
|
||||
- Dotfile directories still skipped (regression)
|
||||
|
||||
---
|
||||
|
||||
## Fix 4: Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
|
||||
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
|
||||
|
||||
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
|
||||
|
||||
### Solution
|
||||
|
||||
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
|
||||
|
||||
```typescript
|
||||
// sync.ts — KEEP: cleans up only pools opened by this sync
|
||||
finally {
|
||||
for (const id of [...new Set(openPoolIds)]) {
|
||||
await closeLbug(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// cli/group.ts — REMOVE: blanket close that kills all pools
|
||||
finally {
|
||||
await closeLbug().catch(() => {}); // DELETE THIS
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
|
||||
|
||||
### Tests (unit level — mock pool adapter)
|
||||
|
||||
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
|
||||
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
|
||||
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- JSON -> LadybugDB migration (tracked in #606)
|
||||
- MEDIUM/LOW issues (items 5-10 from review summary)
|
||||
- Test gap coverage beyond what's needed for these 4 fixes
|
||||
- Any refactoring or architectural changes
|
||||
|
||||
## Execution Order
|
||||
|
||||
Fixes are independent — can be implemented in parallel or any order.
|
||||
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
|
||||
@@ -0,0 +1,83 @@
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/coverage/**',
|
||||
'gitnexus/vendor/**',
|
||||
'gitnexus-web/src/vendor/**',
|
||||
'gitnexus/test/fixtures/**',
|
||||
'gitnexus-web/playwright-report/**',
|
||||
'gitnexus-web/test-results/**',
|
||||
'**/*.d.ts',
|
||||
'.claude/**',
|
||||
'.history/**',
|
||||
],
|
||||
},
|
||||
|
||||
// Base TypeScript config for all packages
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
rules: {
|
||||
// Unused imports — auto-fixable
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
// TypeScript quality
|
||||
'@typescript-eslint/no-unused-vars': 'off', // handled by unused-imports plugin
|
||||
'no-unused-vars': 'off', // handled by unused-imports plugin
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
|
||||
// General quality
|
||||
'no-debugger': 'error',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
},
|
||||
},
|
||||
|
||||
// CLI package — allow console.log (it's a CLI tool)
|
||||
{
|
||||
files: ['gitnexus/src/cli/**/*.ts', 'gitnexus/src/server/**/*.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// React-specific rules for gitnexus-web
|
||||
{
|
||||
files: ['gitnexus-web/src/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Disable formatting rules (prettier handles those)
|
||||
prettierConfig,
|
||||
];
|
||||
@@ -1,8 +1,8 @@
|
||||
# Claude Haiku 4.5 — fast, cheap, good baseline
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-haiku-4.5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/anthropic/claude-haiku-4.5'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# To use Anthropic directly, change to: anthropic/claude-opus-4-20250514
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-opus-4"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/anthropic/claude-opus-4'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 16384
|
||||
temperature: 0
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
# To use Anthropic directly, change to: anthropic/claude-sonnet-4-20250514
|
||||
model:
|
||||
model_name: "openrouter/anthropic/claude-sonnet-4"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/anthropic/claude-sonnet-4'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 16384
|
||||
temperature: 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
model: deepseek-ai/deepseek-chat
|
||||
provider: openrouter
|
||||
cost:
|
||||
input: 0.14 # per 1M tokens
|
||||
output: 0.28 # per 1M tokens
|
||||
|
||||
input: 0.14 # per 1M tokens
|
||||
output: 0.28 # per 1M tokens
|
||||
|
||||
# Native DeepSeek API (direct)
|
||||
api_key: null
|
||||
base_url: null
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
model: deepseek-ai/DeepSeek-V3
|
||||
provider: openrouter
|
||||
cost:
|
||||
input: 0.27 # per 1M tokens
|
||||
output: 1.10 # per 1M tokens
|
||||
|
||||
input: 0.27 # per 1M tokens
|
||||
output: 1.10 # per 1M tokens
|
||||
|
||||
# Native DeepSeek API (direct)
|
||||
# Get your API key at: https://platform.deepseek.com/
|
||||
# Or use OpenRouter with: OPENROUTER_API_KEY
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# GLM 4.7 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
model:
|
||||
model_name: "openrouter/zhipuai/glm-4.7"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/zhipuai/glm-4.7'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# GLM 5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
model:
|
||||
model_name: "openrouter/zhipuai/glm-5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/zhipuai/glm-5'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# MiniMax M1 2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
|
||||
model:
|
||||
model_name: "openrouter/minimax/minimax-m1-2.5"
|
||||
cost_tracking: "ignore_errors"
|
||||
model_name: 'openrouter/minimax/minimax-m1-2.5'
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
|
||||
model:
|
||||
model_class: litellm_textbased
|
||||
model_name: "openrouter/minimax/minimax-m2.5"
|
||||
model_name: 'openrouter/minimax/minimax-m2.5'
|
||||
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
|
||||
cost_tracking: "ignore_errors"
|
||||
cost_tracking: 'ignore_errors'
|
||||
model_kwargs:
|
||||
max_tokens: 8192
|
||||
temperature: 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Baseline mode — no GitNexus, pure mini-swe-agent (control group)
|
||||
agent:
|
||||
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
|
||||
gitnexus_mode: "baseline"
|
||||
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
|
||||
gitnexus_mode: 'baseline'
|
||||
step_limit: 30
|
||||
cost_limit: 3.0
|
||||
|
||||
environment:
|
||||
environment_class: "docker"
|
||||
environment_class: 'docker'
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
#
|
||||
# Use this mode to isolate the value of explicit tools without grep augmentation.
|
||||
agent:
|
||||
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
|
||||
gitnexus_mode: "native"
|
||||
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
|
||||
gitnexus_mode: 'native'
|
||||
step_limit: 30
|
||||
cost_limit: 3.0
|
||||
track_gitnexus_usage: true
|
||||
|
||||
environment:
|
||||
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
|
||||
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
|
||||
enable_gitnexus: true
|
||||
skip_embeddings: true
|
||||
gitnexus_timeout: 120
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#
|
||||
# The agent decides when to use explicit tools vs rely on enriched grep results.
|
||||
agent:
|
||||
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
|
||||
gitnexus_mode: "native_augment"
|
||||
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
|
||||
gitnexus_mode: 'native_augment'
|
||||
step_limit: 30
|
||||
cost_limit: 3.0
|
||||
augment_timeout: 5.0
|
||||
@@ -17,7 +17,7 @@ agent:
|
||||
track_gitnexus_usage: true
|
||||
|
||||
environment:
|
||||
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
|
||||
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
|
||||
enable_gitnexus: true
|
||||
skip_embeddings: true
|
||||
gitnexus_timeout: 120
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mini-swe-agent>=2.0.0",
|
||||
"litellm>=1.50.0",
|
||||
"litellm>=1.50.0,!=1.82.7,!=1.82.8",
|
||||
"datasets>=3.0.0",
|
||||
"typer>=0.12.0",
|
||||
"rich>=13.0.0",
|
||||
|
||||
@@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
|
||||
const tokens = cmd.split(/\s+/);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
const flagsWithValues = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '--glob', '-t', '--type', '--include', '--exclude']);
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'-m',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
]);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
continue;
|
||||
@@ -98,33 +114,42 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
|
||||
let useDirectBinary = false;
|
||||
try {
|
||||
const which = spawnSync(
|
||||
isWin ? 'where' : 'which', ['gitnexus'],
|
||||
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
const which = spawnSync(isWin ? 'where' : 'which', ['gitnexus'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
useDirectBinary = which.status === 0;
|
||||
} catch { /* not on PATH */ }
|
||||
} catch {
|
||||
/* not on PATH */
|
||||
}
|
||||
|
||||
if (useDirectBinary) {
|
||||
return spawnSync(
|
||||
isWin ? 'gitnexus.cmd' : 'gitnexus', args,
|
||||
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script
|
||||
return spawnSync(
|
||||
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args],
|
||||
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a hook response with additional context for the agent.
|
||||
*/
|
||||
function sendHookResponse(hookEventName, message) {
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName, additionalContext: message }
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName, additionalContext: message },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +174,9 @@ function handlePreToolUse(input) {
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch { /* graceful failure */ }
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
@@ -185,10 +212,15 @@ function handlePostToolUse(input) {
|
||||
let currentHead = '';
|
||||
try {
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
currentHead = (headResult.stdout || '').trim();
|
||||
} catch { return; }
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHead) return;
|
||||
|
||||
@@ -197,16 +229,19 @@ function handlePostToolUse(input) {
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
|
||||
} catch { /* no meta — treat as stale */ }
|
||||
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
|
||||
} catch {
|
||||
/* no meta — treat as stale */
|
||||
}
|
||||
|
||||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
sendHookResponse('PostToolUse',
|
||||
sendHookResponse(
|
||||
'PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to update the knowledge graph.`
|
||||
`Run \`${analyzeCmd}\` to update the knowledge graph.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "gitnexus-shared",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus-shared",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "gitnexus-shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Shared type definitions for GitNexus CLI and web",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Graph type definitions — single source of truth.
|
||||
*
|
||||
* Both gitnexus (CLI) and gitnexus-web import from this package.
|
||||
* Do NOT add Node.js-specific or browser-specific imports here.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../languages.js';
|
||||
|
||||
export type NodeLabel =
|
||||
| 'Project'
|
||||
| 'Package'
|
||||
| 'Module'
|
||||
| 'Folder'
|
||||
| 'File'
|
||||
| 'Class'
|
||||
| 'Function'
|
||||
| 'Method'
|
||||
| 'Variable'
|
||||
| 'Interface'
|
||||
| 'Enum'
|
||||
| 'Decorator'
|
||||
| 'Import'
|
||||
| 'Type'
|
||||
| 'CodeElement'
|
||||
| 'Community'
|
||||
| 'Process'
|
||||
// Multi-language node types
|
||||
| 'Struct'
|
||||
| 'Macro'
|
||||
| 'Typedef'
|
||||
| 'Union'
|
||||
| 'Namespace'
|
||||
| 'Trait'
|
||||
| 'Impl'
|
||||
| 'TypeAlias'
|
||||
| 'Const'
|
||||
| 'Static'
|
||||
| 'Property'
|
||||
| 'Record'
|
||||
| 'Delegate'
|
||||
| 'Annotation'
|
||||
| 'Constructor'
|
||||
| 'Template'
|
||||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool';
|
||||
|
||||
export type NodeProperties = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
language?: SupportedLanguages | string;
|
||||
isExported?: boolean;
|
||||
astFrameworkMultiplier?: number;
|
||||
astFrameworkReason?: string;
|
||||
// Community
|
||||
heuristicLabel?: string;
|
||||
cohesion?: number;
|
||||
symbolCount?: number;
|
||||
keywords?: string[];
|
||||
description?: string;
|
||||
enrichedBy?: 'heuristic' | 'llm';
|
||||
// Process
|
||||
processType?: 'intra_community' | 'cross_community';
|
||||
stepCount?: number;
|
||||
communities?: string[];
|
||||
entryPointId?: string;
|
||||
terminalId?: string;
|
||||
entryPointScore?: number;
|
||||
entryPointReason?: string;
|
||||
// Method/property
|
||||
parameterCount?: number;
|
||||
level?: number;
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isReadonly?: boolean;
|
||||
isAbstract?: boolean;
|
||||
isFinal?: boolean;
|
||||
isVirtual?: boolean;
|
||||
isOverride?: boolean;
|
||||
isAsync?: boolean;
|
||||
isPartial?: boolean;
|
||||
annotations?: string[];
|
||||
// Route/response
|
||||
responseKeys?: string[];
|
||||
errorKeys?: string[];
|
||||
middleware?: string[];
|
||||
// Extensible
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RelationshipType =
|
||||
| 'CONTAINS'
|
||||
| 'CALLS'
|
||||
| 'INHERITS'
|
||||
| 'METHOD_OVERRIDES'
|
||||
| 'METHOD_IMPLEMENTS'
|
||||
| 'IMPORTS'
|
||||
| 'USES'
|
||||
| 'DEFINES'
|
||||
| 'DECORATES'
|
||||
| 'IMPLEMENTS'
|
||||
| 'EXTENDS'
|
||||
| 'HAS_METHOD'
|
||||
| 'HAS_PROPERTY'
|
||||
| 'ACCESSES'
|
||||
| 'MEMBER_OF'
|
||||
| 'STEP_IN_PROCESS'
|
||||
| 'HANDLES_ROUTE'
|
||||
| 'FETCHES'
|
||||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: NodeLabel;
|
||||
properties: NodeProperties;
|
||||
}
|
||||
|
||||
export interface GraphRelationship {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: RelationshipType;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
step?: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Graph types
|
||||
export type {
|
||||
NodeLabel,
|
||||
NodeProperties,
|
||||
RelationshipType,
|
||||
GraphNode,
|
||||
GraphRelationship,
|
||||
} from './graph/types.js';
|
||||
|
||||
// Schema constants
|
||||
export {
|
||||
NODE_TABLES,
|
||||
REL_TABLE_NAME,
|
||||
REL_TYPES,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
} from './lbug/schema-constants.js';
|
||||
export type { NodeTableName, RelType } from './lbug/schema-constants.js';
|
||||
|
||||
// Language support
|
||||
export { SupportedLanguages } from './languages.js';
|
||||
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
|
||||
export type { MroStrategy } from './mro-strategy.js';
|
||||
|
||||
// Pipeline progress
|
||||
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Language Detection — maps file paths to SupportedLanguages enum values.
|
||||
*
|
||||
* Shared between CLI (ingestion pipeline) and web (syntax highlighting).
|
||||
*
|
||||
* ADDING A NEW LANGUAGE:
|
||||
* 1. Add enum member to SupportedLanguages in languages.ts
|
||||
* 2. Add file extensions to EXTENSION_MAP below
|
||||
* 3. TypeScript will error if you miss either step (exhaustive Record)
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from './languages.js';
|
||||
|
||||
/** Ruby extensionless filenames recognised as Ruby source */
|
||||
const RUBY_EXTENSIONLESS_FILES = new Set([
|
||||
'Rakefile',
|
||||
'Gemfile',
|
||||
'Guardfile',
|
||||
'Vagrantfile',
|
||||
'Brewfile',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exhaustive map: every SupportedLanguages member → its file extensions.
|
||||
*
|
||||
* If a new language is added to the enum without adding an entry here,
|
||||
* TypeScript emits a compile error: "Property 'NewLang' is missing in type..."
|
||||
*/
|
||||
const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
|
||||
[SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
[SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'],
|
||||
[SupportedLanguages.Python]: ['.py'],
|
||||
[SupportedLanguages.Java]: ['.java'],
|
||||
[SupportedLanguages.C]: ['.c'],
|
||||
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
|
||||
[SupportedLanguages.CSharp]: ['.cs'],
|
||||
[SupportedLanguages.Go]: ['.go'],
|
||||
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
|
||||
[SupportedLanguages.Rust]: ['.rs'],
|
||||
[SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
|
||||
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
|
||||
[SupportedLanguages.Swift]: ['.swift'],
|
||||
[SupportedLanguages.Dart]: ['.dart'],
|
||||
[SupportedLanguages.Vue]: ['.vue'],
|
||||
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
|
||||
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
|
||||
|
||||
/** Pre-built reverse lookup: extension → language (built once at module load). */
|
||||
const extToLang = new Map<string, SupportedLanguages>();
|
||||
for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [
|
||||
SupportedLanguages,
|
||||
readonly string[],
|
||||
][]) {
|
||||
for (const ext of exts) {
|
||||
extToLang.set(ext, lang);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map file extension to SupportedLanguage enum.
|
||||
* Returns null if the file extension is not recognized.
|
||||
*/
|
||||
export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => {
|
||||
// Fast path: check the extension map
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot >= 0) {
|
||||
const ext = filename.slice(lastDot).toLowerCase();
|
||||
const lang = extToLang.get(ext);
|
||||
if (lang !== undefined) return lang;
|
||||
}
|
||||
|
||||
// Ruby extensionless files (Rakefile, Gemfile, etc.)
|
||||
const basename = filename.split('/').pop() || filename;
|
||||
if (RUBY_EXTENSIONLESS_FILES.has(basename)) {
|
||||
return SupportedLanguages.Ruby;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exhaustive map: every SupportedLanguages member → Prism syntax identifier.
|
||||
*
|
||||
* If a new language is added to the enum without adding an entry here,
|
||||
* TypeScript emits a compile error.
|
||||
*/
|
||||
const SYNTAX_MAP: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.JavaScript]: 'javascript',
|
||||
[SupportedLanguages.TypeScript]: 'typescript',
|
||||
[SupportedLanguages.Python]: 'python',
|
||||
[SupportedLanguages.Java]: 'java',
|
||||
[SupportedLanguages.C]: 'c',
|
||||
[SupportedLanguages.CPlusPlus]: 'cpp',
|
||||
[SupportedLanguages.CSharp]: 'csharp',
|
||||
[SupportedLanguages.Go]: 'go',
|
||||
[SupportedLanguages.Ruby]: 'ruby',
|
||||
[SupportedLanguages.Rust]: 'rust',
|
||||
[SupportedLanguages.PHP]: 'php',
|
||||
[SupportedLanguages.Kotlin]: 'kotlin',
|
||||
[SupportedLanguages.Swift]: 'swift',
|
||||
[SupportedLanguages.Dart]: 'dart',
|
||||
[SupportedLanguages.Vue]: 'typescript',
|
||||
[SupportedLanguages.Cobol]: 'cobol',
|
||||
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
|
||||
|
||||
/** Non-code file extensions → Prism-compatible syntax identifiers */
|
||||
const AUXILIARY_SYNTAX_MAP: Record<string, string> = {
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
mdx: 'markdown',
|
||||
html: 'markup',
|
||||
htm: 'markup',
|
||||
erb: 'markup',
|
||||
xml: 'markup',
|
||||
css: 'css',
|
||||
scss: 'css',
|
||||
sass: 'css',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
sql: 'sql',
|
||||
toml: 'toml',
|
||||
ini: 'ini',
|
||||
dockerfile: 'docker',
|
||||
};
|
||||
|
||||
/** Extensionless filenames → Prism-compatible syntax identifiers */
|
||||
const AUXILIARY_BASENAME_MAP: Record<string, string> = {
|
||||
Makefile: 'makefile',
|
||||
Dockerfile: 'docker',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map file path to a Prism-compatible syntax highlight language string.
|
||||
* Covers all SupportedLanguages (code files) plus common non-code formats.
|
||||
* Returns 'text' for unrecognised files.
|
||||
*/
|
||||
export const getSyntaxLanguageFromFilename = (filePath: string): string => {
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (lang) return SYNTAX_MAP[lang];
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
if (ext && ext in AUXILIARY_SYNTAX_MAP) return AUXILIARY_SYNTAX_MAP[ext];
|
||||
const basename = filePath.split('/').pop() || '';
|
||||
if (basename in AUXILIARY_BASENAME_MAP) return AUXILIARY_BASENAME_MAP[basename];
|
||||
return 'text';
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Supported language enum — single source of truth.
|
||||
*
|
||||
* Both CLI and web use this to identify which language a file/node belongs to.
|
||||
* The CLI uses it throughout the ingestion pipeline; the web uses it for display.
|
||||
*/
|
||||
export enum SupportedLanguages {
|
||||
JavaScript = 'javascript',
|
||||
TypeScript = 'typescript',
|
||||
Python = 'python',
|
||||
Java = 'java',
|
||||
C = 'c',
|
||||
CPlusPlus = 'cpp',
|
||||
CSharp = 'csharp',
|
||||
Go = 'go',
|
||||
Ruby = 'ruby',
|
||||
Rust = 'rust',
|
||||
PHP = 'php',
|
||||
Kotlin = 'kotlin',
|
||||
Swift = 'swift',
|
||||
Dart = 'dart',
|
||||
Vue = 'vue',
|
||||
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
|
||||
Cobol = 'cobol',
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* LadybugDB schema constants — single source of truth.
|
||||
*
|
||||
* NODE_TABLES and REL_TYPES define what the knowledge graph can contain.
|
||||
* Both CLI and web must agree on these for data compatibility.
|
||||
*
|
||||
* Full DDL schemas remain in each package's own schema.ts because
|
||||
* the CLI uses native LadybugDB and the web uses WASM.
|
||||
*/
|
||||
|
||||
export const NODE_TABLES = [
|
||||
'File',
|
||||
'Folder',
|
||||
'Function',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Method',
|
||||
'CodeElement',
|
||||
'Community',
|
||||
'Process',
|
||||
'Section',
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Macro',
|
||||
'Typedef',
|
||||
'Union',
|
||||
'Namespace',
|
||||
'Trait',
|
||||
'Impl',
|
||||
'TypeAlias',
|
||||
'Const',
|
||||
'Static',
|
||||
'Property',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Constructor',
|
||||
'Template',
|
||||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
] as const;
|
||||
|
||||
export type NodeTableName = (typeof NODE_TABLES)[number];
|
||||
|
||||
export const REL_TABLE_NAME = 'CodeRelation';
|
||||
|
||||
export const REL_TYPES = [
|
||||
'CONTAINS',
|
||||
'DEFINES',
|
||||
'IMPORTS',
|
||||
'CALLS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'ACCESSES',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated
|
||||
'METHOD_IMPLEMENTS',
|
||||
'MEMBER_OF',
|
||||
'STEP_IN_PROCESS',
|
||||
'HANDLES_ROUTE',
|
||||
'FETCHES',
|
||||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
'WRAPS',
|
||||
'QUERIES',
|
||||
] as const;
|
||||
|
||||
export type RelType = (typeof REL_TYPES)[number];
|
||||
|
||||
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding';
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* MRO (Method Resolution Order) strategy — shared between CLI and any
|
||||
* future consumer that reasons about multiple-inheritance semantics.
|
||||
*
|
||||
* Lives in `gitnexus-shared` so the low-level resolution module
|
||||
* (`core/ingestion/model/resolve.ts`) does not need to import from
|
||||
* `languages/` — keeping the `model/` layer free of language-registry
|
||||
* coupling.
|
||||
*
|
||||
* Strategy semantics:
|
||||
* - `first-wins`: BFS ancestor walk, first match wins (default).
|
||||
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
|
||||
* - `c3`: C3-linearized ancestor order, first match wins (Python).
|
||||
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
|
||||
* interface-default ambiguity is handled at graph level.
|
||||
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
|
||||
*/
|
||||
export type MroStrategy =
|
||||
| 'first-wins'
|
||||
| 'c3'
|
||||
| 'leftmost-base'
|
||||
| 'implements-split'
|
||||
| 'qualified-syntax';
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Pipeline progress types — shared between CLI and web.
|
||||
*/
|
||||
|
||||
export type PipelinePhase =
|
||||
| 'idle'
|
||||
| 'extracting'
|
||||
| 'structure'
|
||||
| 'parsing'
|
||||
| 'imports'
|
||||
| 'calls'
|
||||
| 'heritage'
|
||||
| 'communities'
|
||||
| 'processes'
|
||||
| 'enriching'
|
||||
| 'complete'
|
||||
| 'error';
|
||||
|
||||
export interface PipelineProgress {
|
||||
phase: PipelinePhase;
|
||||
percent: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
stats?: {
|
||||
filesProcessed: number;
|
||||
totalFiles: number;
|
||||
nodesCreated: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
|
||||
/**
|
||||
* CORS Proxy for isomorphic-git
|
||||
*
|
||||
* isomorphic-git calls: /api/proxy?url=https://github.com/...
|
||||
*/
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Git-Protocol, Accept');
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get URL from query parameter
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
res.status(400).json({ error: 'Missing url query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow GitHub URLs for security
|
||||
const allowedHosts = ['github.com', 'raw.githubusercontent.com'];
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Invalid URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowedHosts.some(host => parsedUrl.hostname.endsWith(host))) {
|
||||
res.status(403).json({ error: 'Only GitHub URLs are allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'git/isomorphic-git',
|
||||
};
|
||||
|
||||
// Forward relevant headers
|
||||
if (req.headers.authorization) {
|
||||
headers['Authorization'] = req.headers.authorization as string;
|
||||
}
|
||||
if (req.headers['content-type']) {
|
||||
headers['Content-Type'] = req.headers['content-type'] as string;
|
||||
}
|
||||
if (req.headers['git-protocol']) {
|
||||
headers['Git-Protocol'] = req.headers['git-protocol'] as string;
|
||||
}
|
||||
if (req.headers.accept) {
|
||||
headers['Accept'] = req.headers.accept as string;
|
||||
}
|
||||
|
||||
// Get request body for POST requests
|
||||
let body: Buffer | undefined;
|
||||
if (req.method === 'POST') {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
body = Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: req.method || 'GET',
|
||||
headers,
|
||||
body: body ? new Uint8Array(body) : undefined,
|
||||
});
|
||||
|
||||
// Set CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Expose-Headers', '*');
|
||||
|
||||
// Forward response headers (except ones that cause issues)
|
||||
const skipHeaders = [
|
||||
'content-encoding',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'www-authenticate', // IMPORTANT: Strip this to prevent browser's native auth popup!
|
||||
];
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(response.status);
|
||||
const buffer = await response.arrayBuffer();
|
||||
res.send(Buffer.from(buffer));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.status(500).json({ error: 'Proxy request failed', details: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, type TestInfo } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Debug harnesses for investigating specific UI issues.
|
||||
@@ -9,7 +9,7 @@ const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const debugTest = process.env.DEBUG_E2E ? test : test.skip;
|
||||
|
||||
async function connectToServer(page: import('@playwright/test').Page) {
|
||||
page.on('console', msg => {
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') console.log(`[error] ${msg.text()}`);
|
||||
});
|
||||
|
||||
@@ -86,7 +86,10 @@ debugTest('debug: process view Reset View button', async ({ page }, testInfo) =>
|
||||
|
||||
const transformAfterReset = await diagramDiv.getAttribute('style');
|
||||
console.log('Transform AFTER reset:', transformAfterReset);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-modal-after-reset.png'), fullPage: true });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('debug-modal-after-reset.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// Verify transform actually changed back
|
||||
expect(transformAfterZoom).not.toBe(transformBefore);
|
||||
@@ -123,5 +126,8 @@ debugTest('debug: lightbulb clears node selection dimming', async ({ page }, tes
|
||||
// Click it again to toggle back on
|
||||
await lightbulbBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'), fullPage: true });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for heartbeat disconnect/reconnect behavior.
|
||||
*
|
||||
* Verifies the key regression: when the heartbeat fails, the UI shows a
|
||||
* "reconnecting" banner instead of resetting to the onboarding screen.
|
||||
*
|
||||
* Strategy: block /api/heartbeat via route interception BEFORE loading the
|
||||
* graph. The heartbeat EventSource can never connect, so onReconnecting
|
||||
* fires on the first retry attempt. This reliably tests the banner behavior
|
||||
* without depending on setOffline timing (which varies across CI environments).
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Heartbeat Reconnect', () => {
|
||||
test('shows reconnecting banner instead of onboarding reset when heartbeat is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Block the heartbeat BEFORE navigating — the EventSource will fail
|
||||
// immediately on every connection attempt, triggering onReconnecting.
|
||||
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
|
||||
|
||||
// Load the app and connect to a repo (all other endpoints work normally)
|
||||
await page.goto('/');
|
||||
|
||||
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
|
||||
try {
|
||||
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// auto-connect may skip the landing screen
|
||||
}
|
||||
|
||||
// Wait for graph to load (heartbeat is blocked, but graph loads fine)
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The reconnecting banner should appear (heartbeat is failing)
|
||||
const banner = page.getByText('Server connection lost');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The graph canvas should STILL be visible — NOT reset to onboarding
|
||||
await expect(page.locator('canvas').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('banner clears when heartbeat becomes available', async ({ page }) => {
|
||||
// Start with heartbeat blocked
|
||||
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
|
||||
|
||||
await page.goto('/');
|
||||
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
|
||||
try {
|
||||
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// auto-connect may skip the landing screen
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify banner appears
|
||||
const banner = page.getByText('Server connection lost');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Unblock heartbeat — the real server is running, so reconnect will succeed
|
||||
await page.unroute('**/api/heartbeat');
|
||||
|
||||
// Banner should disappear as heartbeat reconnects
|
||||
await expect(banner).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Graph should still be there
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,16 @@ import { test } from '@playwright/test';
|
||||
*/
|
||||
test.skip(
|
||||
!!process.env.CI || process.env.PWDEBUG !== '1',
|
||||
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0'
|
||||
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0',
|
||||
);
|
||||
|
||||
test('manual recording session', async ({ page }) => {
|
||||
page.on('console', msg => {
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' || msg.type() === 'warning') {
|
||||
console.log(`[${msg.type()}] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', err => console.log(`[crash] ${err.message}`));
|
||||
page.on('pageerror', (err) => console.log(`[crash] ${err.message}`));
|
||||
|
||||
await page.goto('http://localhost:5173');
|
||||
await page.pause();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for multi-repo scoping and URL persistence.
|
||||
*
|
||||
* Verifies that:
|
||||
* - Connecting via ?server= loads data and sets ?project= in the URL
|
||||
* - The repo name appears in the UI after connecting
|
||||
* - F5 with ?server=&project= reconnects to the correct repo
|
||||
*
|
||||
* Runs against the single indexed repo in CI — validates the plumbing
|
||||
* works end-to-end even with one repo.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
let firstRepoName: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) {
|
||||
// Still need to fetch the repo name for assertions
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
const repos = await res.json();
|
||||
firstRepoName = repos[0]?.name ?? '';
|
||||
} catch {
|
||||
firstRepoName = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
firstRepoName = repos[0].name;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Multi-Repo Scoping', () => {
|
||||
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
|
||||
// Navigate with ?server= param (the bookmarkable shortcut)
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
// Wait for graph to load
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should now contain ?project= with the repo name
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
expect(project).toBeTruthy();
|
||||
expect(project).toBe(firstRepoName);
|
||||
});
|
||||
|
||||
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should still have ?server=
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('server')).toBeTruthy();
|
||||
|
||||
// F5 should reconnect (not show onboarding)
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('node count in status bar matches backend data', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Fetch expected node count from backend
|
||||
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
|
||||
const repoInfo = await res.json();
|
||||
const expectedNodes = repoInfo.stats?.nodes;
|
||||
|
||||
if (expectedNodes) {
|
||||
// Status bar shows node count — use the status-ready area to avoid
|
||||
// matching multiple elements (file tree, header may also show counts)
|
||||
const statusBar = page.locator('footer');
|
||||
const nodeText = statusBar.getByText(/\d+ nodes/).first();
|
||||
await expect(nodeText).toBeVisible({ timeout: 10_000 });
|
||||
const text = await nodeText.textContent();
|
||||
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
|
||||
expect(displayedNodes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for the onboarding and analysis user flows.
|
||||
*
|
||||
* These tests cover:
|
||||
* - Flow 1: OnboardingGuide shown when no server is running
|
||||
* - Flow 2: Analyze form when server has zero repos
|
||||
* - Flow 3: Auto-connect when server has repos
|
||||
* - Flow 4: Repo dropdown in exploring view
|
||||
*
|
||||
* Most tests mock the backend at the network level so they don't
|
||||
* require a live gitnexus server.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = 'http://localhost:4747';
|
||||
|
||||
async function enterExploringView(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
|
||||
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
|
||||
try {
|
||||
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// Landing screen may not appear (e.g. ?server auto-connect)
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
|
||||
|
||||
test.describe('Flow 1: Onboarding — no server', () => {
|
||||
test('shows OnboardingGuide when backend is unreachable', async ({ page }, testInfo) => {
|
||||
// Block all requests to the backend so the probe fails
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for initial probe to complete and onboarding to appear
|
||||
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('onboarding-visible.png') });
|
||||
});
|
||||
|
||||
test('shows step-by-step instructions', async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
await page.goto('/');
|
||||
|
||||
// Step 1 is active (done once polling starts)
|
||||
await expect(page.getByText('Copy the command')).toBeAttached({ timeout: 10_000 });
|
||||
// Step 2 title changes to "Waiting for server to start" once polling begins
|
||||
await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 });
|
||||
// Step 3 is always rendered
|
||||
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows terminal window with command', async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
await page.goto('/');
|
||||
|
||||
// Should show either dev or prod command in a terminal block
|
||||
const terminal = page.locator('code');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The $ prompt should be present
|
||||
await expect(page.getByText('$')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows polling indicator', async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
await page.goto('/');
|
||||
|
||||
// Polling starts after initial probe fails
|
||||
await expect(page.getByText('Listening for server')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('shows Node.js version requirement', async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText(/Node\.js.*\d+/)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Port 4747')).toBeVisible();
|
||||
});
|
||||
|
||||
test('copy button has accessible label', async ({ page }) => {
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Copy the command')).toBeVisible({ timeout: 10_000 });
|
||||
const copyBtn = page.getByLabel('Copy to clipboard').first();
|
||||
await expect(copyBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Flow 2: Server detected → success → auto-connect ──────────────────────
|
||||
|
||||
test.describe('Flow 2: Server detected — auto-connect', () => {
|
||||
test('shows success card when server becomes reachable', async ({ page }, testInfo) => {
|
||||
// Start with server unreachable
|
||||
let blockBackend = true;
|
||||
await page.route(`${BACKEND_URL}/**`, (route) => {
|
||||
if (blockBackend) return route.abort('connectionrefused');
|
||||
// Let it through to the real handler below
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Mock the backend responses for when we "start" the server
|
||||
await page.route(`${BACKEND_URL}/api/repos`, async (route) => {
|
||||
if (blockBackend) return route.abort('connectionrefused');
|
||||
await route.fulfill({ json: [{ name: 'test-repo', path: '/tmp/test' }] });
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/repo`, async (route) => {
|
||||
if (blockBackend) return route.abort('connectionrefused');
|
||||
await route.fulfill({
|
||||
json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' },
|
||||
});
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/graph**`, async (route) => {
|
||||
if (blockBackend) return route.abort('connectionrefused');
|
||||
await route.fulfill({ json: { nodes: [], relationships: [] } });
|
||||
});
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, async (route) => {
|
||||
if (blockBackend) return route.abort('connectionrefused');
|
||||
// SSE response
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: ':ok\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Verify onboarding is shown first
|
||||
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('before-server-start.png') });
|
||||
|
||||
// "Start" the server by unblocking requests
|
||||
blockBackend = false;
|
||||
|
||||
// Wait for success card
|
||||
await expect(page.getByText('Server Connected')).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('success-card.png') });
|
||||
});
|
||||
|
||||
test('transitions to analyze phase when server has zero repos', async ({ page }, testInfo) => {
|
||||
// Mock server with zero repos — repos endpoint returns empty array
|
||||
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
|
||||
await page.route(`${BACKEND_URL}/api/info`, (route) =>
|
||||
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
|
||||
);
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Should transition: onboarding → success → analyze (zero repos)
|
||||
// The analyze form tabs should be visible
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('analyze-empty-state.png') });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Flow 3: Analyze form ───────────────────────────────────────────────────
|
||||
|
||||
test.describe('Flow 3: Analyze form', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock server with zero repos to show the analyze form
|
||||
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
|
||||
await page.route(`${BACKEND_URL}/api/info`, (route) =>
|
||||
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
|
||||
);
|
||||
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('GitHub URL tab validates input', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for analyze form (transition: onboarding → success → analyze)
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Type an invalid URL
|
||||
const input = page.locator('input[type="url"]');
|
||||
await input.fill('not-a-url');
|
||||
|
||||
// Analyze button should be visible but disabled
|
||||
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
|
||||
await expect(analyzeBtn).toBeVisible();
|
||||
|
||||
// Type a valid GitHub URL
|
||||
await input.fill('https://github.com/anthropics/courses');
|
||||
await page.screenshot({ path: testInfo.outputPath('valid-github-url.png') });
|
||||
});
|
||||
|
||||
test('Local Folder tab shows browse button', async ({ page }, testInfo) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Switch to Local Folder tab
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
// Browse button should be visible
|
||||
await expect(page.getByText('Browse for folder')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
|
||||
});
|
||||
|
||||
test('switching tabs clears input', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Type in GitHub URL
|
||||
const urlInput = page.locator('input[type="url"]');
|
||||
await urlInput.fill('https://github.com/test/repo');
|
||||
|
||||
// Switch to Local Folder
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
|
||||
// Switch back to GitHub URL — input should be empty
|
||||
await page.getByRole('tab', { name: 'GitHub URL' }).click();
|
||||
const newUrlInput = page.locator('input[type="url"]');
|
||||
await expect(newUrlInput).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Flow 4: Repo dropdown (requires running server) ────────────────────────
|
||||
|
||||
test.describe('Flow 4: Repo dropdown in exploring view', () => {
|
||||
const SKIP_MSG = 'Requires running gitnexus server with indexed repos';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
if (!res.ok) {
|
||||
test.skip(true, SKIP_MSG);
|
||||
return;
|
||||
}
|
||||
const repos = await res.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'Server has no indexed repos');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, SKIP_MSG);
|
||||
}
|
||||
});
|
||||
|
||||
test('project badge opens repo dropdown', async ({ page }, testInfo) => {
|
||||
await enterExploringView(page);
|
||||
await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
|
||||
|
||||
// Click the project badge (has a chevron)
|
||||
const badge = page
|
||||
.locator('header button')
|
||||
.filter({ has: page.locator('svg') })
|
||||
.first();
|
||||
await badge.click();
|
||||
|
||||
// Repo dropdown should be visible
|
||||
await expect(page.getByText('Repositories')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText('Analyze a new repository')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('repo-dropdown-open.png') });
|
||||
});
|
||||
|
||||
test('analyze option opens inline form', async ({ page }, testInfo) => {
|
||||
await enterExploringView(page);
|
||||
|
||||
// Open repo dropdown
|
||||
const badge = page
|
||||
.locator('header button')
|
||||
.filter({ has: page.locator('svg') })
|
||||
.first();
|
||||
await badge.click();
|
||||
|
||||
// Click "Analyze a new repository..."
|
||||
await page.getByText('Analyze a new repository').click();
|
||||
|
||||
// Should show the analyze form inline
|
||||
await expect(page.getByText('GitHub URL')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText('Local Folder')).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('inline-analyze-form.png') });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for the repo-switching and false-404 fixes.
|
||||
*
|
||||
* Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts).
|
||||
* The 503 hold-queue test uses route interception to simulate a slow analysis.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
let firstRepoName: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
const repos = await res.json();
|
||||
firstRepoName = repos[0]?.name ?? '';
|
||||
} catch {
|
||||
firstRepoName = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
firstRepoName = repos[0].name;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 1. Hold-queue: 503 → descriptive user message ────────────────────────────
|
||||
|
||||
test.describe('Hold-queue timeout error', () => {
|
||||
test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => {
|
||||
// Intercept only /api/repo (singular) — not /api/repos — to return a 503
|
||||
// regex: /api/repo followed by end, ?, or # — NOT /api/repos
|
||||
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
// UI should show the 503 error message
|
||||
await expect(page.getByText(/taking longer than expected/i)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') });
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
|
||||
|
||||
test.describe('?project= URL persistence', () => {
|
||||
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
expect(project).toBeTruthy();
|
||||
// first repo returned by the live backend
|
||||
if (firstRepoName) expect(project).toBe(firstRepoName);
|
||||
});
|
||||
|
||||
test('?project= is still present after F5 reload', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// After connect, URL has ?server=&project= — F5 re-uses both params
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('project')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. ?project= + ?server= combined auto-connect ────────────────────────────
|
||||
|
||||
test.describe('?project= auto-connect', () => {
|
||||
test('navigating with ?server=&project= connects to the correct repo', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
if (!firstRepoName) test.skip(true, 'no repo name available');
|
||||
|
||||
await page.goto(
|
||||
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// ?project= in URL should match what we passed in
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('project')).toBe(firstRepoName);
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') });
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. Windows path normalization ─────────────────────────────────────────────
|
||||
|
||||
test.describe('Windows path normalization', () => {
|
||||
test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({
|
||||
page,
|
||||
}) => {
|
||||
const repoName = firstRepoName || 'test-repo';
|
||||
const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`;
|
||||
|
||||
// Mock /api/repo to return a Windows backslash path while keeping name correct
|
||||
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
// intentionally omit `name` to force path-based extraction
|
||||
path: windowsPath,
|
||||
repoPath: windowsPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL ?project= must be the short basename, NOT the full Windows path
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
expect(project).toBeTruthy();
|
||||
expect(project).not.toContain('\\');
|
||||
expect(project).not.toContain('LENOVO');
|
||||
expect(project).toBe(repoName);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test, expect, type TestInfo } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for the GitNexus web UI.
|
||||
* E2E tests for the GitNexus web UI — exploring view features.
|
||||
*
|
||||
* Requires:
|
||||
* - gitnexus serve running on localhost:4747
|
||||
* - gitnexus serve running on localhost:4747 with at least one indexed repo
|
||||
* - gitnexus-web dev server running on localhost:5173
|
||||
*
|
||||
* Skipped when servers aren't available (CI without services, etc.).
|
||||
@@ -12,95 +13,103 @@ import { test, expect, type TestInfo } from '@playwright/test';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
// Skip all tests if the gitnexus server or Vite dev server isn't reachable
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return; // force-run
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (backendRes.status === 'rejected' || (backendRes.status === 'fulfilled' && !backendRes.value.ok)) {
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available on :4747');
|
||||
return;
|
||||
}
|
||||
if (frontendRes.status === 'rejected' || (frontendRes.status === 'fulfilled' && !frontendRes.value.ok)) {
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available on :5173');
|
||||
return;
|
||||
}
|
||||
// Check there's at least one indexed repo
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos — run gitnexus analyze first');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
/** Shared helper: connect to the local server and wait for the graph to load */
|
||||
async function connectAndWaitForGraph(page: import('@playwright/test').Page, testInfo: TestInfo) {
|
||||
// Signal to the app that we are running under Playwright (used to skip heavy Ladybug loads).
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__ = true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Wait for the server-detection flow to complete.
|
||||
*
|
||||
* The app auto-detects the server, then either:
|
||||
* - shows the landing screen when indexed repos exist, or
|
||||
* - goes straight into analyze onboarding when there are zero repos.
|
||||
*
|
||||
* For these tests we require at least one indexed repo, so pick the first
|
||||
* landing card when present and then wait for the exploring view.
|
||||
*/
|
||||
async function waitForGraphLoaded(page: import('@playwright/test').Page) {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the app to fully render before interacting
|
||||
const serverTab = page.getByRole('button', { name: 'Server' });
|
||||
await expect(serverTab).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('step-1-landing.png') });
|
||||
const landingCards = page.locator('[data-testid="landing-repo-card"]');
|
||||
const preferredLandingCard = landingCards
|
||||
.filter({ hasText: /GitNexus|local-integration/ })
|
||||
.first();
|
||||
try {
|
||||
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const landingCard =
|
||||
(await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first();
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// Landing screen may not appear (e.g. ?server auto-connect)
|
||||
}
|
||||
|
||||
// Click "Server" tab and wait for the input to appear
|
||||
await serverTab.click();
|
||||
const serverInput = page.locator('input[name="server-url-input"]');
|
||||
await expect(serverInput).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('step-2-server-tab.png') });
|
||||
await serverInput.fill(BACKEND_URL);
|
||||
await page.screenshot({ path: testInfo.outputPath('step-3-url-filled.png') });
|
||||
|
||||
await page.getByRole('button', { name: /Connect/ }).click();
|
||||
|
||||
// Wait for graph to load — status bar shows "Ready"
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('step-4-graph-loaded.png') });
|
||||
const statusBar = page.getByRole('contentinfo');
|
||||
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
|
||||
await expect(statusBar).toContainText(/nodes/, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Server Connection & Graph Loading', () => {
|
||||
test('connects to server and loads graph', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
await page.screenshot({ path: testInfo.outputPath('graph-loaded.png'), fullPage: true });
|
||||
test('selects a repo from landing and loads graph', async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Nexus AI', () => {
|
||||
test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
test('panel opens and agent initializes without error', async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
|
||||
// Click Nexus AI button to open the panel
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
|
||||
// Should see the Nexus AI tab content
|
||||
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
|
||||
|
||||
// "Database not ready" should NOT be visible
|
||||
const errorBanner = page.getByText('Database not ready');
|
||||
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Processes Panel', () => {
|
||||
test('shows process list and View button works', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
test('shows process list and View button works', async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
|
||||
// Open Nexus AI panel, switch to Processes tab
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
await page.getByText('Processes').click();
|
||||
|
||||
// Should show process count — wait for data-testid instead of fixed timeout
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Hover first process item to reveal View button, then click it
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
await expect(processRow).toBeVisible({ timeout: 10_000 });
|
||||
await processRow.hover();
|
||||
@@ -108,21 +117,18 @@ test.describe('Processes Panel', () => {
|
||||
const viewBtn = processRow.locator('[data-testid="process-view-button"]');
|
||||
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
await viewBtn.click();
|
||||
// Wait for modal to appear
|
||||
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('process-view-clicked.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
test('lightbulb highlights nodes in graph', async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Nexus AI' }).click();
|
||||
await page.getByText('Processes').click();
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('before-highlight.png'), fullPage: true });
|
||||
|
||||
// Hover first process to reveal lightbulb
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
await expect(processRow).toBeVisible({ timeout: 10_000 });
|
||||
await processRow.hover();
|
||||
@@ -130,36 +136,28 @@ test.describe('Processes Panel', () => {
|
||||
const lightbulb = processRow.locator('[data-testid="process-highlight-button"]');
|
||||
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
await lightbulb.click();
|
||||
// Wait for highlight to apply — the process row gets amber styling when focused
|
||||
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Turn Off All Highlights', () => {
|
||||
test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
|
||||
await connectAndWaitForGraph(page, testInfo);
|
||||
test('selecting a node dims others, button clears it', async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
|
||||
// Wait for graph to fully render by checking for canvas element
|
||||
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('before-select.png'), fullPage: true });
|
||||
|
||||
// Click a file in the file tree to select a node
|
||||
const fileItem = page.getByText('package.json').first();
|
||||
await expect(fileItem).toBeVisible({ timeout: 10_000 });
|
||||
await fileItem.click();
|
||||
|
||||
// Wait for highlight toggle to show "Turn off" (indicates highlights are active)
|
||||
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]');
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('node-selected.png'), fullPage: true });
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Click the toggle to clear all highlights
|
||||
await highlightToggle.click();
|
||||
|
||||
// Verify highlights are now off — button title changes to "Turn on"
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GitNexus</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+54
-1835
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,7 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@isomorphic-git/lightning-fs": "^4.6.2",
|
||||
"@ladybugdb/wasm-core": "^0.15.1",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.10",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/google-genai": "^2.1.10",
|
||||
@@ -30,8 +28,6 @@
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"buffer": "^6.0.3",
|
||||
"comlink": "^4.4.2",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"graphology": "^0.26.0",
|
||||
@@ -40,13 +36,10 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"isomorphic-git": "^1.36.1",
|
||||
"jszip": "^3.10.1",
|
||||
"langchain": "^1.2.10",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"minisearch": "^7.2.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -58,9 +51,6 @@
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"uuid": "^13.0.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"web-tree-sitter": "^0.20.8",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,7 +60,6 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -82,7 +71,6 @@
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"wait-on": "^8.0.5"
|
||||
}
|
||||
|
||||
@@ -40,9 +40,6 @@ export default defineConfig({
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: 'playwright-report' }],
|
||||
],
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
outputDir: 'test-results',
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+193
-176
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppStateProvider, useAppState } from './hooks/useAppState';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
@@ -9,10 +9,17 @@ 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';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
|
||||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
normalizeServerUrl,
|
||||
connectHeartbeat,
|
||||
BackendError,
|
||||
type ConnectResult,
|
||||
type BackendRepo,
|
||||
} from './services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
|
||||
const AppContent = () => {
|
||||
@@ -20,19 +27,15 @@ const AppContent = () => {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setFileContents,
|
||||
setProgress,
|
||||
setProjectName,
|
||||
progress,
|
||||
isRightPanelOpen,
|
||||
runPipeline,
|
||||
runPipelineFromFiles,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
embeddingStatus,
|
||||
codeReferences,
|
||||
selectedNode,
|
||||
isCodePanelOpen,
|
||||
@@ -41,185 +44,143 @@ const AppContent = () => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
loadServerGraph,
|
||||
setCurrentRepo,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const [serverDisconnected, setServerDisconnected] = useState(false);
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
const projectName = file.name.replace('.zip', '');
|
||||
setProjectName(projectName);
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to extract files' });
|
||||
setViewMode('loading');
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
// Use the canonical repo name from the server response so all subsequent
|
||||
// backend calls (queries, search, grep, readFile) scope to this repo.
|
||||
const repoName = result.repoInfo.name;
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
// Normalize both Windows (\) and Unix (/) path separators before splitting
|
||||
const projectName =
|
||||
result.repoInfo.name ||
|
||||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
|
||||
'server-project';
|
||||
setProjectName(projectName);
|
||||
setCurrentRepo(projectName);
|
||||
|
||||
try {
|
||||
const result = await runPipeline(file, (progress) => {
|
||||
setProgress(progress);
|
||||
});
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
|
||||
setGraph(result.graph);
|
||||
setFileContents(result.fileContents);
|
||||
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
|
||||
const urlObj = new URL(window.location.href);
|
||||
urlObj.searchParams.set('project', projectName);
|
||||
window.history.replaceState(null, '', urlObj.toString());
|
||||
|
||||
// Transition directly to exploring view
|
||||
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
|
||||
startEmbeddingsWithFallback();
|
||||
} 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);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddingsWithFallback, initializeAgent]);
|
||||
|
||||
const handleGitClone = useCallback(async (files: FileEntry[], repoName?: string) => {
|
||||
let projectName = repoName;
|
||||
if (!projectName) {
|
||||
const firstPath = files[0]?.path || 'repository';
|
||||
projectName = firstPath.split('/')[0].replace(/-\d+$/, '') || 'repository';
|
||||
}
|
||||
|
||||
setProjectName(projectName);
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to process files' });
|
||||
setViewMode('loading');
|
||||
|
||||
try {
|
||||
const result = await runPipelineFromFiles(files, (progress) => {
|
||||
setProgress(progress);
|
||||
});
|
||||
|
||||
setGraph(result.graph);
|
||||
setFileContents(result.fileContents);
|
||||
setViewMode('exploring');
|
||||
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(projectName);
|
||||
}
|
||||
|
||||
startEmbeddingsWithFallback();
|
||||
} 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);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddingsWithFallback, initializeAgent]);
|
||||
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const parts = repoPath.split('/').filter(p => p && !p.startsWith('.'));
|
||||
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
|
||||
setProjectName(projectName);
|
||||
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
|
||||
// Set file contents from extracted File node content
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [path, content] of Object.entries(result.fileContents)) {
|
||||
fileMap.set(path, content);
|
||||
}
|
||||
setFileContents(fileMap);
|
||||
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Load graph into LadybugDB (in-browser WASM database) for Nexus AI queries,
|
||||
// then initialize agent once the database is ready
|
||||
const loadGraphPromise = loadServerGraph(result.nodes, result.relationships, result.fileContents)
|
||||
.then(() => {
|
||||
// Initialize agent with backend queries, then start embeddings
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
return initializeAgent(projectName);
|
||||
await initializeAgent(projectName);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
startEmbeddingsWithFallback();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to load graph into LadybugDB:', err);
|
||||
// Agent won't work but graph visualization still does
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to initialize agent:', err);
|
||||
}
|
||||
},
|
||||
[
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setProjectName,
|
||||
setCurrentRepo,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
],
|
||||
);
|
||||
|
||||
return loadGraphPromise;
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddingsWithFallback]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has('server')) return;
|
||||
const serverUrlParam = params.get('server');
|
||||
const projectParam = params.get('project');
|
||||
|
||||
if (!serverUrlParam && !projectParam) return;
|
||||
autoConnectRan.current = true;
|
||||
|
||||
// Clean the URL so a refresh won't re-trigger
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
setViewMode('loading');
|
||||
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
|
||||
const serverUrl = serverUrlParam || window.location.origin;
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}).then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
}).catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Failed to connect to server',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
const tryConnect = async () => {
|
||||
return await connectToServer(
|
||||
serverUrl,
|
||||
(phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
projectParam || undefined,
|
||||
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
|
||||
);
|
||||
};
|
||||
|
||||
tryConnect()
|
||||
.then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
});
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos()
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Failed to connect to server',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, ERROR_RESET_DELAY_MS);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
@@ -233,21 +194,38 @@ const AppContent = () => {
|
||||
initializeAgent();
|
||||
}, [refreshLLMSettings, initializeAgent]);
|
||||
|
||||
// ── Server heartbeat: detect when server goes down while exploring ────────
|
||||
// Uses SSE (EventSource) for instant detection — no polling delay.
|
||||
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
|
||||
// The heartbeat retries indefinitely with capped backoff and recovers automatically.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'exploring') return;
|
||||
|
||||
const cleanup = connectHeartbeat(
|
||||
() => setServerDisconnected(false),
|
||||
() => setServerDisconnected(true),
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
}, [viewMode]);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
return (
|
||||
<DropZone
|
||||
onFileSelect={handleFileSelect}
|
||||
onGitClone={handleGitClone}
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
// Refresh repo list before transitioning so it's ready in the header
|
||||
const repos = await fetchRepos().catch(() => [] as BackendRepo[]);
|
||||
setAvailableRepos(repos);
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
if (serverUrl) {
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(baseUrl);
|
||||
fetchRepos(baseUrl)
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch((e) => console.warn('Failed to fetch repo list:', e));
|
||||
const base = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(base);
|
||||
// Add ?server= so F5 reconnects to this server
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('server', base);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -260,20 +238,54 @@ const AppContent = () => {
|
||||
|
||||
// Exploring view
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-void overflow-hidden">
|
||||
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-void">
|
||||
<Header
|
||||
onFocusNode={handleFocusNode}
|
||||
availableRepos={availableRepos}
|
||||
onSwitchRepo={switchRepo}
|
||||
onReposChanged={(repos) => setAvailableRepos(repos)}
|
||||
onAnalyzeComplete={async (repoName) => {
|
||||
// A new repo was just indexed via the header dropdown.
|
||||
// Refresh the repo list, connect to the new repo, and switch to it.
|
||||
// Retry once after 1s if the repo isn't found yet (server may still
|
||||
// be reinitializing after the worker completed).
|
||||
const url = serverBaseUrl ?? 'http://localhost:4747';
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const repos = await fetchRepos();
|
||||
setAvailableRepos(repos);
|
||||
const result = await connectToServer(url, undefined, undefined, repoName);
|
||||
await handleServerConnect(result);
|
||||
setServerBaseUrl(normalizeServerUrl(url));
|
||||
setProgress(null);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (attempt === 0 && err instanceof BackendError && err.status === 404) {
|
||||
// Server may still be reinitializing — wait and retry
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
console.error('Failed to connect after analyze:', err);
|
||||
fetchRepos()
|
||||
.then((repos) => setAvailableRepos(repos))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex min-h-0">
|
||||
<main className="flex min-h-0 flex-1">
|
||||
{/* Left Panel - File Tree */}
|
||||
<FileTreePanel onFocusNode={handleFocusNode} />
|
||||
|
||||
{/* Graph area - takes remaining space */}
|
||||
<div className="flex-1 relative min-w-0">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<GraphCanvas ref={graphCanvasRef} />
|
||||
|
||||
{/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */}
|
||||
{isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && (
|
||||
<div className="absolute inset-y-0 left-0 z-30 pointer-events-auto">
|
||||
<div className="pointer-events-auto absolute inset-y-0 left-0 z-30">
|
||||
<CodeReferencesPanel onFocusNode={handleFocusNode} />
|
||||
</div>
|
||||
)}
|
||||
@@ -285,13 +297,18 @@ const AppContent = () => {
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{serverDisconnected && (
|
||||
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
|
||||
Server connection lost — reconnecting…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Panel (modal) */}
|
||||
<SettingsPanel
|
||||
isOpen={isSettingsPanelOpen}
|
||||
onClose={() => setSettingsPanelOpen(false)}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AnalyzeOnboarding
|
||||
*
|
||||
* The "empty state" card rendered inside DropZone's Crossfade when the server
|
||||
* is connected but zero repos are indexed. Replaces the generic error message
|
||||
* with a first-class GitHub URL input flow.
|
||||
*
|
||||
* Rendering context:
|
||||
* DropZone (Crossfade, phase="analyze")
|
||||
* └─ AnalyzeOnboarding
|
||||
* └─ RepoAnalyzer (variant="onboarding")
|
||||
*
|
||||
* When the analysis job completes, onComplete fires with the repoName, and
|
||||
* DropZone's handleAutoConnect re-runs (now that repos > 0), transitioning
|
||||
* the app to the graph explorer.
|
||||
*/
|
||||
|
||||
import { Sparkles, Github } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
|
||||
interface AnalyzeOnboardingProps {
|
||||
/** Called when analysis finishes and the repo is ready to load. */
|
||||
onComplete: (repoName: string) => void;
|
||||
}
|
||||
|
||||
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
|
||||
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative mb-6">
|
||||
<div className="text-center">
|
||||
{/* Eyebrow */}
|
||||
<div className="mb-2 inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
|
||||
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
|
||||
GitNexus
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
|
||||
<Github className="h-7 w-7 text-accent" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Analyze your first repository
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live
|
||||
knowledge graph — right in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyzer form */}
|
||||
<div className="relative">
|
||||
<RepoAnalyzer variant="onboarding" onComplete={onComplete} />
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
|
||||
Public repos only · Cloned locally by the server · No data leaves your machine
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from '@/lib/lucide-icons';
|
||||
import type { JobProgress as AnalyzeJobProgress } from '../services/backend-client';
|
||||
|
||||
interface AnalyzeProgressProps {
|
||||
progress: AnalyzeJobProgress;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
queued: 'Queued',
|
||||
cloning: 'Cloning repository',
|
||||
pulling: 'Pulling latest',
|
||||
extracting: 'Scanning files',
|
||||
structure: 'Building structure',
|
||||
parsing: 'Parsing code',
|
||||
imports: 'Resolving imports',
|
||||
calls: 'Tracing calls',
|
||||
heritage: 'Extracting inheritance',
|
||||
communities: 'Detecting communities',
|
||||
processes: 'Detecting processes',
|
||||
complete: 'Pipeline complete',
|
||||
lbug: 'Loading into database',
|
||||
fts: 'Creating search indexes',
|
||||
embeddings: 'Generating embeddings',
|
||||
done: 'Done',
|
||||
retrying: 'Retrying after crash',
|
||||
};
|
||||
|
||||
export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) => {
|
||||
const [startTime] = useState(() => Date.now());
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setElapsed(Date.now() - startTime), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [startTime]);
|
||||
|
||||
const formatElapsed = (ms: number) => {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
};
|
||||
|
||||
const label = PHASE_LABELS[progress.phase] || progress.message || progress.phase;
|
||||
const pct = Math.max(0, Math.min(100, progress.percent));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Phase label + elapsed */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-text-secondary">{label}</span>
|
||||
<span className="font-mono text-xs text-text-muted">{formatElapsed(elapsed)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all duration-300 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Percent + cancel */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs text-text-muted">{pct}%</span>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-500/10 px-3 py-1.5 text-xs text-red-400 transition-all duration-200 hover:bg-red-500/20"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Server, ArrowRight } from '@/lib/lucide-icons';
|
||||
import { BackendRepo } from '../services/backend';
|
||||
|
||||
interface BackendRepoSelectorProps {
|
||||
repos: BackendRepo[];
|
||||
onSelectRepo: (repoName: string) => void;
|
||||
backendUrl: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export const BackendRepoSelector = ({
|
||||
repos,
|
||||
onSelectRepo,
|
||||
backendUrl,
|
||||
isConnected,
|
||||
}: BackendRepoSelectorProps) => {
|
||||
return (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-2xl shadow-glow">
|
||||
<Server className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Local Repositories
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-4">
|
||||
Select an indexed repository from your local GitNexus server
|
||||
</p>
|
||||
|
||||
{/* Connected status badge */}
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
<span className="text-xs text-green-400">Connected to {backendUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repo list or empty state */}
|
||||
{repos.length > 0 ? (
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => onSelectRepo(repo.name)}
|
||||
className="w-full p-4 bg-elevated border border-border-subtle rounded-xl hover:border-accent/50 hover:bg-hover transition-all text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary group-hover:text-accent transition-colors">
|
||||
{repo.name}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-text-muted group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted">
|
||||
{repo.stats?.files != null && <span>{repo.stats.files} files</span>}
|
||||
{repo.stats?.nodes != null && <span>{repo.stats.nodes} nodes</span>}
|
||||
{repo.stats?.edges != null && <span>{repo.stats.edges} edges</span>}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
Indexed {new Date(repo.indexedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-text-muted py-8">
|
||||
<p className="text-sm mb-2">No indexed repositories found</p>
|
||||
<p className="text-xs">
|
||||
Run{' '}
|
||||
<code className="px-1 py-0.5 bg-elevated rounded">gitnexus analyze</code>{' '}
|
||||
in a repository
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
{repos.length} {repos.length === 1 ? 'repo' : 'repos'}
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Pre-indexed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +1,26 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from '@/lib/lucide-icons';
|
||||
import {
|
||||
Code,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Trash2,
|
||||
X,
|
||||
Target,
|
||||
FileCode,
|
||||
Sparkles,
|
||||
MousePointerClick,
|
||||
Loader2,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { GraphNode } from '../core/graph/types';
|
||||
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
import { readFile, type ReadFileResult } from '../services/backend-client';
|
||||
|
||||
/** Map file extension to Prism syntax highlighter language identifier */
|
||||
const getSyntaxLanguage = (filePath: string | undefined): string => {
|
||||
if (!filePath) return 'text';
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript';
|
||||
case 'ts': case 'tsx': case 'mts': case 'cts': return 'typescript';
|
||||
case 'py': case 'pyw': return 'python';
|
||||
case 'rb': case 'rake': case 'gemspec': return 'ruby';
|
||||
case 'java': return 'java';
|
||||
case 'go': return 'go';
|
||||
case 'rs': return 'rust';
|
||||
case 'c': case 'h': return 'c';
|
||||
case 'cpp': case 'cc': case 'cxx': case 'hpp': case 'hxx': case 'hh': return 'cpp';
|
||||
case 'cs': return 'csharp';
|
||||
case 'php': return 'php';
|
||||
case 'kt': case 'kts': return 'kotlin';
|
||||
case 'swift': return 'swift';
|
||||
case 'json': return 'json';
|
||||
case 'yaml': case 'yml': return 'yaml';
|
||||
case 'md': case 'mdx': return 'markdown';
|
||||
case 'html': case 'htm': case 'erb': return 'markup';
|
||||
case 'css': case 'scss': case 'sass': return 'css';
|
||||
case 'sh': case 'bash': case 'zsh': return 'bash';
|
||||
case 'sql': return 'sql';
|
||||
case 'xml': return 'xml';
|
||||
default: break;
|
||||
}
|
||||
// Handle extensionless Ruby files
|
||||
const basename = filePath.split('/').pop() || '';
|
||||
if (['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile'].includes(basename)) return 'ruby';
|
||||
if (['Makefile'].includes(basename)) return 'makefile';
|
||||
if (['Dockerfile'].includes(basename)) return 'docker';
|
||||
return 'text';
|
||||
return getSyntaxLanguageFromFilename(filePath);
|
||||
};
|
||||
|
||||
// Match the code theme used elsewhere in the app
|
||||
@@ -67,18 +48,18 @@ export interface CodeReferencesPanelProps {
|
||||
export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => {
|
||||
const {
|
||||
graph,
|
||||
fileContents,
|
||||
selectedNode,
|
||||
codeReferences,
|
||||
removeCodeReference,
|
||||
clearCodeReferences,
|
||||
setSelectedNode,
|
||||
codeReferenceFocus,
|
||||
projectName,
|
||||
} = useAppState();
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
if (!graph) return new Map<string, GraphNode>();
|
||||
return new Map(graph.nodes.map(n => [n.id, n]));
|
||||
return new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
}, [graph]);
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -116,34 +97,40 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
}
|
||||
}, [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 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 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);
|
||||
};
|
||||
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]);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]);
|
||||
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
|
||||
@@ -157,12 +144,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
|
||||
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);
|
||||
aiReferences.find(
|
||||
(r) => r.filePath === filePath && r.startLine === startLine && r.endLine === endLine,
|
||||
) ?? aiReferences.find((r) => r.filePath === filePath);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
@@ -189,64 +173,139 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
rafIds.push(outerRafId);
|
||||
|
||||
return () => {
|
||||
rafIds.forEach(id => cancelAnimationFrame(id));
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [codeReferenceFocus?.ts, aiReferences]);
|
||||
}, [codeReferenceFocus, 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,
|
||||
content: null as string | null,
|
||||
start: 0,
|
||||
end: 0,
|
||||
highlightStart: 0,
|
||||
highlightEnd: 0,
|
||||
totalLines: 0,
|
||||
};
|
||||
});
|
||||
}, [aiReferences, fileContents]);
|
||||
}, [aiReferences]);
|
||||
|
||||
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;
|
||||
|
||||
// Fetch file content from the server when a node with a filePath is selected.
|
||||
// For non-File nodes (functions, classes, etc.), fetch a buffer around the symbol
|
||||
// instead of the entire file. For File nodes, fetch the whole file.
|
||||
const CONTEXT_LINES = 50; // lines of context above and below the symbol
|
||||
|
||||
const [fileResult, setFileResult] = useState<ReadFileResult | null>(null);
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const selectedViewerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedFileContent = fileResult?.content;
|
||||
const fileStartLine = fileResult?.startLine ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFilePath) {
|
||||
setFileResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
setFileResult(null);
|
||||
|
||||
// Determine read range: full file for File nodes, buffered for symbols
|
||||
const startLine = selectedNode?.properties?.startLine as number | undefined;
|
||||
const endLine = selectedNode?.properties?.endLine as number | undefined;
|
||||
const isWholeFile = selectedIsFile || startLine === undefined;
|
||||
|
||||
const options = isWholeFile
|
||||
? { repo: projectName }
|
||||
: {
|
||||
startLine: Math.max(0, startLine - CONTEXT_LINES),
|
||||
endLine: (endLine ?? startLine) + CONTEXT_LINES,
|
||||
repo: projectName,
|
||||
};
|
||||
|
||||
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setFileResult(result);
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFileResult(null);
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedFilePath,
|
||||
selectedNode?.properties?.startLine,
|
||||
selectedNode?.properties?.endLine,
|
||||
selectedIsFile,
|
||||
projectName,
|
||||
]);
|
||||
|
||||
// Scroll to the selected node's startLine after content loads
|
||||
useEffect(() => {
|
||||
if (!selectedFileContent || !selectedNode?.properties?.startLine) return;
|
||||
const startLine = selectedNode.properties.startLine as number;
|
||||
|
||||
// Double rAF: wait for SyntaxHighlighter to fully render before scrolling
|
||||
let cancelled = false;
|
||||
const outerRaf = requestAnimationFrame(() => {
|
||||
const innerRaf = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
const container = selectedViewerRef.current;
|
||||
if (!container) return;
|
||||
const lineEl =
|
||||
(container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
|
||||
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
|
||||
if (lineEl) {
|
||||
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Fallback: estimate scroll position based on line height
|
||||
const lineHeight = 20.8; // 13px font * 1.6 line-height
|
||||
container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3);
|
||||
}
|
||||
});
|
||||
rafIds.push(innerRaf);
|
||||
});
|
||||
const rafIds = [outerRaf];
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [selectedFileContent, selectedNode?.properties?.startLine]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<aside className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2 flex-shrink-0">
|
||||
<aside className="flex h-full w-12 flex-shrink-0 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="p-2 text-text-secondary hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-colors"
|
||||
className="rounded p-2 text-text-secondary transition-colors hover:bg-cyan-500/10 hover:text-cyan-400"
|
||||
title="Expand Code Panel"
|
||||
>
|
||||
<PanelLeft className="w-5 h-5" />
|
||||
<PanelLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-6 h-px bg-border-subtle my-1" />
|
||||
<div className="my-1 h-px w-6 bg-border-subtle" />
|
||||
{showSelectedViewer && (
|
||||
<div className="text-[9px] text-amber-400 rotate-90 whitespace-nowrap font-medium tracking-wide">
|
||||
<div className="rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-amber-400">
|
||||
SELECTED
|
||||
</div>
|
||||
)}
|
||||
{showCitations && (
|
||||
<div className="text-[9px] text-cyan-400 rotate-90 whitespace-nowrap font-medium tracking-wide mt-4">
|
||||
<div className="mt-4 rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-cyan-400">
|
||||
AI • {aiReferences.length}
|
||||
</div>
|
||||
)}
|
||||
@@ -256,70 +315,80 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={(el) => { panelRef.current = el; }}
|
||||
className="h-full bg-surface/95 backdrop-blur-md border-r border-border-subtle flex flex-col animate-slide-in relative shadow-2xl"
|
||||
ref={(el) => {
|
||||
panelRef.current = el;
|
||||
}}
|
||||
className="relative flex h-full animate-slide-in flex-col border-r border-border-subtle bg-surface/95 shadow-2xl backdrop-blur-md"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onMouseDown={startResize}
|
||||
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent hover:bg-cyan-500/25 transition-colors"
|
||||
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent transition-colors hover:bg-cyan-500/25"
|
||||
title="Drag to resize"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4 text-cyan-400" />
|
||||
<Code className="h-4 w-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-text-primary">Code Inspector</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showCitations && (
|
||||
<button
|
||||
onClick={() => clearCodeReferences()}
|
||||
className="p-1.5 text-text-muted hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
title="Clear AI citations"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Collapse Panel"
|
||||
>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Top: Selected file viewer (when a node is selected) */}
|
||||
{showSelectedViewer && (
|
||||
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} min-h-0 flex flex-col`}>
|
||||
<div className="px-3 py-2 bg-gradient-to-r from-amber-500/8 to-orange-500/5 border-b border-amber-500/20 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-amber-500/15 rounded-md border border-amber-500/25">
|
||||
<MousePointerClick className="w-3 h-3 text-amber-400" />
|
||||
<span className="text-[10px] text-amber-300 font-semibold uppercase tracking-wide">Selected</span>
|
||||
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} flex min-h-0 flex-col`}>
|
||||
<div className="flex items-center gap-2 border-b border-amber-500/20 bg-gradient-to-r from-amber-500/8 to-orange-500/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/15 px-2 py-0.5">
|
||||
<MousePointerClick className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
|
||||
Selected
|
||||
</span>
|
||||
</div>
|
||||
<FileCode className="w-3.5 h-3.5 text-amber-400/70 ml-1" />
|
||||
<span className="text-xs text-text-primary font-mono truncate flex-1">
|
||||
{selectedNode?.properties?.filePath?.split('/').pop() ?? selectedNode?.properties?.name}
|
||||
<FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
|
||||
<span className="flex-1 truncate font-mono text-xs text-text-primary">
|
||||
{selectedNode?.properties?.filePath?.split('/').pop() ??
|
||||
selectedNode?.properties?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="p-1 text-text-muted hover:text-amber-400 hover:bg-amber-500/10 rounded transition-colors"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-amber-500/10 hover:text-amber-400"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto scrollbar-thin">
|
||||
{selectedFileContent ? (
|
||||
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
|
||||
{isLoadingFile ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading source...</span>
|
||||
</div>
|
||||
) : selectedFileContent ? (
|
||||
<SyntaxHighlighter
|
||||
language={getSyntaxLanguage(selectedFilePath)}
|
||||
style={customTheme as any}
|
||||
showLineNumbers
|
||||
startingLineNumber={1}
|
||||
startingLineNumber={fileStartLine + 1}
|
||||
lineNumberStyle={{
|
||||
minWidth: '3em',
|
||||
paddingRight: '1em',
|
||||
@@ -328,12 +397,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
userSelect: 'none',
|
||||
}}
|
||||
lineProps={(lineNumber) => {
|
||||
const startLine = selectedNode?.properties?.startLine;
|
||||
const endLine = selectedNode?.properties?.endLine ?? startLine;
|
||||
const symStart = selectedNode?.properties?.startLine;
|
||||
const symEnd = selectedNode?.properties?.endLine ?? symStart;
|
||||
const isHighlighted =
|
||||
typeof startLine === 'number' &&
|
||||
lineNumber >= startLine + 1 &&
|
||||
lineNumber <= (endLine ?? startLine) + 1;
|
||||
typeof symStart === 'number' &&
|
||||
lineNumber >= symStart + 1 &&
|
||||
lineNumber <= (symEnd ?? symStart) + 1;
|
||||
return {
|
||||
style: {
|
||||
display: 'block',
|
||||
@@ -351,7 +420,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
{selectedIsFile ? (
|
||||
<>Code not available in memory for <span className="font-mono">{selectedFilePath}</span></>
|
||||
<>
|
||||
Code not available in memory for{' '}
|
||||
<span className="font-mono">{selectedFilePath}</span>
|
||||
</>
|
||||
) : (
|
||||
<>Select a file node to preview its contents.</>
|
||||
)}
|
||||
@@ -368,128 +440,147 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
|
||||
{/* Bottom: AI citations list */}
|
||||
{showCitations && (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* AI Citations Section Header */}
|
||||
<div className="px-3 py-2 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 border-b border-cyan-500/20 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-cyan-500/15 rounded-md border border-cyan-500/25">
|
||||
<Sparkles className="w-3 h-3 text-cyan-400" />
|
||||
<span className="text-[10px] text-cyan-300 font-semibold uppercase tracking-wide">AI Citations</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted ml-1">{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin p-3 space-y-3">
|
||||
{refsWithSnippets.map(({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
|
||||
const nodeColor = ref.label ? (NODE_COLORS as any)[ref.label] || '#6b7280' : '#6b7280';
|
||||
const hasRange = typeof ref.startLine === 'number';
|
||||
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
|
||||
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
|
||||
const language = getSyntaxLanguage(ref.filePath);
|
||||
|
||||
const isGlowing = glowRefId === ref.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
ref={(el) => { refCardEls.current.set(ref.id, el); }}
|
||||
className={[
|
||||
'bg-elevated border border-border-subtle rounded-xl overflow-hidden transition-all',
|
||||
isGlowing ? 'ring-2 ring-cyan-300/70 shadow-[0_0_0_6px_rgba(34,211,238,0.14)] animate-pulse' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-border-subtle bg-surface/40 flex items-start gap-2">
|
||||
<span
|
||||
className="mt-0.5 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide flex-shrink-0"
|
||||
style={{ backgroundColor: nodeColor, color: '#06060a' }}
|
||||
title={ref.label ?? 'Code'}
|
||||
>
|
||||
{ref.label ?? 'Code'}
|
||||
<div className="flex items-center gap-2 border-b border-cyan-500/20 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-cyan-500/25 bg-cyan-500/15 px-2 py-0.5">
|
||||
<Sparkles className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
|
||||
AI Citations
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-text-primary font-medium truncate">
|
||||
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted font-mono truncate">
|
||||
{ref.filePath}
|
||||
{startDisplay !== undefined && (
|
||||
<span className="text-text-secondary">
|
||||
{' '}
|
||||
• L{startDisplay}
|
||||
{endDisplay !== startDisplay ? `–${endDisplay}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{totalLines > 0 && <span className="text-text-muted"> • {totalLines} lines</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{ref.nodeId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nodeId = ref.nodeId!;
|
||||
// Sync selection + focus graph
|
||||
if (graph) {
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) setSelectedNode(node);
|
||||
}
|
||||
onFocusNode(nodeId);
|
||||
}}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
title="Focus in graph"
|
||||
>
|
||||
<Target className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeCodeReference(ref.id)}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{content ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={customTheme as any}
|
||||
showLineNumbers
|
||||
startingLineNumber={start + 1}
|
||||
lineNumberStyle={{
|
||||
minWidth: '3em',
|
||||
paddingRight: '1em',
|
||||
color: '#5a5a70',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
lineProps={(lineNumber) => {
|
||||
const isHighlighted =
|
||||
hasRange &&
|
||||
lineNumber >= start + highlightStart + 1 &&
|
||||
lineNumber <= start + highlightEnd + 1;
|
||||
return {
|
||||
style: {
|
||||
display: 'block',
|
||||
backgroundColor: isHighlighted ? 'rgba(6, 182, 212, 0.14)' : 'transparent',
|
||||
borderLeft: isHighlighted ? '3px solid #06b6d4' : '3px solid transparent',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '16px',
|
||||
},
|
||||
};
|
||||
}}
|
||||
wrapLines
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
Code not available in memory for <span className="font-mono">{ref.filePath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-1 text-xs text-text-muted">
|
||||
{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{refsWithSnippets.map(
|
||||
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
|
||||
const nodeColor = ref.label
|
||||
? (NODE_COLORS as any)[ref.label] || '#6b7280'
|
||||
: '#6b7280';
|
||||
const hasRange = typeof ref.startLine === 'number';
|
||||
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
|
||||
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
|
||||
const language = getSyntaxLanguage(ref.filePath);
|
||||
|
||||
const isGlowing = glowRefId === ref.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
ref={(el) => {
|
||||
refCardEls.current.set(ref.id, el);
|
||||
}}
|
||||
className={[
|
||||
'overflow-hidden rounded-xl border border-border-subtle bg-elevated transition-all',
|
||||
isGlowing
|
||||
? 'animate-pulse shadow-[0_0_0_6px_rgba(34,211,238,0.14)] ring-2 ring-cyan-300/70'
|
||||
: '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-2 border-b border-border-subtle bg-surface/40 px-3 py-2">
|
||||
<span
|
||||
className="mt-0.5 flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase"
|
||||
style={{ backgroundColor: nodeColor, color: '#06060a' }}
|
||||
title={ref.label ?? 'Code'}
|
||||
>
|
||||
{ref.label ?? 'Code'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-text-primary">
|
||||
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-text-muted">
|
||||
{ref.filePath}
|
||||
{startDisplay !== undefined && (
|
||||
<span className="text-text-secondary">
|
||||
{' '}
|
||||
• L{startDisplay}
|
||||
{endDisplay !== startDisplay ? `–${endDisplay}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{totalLines > 0 && (
|
||||
<span className="text-text-muted"> • {totalLines} lines</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{ref.nodeId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nodeId = ref.nodeId!;
|
||||
// Sync selection + focus graph
|
||||
if (graph) {
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) setSelectedNode(node);
|
||||
}
|
||||
onFocusNode(nodeId);
|
||||
}}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Focus in graph"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeCodeReference(ref.id)}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{content ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={customTheme as any}
|
||||
showLineNumbers
|
||||
startingLineNumber={start + 1}
|
||||
lineNumberStyle={{
|
||||
minWidth: '3em',
|
||||
paddingRight: '1em',
|
||||
color: '#5a5a70',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
lineProps={(lineNumber) => {
|
||||
const isHighlighted =
|
||||
hasRange &&
|
||||
lineNumber >= start + highlightStart + 1 &&
|
||||
lineNumber <= start + highlightEnd + 1;
|
||||
return {
|
||||
style: {
|
||||
display: 'block',
|
||||
backgroundColor: isHighlighted
|
||||
? 'rgba(6, 182, 212, 0.14)'
|
||||
: 'transparent',
|
||||
borderLeft: isHighlighted
|
||||
? '3px solid #06b6d4'
|
||||
: '3px solid transparent',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '16px',
|
||||
},
|
||||
};
|
||||
}}
|
||||
wrapLines
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
Code not available in memory for{' '}
|
||||
<span className="font-mono">{ref.filePath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,578 +1,316 @@
|
||||
import { useState, useCallback, useRef, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from '@/lib/lucide-icons';
|
||||
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
|
||||
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
|
||||
import { FileEntry } from '../services/zip';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Loader2, Check, Sparkles } from '@/lib/lucide-icons';
|
||||
import {
|
||||
connectToServer,
|
||||
fetchRepos,
|
||||
type ConnectResult,
|
||||
type BackendRepo,
|
||||
} from '../services/backend-client';
|
||||
import { useBackend } from '../hooks/useBackend';
|
||||
import { OnboardingGuide } from './OnboardingGuide';
|
||||
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
|
||||
import { RepoLanding } from './RepoLanding';
|
||||
|
||||
interface DropZoneProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
onGitClone?: (files: FileEntry[], repoName?: string) => void;
|
||||
onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void;
|
||||
onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
// ── Crossfade wrapper ───────────────────────────────────────────────────────
|
||||
// Captures the outgoing children during fade-out, then swaps to the new children on fade-in.
|
||||
|
||||
export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
function Crossfade({ activeKey, children }: { activeKey: string; children: React.ReactNode }) {
|
||||
const [displayedKey, setDisplayedKey] = useState(activeKey);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const snapshotRef = useRef<React.ReactNode>(children);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Server tab state
|
||||
const [serverUrl, setServerUrl] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('gitnexus-server-url') || '';
|
||||
} catch {
|
||||
return '';
|
||||
// Keep snapshot up to date when NOT transitioning
|
||||
if (!isTransitioning && activeKey === displayedKey) {
|
||||
snapshotRef.current = children;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeKey !== displayedKey) {
|
||||
setIsTransitioning(true);
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
snapshotRef.current = null; // clear snapshot — new children will render
|
||||
setDisplayedKey(activeKey);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [serverProgress, setServerProgress] = useState<{
|
||||
phase: string;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}>({ phase: '', downloaded: 0, total: null });
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.zip')) {
|
||||
onFileSelect(file);
|
||||
} else {
|
||||
setError('Please drop a .zip file');
|
||||
}
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.name.endsWith('.zip')) {
|
||||
onFileSelect(file);
|
||||
} else {
|
||||
setError('Please select a .zip file');
|
||||
}
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleGitClone = async () => {
|
||||
if (!githubUrl.trim()) {
|
||||
setError('Please enter a GitHub URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseGitHubUrl(githubUrl);
|
||||
if (!parsed) {
|
||||
setError('Invalid GitHub URL. Use format: https://github.com/owner/repo');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsCloning(true);
|
||||
setCloneProgress({ phase: 'starting', percent: 0 });
|
||||
|
||||
try {
|
||||
const files = await cloneRepository(
|
||||
githubUrl,
|
||||
(phase, percent) => setCloneProgress({ phase, percent }),
|
||||
githubToken || undefined
|
||||
);
|
||||
|
||||
setGithubToken('');
|
||||
|
||||
if (onGitClone) {
|
||||
onGitClone(files, parsed.repo);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Clone failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to clone repository';
|
||||
if (message.includes('401') || message.includes('403') || message.includes('Authentication')) {
|
||||
if (!githubToken) {
|
||||
setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
|
||||
} else {
|
||||
setError('Authentication failed. Check your token permissions (needs repo access).');
|
||||
}
|
||||
} else if (message.includes('404') || message.includes('not found')) {
|
||||
setError('Repository not found. Check the URL or it might be private (needs PAT).');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerConnect = async () => {
|
||||
const urlToUse = serverUrl.trim() || window.location.origin;
|
||||
if (!urlToUse) {
|
||||
setError('Please enter a server URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist URL to localStorage
|
||||
try {
|
||||
localStorage.setItem('gitnexus-server-url', serverUrl);
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. private browsing, quota exceeded)
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
setServerProgress({ phase: 'validating', downloaded: 0, total: null });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const result = await connectToServer(
|
||||
urlToUse,
|
||||
(phase, downloaded, total) => {
|
||||
setServerProgress({ phase, downloaded, total });
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (onServerConnect) {
|
||||
onServerConnect(result, urlToUse);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
console.error('Server connect failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to server';
|
||||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||
setError('Cannot reach server. Check the URL and ensure the server is running.');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
const serverProgressPercent = serverProgress.total
|
||||
? Math.round((serverProgress.downloaded / serverProgress.total) * 100)
|
||||
: null;
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [activeKey, displayedKey]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
|
||||
<div
|
||||
className="transition-[opacity,transform] duration-300 ease-out"
|
||||
style={{
|
||||
opacity: isTransitioning ? 0 : 1,
|
||||
transform: isTransitioning ? 'scale(0.97) translateY(8px)' : 'scale(1) translateY(0)',
|
||||
}}
|
||||
>
|
||||
{isTransitioning ? snapshotRef.current : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase cards ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SuccessCard() {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-surface p-7"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Success glow */}
|
||||
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-emerald-500/8 blur-3xl" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Animated check icon */}
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 shadow-[0_0_30px_rgba(16,185,129,0.15)]">
|
||||
<Check className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-emerald-400">
|
||||
Server Connected
|
||||
</h2>
|
||||
<p className="text-center text-sm leading-relaxed text-text-secondary">
|
||||
Preparing your code knowledge graph...
|
||||
</p>
|
||||
|
||||
{/* Subtle progress hint */}
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400/60"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-accent/20 bg-surface p-7"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Loading glow */}
|
||||
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-accent/8 blur-3xl" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Spinner */}
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-text-primary">
|
||||
{message || 'Connecting...'}
|
||||
</h2>
|
||||
<p className="text-center text-sm leading-relaxed text-text-secondary">
|
||||
This may take a moment for large repositories
|
||||
</p>
|
||||
|
||||
{/* Decorative sparkle */}
|
||||
<div className="mt-5 flex items-center justify-center">
|
||||
<Sparkles className="h-4 w-4 text-accent/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DropZone ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Backend polling for server detection
|
||||
const {
|
||||
isConnected,
|
||||
isProbing,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
isPolling,
|
||||
backendUrl: detectedBackendUrl,
|
||||
} = useBackend();
|
||||
const [initialProbeComplete, setInitialProbeComplete] = useState(false);
|
||||
const autoConnectRan = useRef(false);
|
||||
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Connection state
|
||||
// 'analyze' = server up but zero repos indexed — show URL input
|
||||
// 'landing' = server up with indexed repos — show repo picker + analyze
|
||||
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'landing' | 'success' | 'loading'>(
|
||||
'onboarding',
|
||||
);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [detectedRepos, setDetectedRepos] = useState<BackendRepo[]>([]);
|
||||
|
||||
// Auto-connect to the detected server — fetch repo list and show the
|
||||
// appropriate screen (landing with repo cards, or analyze for zero repos).
|
||||
const handleAutoConnect = async () => {
|
||||
setPhase('loading');
|
||||
setLoadingMessage('Connecting...');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const repos = await fetchRepos();
|
||||
if (repos.length === 0) {
|
||||
setPhase('analyze');
|
||||
autoConnectRan.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show landing screen so the user can choose which repo to explore
|
||||
setDetectedRepos(repos);
|
||||
setPhase('landing');
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return;
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect';
|
||||
setError(message);
|
||||
setPhase('onboarding');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoConnectRef = useRef(handleAutoConnect);
|
||||
handleAutoConnectRef.current = handleAutoConnect;
|
||||
|
||||
// Shared handler: connect to a specific repo by name (used by both repo
|
||||
// card selection on the landing screen and post-analysis completion).
|
||||
const connectToRepo = (repoName: string) => {
|
||||
autoConnectRan.current = true;
|
||||
setPhase('loading');
|
||||
setLoadingMessage('Loading graph...');
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
try {
|
||||
const result = await connectToServer(
|
||||
detectedBackendUrl,
|
||||
(p, downloaded, total) => {
|
||||
if (p === 'validating') {
|
||||
setLoadingMessage('Validating server...');
|
||||
} else if (p === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 100) : null;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`);
|
||||
} else if (p === 'extracting') {
|
||||
setLoadingMessage('Processing graph...');
|
||||
}
|
||||
},
|
||||
abortController.signal,
|
||||
repoName,
|
||||
);
|
||||
if (onServerConnect) {
|
||||
await onServerConnect(result, detectedBackendUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load graph');
|
||||
setPhase(detectedRepos.length > 0 ? 'landing' : 'analyze');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// Track when the initial probe finishes
|
||||
useEffect(() => {
|
||||
if (!isProbing && !initialProbeComplete) {
|
||||
setInitialProbeComplete(true);
|
||||
}
|
||||
}, [isProbing, initialProbeComplete]);
|
||||
|
||||
// Start polling once after initial probe fails
|
||||
useEffect(() => {
|
||||
if (initialProbeComplete && !isConnected && !isPolling && !autoConnectRan.current) {
|
||||
startPolling();
|
||||
}
|
||||
}, [initialProbeComplete, isConnected, isPolling, startPolling]);
|
||||
|
||||
// Auto-connect when server is detected
|
||||
useEffect(() => {
|
||||
if (isConnected && !autoConnectRan.current) {
|
||||
autoConnectRan.current = true;
|
||||
stopPolling();
|
||||
setPhase('success');
|
||||
autoConnectTimerRef.current = setTimeout(() => {
|
||||
autoConnectTimerRef.current = null;
|
||||
handleAutoConnectRef.current();
|
||||
}, 1200); // hold success state long enough to register
|
||||
}
|
||||
// Server went away — reset to onboarding (or analyze if we were on analyze)
|
||||
if (!isConnected && autoConnectRan.current && !isProbing) {
|
||||
autoConnectRan.current = false;
|
||||
if (autoConnectTimerRef.current !== null) {
|
||||
clearTimeout(autoConnectTimerRef.current);
|
||||
autoConnectTimerRef.current = null;
|
||||
}
|
||||
setPhase('onboarding');
|
||||
setError(null);
|
||||
}
|
||||
// NOTE: No cleanup return here. The autoConnectTimerRef must survive effect
|
||||
// re-runs (e.g. isProbing flipping false while the 1200ms window is active).
|
||||
// The unmount cleanup effect below is the sole owner of timer cancellation.
|
||||
}, [isConnected, isProbing, stopPolling]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoConnectTimerRef.current !== null) clearTimeout(autoConnectTimerRef.current);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render until initial probe completes
|
||||
const displayPhase = !initialProbeComplete ? null : phase;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-void p-8">
|
||||
{/* Background gradient effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl" />
|
||||
<div className="pointer-events-none fixed inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-accent/10 blur-3xl" />
|
||||
<div className="absolute right-1/4 bottom-1/4 h-96 w-96 rounded-full bg-node-interface/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-lg">
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-4 bg-surface border border-border-default rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => { setActiveTab('zip'); setError(null); }}
|
||||
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'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FileArchive className="w-4 h-4" />
|
||||
ZIP Upload
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('github'); setError(null); }}
|
||||
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'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('server'); setError(null); }}
|
||||
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 === 'server'
|
||||
? 'bg-accent text-white shadow-md'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{/* Error — floats above the card */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm text-center">
|
||||
<div className="mb-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-center text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ZIP Upload Tab */}
|
||||
{activeTab === 'zip' && (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
relative p-16
|
||||
bg-surface border-2 border-dashed rounded-3xl
|
||||
transition-all duration-300 cursor-pointer
|
||||
${isDragging
|
||||
? 'border-accent bg-elevated scale-105 shadow-glow'
|
||||
: 'border-border-default hover:border-accent/50 hover:bg-elevated/50 animate-breathe'
|
||||
}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
{/* Crossfade between phases */}
|
||||
{displayPhase && (
|
||||
<Crossfade activeKey={displayPhase}>
|
||||
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
|
||||
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
|
||||
{displayPhase === 'landing' && (
|
||||
<RepoLanding
|
||||
repos={detectedRepos}
|
||||
onSelectRepo={connectToRepo}
|
||||
onAnalyzeComplete={connectToRepo}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`
|
||||
mx-auto w-20 h-20 mb-6
|
||||
flex items-center justify-center
|
||||
bg-gradient-to-br from-accent to-node-interface
|
||||
rounded-2xl shadow-glow
|
||||
transition-transform duration-300
|
||||
${isDragging ? 'scale-110' : ''}
|
||||
`}>
|
||||
{isDragging ? (
|
||||
<Upload className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<FileArchive className="w-10 h-10 text-white" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
{isDragging ? 'Drop it here!' : 'Drop your codebase'}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Drag & drop a .zip file to generate a knowledge graph
|
||||
</p>
|
||||
|
||||
{/* Hints */}
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
.zip
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* GitHub URL Tab */}
|
||||
{activeTab === 'github' && (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-[#333] to-[#24292e] rounded-2xl shadow-lg">
|
||||
<Github className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Clone from GitHub
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Enter a repository URL to clone directly
|
||||
</p>
|
||||
|
||||
{/* Inputs - wrapped in div to prevent form autofill */}
|
||||
<div className="space-y-3" data-form-type="other">
|
||||
<input
|
||||
type="url"
|
||||
name="github-repo-url-input"
|
||||
value={githubUrl}
|
||||
onChange={(e) => setGithubUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isCloning && handleGitClone()}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
disabled={isCloning}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
|
||||
{/* Token input for private repos */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
name="github-pat-token-input"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
placeholder="GitHub PAT (optional, for private repos)"
|
||||
disabled={isCloning}
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full pl-10 pr-10 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGitClone}
|
||||
disabled={isCloning || !githubUrl.trim()}
|
||||
className="
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
{isCloning ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{cloneProgress.phase === 'cloning'
|
||||
? `Cloning... ${cloneProgress.percent}%`
|
||||
: cloneProgress.phase === 'reading'
|
||||
? 'Reading files...'
|
||||
: 'Starting...'
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Clone Repository
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isCloning && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300 ease-out"
|
||||
style={{ width: `${cloneProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security note */}
|
||||
{githubToken && (
|
||||
<p className="mt-3 text-xs text-text-muted text-center">
|
||||
Token stays in your browser only, never sent to any server
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
{githubToken ? 'Private + Public' : 'Public repos'}
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Shallow clone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Tab */}
|
||||
{activeTab === 'server' && (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
|
||||
<Globe className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Connect to Server
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Load a pre-built knowledge graph from a running GitNexus server
|
||||
</p>
|
||||
|
||||
{/* Inputs */}
|
||||
<div className="space-y-3" data-form-type="other">
|
||||
<input
|
||||
type="url"
|
||||
name="server-url-input"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()}
|
||||
placeholder={window.location.origin}
|
||||
disabled={isConnecting}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleServerConnect}
|
||||
disabled={isConnecting}
|
||||
className="
|
||||
flex-1 flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{serverProgress.phase === 'validating'
|
||||
? 'Validating...'
|
||||
: serverProgress.phase === 'downloading'
|
||||
? serverProgressPercent !== null
|
||||
? `Downloading... ${serverProgressPercent}%`
|
||||
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
|
||||
: serverProgress.phase === 'extracting'
|
||||
? 'Processing...'
|
||||
: 'Connecting...'
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Connect
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isConnecting && (
|
||||
<button
|
||||
onClick={handleCancelConnect}
|
||||
className="
|
||||
flex items-center justify-center
|
||||
px-4 py-3
|
||||
bg-red-500/20 hover:bg-red-500/30
|
||||
text-red-400 font-medium rounded-xl
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isConnecting && serverProgress.phase === 'downloading' && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-accent transition-all duration-300 ease-out ${
|
||||
serverProgressPercent === null ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: serverProgressPercent !== null
|
||||
? `${serverProgressPercent}%`
|
||||
: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serverProgress.total && (
|
||||
<p className="mt-1 text-xs text-text-muted text-center">
|
||||
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Pre-indexed
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
No WASM needed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{displayPhase === 'success' && <SuccessCard />}
|
||||
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
|
||||
</Crossfade>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from '@/lib/lucide-icons';
|
||||
import { Brain, Loader2, Check, AlertCircle, Zap } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useState } from 'react';
|
||||
import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
||||
@@ -8,17 +8,9 @@ import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
||||
* Shows in header when graph is loaded
|
||||
*/
|
||||
export const EmbeddingStatus = () => {
|
||||
const {
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
startEmbeddings,
|
||||
graph,
|
||||
viewMode,
|
||||
serverBaseUrl,
|
||||
testArrayParams,
|
||||
} = useAppState();
|
||||
const { embeddingStatus, embeddingProgress, startEmbeddings, graph, viewMode, serverBaseUrl } =
|
||||
useAppState();
|
||||
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
|
||||
|
||||
// Only show when exploring a loaded graph; hide in backend mode (no WASM DB)
|
||||
@@ -26,13 +18,15 @@ export const EmbeddingStatus = () => {
|
||||
|
||||
const nodeCount = graph.nodes.length;
|
||||
|
||||
const handleStartEmbeddings = async (forceDevice?: 'webgpu' | 'wasm') => {
|
||||
const handleStartEmbeddings = async (_forceDevice?: 'webgpu' | 'wasm') => {
|
||||
try {
|
||||
await startEmbeddings(forceDevice);
|
||||
await startEmbeddings();
|
||||
} catch (error: any) {
|
||||
// Check if it's a WebGPU not available error
|
||||
if (error?.name === 'WebGPUNotAvailableError' ||
|
||||
error?.message?.includes('WebGPU not available')) {
|
||||
if (
|
||||
error?.name === 'WebGPUNotAvailableError' ||
|
||||
error?.message?.includes('WebGPU not available')
|
||||
) {
|
||||
setShowFallbackDialog(true);
|
||||
} else {
|
||||
console.error('Embedding failed:', error);
|
||||
@@ -49,18 +43,6 @@ export const EmbeddingStatus = () => {
|
||||
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 = (
|
||||
@@ -78,26 +60,14 @@ export const EmbeddingStatus = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Test button (dev only) */}
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={handleTestArrayParams}
|
||||
className="flex items-center gap-1 px-2 py-1.5 bg-surface border border-border-subtle rounded-lg text-xs text-text-muted hover:bg-hover hover:text-text-secondary transition-all"
|
||||
title="Test if LadybugDB supports array params"
|
||||
>
|
||||
<FlaskConical className="w-3 h-3" />
|
||||
{testResult || 'Test'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleStartEmbeddings()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary hover:bg-hover hover:text-text-primary hover:border-accent/50 transition-all group"
|
||||
className="group flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-sm text-text-secondary transition-all hover:border-accent/50 hover:bg-hover hover:text-text-primary"
|
||||
title="Generate embeddings for semantic search"
|
||||
>
|
||||
<Brain className="w-4 h-4 text-node-interface group-hover:text-accent transition-colors" />
|
||||
<Brain className="h-4 w-4 text-node-interface transition-colors group-hover:text-accent" />
|
||||
<span className="hidden sm:inline">Enable Semantic Search</span>
|
||||
<Zap className="w-3 h-3 text-text-muted" />
|
||||
<Zap className="h-3 w-3 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
{fallbackDialog}
|
||||
@@ -107,16 +77,16 @@ export const EmbeddingStatus = () => {
|
||||
|
||||
// Loading model
|
||||
if (embeddingStatus === 'loading') {
|
||||
const downloadPercent = embeddingProgress?.modelDownloadPercent ?? 0;
|
||||
const downloadPercent = embeddingProgress?.percent ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-accent/30 rounded-lg text-sm">
|
||||
<Loader2 className="w-4 h-4 text-accent animate-spin" />
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-accent/30 bg-surface px-3 py-1.5 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-accent" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-text-secondary text-xs">Loading AI model...</span>
|
||||
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
|
||||
<span className="text-xs text-text-secondary">Loading AI model...</span>
|
||||
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
|
||||
style={{ width: `${downloadPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -129,20 +99,20 @@ export const EmbeddingStatus = () => {
|
||||
|
||||
// Embedding in progress
|
||||
if (embeddingStatus === 'embedding') {
|
||||
const processed = embeddingProgress?.nodesProcessed ?? 0;
|
||||
const total = embeddingProgress?.totalNodes ?? 0;
|
||||
const processed = 0;
|
||||
const total = 0;
|
||||
const percent = embeddingProgress?.percent ?? 0;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-node-function/30 rounded-lg text-sm">
|
||||
<Loader2 className="w-4 h-4 text-node-function animate-spin" />
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-node-function/30 bg-surface px-3 py-1.5 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-node-function" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-text-secondary text-xs">
|
||||
<span className="text-xs text-text-secondary">
|
||||
Embedding {processed}/{total} nodes
|
||||
</span>
|
||||
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-node-function to-accent rounded-full transition-all duration-300"
|
||||
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-node-function to-accent transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,8 +124,8 @@ export const EmbeddingStatus = () => {
|
||||
// Indexing
|
||||
if (embeddingStatus === 'indexing') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-node-interface/30 rounded-lg text-sm text-text-secondary">
|
||||
<Loader2 className="w-4 h-4 text-node-interface animate-spin" />
|
||||
<div className="flex items-center gap-2 rounded-lg border border-node-interface/30 bg-surface px-3 py-1.5 text-sm text-text-secondary">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-node-interface" />
|
||||
<span className="text-xs">Creating vector index...</span>
|
||||
</div>
|
||||
);
|
||||
@@ -164,11 +134,11 @@ export const EmbeddingStatus = () => {
|
||||
// Ready
|
||||
if (embeddingStatus === 'ready') {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-node-function/10 border border-node-function/30 rounded-lg text-sm text-node-function"
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border border-node-function/30 bg-node-function/10 px-3 py-1.5 text-sm text-node-function"
|
||||
title="Semantic search is ready! Use natural language in the AI chat."
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Semantic Ready</span>
|
||||
</div>
|
||||
);
|
||||
@@ -180,10 +150,10 @@ export const EmbeddingStatus = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStartEmbeddings()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
title={embeddingProgress?.error || 'Embedding failed. Click to retry.'}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/20"
|
||||
title="Embedding failed. Click to retry."
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-xs">Failed - Retry</span>
|
||||
</button>
|
||||
{fallbackDialog}
|
||||
@@ -193,4 +163,3 @@ export const EmbeddingStatus = () => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
Type,
|
||||
} from '@/lib/lucide-icons';
|
||||
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';
|
||||
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO } from '../lib/constants';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
// Tree node structure
|
||||
interface TreeNode {
|
||||
@@ -38,12 +38,12 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
|
||||
const pathMap = new Map<string, TreeNode>();
|
||||
|
||||
// Filter to only folders and files
|
||||
const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File');
|
||||
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 => {
|
||||
fileNodes.forEach((node) => {
|
||||
const parts = node.properties.filePath.split('/').filter(Boolean);
|
||||
let currentPath = '';
|
||||
let currentLevel = root;
|
||||
@@ -107,9 +107,9 @@ const TreeItem = ({
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const matchesSearch = (node: TreeNode, query: string): boolean => {
|
||||
if (node.name.toLowerCase().includes(query)) return true;
|
||||
return node.children?.some(child => matchesSearch(child, query)) ?? false;
|
||||
return node.children?.some((child) => matchesSearch(child, query)) ?? false;
|
||||
};
|
||||
return node.children.filter(child => matchesSearch(child, searchLower));
|
||||
return node.children.filter((child) => matchesSearch(child, searchLower));
|
||||
}, [node.children, searchQuery]);
|
||||
|
||||
// Check if this node matches search
|
||||
@@ -126,20 +126,15 @@ const TreeItem = ({
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
w-full flex items-center gap-1.5 px-2 py-1 text-left text-sm
|
||||
hover:bg-hover transition-colors rounded relative
|
||||
${isSelected ? 'bg-amber-500/15 text-amber-300 border-l-2 border-amber-400' : 'text-text-secondary hover:text-text-primary border-l-2 border-transparent'}
|
||||
${matchesSearch ? 'bg-accent/10' : ''}
|
||||
`}
|
||||
className={`relative flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-hover ${isSelected ? 'border-l-2 border-amber-400 bg-amber-500/15 text-amber-300' : 'border-l-2 border-transparent text-text-secondary hover:text-text-primary'} ${matchesSearch ? 'bg-accent/10' : ''} `}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
@@ -148,12 +143,12 @@ const TreeItem = ({
|
||||
{/* Node icon */}
|
||||
{node.type === 'folder' ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
<FolderOpen className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
<Folder className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
)
|
||||
) : (
|
||||
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
|
||||
<FileCode className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.File }} />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
@@ -163,7 +158,7 @@ const TreeItem = ({
|
||||
{/* Children */}
|
||||
{isExpanded && filteredChildren.length > 0 && (
|
||||
<div>
|
||||
{filteredChildren.map(child => (
|
||||
{filteredChildren.map((child) => (
|
||||
<TreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
@@ -184,18 +179,30 @@ const TreeItem = ({
|
||||
// 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 'Enum': return List;
|
||||
case 'Type': return Type;
|
||||
case 'Decorator': return AtSign;
|
||||
case 'Import': return FileCode;
|
||||
case 'Variable': return Variable;
|
||||
default: return Variable;
|
||||
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 'Enum':
|
||||
return List;
|
||||
case 'Type':
|
||||
return Type;
|
||||
case 'Decorator':
|
||||
return AtSign;
|
||||
case 'Import':
|
||||
return FileCode;
|
||||
case 'Variable':
|
||||
return Variable;
|
||||
default:
|
||||
return Variable;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,7 +211,18 @@ interface FileTreePanelProps {
|
||||
}
|
||||
|
||||
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
|
||||
const {
|
||||
graph,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
toggleEdgeVisibility,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
openCodePanel,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
} = useAppState();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -220,7 +238,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
// Auto-expand first level on initial load
|
||||
useEffect(() => {
|
||||
if (fileTree.length > 0 && expandedPaths.size === 0) {
|
||||
const firstLevel = new Set(fileTree.map(n => n.path));
|
||||
const firstLevel = new Set(fileTree.map((n) => n.path));
|
||||
setExpandedPaths(firstLevel);
|
||||
}
|
||||
}, [fileTree.length]); // Only run when tree first loads
|
||||
@@ -242,16 +260,16 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
}
|
||||
|
||||
if (pathsToExpand.length > 0) {
|
||||
setExpandedPaths(prev => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
pathsToExpand.forEach(p => next.add(p));
|
||||
pathsToExpand.forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedNode?.id]); // Trigger when selected node changes
|
||||
|
||||
const toggleExpanded = useCallback((path: string) => {
|
||||
setExpandedPaths(prev => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
@@ -262,106 +280,115 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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]);
|
||||
},
|
||||
[setSelectedNode, openCodePanel, onFocusNode, selectedNode],
|
||||
);
|
||||
|
||||
const selectedPath = selectedNode?.properties.filePath || null;
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2">
|
||||
<div className="flex h-full w-12 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="p-2 text-text-secondary hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
className="rounded p-2 text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Expand Panel"
|
||||
>
|
||||
<PanelLeft className="w-5 h-5" />
|
||||
<PanelLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-6 h-px bg-border-subtle my-1" />
|
||||
<div className="my-1 h-px w-6 bg-border-subtle" />
|
||||
<button
|
||||
onClick={() => { setIsCollapsed(false); setActiveTab('files'); }}
|
||||
className={`p-2 rounded transition-colors ${activeTab === 'files' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
||||
onClick={() => {
|
||||
setIsCollapsed(false);
|
||||
setActiveTab('files');
|
||||
}}
|
||||
className={`rounded p-2 transition-colors ${activeTab === 'files' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
||||
title="File Explorer"
|
||||
>
|
||||
<Folder className="w-5 h-5" />
|
||||
<Folder className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsCollapsed(false); setActiveTab('filters'); }}
|
||||
className={`p-2 rounded transition-colors ${activeTab === 'filters' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
||||
onClick={() => {
|
||||
setIsCollapsed(false);
|
||||
setActiveTab('filters');
|
||||
}}
|
||||
className={`rounded p-2 transition-colors ${activeTab === 'filters' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
||||
title="Filters"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
<Filter className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
|
||||
<div className="flex h-full w-64 animate-slide-in flex-col border-r border-border-subtle bg-surface">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'files'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||
activeTab === 'files'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Explorer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('filters')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'filters'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||
activeTab === 'filters'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Collapse Panel"
|
||||
>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'files' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 border-b border-border-subtle">
|
||||
<div className="border-b border-border-subtle px-3 py-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
|
||||
<Search className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
className="w-full rounded border border-border-subtle bg-elevated py-1.5 pr-3 pl-8 text-xs text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin py-2">
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
|
||||
{fileTree.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-text-muted text-xs">
|
||||
No files loaded
|
||||
</div>
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">No files loaded</div>
|
||||
) : (
|
||||
fileTree.map(node => (
|
||||
fileTree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -379,12 +406,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
)}
|
||||
|
||||
{activeTab === 'filters' && (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-3">
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Node Types
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted mb-3">
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Toggle visibility of node types in the graph
|
||||
</p>
|
||||
</div>
|
||||
@@ -398,23 +425,21 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => toggleLabelVisibility(label)}
|
||||
className={`
|
||||
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
|
||||
${isVisible
|
||||
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
|
||||
isVisible
|
||||
? 'bg-elevated text-text-primary'
|
||||
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${isVisible ? '' : 'opacity-40'}`}
|
||||
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
|
||||
>
|
||||
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
|
||||
<Icon className="h-3 w-3" style={{ color: NODE_COLORS[label] }} />
|
||||
</div>
|
||||
<span className="text-xs flex-1">{label}</span>
|
||||
<span className="flex-1 text-xs">{label}</span>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -422,11 +447,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
|
||||
{/* Edge Type Toggles */}
|
||||
<div className="mt-6 pt-4 border-t border-border-subtle">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Edge Types
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted mb-3">
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Toggle visibility of relationship types
|
||||
</p>
|
||||
|
||||
@@ -439,21 +464,19 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<button
|
||||
key={edgeType}
|
||||
onClick={() => toggleEdgeVisibility(edgeType)}
|
||||
className={`
|
||||
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
|
||||
${isVisible
|
||||
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
|
||||
isVisible
|
||||
? 'bg-elevated text-text-primary'
|
||||
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-1.5 rounded-full ${isVisible ? '' : 'opacity-40'}`}
|
||||
className={`h-1.5 w-6 rounded-full ${isVisible ? '' : 'opacity-40'}`}
|
||||
style={{ backgroundColor: info.color }}
|
||||
/>
|
||||
<span className="text-xs flex-1">{info.label}</span>
|
||||
<span className="flex-1 text-xs">{info.label}</span>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -462,12 +485,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
|
||||
{/* Depth Filter */}
|
||||
<div className="mt-6 pt-4 border-t border-border-subtle">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
||||
<Target className="w-3 h-3 inline mr-1.5" />
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
<Target className="mr-1.5 inline h-3 w-3" />
|
||||
Focus Depth
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted mb-3">
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Show nodes within N hops of selection
|
||||
</p>
|
||||
|
||||
@@ -482,13 +505,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setDepthFilter(value)}
|
||||
className={`
|
||||
px-2 py-1 text-xs rounded transition-colors
|
||||
${depthFilter === value
|
||||
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||
depthFilter === value
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -496,22 +517,33 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
|
||||
{depthFilter !== null && !selectedNode && (
|
||||
<p className="mt-2 text-[10px] text-amber-400">
|
||||
Select a node to apply depth filter
|
||||
</p>
|
||||
<p className="mt-2 text-[10px] text-amber-400">Select a node to apply depth filter</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 pt-4 border-t border-border-subtle">
|
||||
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-3 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Color Legend
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Function', 'Method', 'Variable', 'Decorator'] as NodeLabel[]).map(label => (
|
||||
{(
|
||||
[
|
||||
'Folder',
|
||||
'File',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Decorator',
|
||||
] as NodeLabel[]
|
||||
).map((label) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: NODE_COLORS[label] }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted">{label}</span>
|
||||
@@ -524,7 +556,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
|
||||
{/* Stats footer */}
|
||||
{graph && (
|
||||
<div className="px-3 py-2 border-t border-border-subtle bg-elevated/50">
|
||||
<div className="border-t border-border-subtle bg-elevated/50 px-3 py-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
||||
<span>{graph.nodes.length} nodes</span>
|
||||
<span>{graph.relationships.length} edges</span>
|
||||
@@ -534,4 +566,3 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from '@/lib/lucide-icons';
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Focus,
|
||||
RotateCcw,
|
||||
Play,
|
||||
Pause,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useSigma } from '../hooks/useSigma';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
|
||||
import type { GraphNode } from '../core/graph/types';
|
||||
import {
|
||||
knowledgeGraphToGraphology,
|
||||
filterGraphByDepth,
|
||||
SigmaNodeAttributes,
|
||||
SigmaEdgeAttributes,
|
||||
} from '../lib/graph-adapter';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { QueryFAB } from './QueryFAB';
|
||||
import Graph from 'graphology';
|
||||
|
||||
@@ -41,7 +56,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
for (const id of aiToolHighlightedNodeIds) next.add(id);
|
||||
// Note: blast radius nodes are handled separately with red color
|
||||
return next;
|
||||
}, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]);
|
||||
}, [
|
||||
highlightedNodeIds,
|
||||
aiCitationHighlightedNodeIds,
|
||||
aiToolHighlightedNodeIds,
|
||||
isAIHighlightsEnabled,
|
||||
]);
|
||||
|
||||
// Blast radius nodes (only when AI highlights enabled)
|
||||
const effectiveBlastRadiusNodeIds = useMemo(() => {
|
||||
@@ -57,26 +77,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
if (!graph) return new Map<string, GraphNode>();
|
||||
return new Map(graph.nodes.map(n => [n.id, n]));
|
||||
return new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
}, [graph]);
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
if (!graph) return;
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
}, [graph, nodeById, setSelectedNode, openCodePanel]);
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!graph) return;
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
},
|
||||
[graph, nodeById, setSelectedNode, openCodePanel],
|
||||
);
|
||||
|
||||
const handleNodeHover = useCallback((nodeId: string | null) => {
|
||||
if (!nodeId || !graph) {
|
||||
setHoveredNodeName(null);
|
||||
return;
|
||||
}
|
||||
const node = nodeById.get(nodeId);
|
||||
setHoveredNodeName(node ? node.properties.name : null);
|
||||
}, [graph, nodeById]);
|
||||
const handleNodeHover = useCallback(
|
||||
(nodeId: string | null) => {
|
||||
if (!nodeId || !graph) {
|
||||
setHoveredNodeName(null);
|
||||
return;
|
||||
}
|
||||
const node = nodeById.get(nodeId);
|
||||
setHoveredNodeName(node ? node.properties.name : null);
|
||||
},
|
||||
[graph, nodeById],
|
||||
);
|
||||
|
||||
const handleStageClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
@@ -91,7 +117,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
setSigmaSelectedNode(null);
|
||||
}
|
||||
toggleAIHighlights();
|
||||
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]);
|
||||
}, [
|
||||
isAIHighlightsEnabled,
|
||||
clearAIToolHighlights,
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
setSelectedNode,
|
||||
toggleAIHighlights,
|
||||
]);
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
@@ -117,19 +150,23 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
});
|
||||
|
||||
// Expose focusNode to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusNode: (nodeId: string) => {
|
||||
// Also update app state so the selection syncs properly
|
||||
if (graph) {
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusNode: (nodeId: string) => {
|
||||
// Also update app state so the selection syncs properly
|
||||
if (graph) {
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
openCodePanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
focusNode(nodeId);
|
||||
}
|
||||
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]);
|
||||
focusNode(nodeId);
|
||||
},
|
||||
}),
|
||||
[focusNode, graph, nodeById, setSelectedNode, openCodePanel],
|
||||
);
|
||||
|
||||
// Update Sigma graph when KnowledgeGraph changes
|
||||
useEffect(() => {
|
||||
@@ -138,7 +175,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
// Build communityMemberships map from MEMBER_OF relationships
|
||||
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
|
||||
const communityMemberships = new Map<string, number>();
|
||||
graph.relationships.forEach(rel => {
|
||||
graph.relationships.forEach((rel) => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
// Find the community node to get its index
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
@@ -165,7 +202,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
|
||||
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
|
||||
sigma.refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
|
||||
}, [visibleLabels, depthFilter, appSelectedNode]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
@@ -192,16 +229,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-void">
|
||||
<div className="relative h-full w-full bg-void">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%),
|
||||
linear-gradient(to bottom, #06060a, #0a0a10)
|
||||
`
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -209,29 +246,27 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
{/* Sigma container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="sigma-container w-full h-full cursor-grab active:cursor-grabbing"
|
||||
className="sigma-container h-full w-full cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
|
||||
{/* Hovered node tooltip - only show when NOT selected */}
|
||||
{hoveredNodeName && !sigmaSelectedNode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-elevated/95 border border-border-subtle rounded-lg backdrop-blur-sm z-20 pointer-events-none animate-fade-in">
|
||||
<div className="pointer-events-none absolute top-4 left-1/2 z-20 -translate-x-1/2 animate-fade-in rounded-lg border border-border-subtle bg-elevated/95 px-3 py-1.5 backdrop-blur-sm">
|
||||
<span className="font-mono text-sm text-text-primary">{hoveredNodeName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection info bar */}
|
||||
{sigmaSelectedNode && appSelectedNode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-accent/20 border border-accent/30 rounded-xl backdrop-blur-sm z-20 animate-slide-up">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||
<div className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 animate-slide-up items-center gap-2 rounded-xl border border-accent/30 bg-accent/20 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
<span className="font-mono text-sm text-text-primary">
|
||||
{appSelectedNode.properties.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
({appSelectedNode.label})
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">({appSelectedNode.label})</span>
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="ml-2 px-2 py-0.5 text-xs text-text-secondary hover:text-text-primary hover:bg-white/10 rounded transition-colors"
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -239,40 +274,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
)}
|
||||
|
||||
{/* Graph Controls - Bottom Right */}
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-1 z-10">
|
||||
<div className="absolute right-4 bottom-4 z-10 flex flex-col gap-1">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Fit to Screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border-subtle my-1" />
|
||||
<div className="my-1 h-px bg-border-subtle" />
|
||||
|
||||
{/* Focus on selected */}
|
||||
{appSelectedNode && (
|
||||
<button
|
||||
onClick={handleFocusSelected}
|
||||
className="w-9 h-9 flex items-center justify-center bg-accent/20 border border-accent/30 rounded-md text-accent hover:bg-accent/30 transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-accent/30 bg-accent/20 text-accent transition-colors hover:bg-accent/30"
|
||||
title="Focus on Selected Node"
|
||||
>
|
||||
<Focus className="w-4 h-4" />
|
||||
<Focus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -280,41 +315,35 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
{sigmaSelectedNode && (
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Clear Selection"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border-subtle my-1" />
|
||||
<div className="my-1 h-px bg-border-subtle" />
|
||||
|
||||
{/* Layout control */}
|
||||
<button
|
||||
onClick={isLayoutRunning ? stopLayout : startLayout}
|
||||
className={`
|
||||
w-9 h-9 flex items-center justify-center border rounded-md transition-all
|
||||
${isLayoutRunning
|
||||
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
|
||||
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-md border transition-all ${
|
||||
isLayoutRunning
|
||||
? 'animate-pulse border-accent bg-accent text-white shadow-glow'
|
||||
: 'border-border-subtle bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
} `}
|
||||
title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'}
|
||||
>
|
||||
{isLayoutRunning ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
{isLayoutRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Layout running indicator */}
|
||||
{isLayoutRunning && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-full backdrop-blur-sm z-10 animate-fade-in">
|
||||
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-ping" />
|
||||
<span className="text-xs text-emerald-400 font-medium">Layout optimizing...</span>
|
||||
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/20 px-3 py-1.5 backdrop-blur-sm">
|
||||
<div className="h-2 w-2 animate-ping rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">Layout optimizing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -327,13 +356,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
onClick={handleToggleAIHighlights}
|
||||
className={
|
||||
isAIHighlightsEnabled
|
||||
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'
|
||||
: 'w-10 h-10 flex items-center justify-center bg-elevated border border-border-subtle rounded-lg text-text-muted hover:bg-hover hover:text-text-primary transition-colors'
|
||||
? 'flex h-10 w-10 items-center justify-center rounded-lg border border-cyan-400/40 bg-cyan-500/15 text-cyan-200 transition-colors hover:border-cyan-300/60 hover:bg-cyan-500/20'
|
||||
: 'flex h-10 w-10 items-center justify-center rounded-lg border border-border-subtle bg-elevated text-text-muted transition-colors hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
|
||||
data-testid="ai-highlights-toggle"
|
||||
>
|
||||
{isAIHighlightsEnabled ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||
{isAIHighlightsEnabled ? (
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
) : (
|
||||
<LightbulbOff className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from '@/lib/lucide-icons';
|
||||
import {
|
||||
Search,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
Github,
|
||||
Star,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { RepoSummary } from '../services/server-connection';
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { GraphNode } from '../core/graph/types';
|
||||
import {
|
||||
deleteRepo,
|
||||
fetchRepos,
|
||||
startAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
type BackendRepo,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { GraphNode } from 'gitnexus-shared';
|
||||
import { EmbeddingStatus } from './EmbeddingStatus';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
|
||||
// Color mapping for node types in search results
|
||||
const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -20,11 +40,21 @@ const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
|
||||
interface HeaderProps {
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
availableRepos?: RepoSummary[];
|
||||
availableRepos?: BackendRepo[];
|
||||
onSwitchRepo?: (repoName: string) => void;
|
||||
/** Called when a newly-analyzed repo is ready; triggers connectToServer. */
|
||||
onAnalyzeComplete?: (repoName: string) => void;
|
||||
/** Called after a repo is deleted or list needs refresh. */
|
||||
onReposChanged?: (repos: BackendRepo[]) => void;
|
||||
}
|
||||
|
||||
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => {
|
||||
export const Header = ({
|
||||
onFocusNode,
|
||||
availableRepos = [],
|
||||
onSwitchRepo,
|
||||
onAnalyzeComplete,
|
||||
onReposChanged,
|
||||
}: HeaderProps) => {
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
@@ -32,11 +62,15 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
setHelpDialogBoxOpen
|
||||
setHelpDialogBoxOpen,
|
||||
} = useAppState();
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const [showAnalyzer, setShowAnalyzer] = useState(false);
|
||||
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
|
||||
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
|
||||
const reanalyzeSseRef = useRef<AbortController | null>(null);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,11 +85,11 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return graph.nodes
|
||||
.filter(node => node.properties.name.toLowerCase().includes(query))
|
||||
.filter((node) => node.properties.name.toLowerCase().includes(query))
|
||||
.slice(0, 10); // Limit to 10 results
|
||||
}, [graph, searchQuery]);
|
||||
|
||||
// Handle clicking outside to close dropdowns
|
||||
// Handle clicking outside search or repo dropdown to close them
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
@@ -63,12 +97,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
}
|
||||
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
|
||||
setIsRepoDropdownOpen(false);
|
||||
setShowAnalyzer(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Cleanup re-analyze SSE on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
reanalyzeSseRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut (Cmd+K / Ctrl+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -92,10 +134,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.min(i + 1, searchResults.length - 1));
|
||||
setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selected = searchResults[selectedIndex];
|
||||
@@ -114,59 +156,215 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-5 py-3 bg-deep border-b border-dashed border-border-subtle">
|
||||
<header className="flex items-center justify-between border-b border-dashed border-border-subtle bg-deep px-5 py-3">
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-md shadow-glow text-white text-sm font-bold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-accent to-node-interface text-sm font-bold text-white shadow-glow">
|
||||
◇
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
|
||||
<span className="text-[15px] font-semibold tracking-tight">GitNexus</span>
|
||||
</div>
|
||||
|
||||
{/* Project badge / Repo selector dropdown */}
|
||||
{/* Project badge + repo dropdown */}
|
||||
{projectName && (
|
||||
<div className="relative" ref={repoDropdownRef}>
|
||||
<button
|
||||
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
|
||||
onClick={() => {
|
||||
setIsRepoDropdownOpen((prev) => !prev);
|
||||
setShowAnalyzer(false);
|
||||
}}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all ${
|
||||
isRepoDropdownOpen
|
||||
? 'border-accent/40 bg-accent/10 text-text-primary'
|
||||
: 'border-border-subtle bg-surface text-text-secondary hover:border-border-default hover:bg-hover'
|
||||
} `}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
{availableRepos.length >= 2 && (
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-node-function" />
|
||||
<span className="max-w-[160px] truncate">{projectName}</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Repo dropdown */}
|
||||
{isRepoDropdownOpen && availableRepos.length >= 2 && (
|
||||
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
|
||||
{availableRepos.map((repo) => {
|
||||
const isCurrent = repo.name === projectName;
|
||||
return (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => {
|
||||
if (!isCurrent && onSwitchRepo) {
|
||||
onSwitchRepo(repo.name);
|
||||
}
|
||||
{isRepoDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1.5 w-80 animate-slide-up overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
|
||||
{showAnalyzer ? (
|
||||
<div className="p-4">
|
||||
<RepoAnalyzer
|
||||
variant="sheet"
|
||||
onComplete={(repoName) => {
|
||||
setShowAnalyzer(false);
|
||||
setIsRepoDropdownOpen(false);
|
||||
onAnalyzeComplete?.(repoName);
|
||||
}}
|
||||
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
|
||||
{repo.name}
|
||||
onCancel={() => setShowAnalyzer(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Repo list */}
|
||||
{availableRepos.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Repositories
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{repo.stats?.nodes ?? '?'} nodes · {repo.stats?.files ?? '?'} files
|
||||
{availableRepos.map((repo) => (
|
||||
<div
|
||||
key={repo.name}
|
||||
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
|
||||
repo.name === projectName
|
||||
? 'border-l-2 border-accent bg-accent/10'
|
||||
: 'hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (repo.name !== projectName) onSwitchRepo?.(repo.name);
|
||||
setIsRepoDropdownOpen(false);
|
||||
}}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
|
||||
<span className="flex-1 truncate font-mono text-sm text-text-primary">
|
||||
{repo.name}
|
||||
</span>
|
||||
{repo.name === projectName && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-accent">
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Re-analyze */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (reanalyzing) return; // already running
|
||||
setReanalyzing(repo.name);
|
||||
setReanalyzeProgress({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Starting...',
|
||||
});
|
||||
try {
|
||||
const { jobId } = await startAnalyze({
|
||||
path: repo.path,
|
||||
force: true,
|
||||
});
|
||||
reanalyzeSseRef.current = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setReanalyzeProgress(p),
|
||||
() => {
|
||||
setReanalyzing(null);
|
||||
setReanalyzeProgress(null);
|
||||
reanalyzeSseRef.current = null;
|
||||
onAnalyzeComplete?.(repo.name);
|
||||
},
|
||||
(errMsg) => {
|
||||
console.error('Re-analyze failed:', errMsg);
|
||||
setReanalyzing(null);
|
||||
setReanalyzeProgress(null);
|
||||
reanalyzeSseRef.current = null;
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to start re-analysis:', err);
|
||||
setReanalyzing(null);
|
||||
setReanalyzeProgress(null);
|
||||
}
|
||||
}}
|
||||
disabled={!!reanalyzing}
|
||||
className={`cursor-pointer rounded p-1 transition-all ${
|
||||
reanalyzing === repo.name
|
||||
? 'text-accent'
|
||||
: 'text-text-muted/0 group-hover:text-text-muted hover:!text-accent'
|
||||
}`}
|
||||
title={
|
||||
reanalyzing === repo.name
|
||||
? 'Re-analyzing...'
|
||||
: `Re-analyze ${repo.name}`
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
// Abort any running re-analysis for this repo
|
||||
if (reanalyzing === repo.name) {
|
||||
reanalyzeSseRef.current?.abort();
|
||||
setReanalyzing(null);
|
||||
setReanalyzeProgress(null);
|
||||
reanalyzeSseRef.current = null;
|
||||
}
|
||||
try {
|
||||
await deleteRepo(repo.name);
|
||||
const updated = await fetchRepos();
|
||||
onReposChanged?.(updated);
|
||||
// If we deleted the active repo, switch to first available
|
||||
if (repo.name === projectName && updated.length > 0) {
|
||||
onSwitchRepo?.(updated[0].name);
|
||||
} else if (updated.length === 0) {
|
||||
// No repos left — go back to onboarding
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete repo:', err);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
|
||||
title={`Delete ${repo.name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Re-analyze progress bar */}
|
||||
{reanalyzing && reanalyzeProgress && (
|
||||
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${Math.max(2, reanalyzeProgress.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
|
||||
{/* Analyze new */}
|
||||
<div
|
||||
className={
|
||||
availableRepos.length > 0 || reanalyzing
|
||||
? 'border-t border-border-subtle'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAnalyzer(true)}
|
||||
disabled={!!reanalyzing}
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
Analyze a new repository...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,9 +372,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
</div>
|
||||
|
||||
{/* Center - Search */}
|
||||
<div className="flex-1 max-w-md mx-6 relative" ref={searchRef}>
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2 bg-surface border border-border-subtle rounded-lg transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<Search className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
<div className="relative mx-6 max-w-md flex-1" ref={searchRef}>
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-border-subtle bg-surface px-3.5 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<Search className="h-4 w-4 flex-shrink-0 text-text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -189,19 +387,19 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
}}
|
||||
onFocus={() => setIsSearchOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
|
||||
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
<kbd className="px-1.5 py-0.5 bg-elevated border border-border-subtle rounded text-[10px] text-text-muted font-mono">
|
||||
<kbd className="rounded border border-border-subtle bg-elevated px-1.5 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{isSearchOpen && searchQuery.trim() && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
No nodes found for "{searchQuery}"
|
||||
No nodes found for “{searchQuery}”
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
@@ -209,22 +407,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleSelectNode(node)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${index === selectedIndex
|
||||
? 'bg-accent/20 text-text-primary'
|
||||
: 'hover:bg-hover text-text-secondary'
|
||||
}`}
|
||||
className={`flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-accent/20 text-text-primary'
|
||||
: 'text-text-secondary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Node type indicator */}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: NODE_TYPE_COLORS[node.label] || '#6b7280' }}
|
||||
/>
|
||||
{/* Node name */}
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{node.properties.name}
|
||||
</span>
|
||||
{/* Node type badge */}
|
||||
<span className="text-xs text-text-muted px-2 py-0.5 bg-elevated rounded">
|
||||
<span className="rounded bg-elevated px-2 py-0.5 text-xs text-text-muted">
|
||||
{node.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -242,17 +438,17 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
href="https://github.com/abhigyanpatwari/GitNexus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3.5 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 group"
|
||||
className="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-pink-600 px-3.5 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:from-purple-500 hover:to-pink-500 hover:shadow-xl"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Star if cool</span>
|
||||
<Star className="w-3.5 h-3.5 group-hover:fill-yellow-300 group-hover:text-yellow-300 transition-all" />
|
||||
<Star className="h-3.5 w-3.5 transition-all group-hover:fill-yellow-300 group-hover:text-yellow-300" />
|
||||
<span className="hidden sm:inline">✨</span>
|
||||
</a>
|
||||
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
<div className="flex items-center gap-4 mr-2 text-xs text-text-muted">
|
||||
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
|
||||
<span>{nodeCount} nodes</span>
|
||||
<span>{edgeCount} edges</span>
|
||||
</div>
|
||||
@@ -264,34 +460,32 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
|
||||
{/* Icon buttons */}
|
||||
<button
|
||||
onClick={() => setSettingsPanelOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="AI Settings"
|
||||
>
|
||||
<Settings className="w-4.5 h-4.5" />
|
||||
<Settings className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<button
|
||||
title="Help"
|
||||
onClick={() => setHelpDialogBoxOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
|
||||
<HelpCircle className="w-4.5 h-4.5" />
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<HelpCircle className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
|
||||
{/* AI Button */}
|
||||
<button
|
||||
onClick={openChatPanel}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isRightPanelOpen && rightPanelTab === 'chat'
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium transition-all ${
|
||||
isRightPanelOpen && rightPanelTab === 'chat'
|
||||
? 'bg-accent text-white shadow-glow'
|
||||
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:shadow-lg hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:-translate-y-0.5 hover:shadow-lg'
|
||||
} `}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Nexus AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { PipelineProgress } from '../types/pipeline';
|
||||
import type { PipelineProgress } from 'gitnexus-shared';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
progress: PipelineProgress;
|
||||
@@ -6,24 +6,24 @@ interface LoadingOverlayProps {
|
||||
|
||||
export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center bg-void z-50">
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-void">
|
||||
{/* Background gradient effects */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-accent/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute top-1/3 left-1/3 h-96 w-96 animate-pulse rounded-full bg-accent/10 blur-3xl" />
|
||||
<div className="absolute right-1/3 bottom-1/3 h-96 w-96 animate-pulse rounded-full bg-node-interface/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Pulsing orb */}
|
||||
<div className="relative mb-10">
|
||||
<div className="w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full animate-pulse-glow" />
|
||||
<div className="absolute inset-0 w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full blur-xl opacity-50" />
|
||||
<div className="h-28 w-28 animate-pulse-glow rounded-full bg-gradient-to-br from-accent to-node-interface" />
|
||||
<div className="absolute inset-0 h-28 w-28 rounded-full bg-gradient-to-br from-accent to-node-interface opacity-50 blur-xl" />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-80 mb-4">
|
||||
<div className="h-1.5 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300 ease-out"
|
||||
<div className="mb-4 w-80">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -31,14 +31,12 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
|
||||
{/* Status text */}
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-sm text-text-secondary mb-1">
|
||||
<p className="mb-1 font-mono text-sm text-text-secondary">
|
||||
{progress.message}
|
||||
<span className="animate-pulse">|</span>
|
||||
</p>
|
||||
{progress.detail && (
|
||||
<p className="font-mono text-xs text-text-muted truncate max-w-md">
|
||||
{progress.detail}
|
||||
</p>
|
||||
<p className="max-w-md truncate font-mono text-xs text-text-muted">{progress.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,21 +44,20 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
{progress.stats && (
|
||||
<div className="mt-8 flex items-center gap-6 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-node-file rounded-full" />
|
||||
<span>{progress.stats.filesProcessed} / {progress.stats.totalFiles} files</span>
|
||||
<span className="h-2 w-2 rounded-full bg-node-file" />
|
||||
<span>
|
||||
{progress.stats.filesProcessed} / {progress.stats.totalFiles} files
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-node-function rounded-full" />
|
||||
<span className="h-2 w-2 rounded-full bg-node-function" />
|
||||
<span>{progress.stats.nodesCreated} nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percent */}
|
||||
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">
|
||||
{progress.percent}%
|
||||
</p>
|
||||
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">{progress.percent}%</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,213 +9,222 @@ import { Copy, Check } from '@/lib/lucide-icons';
|
||||
|
||||
// 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',
|
||||
},
|
||||
...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
|
||||
showCopyButton?: boolean;
|
||||
content: string;
|
||||
onLinkClick?: (href: string) => void;
|
||||
toolCalls?: any[]; // Keep flexible for now
|
||||
showCopyButton?: boolean;
|
||||
}
|
||||
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
onLinkClick,
|
||||
toolCalls,
|
||||
showCopyButton = false
|
||||
content,
|
||||
onLinkClick,
|
||||
toolCalls,
|
||||
showCopyButton = false,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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})`;
|
||||
}
|
||||
);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
|
||||
e.preventDefault();
|
||||
onLinkClick?.(href);
|
||||
}
|
||||
// External links open in new tab (default behavior)
|
||||
}, [onLinkClick]);
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={hrefStr}
|
||||
onClick={(e) => handleLinkClick(e, hrefStr)}
|
||||
className={`${baseParams} ${colorParams}`}
|
||||
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-inherit">{children}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// External links
|
||||
return (
|
||||
<a
|
||||
href={hrefStr}
|
||||
className="text-accent underline underline-offset-2 hover:text-purple-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
// 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})`;
|
||||
},
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !className && !match;
|
||||
const codeContent = String(children).replace(/\n$/, '');
|
||||
);
|
||||
|
||||
if (isInline) {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
const language = match ? match[1] : 'text';
|
||||
|
||||
// Render Mermaid diagrams
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidDiagram code={codeContent} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={customTheme}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '14px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
background: '#0a0a10',
|
||||
border: '1px solid #1e1e2a',
|
||||
}}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
// 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})`;
|
||||
},
|
||||
pre: ({ children }: any) => <>{children}</>,
|
||||
}), [handleLinkClick]);
|
||||
);
|
||||
}
|
||||
return parts.join('```');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-text-primary text-sm">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
|
||||
// Default behavior for http/https/etc
|
||||
return url;
|
||||
}}
|
||||
components={markdownComponents}
|
||||
const handleLinkClick = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
|
||||
e.preventDefault();
|
||||
onLinkClick?.(href);
|
||||
}
|
||||
// External links open in new tab (default behavior)
|
||||
},
|
||||
[onLinkClick],
|
||||
);
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={hrefStr}
|
||||
onClick={(e) => handleLinkClick(e, hrefStr)}
|
||||
className={`${baseParams} ${colorParams}`}
|
||||
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
|
||||
{...props}
|
||||
>
|
||||
{formattedContent}
|
||||
</ReactMarkdown>
|
||||
<span className="text-inherit">{children}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Copy Button */}
|
||||
{showCopyButton && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-muted hover:text-text-primary hover:bg-surface border border-transparent hover:border-border-subtle rounded transition-all"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
// External links
|
||||
return (
|
||||
<a
|
||||
href={hrefStr}
|
||||
className="text-accent underline underline-offset-2 hover:text-purple-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !className && !match;
|
||||
const codeContent = String(children).replace(/\n$/, '');
|
||||
|
||||
{/* Tool Call Cards appended at the bottom if provided */}
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{toolCalls.map(tc => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
|
||||
))}
|
||||
</div>
|
||||
if (isInline) {
|
||||
return <code {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
const language = match ? match[1] : 'text';
|
||||
|
||||
// Render Mermaid diagrams
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidDiagram code={codeContent} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={customTheme}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '14px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
background: '#0a0a10',
|
||||
border: '1px solid #1e1e2a',
|
||||
}}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
pre: ({ children }: any) => <>{children}</>,
|
||||
}),
|
||||
[handleLinkClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-sm text-text-primary">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
|
||||
// Default behavior for http/https/etc
|
||||
return url;
|
||||
}}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{formattedContent}
|
||||
</ReactMarkdown>
|
||||
|
||||
{/* Copy Button */}
|
||||
{showCopyButton && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 rounded border border-transparent px-2 py-1 text-xs text-text-muted transition-all hover:border-border-subtle hover:bg-surface hover:text-text-primary"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
{/* Tool Call Cards appended at the bottom if provided */}
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useRef, useState, lazy } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { AlertTriangle, Maximize2 } from '@/lib/lucide-icons';
|
||||
import type { ProcessData } from '../lib/mermaid-generator';
|
||||
|
||||
const ProcessFlowModal = lazy(() =>
|
||||
import('./ProcessFlowModal').then((m) => ({ default: m.ProcessFlowModal })),
|
||||
@@ -71,11 +70,14 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
|
||||
// Render the diagram
|
||||
const { svg: renderedSvg } = await mermaid.render(id, code.trim());
|
||||
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
|
||||
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
});
|
||||
setSvg(sanitizedSvg);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Silent catch for streaming:
|
||||
// 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)
|
||||
@@ -93,29 +95,31 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
}, [code]);
|
||||
|
||||
// Create a pseudo ProcessData for the modal (with custom rawMermaid property)
|
||||
const processData: any = showModal ? {
|
||||
id: 'ai-generated',
|
||||
label: 'AI Generated Diagram',
|
||||
processType: 'intra_community',
|
||||
steps: [], // Empty - we'll render raw mermaid
|
||||
edges: [],
|
||||
clusters: [],
|
||||
rawMermaid: code, // Pass raw mermaid code
|
||||
} : null;
|
||||
const processData: any = showModal
|
||||
? {
|
||||
id: 'ai-generated',
|
||||
label: 'AI Generated Diagram',
|
||||
processType: 'intra_community',
|
||||
steps: [], // Empty - we'll render raw mermaid
|
||||
edges: [],
|
||||
clusters: [],
|
||||
rawMermaid: code, // Pass raw mermaid code
|
||||
}
|
||||
: null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="my-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-rose-300 text-sm mb-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<div className="my-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-rose-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-medium">Diagram Error</span>
|
||||
</div>
|
||||
<pre className="text-xs text-rose-200/70 font-mono whitespace-pre-wrap">{error}</pre>
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-rose-200/70">{error}</pre>
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary">
|
||||
<summary className="cursor-pointer text-xs text-text-muted hover:text-text-secondary">
|
||||
Show source
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-surface rounded text-xs text-text-muted overflow-x-auto">
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-surface p-2 text-xs text-text-muted">
|
||||
{code}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -125,42 +129,40 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-3 relative group">
|
||||
<div className="relative bg-gradient-to-b from-surface to-elevated border border-border-subtle rounded-xl overflow-hidden">
|
||||
<div className="group relative my-3">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border-subtle bg-gradient-to-b from-surface to-elevated">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle">
|
||||
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-surface/60 px-3 py-2">
|
||||
<span className="text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Diagram
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Expand"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Diagram container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }) }}
|
||||
className="flex max-h-[400px] items-center justify-center overflow-auto p-4"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use ProcessFlowModal for expansion */}
|
||||
{showModal && processData && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="p-4 text-sm text-text-muted">Loading diagram…</div>
|
||||
}
|
||||
>
|
||||
<ProcessFlowModal
|
||||
process={processData}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
<Suspense fallback={<div className="p-4 text-sm text-text-muted">Loading diagram…</div>}>
|
||||
<ProcessFlowModal process={processData} onClose={() => setShowModal(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Check, Copy, Terminal, Server, Zap, Sparkles } from '@/lib/lucide-icons';
|
||||
import { REQUIRED_NODE_VERSION } from '../config/ui-constants';
|
||||
|
||||
// ── Design constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// ── Copy-to-clipboard button ─────────────────────────────────────────────────
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API requires secure context; localhost qualifies
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 transition-all duration-200 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none ${
|
||||
copied
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
: 'text-text-muted hover:bg-white/5 hover:text-text-primary'
|
||||
} `}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Faux terminal window ─────────────────────────────────────────────────────
|
||||
|
||||
function TerminalWindow({
|
||||
command,
|
||||
label,
|
||||
isActive = false,
|
||||
}: {
|
||||
command: string;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-xl border transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-accent/40 shadow-glow-soft'
|
||||
: 'border-border-default hover:border-accent/20 hover:shadow-glow-soft'
|
||||
} `}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-2 border-b border-border-subtle bg-deep px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/60" />
|
||||
</div>
|
||||
<span className="flex-1 text-center font-mono text-[11px] text-text-muted">{label}</span>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
{/* Command body */}
|
||||
<div className="flex items-center gap-3 bg-void px-4 py-3.5 font-mono text-sm">
|
||||
<span className="text-accent/60 select-none" aria-hidden="true">
|
||||
$
|
||||
</span>
|
||||
<code className="flex-1 overflow-x-auto tracking-wide whitespace-nowrap text-text-primary">
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
type StepState = 'waiting' | 'active' | 'done';
|
||||
|
||||
function StepDot({ state, number }: { state: StepState; number: number }) {
|
||||
if (state === 'done') {
|
||||
return (
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-500/50 bg-emerald-500/20">
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === 'active') {
|
||||
return (
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className="absolute inset-0 animate-ping rounded-full border border-accent/30" />
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-accent/60 bg-accent/20">
|
||||
<span className="text-[10px] leading-none font-semibold text-accent">{number}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border-subtle bg-elevated">
|
||||
<span className="text-[10px] leading-none font-semibold text-text-muted">{number}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
state,
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
state: StepState;
|
||||
number: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const isVisible = state !== 'waiting';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transition-all duration-300 ${state === 'waiting' ? 'opacity-40' : 'opacity-100'} `}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<StepDot state={state} number={number} />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-sm font-medium transition-colors duration-200 ${
|
||||
state === 'done'
|
||||
? 'text-emerald-400'
|
||||
: state === 'active'
|
||||
? 'text-text-primary'
|
||||
: 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{state === 'done' && (
|
||||
<span className="animate-fade-in font-mono text-[10px] tracking-wider text-emerald-400/60 uppercase">
|
||||
done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-text-muted">{description}</p>
|
||||
)}
|
||||
{isVisible && children && <div className="mt-3 animate-slide-up">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Polling status bar ────────────────────────────────────────────────────────
|
||||
|
||||
function PollingBar() {
|
||||
return (
|
||||
<div
|
||||
className="flex animate-fade-in items-center gap-3 rounded-xl border border-accent/15 bg-accent/5 px-4 py-3"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Zap className="h-4 w-4 text-accent/70" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-5 w-5 animate-pulse rounded-full border border-accent/25" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
Listening for server
|
||||
<span className="ml-0.5 inline-flex text-text-muted">
|
||||
<span className="animate-pulse">...</span>
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">Will auto-connect when detected</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── OnboardingGuide ───────────────────────────────────────────────────────────
|
||||
|
||||
interface OnboardingGuideProps {
|
||||
isPolling?: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
const primary = isDev ? 'npm run --prefix gitnexus serve' : 'npx gitnexus@latest serve';
|
||||
const termLabel = isDev ? 'Start backend' : 'Terminal';
|
||||
|
||||
// Step states: step 1 = copy command, step 2 = run/wait, step 3 = auto-connect
|
||||
// Once polling starts the user has presumably run the command — mark step 1 done.
|
||||
const step1State: StepState = isPolling ? 'done' : 'active';
|
||||
const step2State: StepState = isPolling ? 'active' : 'waiting';
|
||||
const step3State: StepState = 'waiting';
|
||||
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient background glows */}
|
||||
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
|
||||
|
||||
{/* ── Headline ─────────────────────────────────────────────── */}
|
||||
<div className="relative mb-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
|
||||
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
|
||||
GitNexus
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Start your local server
|
||||
</h2>
|
||||
<p className="mx-auto mt-1 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
{isDev
|
||||
? 'Fire up the Express backend in a separate terminal to unlock the full graph.'
|
||||
: 'One command is all it takes. The browser connects automatically.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Step-by-step flow ───────────────────────────────────────── */}
|
||||
<div className="relative space-y-5">
|
||||
{/* Vertical connector line behind the dots */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-6 bottom-6 left-[11px] w-px bg-border-subtle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Step 1 — Copy the command */}
|
||||
<StepRow
|
||||
state={step1State}
|
||||
number={1}
|
||||
title="Copy the command"
|
||||
description={isPolling ? undefined : 'Click the icon in the terminal to copy.'}
|
||||
>
|
||||
<TerminalWindow command={primary} label={termLabel} isActive={step1State === 'active'} />
|
||||
|
||||
{/* Secondary global-install option — production only */}
|
||||
{!isDev && (
|
||||
<>
|
||||
<div className="my-3 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or install globally
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
<TerminalWindow
|
||||
command="npm install -g gitnexus && gitnexus serve"
|
||||
label="Global install"
|
||||
isActive={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StepRow>
|
||||
|
||||
{/* Step 2 — Run and wait */}
|
||||
<StepRow
|
||||
state={step2State}
|
||||
number={2}
|
||||
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
|
||||
description={
|
||||
isPolling ? undefined : 'Open a terminal at the project root, paste, and hit Enter.'
|
||||
}
|
||||
>
|
||||
{isPolling && <PollingBar />}
|
||||
</StepRow>
|
||||
|
||||
{/* Step 3 — Auto-connect */}
|
||||
<StepRow
|
||||
state={step3State}
|
||||
number={3}
|
||||
title="Auto-connects and opens the graph"
|
||||
description="No refresh needed — the page detects the server automatically."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Prerequisite footnote ────────────────────────────────────── */}
|
||||
<div className="mt-6 flex items-center justify-center gap-1.5 border-t border-border-subtle pt-5 text-xs text-text-muted">
|
||||
<Server className="h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
Requires{' '}
|
||||
<a
|
||||
href="https://nodejs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent transition-colors hover:text-accent/80 hover:underline"
|
||||
>
|
||||
Node.js {REQUIRED_NODE_VERSION}+
|
||||
</a>
|
||||
</span>
|
||||
<span className="mx-1 text-border-default">·</span>
|
||||
<Terminal className="h-3 w-3 shrink-0" />
|
||||
<span>Port 4747</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,305 +1,322 @@
|
||||
/**
|
||||
* Process Flow Modal
|
||||
*
|
||||
*
|
||||
* Displays a Mermaid flowchart for a process in a centered modal popup.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { Copy, Focus, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
|
||||
|
||||
interface ProcessFlowModalProps {
|
||||
process: ProcessData | null;
|
||||
onClose: () => void;
|
||||
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
|
||||
isFullScreen?: boolean;
|
||||
process: ProcessData | null;
|
||||
onClose: () => void;
|
||||
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
// Initialize mermaid with cyan/purple theme matching GitNexus
|
||||
// Initialize mermaid with cyan/purple theme matching GitNexus
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true, // Try to suppress if supported
|
||||
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
|
||||
theme: 'base',
|
||||
themeVariables: {
|
||||
primaryColor: '#1e293b', // node bg
|
||||
primaryTextColor: '#f1f5f9',
|
||||
primaryBorderColor: '#22d3ee',
|
||||
lineColor: '#94a3b8',
|
||||
secondaryColor: '#1e293b',
|
||||
tertiaryColor: '#0f172a',
|
||||
mainBkg: '#1e293b', // background
|
||||
nodeBorder: '#22d3ee',
|
||||
clusterBkg: '#1e293b',
|
||||
clusterBorder: '#475569',
|
||||
titleColor: '#f1f5f9',
|
||||
edgeLabelBackground: '#0f172a',
|
||||
},
|
||||
flowchart: {
|
||||
curve: 'basis',
|
||||
padding: 50,
|
||||
nodeSpacing: 120,
|
||||
rankSpacing: 140,
|
||||
htmlLabels: true,
|
||||
},
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true, // Try to suppress if supported
|
||||
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
|
||||
theme: 'base',
|
||||
themeVariables: {
|
||||
primaryColor: '#1e293b', // node bg
|
||||
primaryTextColor: '#f1f5f9',
|
||||
primaryBorderColor: '#22d3ee',
|
||||
lineColor: '#94a3b8',
|
||||
secondaryColor: '#1e293b',
|
||||
tertiaryColor: '#0f172a',
|
||||
mainBkg: '#1e293b', // background
|
||||
nodeBorder: '#22d3ee',
|
||||
clusterBkg: '#1e293b',
|
||||
clusterBorder: '#475569',
|
||||
titleColor: '#f1f5f9',
|
||||
edgeLabelBackground: '#0f172a',
|
||||
},
|
||||
flowchart: {
|
||||
curve: 'basis',
|
||||
padding: 50,
|
||||
nodeSpacing: 120,
|
||||
rankSpacing: 140,
|
||||
htmlLabels: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Suppress distinct syntax error overlay
|
||||
mermaid.parseError = (err) => {
|
||||
// Suppress visual error - we handle errors in the render try/catch
|
||||
console.debug('Mermaid parse error (suppressed):', err);
|
||||
// Suppress visual error - we handle errors in the render try/catch
|
||||
console.debug('Mermaid parse error (suppressed):', err);
|
||||
};
|
||||
|
||||
export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false }: ProcessFlowModalProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Full process map gets higher default zoom (667%) and max zoom (3000%)
|
||||
const defaultZoom = isFullScreen ? 6.67 : 1;
|
||||
const maxZoom = isFullScreen ? 30 : 10;
|
||||
|
||||
const [zoom, setZoom] = useState(defaultZoom);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Reset zoom when switching between full screen and regular mode
|
||||
useEffect(() => {
|
||||
setZoom(defaultZoom);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, [isFullScreen, defaultZoom]);
|
||||
export const ProcessFlowModal = ({
|
||||
process,
|
||||
onClose,
|
||||
onFocusInGraph,
|
||||
isFullScreen = false,
|
||||
}: ProcessFlowModalProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle zoom with scroll wheel
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY * -0.001;
|
||||
setZoom(prev => Math.min(Math.max(0.1, prev + delta), maxZoom));
|
||||
};
|
||||
// Full process map gets higher default zoom (667%) and max zoom (3000%)
|
||||
const defaultZoom = isFullScreen ? 6.67 : 1;
|
||||
const maxZoom = isFullScreen ? 30 : 10;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
|
||||
const [zoom, setZoom] = useState(defaultZoom);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Handle keyboard zoom
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
setZoom(prev => Math.min(prev + 0.2, maxZoom));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
setZoom(prev => Math.max(prev - 0.2, 0.1));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [maxZoom]);
|
||||
// Reset zoom when switching between full screen and regular mode
|
||||
useEffect(() => {
|
||||
setZoom(defaultZoom);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, [isFullScreen, defaultZoom]);
|
||||
|
||||
// Zoom in/out handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom(prev => Math.min(prev + 0.25, maxZoom));
|
||||
}, [maxZoom]);
|
||||
// Handle zoom with scroll wheel
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY * -0.001;
|
||||
setZoom((prev) => Math.min(Math.max(0.1, prev + delta), maxZoom));
|
||||
};
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom(prev => Math.max(prev - 0.25, 0.1));
|
||||
}, []);
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
|
||||
|
||||
// Handle pan with mouse drag
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}, [pan]);
|
||||
// Handle keyboard zoom
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
setZoom((prev) => Math.min(prev + 0.2, maxZoom));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
setZoom((prev) => Math.max(prev - 0.2, 0.1));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [maxZoom]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
|
||||
}, [isPanning, panStart]);
|
||||
// Zoom in/out handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((prev) => Math.min(prev + 0.25, maxZoom));
|
||||
}, [maxZoom]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((prev) => Math.max(prev - 0.25, 0.1));
|
||||
}, []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setZoom(defaultZoom);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, [defaultZoom]);
|
||||
// Handle pan with mouse drag
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
},
|
||||
[pan],
|
||||
);
|
||||
|
||||
// Render mermaid diagram
|
||||
useEffect(() => {
|
||||
if (!process || !diagramRef.current) return;
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
|
||||
},
|
||||
[isPanning, panStart],
|
||||
);
|
||||
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
// Check if we have raw mermaid code (from AI chat) or need to generate it
|
||||
const mermaidCode = process.rawMermaid
|
||||
? process.rawMermaid
|
||||
: generateProcessMermaid(process);
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Clear previous content
|
||||
diagramRef.current!.innerHTML = '';
|
||||
const resetView = useCallback(() => {
|
||||
setZoom(defaultZoom);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, [defaultZoom]);
|
||||
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
if (!diagramRef.current) return;
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
|
||||
} catch (error) {
|
||||
console.error('Mermaid render error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
|
||||
// Render mermaid diagram
|
||||
useEffect(() => {
|
||||
if (!process || !diagramRef.current) return;
|
||||
|
||||
diagramRef.current!.innerHTML = `
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
// Check if we have raw mermaid code (from AI chat) or need to generate it
|
||||
const mermaidCode = process.rawMermaid
|
||||
? process.rawMermaid
|
||||
: generateProcessMermaid(process);
|
||||
const id = `mermaid-${Date.now()}`;
|
||||
|
||||
// Clear previous content
|
||||
diagramRef.current!.innerHTML = '';
|
||||
|
||||
const { svg } = await mermaid.render(id, mermaidCode);
|
||||
if (!diagramRef.current) return;
|
||||
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Mermaid render error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
|
||||
|
||||
diagramRef.current!.innerHTML = `
|
||||
<div class="text-center p-8">
|
||||
<div class="text-red-400 text-sm font-medium mb-2">
|
||||
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
|
||||
</div>
|
||||
<div class="text-slate-400 text-xs max-w-md">
|
||||
${isSizeError
|
||||
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
|
||||
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
|
||||
}
|
||||
${
|
||||
isSizeError
|
||||
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
|
||||
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [process]);
|
||||
renderDiagram();
|
||||
}, [process]);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onClose]);
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Copy mermaid code to clipboard
|
||||
const handleCopyMermaid = useCallback(async () => {
|
||||
if (!process) return;
|
||||
const mermaidCode = generateProcessMermaid(process);
|
||||
await navigator.clipboard.writeText(mermaidCode);
|
||||
}, [process]);
|
||||
|
||||
// Focus in graph
|
||||
const handleFocusInGraph = useCallback(() => {
|
||||
if (!process || !onFocusInGraph) return;
|
||||
const nodeIds = process.steps.map(s => s.id);
|
||||
onFocusInGraph(nodeIds, process.id);
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current) {
|
||||
onClose();
|
||||
}, [process, onFocusInGraph, onClose]);
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!process) return null;
|
||||
// Copy mermaid code to clipboard
|
||||
const handleCopyMermaid = useCallback(async () => {
|
||||
if (!process) return;
|
||||
const mermaidCode = generateProcessMermaid(process);
|
||||
await navigator.clipboard.writeText(mermaidCode);
|
||||
}, [process]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="process-modal"
|
||||
>
|
||||
{/* Glassmorphism Modal */}
|
||||
<div className={`bg-slate-900/60 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl shadow-cyan-500/10 flex flex-col animate-scale-in overflow-hidden relative ${isFullScreen
|
||||
? 'w-[98%] h-[95vh] max-w-none'
|
||||
: 'w-[95%] max-w-5xl max-h-[90vh]'
|
||||
}`}>
|
||||
{/* Subtle gradient overlay for extra glass feel */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
|
||||
// Focus in graph
|
||||
const handleFocusInGraph = useCallback(() => {
|
||||
if (!process || !onFocusInGraph) return;
|
||||
const nodeIds = process.steps.map((s) => s.id);
|
||||
onFocusInGraph(nodeIds, process.id);
|
||||
onClose();
|
||||
}, [process, onFocusInGraph, onClose]);
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-white/10 relative z-10">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Process: {process.label}
|
||||
</h2>
|
||||
</div>
|
||||
if (!process) return null;
|
||||
|
||||
{/* Diagram */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`flex-1 p-8 flex items-center justify-center relative z-10 overflow-hidden ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="[&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400 transition-transform origin-center w-fit h-fit"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-black/20"
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="process-modal"
|
||||
>
|
||||
{/* Glassmorphism Modal */}
|
||||
<div
|
||||
className={`animate-scale-in relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-slate-900/60 shadow-2xl shadow-cyan-500/10 backdrop-blur-2xl ${
|
||||
isFullScreen ? 'h-[95vh] w-[98%] max-w-none' : 'max-h-[90vh] w-[95%] max-w-5xl'
|
||||
}`}
|
||||
>
|
||||
{/* Subtle gradient overlay for extra glass feel */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-center gap-3 px-6 py-4 border-t border-white/10 bg-slate-900/50 relative z-10">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1 bg-white/5 border border-white/10 rounded-lg p-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
|
||||
title="Zoom out (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-2 text-xs text-slate-400 font-mono min-w-[3rem] text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
|
||||
title="Zoom in (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
|
||||
title="Reset zoom and pan"
|
||||
>
|
||||
Reset View
|
||||
</button>
|
||||
{onFocusInGraph && (
|
||||
<button
|
||||
onClick={handleFocusInGraph}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-slate-900 bg-cyan-400 hover:bg-cyan-300 rounded-lg transition-all shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<Focus className="w-4 h-4" />
|
||||
Toggle Focus
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopyMermaid}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-all shadow-lg shadow-purple-500/20"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy Mermaid
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="relative z-10 border-b border-white/10 px-6 py-5">
|
||||
<h2 className="text-lg font-semibold text-white">Process: {process.label}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Diagram */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`relative z-10 flex flex-1 items-center justify-center overflow-hidden p-8 ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="h-fit w-fit origin-center transition-transform [&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="relative z-10 flex items-center justify-center gap-3 border-t border-white/10 bg-slate-900/50 px-6 py-4">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 p-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Zoom out (-)"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] px-2 text-center font-mono text-xs text-slate-400">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Zoom in (+)"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Reset zoom and pan"
|
||||
>
|
||||
Reset View
|
||||
</button>
|
||||
{onFocusInGraph && (
|
||||
<button
|
||||
onClick={handleFocusInGraph}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-400 px-5 py-2.5 text-sm font-medium text-slate-900 shadow-lg shadow-cyan-500/20 transition-all hover:bg-cyan-300"
|
||||
>
|
||||
<Focus className="h-4 w-4" />
|
||||
Toggle Focus
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopyMermaid}
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-purple-500/20 transition-all hover:bg-purple-500"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Mermaid
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* Processes Panel
|
||||
*
|
||||
*
|
||||
* Lists all detected processes grouped by type (cross-community / intra-community).
|
||||
* Clicking a process opens the ProcessFlowModal with a flowchart.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { GitBranch, Search, Eye, Zap, Home, ChevronDown, ChevronRight, Sparkles, Lightbulb, Layers } from 'lucide-react';
|
||||
import {
|
||||
GitBranch,
|
||||
Search,
|
||||
Eye,
|
||||
Zap,
|
||||
Home,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { ProcessFlowModal } from './ProcessFlowModal';
|
||||
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
|
||||
@@ -15,502 +26,534 @@ import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
|
||||
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
|
||||
export const ProcessesPanel = () => {
|
||||
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['cross', 'intra']));
|
||||
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
|
||||
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
|
||||
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['cross', 'intra']),
|
||||
);
|
||||
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
|
||||
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
|
||||
|
||||
// Extract processes from graph
|
||||
const processes = useMemo(() => {
|
||||
if (!graph) return { cross: [], intra: [] };
|
||||
// Extract processes from graph
|
||||
const processes = useMemo(() => {
|
||||
if (!graph) return { cross: [], intra: [] };
|
||||
|
||||
const processNodes = graph.nodes.filter(n => n.label === 'Process');
|
||||
const processNodes = graph.nodes.filter((n) => n.label === 'Process');
|
||||
|
||||
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
|
||||
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
|
||||
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
|
||||
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
|
||||
|
||||
for (const node of processNodes) {
|
||||
const item = {
|
||||
id: node.id,
|
||||
label: node.properties.heuristicLabel || node.properties.name || node.id,
|
||||
stepCount: node.properties.stepCount || 0,
|
||||
clusters: node.properties.communities || [],
|
||||
};
|
||||
for (const node of processNodes) {
|
||||
const item = {
|
||||
id: node.id,
|
||||
label: node.properties.heuristicLabel || node.properties.name || node.id,
|
||||
stepCount: node.properties.stepCount || 0,
|
||||
clusters: node.properties.communities || [],
|
||||
};
|
||||
|
||||
if (node.properties.processType === 'cross_community') {
|
||||
cross.push(item);
|
||||
} else {
|
||||
intra.push(item);
|
||||
}
|
||||
}
|
||||
if (node.properties.processType === 'cross_community') {
|
||||
cross.push(item);
|
||||
} else {
|
||||
intra.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by step count (most complex first)
|
||||
cross.sort((a, b) => b.stepCount - a.stepCount);
|
||||
intra.sort((a, b) => b.stepCount - a.stepCount);
|
||||
// Sort by step count (most complex first)
|
||||
cross.sort((a, b) => b.stepCount - a.stepCount);
|
||||
intra.sort((a, b) => b.stepCount - a.stepCount);
|
||||
|
||||
return { cross, intra };
|
||||
}, [graph]);
|
||||
return { cross, intra };
|
||||
}, [graph]);
|
||||
|
||||
// Filter by search
|
||||
const filteredProcesses = useMemo(() => {
|
||||
if (!searchQuery.trim()) return processes;
|
||||
// Filter by search
|
||||
const filteredProcesses = useMemo(() => {
|
||||
if (!searchQuery.trim()) return processes;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return {
|
||||
cross: processes.cross.filter(p => p.label.toLowerCase().includes(query)),
|
||||
intra: processes.intra.filter(p => p.label.toLowerCase().includes(query)),
|
||||
};
|
||||
}, [processes, searchQuery]);
|
||||
const query = searchQuery.toLowerCase();
|
||||
return {
|
||||
cross: processes.cross.filter((p) => p.label.toLowerCase().includes(query)),
|
||||
intra: processes.intra.filter((p) => p.label.toLowerCase().includes(query)),
|
||||
};
|
||||
}, [processes, searchQuery]);
|
||||
|
||||
// Toggle section expansion
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Toggle section expansion
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load ALL processes and combine into one mega-diagram
|
||||
const handleViewAllProcesses = useCallback(async () => {
|
||||
setLoadingProcess('all');
|
||||
// Load ALL processes and combine into one mega-diagram
|
||||
const handleViewAllProcesses = useCallback(async () => {
|
||||
setLoadingProcess('all');
|
||||
|
||||
try {
|
||||
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
|
||||
try {
|
||||
const allProcessIds = [...processes.cross, ...processes.intra]
|
||||
.map((p) => p.id)
|
||||
.filter(isSafeId);
|
||||
|
||||
if (allProcessIds.length === 0) return;
|
||||
if (allProcessIds.length === 0) return;
|
||||
|
||||
// Collect all steps from all processes
|
||||
const allStepsMap = new Map<string, ProcessStep>();
|
||||
const allEdges: Array<{ from: string; to: string; type: string }> = [];
|
||||
// Collect all steps from all processes
|
||||
const allStepsMap = new Map<string, ProcessStep>();
|
||||
const allEdges: Array<{ from: string; to: string; type: string }> = [];
|
||||
|
||||
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
|
||||
// Optimization: Fetch all steps in one query if possible
|
||||
const allStepsQuery = `
|
||||
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
|
||||
// Optimization: Fetch all steps in one query if possible
|
||||
const allStepsQuery = `
|
||||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE p.id IN [${allProcessIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
WHERE p.id IN [${allProcessIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
|
||||
`;
|
||||
|
||||
const stepsResult = await runQuery(allStepsQuery);
|
||||
const stepsResult = await runQuery(allStepsQuery);
|
||||
|
||||
for (const row of stepsResult) {
|
||||
const stepId = row.id || row[0];
|
||||
if (!allStepsMap.has(stepId)) {
|
||||
allStepsMap.set(stepId, {
|
||||
id: stepId,
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const row of stepsResult) {
|
||||
const stepId = row.id || row[0];
|
||||
if (!allStepsMap.has(stepId)) {
|
||||
allStepsMap.set(stepId, {
|
||||
id: stepId,
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allSteps = Array.from(allStepsMap.values());
|
||||
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
|
||||
const allSteps = Array.from(allStepsMap.values());
|
||||
const stepIds = allSteps.map((s) => s.id).filter(isSafeId);
|
||||
|
||||
// Query for all CALLS edges between the combined steps
|
||||
if (stepIds.length > 0) {
|
||||
// Batch query if too many steps
|
||||
const edgesQuery = `
|
||||
// Query for all CALLS edges between the combined steps
|
||||
if (stepIds.length > 0) {
|
||||
// Batch query if too many steps
|
||||
const edgesQuery = `
|
||||
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
|
||||
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
RETURN from.id AS fromId, to.id AS toId, r.type AS type
|
||||
`;
|
||||
|
||||
try {
|
||||
const edgesResult = await runQuery(edgesQuery);
|
||||
allEdges.push(...edgesResult
|
||||
.map((row: any) => ({
|
||||
from: row.fromId || row[0],
|
||||
to: row.toId || row[1],
|
||||
type: row.type || row[2] || 'CALLS',
|
||||
}))
|
||||
.filter(edge => edge.from !== edge.to));
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch combined edges:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedProcessData: ProcessData = {
|
||||
id: 'combined-all',
|
||||
label: `All Processes (${allProcessIds.length} combined)`,
|
||||
processType: 'cross_community', // Treat as cross-community for styling
|
||||
steps: allSteps,
|
||||
edges: allEdges,
|
||||
clusters: [],
|
||||
};
|
||||
|
||||
setSelectedProcess(combinedProcessData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load combined processes:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
}, [processes, runQuery]);
|
||||
|
||||
// Load process steps and open modal
|
||||
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
setLoadingProcess(processId);
|
||||
|
||||
try {
|
||||
// Query for process steps
|
||||
const stepsQuery = `
|
||||
const edgesResult = await runQuery(edgesQuery);
|
||||
allEdges.push(
|
||||
...edgesResult
|
||||
.map((row: any) => ({
|
||||
from: row.fromId || row[0],
|
||||
to: row.toId || row[1],
|
||||
type: row.type || row[2] || 'CALLS',
|
||||
}))
|
||||
.filter((edge) => edge.from !== edge.to),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch combined edges:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedProcessData: ProcessData = {
|
||||
id: 'combined-all',
|
||||
label: `All Processes (${allProcessIds.length} combined)`,
|
||||
processType: 'cross_community', // Treat as cross-community for styling
|
||||
steps: allSteps,
|
||||
edges: allEdges,
|
||||
clusters: [],
|
||||
};
|
||||
|
||||
setSelectedProcess(combinedProcessData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load combined processes:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
}, [processes, runQuery]);
|
||||
|
||||
// Load process steps and open modal
|
||||
const handleViewProcess = useCallback(
|
||||
async (processId: string, label: string, processType: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
setLoadingProcess(processId);
|
||||
|
||||
try {
|
||||
// Query for process steps
|
||||
const stepsQuery = `
|
||||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
|
||||
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
|
||||
ORDER BY r.step
|
||||
`;
|
||||
|
||||
const stepsResult = await runQuery(stepsQuery);
|
||||
const stepsResult = await runQuery(stepsQuery);
|
||||
|
||||
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
|
||||
id: row.id || row[0],
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
}));
|
||||
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
|
||||
id: row.id || row[0],
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
}));
|
||||
|
||||
// Get step IDs for edge query
|
||||
const stepIds = steps.map(s => s.id).filter(isSafeId);
|
||||
// Get step IDs for edge query
|
||||
const stepIds = steps.map((s) => s.id).filter(isSafeId);
|
||||
|
||||
// Query for CALLS edges between the steps in this process
|
||||
let edges: Array<{ from: string; to: string; type: string }> = [];
|
||||
if (stepIds.length > 0) {
|
||||
const edgesQuery = `
|
||||
// Query for CALLS edges between the steps in this process
|
||||
let edges: Array<{ from: string; to: string; type: string }> = [];
|
||||
if (stepIds.length > 0) {
|
||||
const edgesQuery = `
|
||||
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
|
||||
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
|
||||
RETURN from.id AS fromId, to.id AS toId, r.type AS type
|
||||
`;
|
||||
|
||||
try {
|
||||
const edgesResult = await runQuery(edgesQuery);
|
||||
edges = edgesResult
|
||||
.map((row: any) => ({
|
||||
from: row.fromId || row[0],
|
||||
to: row.toId || row[1],
|
||||
type: row.type || row[2] || 'CALLS',
|
||||
}))
|
||||
.filter(edge => edge.from !== edge.to); // Remove self-loops
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch edges:', err);
|
||||
// Continue with empty edges - will fallback to linear
|
||||
}
|
||||
}
|
||||
|
||||
// Get clusters for this process
|
||||
const processNode = graph?.nodes.find(n => n.id === processId);
|
||||
const clusters = processNode?.properties.communities || [];
|
||||
|
||||
const processData: ProcessData = {
|
||||
id: processId,
|
||||
label,
|
||||
processType: processType as 'cross_community' | 'intra_community',
|
||||
steps,
|
||||
edges,
|
||||
clusters,
|
||||
};
|
||||
|
||||
setSelectedProcess(processData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load process steps:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
}, [runQuery, graph]);
|
||||
|
||||
// Cache for process steps (so we don't re-query when toggling focus)
|
||||
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Toggle focus for any process - loads steps on demand
|
||||
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
// If already focused on this process, turn off
|
||||
if (focusedProcessId === processId) {
|
||||
setHighlightedNodeIds(new Set());
|
||||
setFocusedProcessId(null);
|
||||
return;
|
||||
try {
|
||||
const edgesResult = await runQuery(edgesQuery);
|
||||
edges = edgesResult
|
||||
.map((row: any) => ({
|
||||
from: row.fromId || row[0],
|
||||
to: row.toId || row[1],
|
||||
type: row.type || row[2] || 'CALLS',
|
||||
}))
|
||||
.filter((edge) => edge.from !== edge.to); // Remove self-loops
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch edges:', err);
|
||||
// Continue with empty edges - will fallback to linear
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have cached steps
|
||||
if (processStepsCache.has(processId)) {
|
||||
const stepIds = processStepsCache.get(processId)!;
|
||||
setHighlightedNodeIds(new Set(stepIds));
|
||||
setFocusedProcessId(processId);
|
||||
return;
|
||||
}
|
||||
// Get clusters for this process
|
||||
const processNode = graph?.nodes.find((n) => n.id === processId);
|
||||
const clusters = processNode?.properties.communities || [];
|
||||
|
||||
// Load steps for this process
|
||||
setLoadingProcess(processId);
|
||||
try {
|
||||
const stepsQuery = `
|
||||
const processData: ProcessData = {
|
||||
id: processId,
|
||||
label,
|
||||
processType: processType as 'cross_community' | 'intra_community',
|
||||
steps,
|
||||
edges,
|
||||
clusters,
|
||||
};
|
||||
|
||||
setSelectedProcess(processData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load process steps:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
},
|
||||
[runQuery, graph],
|
||||
);
|
||||
|
||||
// Cache for process steps (so we don't re-query when toggling focus)
|
||||
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Toggle focus for any process - loads steps on demand
|
||||
const handleToggleFocusForProcess = useCallback(
|
||||
async (processId: string) => {
|
||||
if (!isSafeId(processId)) return;
|
||||
// If already focused on this process, turn off
|
||||
if (focusedProcessId === processId) {
|
||||
setHighlightedNodeIds(new Set());
|
||||
setFocusedProcessId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have cached steps
|
||||
if (processStepsCache.has(processId)) {
|
||||
const stepIds = processStepsCache.get(processId)!;
|
||||
setHighlightedNodeIds(new Set(stepIds));
|
||||
setFocusedProcessId(processId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load steps for this process
|
||||
setLoadingProcess(processId);
|
||||
try {
|
||||
const stepsQuery = `
|
||||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
|
||||
RETURN s.id AS id
|
||||
`;
|
||||
const stepsResult = await runQuery(stepsQuery);
|
||||
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
|
||||
const stepsResult = await runQuery(stepsQuery);
|
||||
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
|
||||
|
||||
// Cache the result
|
||||
setProcessStepsCache(prev => new Map(prev).set(processId, stepIds));
|
||||
// Cache the result
|
||||
setProcessStepsCache((prev) => new Map(prev).set(processId, stepIds));
|
||||
|
||||
// Set focus
|
||||
setHighlightedNodeIds(new Set(stepIds));
|
||||
setFocusedProcessId(processId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load process steps for focus:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
}, [focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds]);
|
||||
// Set focus
|
||||
setHighlightedNodeIds(new Set(stepIds));
|
||||
setFocusedProcessId(processId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load process steps for focus:', error);
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
},
|
||||
[focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds],
|
||||
);
|
||||
|
||||
// Focus in graph callback - toggles highlight (used by modal)
|
||||
const handleFocusInGraph = useCallback((nodeIds: string[], processId: string) => {
|
||||
// Check if this process is already focused
|
||||
if (focusedProcessId === processId) {
|
||||
// Clear focus
|
||||
setHighlightedNodeIds(new Set());
|
||||
setFocusedProcessId(null);
|
||||
} else {
|
||||
// Set focus and cache
|
||||
setHighlightedNodeIds(new Set(nodeIds));
|
||||
setFocusedProcessId(processId);
|
||||
setProcessStepsCache(prev => new Map(prev).set(processId, nodeIds));
|
||||
}
|
||||
}, [focusedProcessId, setHighlightedNodeIds]);
|
||||
// Focus in graph callback - toggles highlight (used by modal)
|
||||
const handleFocusInGraph = useCallback(
|
||||
(nodeIds: string[], processId: string) => {
|
||||
// Check if this process is already focused
|
||||
if (focusedProcessId === processId) {
|
||||
// Clear focus
|
||||
setHighlightedNodeIds(new Set());
|
||||
setFocusedProcessId(null);
|
||||
} else {
|
||||
// Set focus and cache
|
||||
setHighlightedNodeIds(new Set(nodeIds));
|
||||
setFocusedProcessId(processId);
|
||||
setProcessStepsCache((prev) => new Map(prev).set(processId, nodeIds));
|
||||
}
|
||||
},
|
||||
[focusedProcessId, setHighlightedNodeIds],
|
||||
);
|
||||
|
||||
// Clear focused process when highlights are cleared externally
|
||||
useEffect(() => {
|
||||
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
|
||||
setFocusedProcessId(null);
|
||||
}
|
||||
}, [highlightedNodeIds, focusedProcessId]);
|
||||
|
||||
const totalCount = processes.cross.length + processes.intra.length;
|
||||
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
|
||||
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-surface rounded-xl">
|
||||
<GitBranch className="w-7 h-7 text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-primary mb-2">No Processes Detected</h3>
|
||||
<p className="text-sm text-text-secondary max-w-xs">
|
||||
Processes are execution flows traced from entry points. Load a codebase to see detected processes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
// Clear focused process when highlights are cleared externally
|
||||
useEffect(() => {
|
||||
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
|
||||
setFocusedProcessId(null);
|
||||
}
|
||||
}, [highlightedNodeIds, focusedProcessId]);
|
||||
|
||||
const totalCount = processes.cross.length + processes.intra.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with search */}
|
||||
<div className="p-3 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-lg focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<Search className="w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter processes..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted" data-testid="process-list-loaded">
|
||||
<span>{totalCount} processes detected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process list */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
{/* View All Processes Card */}
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
onClick={handleViewAllProcesses}
|
||||
disabled={loadingProcess !== null}
|
||||
className="w-full flex items-center gap-3 p-3 bg-elevated/40 hover:bg-elevated/80 border border-border-subtle hover:border-cyan-500/30 rounded-xl transition-all group shadow-sm hover:shadow-cyan-900/10 text-left"
|
||||
>
|
||||
<div className="p-2 bg-cyan-500/10 rounded-lg group-hover:bg-cyan-500/20 transition-colors">
|
||||
<Layers className="w-5 h-5 text-cyan-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">Full Process Map</h4>
|
||||
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
|
||||
</div>
|
||||
{loadingProcess === 'all' ? (
|
||||
<span className="animate-spin mr-1">
|
||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
||||
</span>
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-text-muted group-hover:text-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cross-Community Section */}
|
||||
{filteredProcesses.cross.length > 0 && (
|
||||
<div className="border-b border-border-subtle">
|
||||
<button
|
||||
onClick={() => toggleSection('cross')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
|
||||
>
|
||||
{expandedSections.has('cross') ? (
|
||||
<ChevronDown className="w-4 h-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-text-muted" />
|
||||
)}
|
||||
<Zap className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
|
||||
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
|
||||
{filteredProcesses.cross.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('cross') && (
|
||||
<div className="pb-2">
|
||||
{filteredProcesses.cross.map((process) => (
|
||||
<ProcessItem
|
||||
key={process.id}
|
||||
process={process}
|
||||
isLoading={loadingProcess === process.id}
|
||||
isSelected={selectedProcess?.id === process.id}
|
||||
isFocused={focusedProcessId === process.id}
|
||||
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
|
||||
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intra-Community Section */}
|
||||
{filteredProcesses.intra.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('intra')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
|
||||
>
|
||||
{expandedSections.has('intra') ? (
|
||||
<ChevronDown className="w-4 h-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-text-muted" />
|
||||
)}
|
||||
<Home className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
|
||||
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
|
||||
{filteredProcesses.intra.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('intra') && (
|
||||
<div className="pb-2">
|
||||
{filteredProcesses.intra.map((process) => (
|
||||
<ProcessItem
|
||||
key={process.id}
|
||||
process={process}
|
||||
isLoading={loadingProcess === process.id}
|
||||
isSelected={selectedProcess?.id === process.id}
|
||||
isFocused={focusedProcessId === process.id}
|
||||
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
|
||||
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<ProcessFlowModal
|
||||
process={selectedProcess}
|
||||
onClose={() => setSelectedProcess(null)}
|
||||
onFocusInGraph={handleFocusInGraph}
|
||||
isFullScreen={selectedProcess?.id === 'combined-all'}
|
||||
/>
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-surface">
|
||||
<GitBranch className="h-7 w-7 text-text-muted" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">No Processes Detected</h3>
|
||||
<p className="max-w-xs text-sm text-text-secondary">
|
||||
Processes are execution flows traced from entry points. Load a codebase to see detected
|
||||
processes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with search */}
|
||||
<div className="border-b border-border-subtle p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<Search className="h-4 w-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter processes..."
|
||||
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-text-muted"
|
||||
data-testid="process-list-loaded"
|
||||
>
|
||||
<span>{totalCount} processes detected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process list */}
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||
{/* View All Processes Card */}
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
onClick={handleViewAllProcesses}
|
||||
disabled={loadingProcess !== null}
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-border-subtle bg-elevated/40 p-3 text-left shadow-sm transition-all hover:border-cyan-500/30 hover:bg-elevated/80 hover:shadow-cyan-900/10"
|
||||
>
|
||||
<div className="rounded-lg bg-cyan-500/10 p-2 transition-colors group-hover:bg-cyan-500/20">
|
||||
<Layers className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">
|
||||
Full Process Map
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
|
||||
</div>
|
||||
{loadingProcess === 'all' ? (
|
||||
<span className="mr-1 animate-spin">
|
||||
<Sparkles className="h-4 w-4 text-cyan-400" />
|
||||
</span>
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-text-muted group-hover:text-cyan-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cross-Community Section */}
|
||||
{filteredProcesses.cross.length > 0 && (
|
||||
<div className="border-b border-border-subtle">
|
||||
<button
|
||||
onClick={() => toggleSection('cross')}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{expandedSections.has('cross') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<Zap className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
|
||||
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
|
||||
{filteredProcesses.cross.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('cross') && (
|
||||
<div className="pb-2">
|
||||
{filteredProcesses.cross.map((process) => (
|
||||
<ProcessItem
|
||||
key={process.id}
|
||||
process={process}
|
||||
isLoading={loadingProcess === process.id}
|
||||
isSelected={selectedProcess?.id === process.id}
|
||||
isFocused={focusedProcessId === process.id}
|
||||
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
|
||||
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intra-Community Section */}
|
||||
{filteredProcesses.intra.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('intra')}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
|
||||
>
|
||||
{expandedSections.has('intra') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<Home className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
|
||||
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
|
||||
{filteredProcesses.intra.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('intra') && (
|
||||
<div className="pb-2">
|
||||
{filteredProcesses.intra.map((process) => (
|
||||
<ProcessItem
|
||||
key={process.id}
|
||||
process={process}
|
||||
isLoading={loadingProcess === process.id}
|
||||
isSelected={selectedProcess?.id === process.id}
|
||||
isFocused={focusedProcessId === process.id}
|
||||
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
|
||||
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<ProcessFlowModal
|
||||
process={selectedProcess}
|
||||
onClose={() => setSelectedProcess(null)}
|
||||
onFocusInGraph={handleFocusInGraph}
|
||||
isFullScreen={selectedProcess?.id === 'combined-all'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Individual process item
|
||||
interface ProcessItemProps {
|
||||
process: { id: string; label: string; stepCount: number; clusters: string[] };
|
||||
isLoading: boolean;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
onView: () => void;
|
||||
onToggleFocus: () => void;
|
||||
process: { id: string; label: string; stepCount: number; clusters: string[] };
|
||||
isLoading: boolean;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
onView: () => void;
|
||||
onToggleFocus: () => void;
|
||||
}
|
||||
|
||||
const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onToggleFocus }: ProcessItemProps) => {
|
||||
// Determine row styling - focused gets special highlight
|
||||
const rowClass = isFocused
|
||||
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
|
||||
: isSelected
|
||||
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
|
||||
: '';
|
||||
const ProcessItem = ({
|
||||
process,
|
||||
isLoading,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onView,
|
||||
onToggleFocus,
|
||||
}: ProcessItemProps) => {
|
||||
// Determine row styling - focused gets special highlight
|
||||
const rowClass = isFocused
|
||||
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
|
||||
: isSelected
|
||||
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div data-testid="process-row" className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
|
||||
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-text-primary truncate">{process.label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{process.stepCount} steps</span>
|
||||
{process.clusters.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{process.clusters.length} clusters</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Lightbulb icon - appears on hover, always visible when focused */}
|
||||
<button
|
||||
onClick={onToggleFocus}
|
||||
className={`p-1.5 rounded-md transition-all ${isFocused
|
||||
? 'text-amber-400 hover:text-amber-300 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-400/40 animate-pulse opacity-100'
|
||||
: 'text-text-muted hover:text-cyan-400 bg-white/5 hover:bg-cyan-500/20 border border-white/10 hover:border-cyan-400/40 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
|
||||
data-testid="process-highlight-button"
|
||||
>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onView}
|
||||
disabled={isLoading}
|
||||
data-testid="process-view-button"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-all disabled:opacity-50 shadow-sm ${isSelected
|
||||
? 'text-cyan-300 bg-cyan-900/60 border border-cyan-400/60 opacity-100'
|
||||
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-900/50 border border-cyan-500/30 hover:border-cyan-400/50 opacity-0 group-hover:opacity-100 shadow-cyan-900/20'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="animate-pulse">Loading...</span>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
Viewing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
View
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
data-testid="process-row"
|
||||
className={`group mx-2 flex items-center gap-2 rounded-lg px-4 py-2 transition-all hover:bg-hover ${rowClass}`}
|
||||
>
|
||||
<GitBranch className="h-4 w-4 flex-shrink-0 text-text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-text-primary">{process.label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{process.stepCount} steps</span>
|
||||
{process.clusters.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{process.clusters.length} clusters</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
{/* Lightbulb icon - appears on hover, always visible when focused */}
|
||||
<button
|
||||
onClick={onToggleFocus}
|
||||
className={`rounded-md p-1.5 transition-all ${
|
||||
isFocused
|
||||
? 'animate-pulse border border-amber-400/40 bg-amber-500/20 text-amber-400 opacity-100 hover:bg-amber-500/30 hover:text-amber-300'
|
||||
: 'border border-white/10 bg-white/5 text-text-muted opacity-0 group-hover:opacity-100 hover:border-cyan-400/40 hover:bg-cyan-500/20 hover:text-cyan-400'
|
||||
}`}
|
||||
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
|
||||
data-testid="process-highlight-button"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onView}
|
||||
disabled={isLoading}
|
||||
data-testid="process-view-button"
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all disabled:opacity-50 ${
|
||||
isSelected
|
||||
? 'border border-cyan-400/60 bg-cyan-900/60 text-cyan-300 opacity-100'
|
||||
: 'border border-cyan-500/30 bg-cyan-950/30 text-cyan-400 opacity-0 shadow-cyan-900/20 group-hover:opacity-100 hover:border-cyan-400/50 hover:bg-cyan-900/50 hover:text-cyan-300'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="animate-pulse">Loading...</span>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Viewing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
View
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from '@/lib/lucide-icons';
|
||||
import {
|
||||
Terminal,
|
||||
Play,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Table,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
@@ -26,7 +35,15 @@ const EXAMPLE_QUERIES = [
|
||||
];
|
||||
|
||||
export const QueryFAB = () => {
|
||||
const { setHighlightedNodeIds, setQueryResult, queryResult, clearQueryHighlights, graph, runQuery, isDatabaseReady } = useAppState();
|
||||
const {
|
||||
setHighlightedNodeIds,
|
||||
setQueryResult,
|
||||
queryResult,
|
||||
clearQueryHighlights,
|
||||
graph,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
} = useAppState();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -95,12 +112,12 @@ export const QueryFAB = () => {
|
||||
const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/;
|
||||
|
||||
const nodeIds = rows
|
||||
.flatMap(row => {
|
||||
.flatMap((row) => {
|
||||
const ids: string[] = [];
|
||||
|
||||
if (Array.isArray(row)) {
|
||||
// Array format - check all elements for node ID patterns
|
||||
row.forEach(val => {
|
||||
row.forEach((val) => {
|
||||
if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) {
|
||||
ids.push(val);
|
||||
}
|
||||
@@ -169,25 +186,12 @@ export const QueryFAB = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="
|
||||
group absolute bottom-4 left-4 z-20
|
||||
flex items-center gap-2 px-4 py-2.5
|
||||
bg-gradient-to-r from-cyan-500 to-teal-500
|
||||
rounded-xl text-white font-medium text-sm
|
||||
shadow-[0_0_20px_rgba(6,182,212,0.4)]
|
||||
hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]
|
||||
hover:-translate-y-0.5
|
||||
transition-all duration-200
|
||||
"
|
||||
className="group absolute bottom-4 left-4 z-20 flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-2.5 text-sm font-medium text-white shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Query</span>
|
||||
{queryResult && queryResult.nodeIds.length > 0 && (
|
||||
<span className="
|
||||
px-1.5 py-0.5 ml-1
|
||||
bg-white/20 rounded-md
|
||||
text-xs font-semibold
|
||||
">
|
||||
<span className="ml-1 rounded-md bg-white/20 px-1.5 py-0.5 text-xs font-semibold">
|
||||
{queryResult.nodeIds.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -198,28 +202,20 @@ export const QueryFAB = () => {
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="
|
||||
absolute bottom-4 left-4 z-20
|
||||
w-[480px] max-w-[calc(100%-2rem)]
|
||||
bg-deep/95 backdrop-blur-md
|
||||
border border-cyan-500/30
|
||||
rounded-xl
|
||||
shadow-[0_0_40px_rgba(6,182,212,0.2)]
|
||||
animate-fade-in
|
||||
"
|
||||
className="absolute bottom-4 left-4 z-20 w-[480px] max-w-[calc(100%-2rem)] animate-fade-in rounded-xl border border-cyan-500/30 bg-deep/95 shadow-[0_0_40px_rgba(6,182,212,0.2)] backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-cyan-500 to-teal-500 rounded-lg">
|
||||
<Terminal className="w-4 h-4 text-white" />
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-500">
|
||||
<Terminal className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="font-medium text-sm">Cypher Query</span>
|
||||
<span className="text-sm font-medium">Cypher Query</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded-md transition-colors"
|
||||
className="rounded-md p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -232,52 +228,30 @@ export const QueryFAB = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="MATCH (n:Function) RETURN n.name, n.filePath LIMIT 10"
|
||||
rows={3}
|
||||
className="
|
||||
w-full px-3 py-2.5
|
||||
bg-surface border border-border-subtle rounded-lg
|
||||
text-sm font-mono text-text-primary
|
||||
placeholder:text-text-muted
|
||||
focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20
|
||||
outline-none resize-none
|
||||
transition-all
|
||||
"
|
||||
className="w-full resize-none rounded-lg border border-border-subtle bg-surface px-3 py-2.5 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowExamples(!showExamples)}
|
||||
className="
|
||||
flex items-center gap-1.5 px-3 py-1.5
|
||||
text-xs text-text-secondary
|
||||
hover:text-text-primary hover:bg-hover
|
||||
rounded-md transition-colors
|
||||
"
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>Examples</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showExamples && (
|
||||
<div className="
|
||||
absolute bottom-full left-0 mb-2
|
||||
w-64 py-1
|
||||
bg-surface border border-border-subtle rounded-lg
|
||||
shadow-xl
|
||||
animate-fade-in
|
||||
">
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 animate-fade-in rounded-lg border border-border-subtle bg-surface py-1 shadow-xl">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
<button
|
||||
key={example.label}
|
||||
onClick={() => handleSelectExample(example.query)}
|
||||
className="
|
||||
w-full px-3 py-2 text-left
|
||||
text-sm text-text-secondary
|
||||
hover:bg-hover hover:text-text-primary
|
||||
transition-colors
|
||||
"
|
||||
className="w-full px-3 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
{example.label}
|
||||
</button>
|
||||
@@ -290,12 +264,7 @@ export const QueryFAB = () => {
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="
|
||||
px-3 py-1.5
|
||||
text-xs text-text-secondary
|
||||
hover:text-text-primary hover:bg-hover
|
||||
rounded-md transition-colors
|
||||
"
|
||||
className="rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -303,76 +272,74 @@ export const QueryFAB = () => {
|
||||
<button
|
||||
onClick={handleRunQuery}
|
||||
disabled={!query.trim() || isRunning}
|
||||
className="
|
||||
flex items-center gap-1.5 px-4 py-1.5
|
||||
bg-gradient-to-r from-cyan-500 to-teal-500
|
||||
rounded-md text-white text-sm font-medium
|
||||
shadow-[0_0_15px_rgba(6,182,212,0.3)]
|
||||
hover:shadow-[0_0_20px_rgba(6,182,212,0.5)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none
|
||||
transition-all
|
||||
"
|
||||
className="flex items-center gap-1.5 rounded-md bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.5)] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Run</span>
|
||||
<kbd className="ml-1 px-1 py-0.5 bg-white/20 rounded text-[10px]">⌘↵</kbd>
|
||||
<kbd className="ml-1 rounded bg-white/20 px-1 py-0.5 text-[10px]">⌘↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-500/10 border-t border-red-500/20">
|
||||
<p className="text-xs text-red-400 font-mono">{error}</p>
|
||||
<div className="border-t border-red-500/20 bg-red-500/10 px-4 py-2">
|
||||
<p className="font-mono text-xs text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult && !error && (
|
||||
<div className="border-t border-cyan-500/20">
|
||||
<div className="px-4 py-2.5 bg-cyan-500/5 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between bg-cyan-500/5 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-text-secondary">
|
||||
<span className="text-cyan-400 font-semibold">{queryResult.rows.length}</span> rows
|
||||
<span className="font-semibold text-cyan-400">{queryResult.rows.length}</span> rows
|
||||
</span>
|
||||
{queryResult.nodeIds.length > 0 && (
|
||||
<span className="text-text-secondary">
|
||||
<span className="text-cyan-400 font-semibold">{queryResult.nodeIds.length}</span> highlighted
|
||||
<span className="font-semibold text-cyan-400">{queryResult.nodeIds.length}</span>{' '}
|
||||
highlighted
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{queryResult.executionTime.toFixed(1)}ms
|
||||
</span>
|
||||
<span className="text-text-muted">{queryResult.executionTime.toFixed(1)}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{queryResult.nodeIds.length > 0 && (
|
||||
<button
|
||||
onClick={clearQueryHighlights}
|
||||
className="text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
className="text-xs text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResults(!showResults)}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<Table className="w-3 h-3" />
|
||||
{showResults ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
|
||||
<Table className="h-3 w-3" />
|
||||
{showResults ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResults && queryResult.rows.length > 0 && (
|
||||
<div className="max-h-48 overflow-auto scrollbar-thin border-t border-border-subtle">
|
||||
<div className="scrollbar-thin max-h-48 overflow-auto border-t border-border-subtle">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface sticky top-0">
|
||||
<thead className="sticky top-0 bg-surface">
|
||||
<tr>
|
||||
{Object.keys(queryResult.rows[0]).map((key) => (
|
||||
<th key={key} className="px-3 py-2 text-left text-text-muted font-medium border-b border-border-subtle">
|
||||
<th
|
||||
key={key}
|
||||
className="border-b border-border-subtle px-3 py-2 text-left font-medium text-text-muted"
|
||||
>
|
||||
{key}
|
||||
</th>
|
||||
))}
|
||||
@@ -380,9 +347,12 @@ export const QueryFAB = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{queryResult.rows.slice(0, 50).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-hover/50 transition-colors">
|
||||
<tr key={i} className="transition-colors hover:bg-hover/50">
|
||||
{Object.values(row).map((val, j) => (
|
||||
<td key={j} className="px-3 py-1.5 text-text-secondary border-b border-border-subtle/50 font-mono truncate max-w-[200px]">
|
||||
<td
|
||||
key={j}
|
||||
className="max-w-[200px] truncate border-b border-border-subtle/50 px-3 py-1.5 font-mono text-text-secondary"
|
||||
>
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '')}
|
||||
</td>
|
||||
))}
|
||||
@@ -391,7 +361,7 @@ export const QueryFAB = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
{queryResult.rows.length > 50 && (
|
||||
<div className="px-3 py-2 text-xs text-text-muted bg-surface border-t border-border-subtle">
|
||||
<div className="border-t border-border-subtle bg-surface px-3 py-2 text-xs text-text-muted">
|
||||
Showing 50 of {queryResult.rows.length} rows
|
||||
</div>
|
||||
)}
|
||||
@@ -402,4 +372,3 @@ export const QueryFAB = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* RepoAnalyzer
|
||||
*
|
||||
* Two input modes:
|
||||
* - "github" → GitHub URL (https://github.com/owner/repo)
|
||||
* - "local" → Select a local folder via the browser's native directory picker
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useId } from 'react';
|
||||
import {
|
||||
Github,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Check,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
} from '@/lib/lucide-icons';
|
||||
import {
|
||||
startAnalyze,
|
||||
cancelAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'github' | 'local';
|
||||
|
||||
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
|
||||
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
|
||||
|
||||
function isValidGithubUrl(value: string): boolean {
|
||||
return GITHUB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg bg-elevated p-1" role="tablist" aria-label="Input type">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'github'}
|
||||
onClick={() => onChange('github')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'github'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'local'}
|
||||
onClick={() => onChange('local')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'local'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Local Folder
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Analyze button ───────────────────────────────────────────────────────────
|
||||
|
||||
function AnalyzeButton({
|
||||
canSubmit,
|
||||
isLoading,
|
||||
onClick,
|
||||
variant,
|
||||
}: {
|
||||
canSubmit: boolean;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
variant: 'onboarding' | 'sheet';
|
||||
}) {
|
||||
const sizeClass =
|
||||
variant === 'onboarding' ? 'w-full px-5 py-3.5 text-sm' : 'w-full px-4 py-3 text-sm';
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!canSubmit || isLoading}
|
||||
className={` ${sizeClass} flex items-center justify-center gap-2.5 rounded-xl font-medium transition-all duration-200 ${
|
||||
canSubmit && !isLoading
|
||||
? 'cursor-pointer bg-accent text-white shadow-glow-soft hover:-translate-y-0.5 hover:bg-accent/90 hover:shadow-glow'
|
||||
: 'cursor-not-allowed border border-border-subtle bg-elevated text-text-muted'
|
||||
} `}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
<span>{isLoading ? 'Starting analysis...' : 'Analyze Repository'}</span>
|
||||
{canSubmit && !isLoading && <ArrowRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Done state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DoneState({ repoName }: { repoName: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex animate-fade-in flex-col items-center gap-3 py-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
|
||||
<Check className="h-6 w-6 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-emerald-400">Analysis complete</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-text-muted">{repoName}</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">Loading graph...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RepoAnalyzer ─────────────────────────────────────────────────────────────
|
||||
|
||||
type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error';
|
||||
|
||||
export interface RepoAnalyzerProps {
|
||||
variant: 'onboarding' | 'sheet';
|
||||
onComplete: (repoName: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
|
||||
const inputId = useId();
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [phase, setPhase] = useState<InternalPhase>('input');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<JobProgress>({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Queued',
|
||||
});
|
||||
const [completedRepoName, setCompletedRepoName] = useState('');
|
||||
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
const sseControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sseControllerRef.current?.abort();
|
||||
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleModeChange = (m: InputMode) => {
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
|
||||
// so we use a text input + a "Browse" button that opens a standard file input
|
||||
// to let users pick files from the folder — the path is typed manually since
|
||||
// browsers don't expose absolute paths for security reasons).
|
||||
// For local paths, the user types or pastes the absolute path.
|
||||
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
setValidationError('Please enter a valid GitHub repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'local' && localPath.trim().length < 2) {
|
||||
setValidationError('Please enter a folder path.');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setPhase('starting');
|
||||
|
||||
try {
|
||||
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
|
||||
const nameSource = mode === 'github' ? githubUrl.trim() : localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ?? nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? 'repository';
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(name);
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || 'Analysis failed. Check server logs.');
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : 'Failed to start analysis');
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
if (jobIdRef.current) {
|
||||
try {
|
||||
await cancelAnalyze(jobIdRef.current);
|
||||
} catch {}
|
||||
jobIdRef.current = null;
|
||||
}
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: 'Queued' });
|
||||
};
|
||||
|
||||
const isLoading = phase === 'starting';
|
||||
const showInput = phase !== 'analyzing' && phase !== 'done';
|
||||
const isWindows = IS_WINDOWS;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode tabs */}
|
||||
{showInput && <ModeTabs mode={mode} onChange={handleModeChange} />}
|
||||
|
||||
{/* GitHub URL input */}
|
||||
{showInput && mode === 'github' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
GitHub Repository URL
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: isValidGithubUrl(githubUrl)
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<Github className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
autoComplete="url"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{githubUrl.length > 10 && (
|
||||
<div className="shrink-0">
|
||||
{isValidGithubUrl(githubUrl) ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={`${inputId}-local`}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
Local Folder Path
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: localPath.trim().length > 1
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={`${inputId}-local`}
|
||||
type="text"
|
||||
value={localPath}
|
||||
onChange={(e) => {
|
||||
setLocalPath(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{localPath.trim().length > 1 && (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Native folder picker + Browse button — below the input */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const rel = files[0].webkitRelativePath;
|
||||
const folderName = rel.split('/')[0];
|
||||
if (folderName) {
|
||||
setLocalPath(folderName);
|
||||
setValidationError(null);
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Browse for folder
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{(phase === 'error' || (phase === 'input' && validationError)) && validationError && (
|
||||
<p className="flex animate-fade-in items-center gap-1.5 text-xs text-red-400">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
{validationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Live progress */}
|
||||
{phase === 'analyzing' && (
|
||||
<div className="animate-slide-up">
|
||||
<AnalyzeProgress progress={progress} onCancel={handleCancel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done */}
|
||||
{phase === 'done' && <DoneState repoName={completedRepoName} />}
|
||||
|
||||
{/* CTA button */}
|
||||
{(phase === 'input' || phase === 'starting') && (
|
||||
<AnalyzeButton
|
||||
canSubmit={canSubmit}
|
||||
isLoading={isLoading}
|
||||
onClick={handleAnalyze}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error retry */}
|
||||
{phase === 'error' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
setPhase('input');
|
||||
}}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-border-subtle bg-elevated px-4 py-2.5 text-sm text-text-secondary transition-all duration-200 hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer px-4 py-2.5 text-sm text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss for sheet variant while analyzing */}
|
||||
{phase === 'analyzing' && variant === 'sheet' && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full cursor-pointer py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Hide (analysis continues in background)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* RepoLanding
|
||||
*
|
||||
* Unified landing screen shown when the backend is connected and at least one
|
||||
* repository is indexed. Displays pre-indexed repos as selectable cards, plus
|
||||
* an "Analyze a New Repository" section powered by RepoAnalyzer.
|
||||
*
|
||||
* Rendering context:
|
||||
* DropZone (Crossfade, phase="landing")
|
||||
* └─ RepoLanding
|
||||
* ├─ RepoCard (× N)
|
||||
* └─ RepoAnalyzer (variant="onboarding")
|
||||
*/
|
||||
|
||||
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import type { BackendRepo } from '../services/backend-client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Repo card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) {
|
||||
const stats = repo.stats;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-testid="landing-repo-card"
|
||||
className="group w-full cursor-pointer rounded-xl border border-border-default bg-elevated p-4 text-left transition-all duration-200 hover:border-accent/40 hover:bg-hover hover:shadow-glow-soft"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 shrink-0 text-accent" />
|
||||
<h3 className="truncate text-sm font-semibold text-text-primary transition-colors group-hover:text-accent">
|
||||
{repo.name}
|
||||
</h3>
|
||||
</div>
|
||||
{repo.indexedAt && (
|
||||
<p className="mt-1 pl-6 text-xs text-text-muted">
|
||||
Indexed {formatRelativeTime(repo.indexedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-text-muted opacity-0 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-accent group-hover:opacity-100" />
|
||||
</div>
|
||||
|
||||
{stats && (stats.files || stats.nodes) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 pl-6">
|
||||
{stats.files != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<FileCode className="h-3 w-3" /> {stats.files.toLocaleString()} files
|
||||
</span>
|
||||
)}
|
||||
{stats.nodes != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<Layers className="h-3 w-3" /> {stats.nodes.toLocaleString()} symbols
|
||||
</span>
|
||||
)}
|
||||
{stats.processes != null && stats.processes > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<Sparkles className="h-3 w-3" /> {stats.processes} flows
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RepoLanding ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface RepoLandingProps {
|
||||
repos: BackendRepo[];
|
||||
onSelectRepo: (repoName: string) => void;
|
||||
onAnalyzeComplete: (repoName: string) => void;
|
||||
}
|
||||
|
||||
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
|
||||
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative mb-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
|
||||
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
|
||||
GitNexus
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Choose a repository
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
Select an indexed repository to explore, or analyze a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repo list */}
|
||||
<div className="relative mb-5 space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or analyze new
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
|
||||
{/* Analyzer form */}
|
||||
<div className="relative">
|
||||
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
|
||||
Public & private repos · Cloned locally by the server · No data leaves
|
||||
your machine
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Send, Square, Sparkles, User,
|
||||
PanelRightClose, Loader2, AlertTriangle, GitBranch
|
||||
Send,
|
||||
Square,
|
||||
Sparkles,
|
||||
User,
|
||||
PanelRightClose,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
GitBranch,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
@@ -12,7 +18,6 @@ export const RightPanel = () => {
|
||||
const {
|
||||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
fileContents,
|
||||
graph,
|
||||
addCodeReference,
|
||||
// LLM / chat state
|
||||
@@ -39,124 +44,123 @@ export const RightPanel = () => {
|
||||
}
|
||||
}, [chatMessages, isChatLoading]);
|
||||
|
||||
const resolveFilePathForUI = useCallback((requestedPath: string): string | null => {
|
||||
const req = requestedPath.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
|
||||
if (!req) return null;
|
||||
const resolveFilePathForUI = useCallback((_requestedPath: string): string | null => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Exact match first (case-insensitive)
|
||||
for (const key of fileContents.keys()) {
|
||||
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
|
||||
if (norm === req) return key;
|
||||
}
|
||||
const findFileNodeIdForUI = useCallback(
|
||||
(filePath: string): string | undefined => {
|
||||
if (!graph) return undefined;
|
||||
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
const node = graph.nodes.find(
|
||||
(n) =>
|
||||
n.label === 'File' &&
|
||||
n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target,
|
||||
);
|
||||
return node?.id;
|
||||
},
|
||||
[graph],
|
||||
);
|
||||
|
||||
// Ends-with match (best for partial paths)
|
||||
let best: { path: string; score: number } | null = null;
|
||||
for (const key of fileContents.keys()) {
|
||||
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
|
||||
if (norm.endsWith(req)) {
|
||||
const score = 1000 - norm.length;
|
||||
if (!best || score > best.score) best = { path: key, score };
|
||||
const handleGroundingClick = useCallback(
|
||||
(inner: string) => {
|
||||
const raw = inner.trim();
|
||||
if (!raw) return;
|
||||
|
||||
let rawPath = raw;
|
||||
let startLine1: number | undefined;
|
||||
let endLine1: number | undefined;
|
||||
|
||||
// Match line:num or line:num-num (supports both hyphen - and en dash –)
|
||||
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
|
||||
if (lineMatch) {
|
||||
rawPath = lineMatch[1].trim();
|
||||
startLine1 = parseInt(lineMatch[2], 10);
|
||||
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
|
||||
}
|
||||
}
|
||||
return best?.path ?? null;
|
||||
}, [fileContents]);
|
||||
|
||||
const findFileNodeIdForUI = useCallback((filePath: string): string | undefined => {
|
||||
if (!graph) return undefined;
|
||||
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
const node = graph.nodes.find(
|
||||
(n) => n.label === 'File' && n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target
|
||||
);
|
||||
return node?.id;
|
||||
}, [graph]);
|
||||
const resolvedPath = resolveFilePathForUI(rawPath);
|
||||
if (!resolvedPath) return;
|
||||
|
||||
const handleGroundingClick = useCallback((inner: string) => {
|
||||
const raw = inner.trim();
|
||||
if (!raw) return;
|
||||
const nodeId = findFileNodeIdForUI(resolvedPath);
|
||||
|
||||
let rawPath = raw;
|
||||
let startLine1: number | undefined;
|
||||
let endLine1: number | undefined;
|
||||
|
||||
// Match line:num or line:num-num (supports both hyphen - and en dash –)
|
||||
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
|
||||
if (lineMatch) {
|
||||
rawPath = lineMatch[1].trim();
|
||||
startLine1 = parseInt(lineMatch[2], 10);
|
||||
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
|
||||
}
|
||||
|
||||
const resolvedPath = resolveFilePathForUI(rawPath);
|
||||
if (!resolvedPath) return;
|
||||
|
||||
const nodeId = findFileNodeIdForUI(resolvedPath);
|
||||
|
||||
addCodeReference({
|
||||
filePath: resolvedPath,
|
||||
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
|
||||
endLine: endLine1 ? Math.max(0, endLine1 - 1) : (startLine1 ? Math.max(0, startLine1 - 1) : undefined),
|
||||
nodeId,
|
||||
label: 'File',
|
||||
name: resolvedPath.split('/').pop() ?? resolvedPath,
|
||||
source: 'ai',
|
||||
});
|
||||
}, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]);
|
||||
addCodeReference({
|
||||
filePath: resolvedPath,
|
||||
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
|
||||
endLine: endLine1
|
||||
? Math.max(0, endLine1 - 1)
|
||||
: startLine1
|
||||
? Math.max(0, startLine1 - 1)
|
||||
: undefined,
|
||||
nodeId,
|
||||
label: 'File',
|
||||
name: resolvedPath.split('/').pop() ?? resolvedPath,
|
||||
source: 'ai',
|
||||
});
|
||||
},
|
||||
[addCodeReference, findFileNodeIdForUI, resolveFilePathForUI],
|
||||
);
|
||||
|
||||
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
|
||||
const handleNodeGroundingClick = useCallback((nodeTypeAndName: string) => {
|
||||
const raw = nodeTypeAndName.trim();
|
||||
if (!raw || !graph) return;
|
||||
const handleNodeGroundingClick = useCallback(
|
||||
(nodeTypeAndName: string) => {
|
||||
const raw = nodeTypeAndName.trim();
|
||||
if (!raw || !graph) return;
|
||||
|
||||
// Parse Type:Name format
|
||||
const match = raw.match(/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/);
|
||||
if (!match) return;
|
||||
// Parse Type:Name format
|
||||
const match = raw.match(
|
||||
/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/,
|
||||
);
|
||||
if (!match) return;
|
||||
|
||||
const [, nodeType, nodeName] = match;
|
||||
const trimmedName = nodeName.trim();
|
||||
const [, nodeType, nodeName] = match;
|
||||
const trimmedName = nodeName.trim();
|
||||
|
||||
// Find node in graph by type + name
|
||||
const node = graph.nodes.find(n =>
|
||||
n.label === nodeType &&
|
||||
n.properties.name === trimmedName
|
||||
);
|
||||
// Find node in graph by type + name
|
||||
const node = graph.nodes.find(
|
||||
(n) => n.label === nodeType && n.properties.name === trimmedName,
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Highlight in graph (add to AI citation highlights)
|
||||
// Note: This requires accessing the state setter from parent context
|
||||
// For now, we'll add to code references which triggers the highlight
|
||||
|
||||
// 2. Add to Code Panel (if node has file/line info)
|
||||
if (node.properties.filePath) {
|
||||
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
|
||||
if (resolvedPath) {
|
||||
addCodeReference({
|
||||
filePath: resolvedPath,
|
||||
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
|
||||
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
|
||||
nodeId: node.id,
|
||||
label: node.label,
|
||||
name: node.properties.name,
|
||||
source: 'ai',
|
||||
});
|
||||
if (!node) {
|
||||
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [graph, resolveFilePathForUI, addCodeReference]);
|
||||
|
||||
const handleLinkClick = useCallback((href: string) => {
|
||||
if (href.startsWith('code-ref:')) {
|
||||
const inner = decodeURIComponent(href.slice('code-ref:'.length));
|
||||
handleGroundingClick(inner);
|
||||
} else if (href.startsWith('node-ref:')) {
|
||||
const inner = decodeURIComponent(href.slice('node-ref:'.length));
|
||||
handleNodeGroundingClick(inner);
|
||||
}
|
||||
}, [handleGroundingClick, handleNodeGroundingClick]);
|
||||
// 1. Highlight in graph (add to AI citation highlights)
|
||||
// Note: This requires accessing the state setter from parent context
|
||||
// For now, we'll add to code references which triggers the highlight
|
||||
|
||||
// 2. Add to Code Panel (if node has file/line info)
|
||||
if (node.properties.filePath) {
|
||||
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
|
||||
if (resolvedPath) {
|
||||
addCodeReference({
|
||||
filePath: resolvedPath,
|
||||
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
|
||||
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
|
||||
nodeId: node.id,
|
||||
label: node.label,
|
||||
name: node.properties.name,
|
||||
source: 'ai',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[graph, resolveFilePathForUI, addCodeReference],
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(href: string) => {
|
||||
if (href.startsWith('code-ref:')) {
|
||||
const inner = decodeURIComponent(href.slice('code-ref:'.length));
|
||||
handleGroundingClick(inner);
|
||||
} else if (href.startsWith('node-ref:')) {
|
||||
const inner = decodeURIComponent(href.slice('node-ref:'.length));
|
||||
handleNodeGroundingClick(inner);
|
||||
}
|
||||
},
|
||||
[handleGroundingClick, handleNodeGroundingClick],
|
||||
);
|
||||
|
||||
// Auto-resize textarea as user types
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
@@ -208,33 +212,35 @@ export const RightPanel = () => {
|
||||
if (!isRightPanelOpen) return null;
|
||||
|
||||
return (
|
||||
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
|
||||
<aside className="relative z-30 flex w-[40%] max-w-[600px] min-w-[400px] flex-shrink-0 animate-slide-in flex-col border-l border-border-subtle bg-deep">
|
||||
{/* Header with Tabs */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-surface px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Chat Tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'chat'
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>Nexus AI</span>
|
||||
</button>
|
||||
|
||||
{/* Processes Tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('processes')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'processes'
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-hover'
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === 'processes'
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span>Processes</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-full font-semibold">
|
||||
<span className="rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
NEW
|
||||
</span>
|
||||
</button>
|
||||
@@ -243,34 +249,34 @@ export const RightPanel = () => {
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setRightPanelOpen(false)}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Close Panel"
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Processes Tab */}
|
||||
{activeTab === 'processes' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<ProcessesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Content - only show when chat tab is active */}
|
||||
{activeTab === 'chat' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2.5 border-b border-border-subtle bg-elevated/50 px-4 py-3">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isAgentReady && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-300">
|
||||
Configure AI
|
||||
</span>
|
||||
)}
|
||||
{isAgentInitializing && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
|
||||
<span className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Connecting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,33 +284,30 @@ export const RightPanel = () => {
|
||||
|
||||
{/* Status / errors */}
|
||||
{agentError && (
|
||||
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{agentError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
||||
{chatMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
|
||||
🧠
|
||||
</div>
|
||||
<h3 className="text-base font-medium mb-2">
|
||||
Ask me anything
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mb-5">
|
||||
I can help you understand the architecture, find functions, or explain connections.
|
||||
<h3 className="mb-2 text-base font-medium">Ask me anything</h3>
|
||||
<p className="mb-5 text-sm leading-relaxed text-text-secondary">
|
||||
I can help you understand the architecture, find functions, or explain
|
||||
connections.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{chatSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => setChatInput(suggestion)}
|
||||
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
|
||||
className="rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent hover:text-text-primary"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
@@ -314,41 +317,40 @@ export const RightPanel = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{chatMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<div key={message.id} className="animate-fade-in">
|
||||
{/* User message - compact label style */}
|
||||
{message.role === 'user' && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<User className="w-4 h-4 text-text-muted" />
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
|
||||
</div>
|
||||
<div className="pl-6 text-sm text-text-primary">
|
||||
{message.content}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-text-muted" />
|
||||
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-6 text-sm text-text-primary">{message.content}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assistant message - copilot style */}
|
||||
{message.role === 'assistant' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
|
||||
Nexus AI
|
||||
</span>
|
||||
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-accent" />
|
||||
<Loader2 className="h-3 w-3 animate-spin text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-6 chat-prose">
|
||||
<div className="chat-prose pl-6">
|
||||
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
|
||||
{message.steps && message.steps.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{message.steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
{step.type === 'reasoning' && step.content && (
|
||||
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
|
||||
<div className="mb-3 border-l-2 border-text-muted/30 pl-3 text-sm text-text-secondary italic">
|
||||
<MarkdownRenderer
|
||||
content={step.content}
|
||||
onLinkClick={handleLinkClick}
|
||||
@@ -357,7 +359,10 @@ export const RightPanel = () => {
|
||||
)}
|
||||
{step.type === 'tool_call' && step.toolCall && (
|
||||
<div className="mb-3">
|
||||
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
|
||||
<ToolCallCard
|
||||
toolCall={step.toolCall}
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.type === 'content' && step.content && (
|
||||
@@ -384,8 +389,6 @@ export const RightPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll anchor for auto-scroll */}
|
||||
@@ -393,8 +396,8 @@ export const RightPanel = () => {
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 bg-surface border-t border-border-subtle">
|
||||
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<div className="border-t border-border-subtle bg-surface p-3">
|
||||
<div className="flex items-end gap-2 rounded-xl border border-border-subtle bg-elevated px-3 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={chatInput}
|
||||
@@ -402,12 +405,12 @@ export const RightPanel = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about the codebase..."
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
|
||||
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
style={{ height: '36px', overflowY: 'hidden' }}
|
||||
/>
|
||||
<button
|
||||
onClick={clearChat}
|
||||
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
className="px-2 py-1 text-xs text-text-muted transition-colors hover:text-text-primary"
|
||||
title="Clear chat"
|
||||
>
|
||||
Clear
|
||||
@@ -415,24 +418,24 @@ export const RightPanel = () => {
|
||||
{isChatLoading ? (
|
||||
<button
|
||||
onClick={stopChatResponse}
|
||||
className="w-9 h-9 flex items-center justify-center bg-red-500/80 rounded-md text-white transition-all hover:bg-red-500"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md bg-red-500/80 text-white transition-all hover:bg-red-500"
|
||||
title="Stop response"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 fill-current" />
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput.trim() || isAgentInitializing}
|
||||
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white transition-all hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isAgentReady && !isAgentInitializing && (
|
||||
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-amber-200">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{isProviderConfigured()
|
||||
? 'Initializing AI agent...'
|
||||
@@ -446,6 +449,3 @@ export const RightPanel = () => {
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons';
|
||||
import {
|
||||
X,
|
||||
Key,
|
||||
Server,
|
||||
Brain,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Search,
|
||||
} from '@/lib/lucide-icons';
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
@@ -31,7 +44,13 @@ interface OpenRouterModelComboboxProps {
|
||||
onLoadModels: () => void;
|
||||
}
|
||||
|
||||
const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadModels }: OpenRouterModelComboboxProps) => {
|
||||
const OpenRouterModelCombobox = ({
|
||||
value,
|
||||
onChange,
|
||||
models,
|
||||
isLoading,
|
||||
onLoadModels,
|
||||
}: OpenRouterModelComboboxProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -41,16 +60,15 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchTerm.trim()) return models;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return models.filter(m =>
|
||||
m.id.toLowerCase().includes(lower) ||
|
||||
m.name.toLowerCase().includes(lower)
|
||||
return models.filter(
|
||||
(m) => m.id.toLowerCase().includes(lower) || m.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}, [models, searchTerm]);
|
||||
|
||||
// Find display name for current value
|
||||
const displayValue = useMemo(() => {
|
||||
if (!value) return '';
|
||||
const found = models.find(m => m.id === value);
|
||||
const found = models.find((m) => m.id === value);
|
||||
return found ? found.name : value;
|
||||
}, [value, models]);
|
||||
|
||||
@@ -93,7 +111,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && searchTerm) {
|
||||
// If exact match in filtered, select it; otherwise use raw input
|
||||
const exact = filteredModels.find(m => m.id.toLowerCase() === searchTerm.toLowerCase());
|
||||
const exact = filteredModels.find((m) => m.id.toLowerCase() === searchTerm.toLowerCase());
|
||||
if (exact) {
|
||||
handleSelect(exact.id);
|
||||
} else if (filteredModels.length === 1) {
|
||||
@@ -115,8 +133,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
|
||||
{/* Main input/button */}
|
||||
<div
|
||||
onClick={handleOpen}
|
||||
className={`w-full px-4 py-3 bg-elevated border rounded-xl cursor-pointer transition-all flex items-center gap-2
|
||||
${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
|
||||
className={`flex w-full cursor-pointer items-center gap-2 rounded-xl border bg-elevated px-4 py-3 transition-all ${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<input
|
||||
@@ -126,58 +143,61 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or type model ID..."
|
||||
className="flex-1 bg-transparent text-text-primary placeholder:text-text-muted outline-none font-mono text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="flex-1 bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 font-mono text-sm truncate ${value ? 'text-text-primary' : 'text-text-muted'}`}>
|
||||
<span
|
||||
className={`flex-1 truncate font-mono text-sm ${value ? 'text-text-primary' : 'text-text-muted'}`}
|
||||
>
|
||||
{displayValue || 'Select or type a model...'}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-text-muted" />}
|
||||
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-text-muted" />}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-elevated border border-border-subtle rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border border-border-subtle bg-elevated shadow-xl">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-6 text-center text-text-muted text-sm flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-6 text-center text-sm text-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="px-4 py-4 text-center">
|
||||
{models.length === 0 ? (
|
||||
<div className="text-text-muted text-sm">
|
||||
<Search className="w-5 h-5 mx-auto mb-2 opacity-50" />
|
||||
<div className="text-sm text-text-muted">
|
||||
<Search className="mx-auto mb-2 h-5 w-5 opacity-50" />
|
||||
<p>Type a model ID or press Enter</p>
|
||||
<p className="text-xs mt-1">e.g. openai/gpt-4o</p>
|
||||
<p className="mt-1 text-xs">e.g. openai/gpt-4o</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-muted text-sm">
|
||||
<div className="text-sm text-text-muted">
|
||||
<p>No models match "{searchTerm}"</p>
|
||||
<p className="text-xs mt-1">Press Enter to use as custom ID</p>
|
||||
<p className="mt-1 text-xs">Press Enter to use as custom ID</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filteredModels.slice(0, 50).map(model => (
|
||||
{filteredModels.slice(0, 50).map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelect(model.id)}
|
||||
className={`w-full px-4 py-2.5 text-left hover:bg-hover transition-colors flex flex-col
|
||||
${model.id === value ? 'bg-accent/10' : ''}`}
|
||||
className={`flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-hover ${model.id === value ? 'bg-accent/10' : ''}`}
|
||||
>
|
||||
<span className="text-text-primary text-sm truncate">{model.name}</span>
|
||||
<span className="text-text-muted text-xs font-mono truncate">{model.id}</span>
|
||||
<span className="truncate text-sm text-text-primary">{model.name}</span>
|
||||
<span className="truncate font-mono text-xs text-text-muted">{model.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredModels.length > 50 && (
|
||||
<div className="px-4 py-2 text-xs text-text-muted text-center border-t border-border-subtle">
|
||||
<div className="border-t border-border-subtle px-4 py-2 text-center text-xs text-text-muted">
|
||||
+{filteredModels.length - 50} more • Refine your search
|
||||
</div>
|
||||
)}
|
||||
@@ -192,7 +212,9 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
|
||||
/**
|
||||
* Check connection to local Ollama instance
|
||||
*/
|
||||
const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error: string | null }> => {
|
||||
const checkOllamaStatus = async (
|
||||
baseUrl: string,
|
||||
): Promise<{ ok: boolean; error: string | null }> => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
@@ -201,7 +223,10 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 0 || response.status === 404) {
|
||||
return { ok: false, error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' };
|
||||
return {
|
||||
ok: false,
|
||||
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
|
||||
};
|
||||
}
|
||||
return { ok: false, error: `Ollama API error: ${response.status}` };
|
||||
}
|
||||
@@ -210,12 +235,19 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`'
|
||||
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, isBackendConnected, onBackendUrlChange }: SettingsPanelProps) => {
|
||||
export const SettingsPanel = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSettingsSaved,
|
||||
backendUrl,
|
||||
isBackendConnected,
|
||||
onBackendUrlChange,
|
||||
}: SettingsPanelProps) => {
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
@@ -274,7 +306,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
}, [settings.ollama?.baseUrl, settings.activeProvider, checkOllamaConnection]);
|
||||
|
||||
const handleProviderChange = (provider: LLMProvider) => {
|
||||
setSettings(prev => ({ ...prev, activeProvider: provider }));
|
||||
setSettings((prev) => ({ ...prev, activeProvider: provider }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -292,29 +324,34 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
};
|
||||
|
||||
const toggleApiKeyVisibility = (key: string) => {
|
||||
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
setShowApiKey((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax', 'glm'];
|
||||
|
||||
const providers: LLMProvider[] = [
|
||||
'openai',
|
||||
'gemini',
|
||||
'anthropic',
|
||||
'azure-openai',
|
||||
'ollama',
|
||||
'openrouter',
|
||||
'minimax',
|
||||
'glm',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden max-h-[90vh] flex flex-col">
|
||||
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-elevated/50">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-elevated/50 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-accent/20 rounded-xl">
|
||||
<Brain className="w-5 h-5 text-accent" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
|
||||
<Brain className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
|
||||
@@ -323,25 +360,25 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
|
||||
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
{/* Local Server */}
|
||||
{backendUrl !== undefined && onBackendUrlChange && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
Local Server
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-text-secondary">Local Server</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Server className="w-4 h-4 text-text-muted" />
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-text-muted" />
|
||||
<span className="text-sm text-text-secondary">Backend URL</span>
|
||||
<span className={`w-2 h-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-text-muted">
|
||||
{isBackendConnected ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
@@ -351,10 +388,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
value={backendUrl}
|
||||
onChange={(e) => onBackendUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:4747"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Run <code className="px-1 py-0.5 bg-elevated rounded">gitnexus serve</code> to start the local server
|
||||
Run <code className="rounded bg-elevated px-1 py-0.5">gitnexus serve</code> to
|
||||
start the local server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,27 +400,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
Provider
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{providers.map(provider => (
|
||||
<label className="block text-sm font-medium text-text-secondary">Provider</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleProviderChange(provider)}
|
||||
className={`
|
||||
flex items-center gap-3 p-4 rounded-xl border-2 transition-all
|
||||
${settings.activeProvider === provider
|
||||
className={`flex items-center gap-3 rounded-xl border-2 p-4 transition-all ${
|
||||
settings.activeProvider === provider
|
||||
? 'border-accent bg-accent/10 text-text-primary'
|
||||
: 'border-border-subtle bg-elevated hover:border-accent/50 text-text-secondary'
|
||||
}
|
||||
`}
|
||||
: 'border-border-subtle bg-elevated text-text-secondary hover:border-accent/50'
|
||||
} `}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-lg flex items-center justify-center text-lg
|
||||
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
|
||||
`}>
|
||||
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : provider === 'glm' ? '🔮' : '☁️'}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg text-lg ${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'} `}
|
||||
>
|
||||
{provider === 'openai'
|
||||
? '🤖'
|
||||
: provider === 'gemini'
|
||||
? '💎'
|
||||
: provider === 'anthropic'
|
||||
? '🧠'
|
||||
: provider === 'ollama'
|
||||
? '🦙'
|
||||
: provider === 'openrouter'
|
||||
? '🌐'
|
||||
: provider === 'minimax'
|
||||
? '⚡'
|
||||
: provider === 'glm'
|
||||
? '🔮'
|
||||
: '☁️'}
|
||||
</div>
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
</button>
|
||||
@@ -390,7 +437,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-200">
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
API keys are stored in session storage and will be cleared when you close this tab.
|
||||
</div>
|
||||
|
||||
@@ -405,38 +452,43 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
helperLink: 'https://platform.openai.com/api-keys',
|
||||
helperLinkLabel: 'OpenAI Platform',
|
||||
isVisible: !!showApiKey['openai'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, apiKey: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('openai'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.openai?.model ?? 'gpt-5.2-chat',
|
||||
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, model: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, model: value },
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="w-4 h-4" />
|
||||
Base URL <span className="text-text-muted font-normal">(optional)</span>
|
||||
<Server className="h-4 w-4" />
|
||||
Base URL <span className="font-normal text-text-muted">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.openai?.baseUrl ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, baseUrl: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai!, baseUrl: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.openai.com/v1 (default)"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
|
||||
Leave empty to use the default OpenAI API. Set a custom URL for proxies or
|
||||
compatible APIs.
|
||||
</p>
|
||||
</div>
|
||||
</ProviderConfigCard>
|
||||
@@ -453,19 +505,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
helperLink: 'https://aistudio.google.com/app/apikey',
|
||||
helperLinkLabel: 'Google AI Studio',
|
||||
isVisible: !!showApiKey['gemini'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, apiKey: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('gemini'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.gemini?.model ?? 'gemini-2.0-flash',
|
||||
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: value },
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -481,66 +535,76 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
helperLink: 'https://console.anthropic.com/settings/keys',
|
||||
helperLinkLabel: 'Anthropic Console',
|
||||
isVisible: !!showApiKey['anthropic'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, apiKey: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('anthropic'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
|
||||
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, model: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
anthropic: { ...prev.anthropic!, model: value },
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Azure OpenAI Settings */}
|
||||
{settings.activeProvider === 'azure-openai' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['azure'] ? 'text' : 'password'}
|
||||
value={settings.azureOpenAI?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your Azure OpenAI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('azure')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
{showApiKey['azure'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showApiKey['azure'] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="w-4 h-4" />
|
||||
<Server className="h-4 w-4" />
|
||||
Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.azureOpenAI?.endpoint ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="https://your-resource.openai.azure.com"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -549,12 +613,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.deploymentName ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., gpt-4o-deployment"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -564,12 +630,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="gpt-4o"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -578,12 +646,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="2024-08-01-preview"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,10 +674,10 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
|
||||
{/* Ollama Settings */}
|
||||
{settings.activeProvider === 'ollama' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
{/* How to run Ollama */}
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl">
|
||||
<p className="text-xs text-amber-300 leading-relaxed">
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-xs leading-relaxed text-amber-300">
|
||||
<span className="font-medium">📋 Quick Start:</span> Install Ollama from{' '}
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
@@ -616,41 +686,46 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
ollama.ai
|
||||
</a>, then run:
|
||||
</a>
|
||||
, then run:
|
||||
</p>
|
||||
<code className="block mt-2 px-3 py-2 bg-black/30 rounded-lg text-amber-200 font-mono text-sm">
|
||||
<code className="mt-2 block rounded-lg bg-black/30 px-3 py-2 font-mono text-sm text-amber-200">
|
||||
ollama serve
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="w-4 h-4" />
|
||||
<Server className="h-4 w-4" />
|
||||
Base URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama!, baseUrl: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama!, baseUrl: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder={DEFAULT_OLLAMA_BASE_URL}
|
||||
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
className="flex-1 rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)}
|
||||
onClick={() =>
|
||||
checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)
|
||||
}
|
||||
disabled={isCheckingOllama}
|
||||
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl border border-border-subtle bg-elevated px-3 py-3 text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary disabled:opacity-50"
|
||||
title="Check connection"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Default port is <code className="px-1 py-0.5 bg-elevated rounded">11434</code>.
|
||||
Default port is <code className="rounded bg-elevated px-1 py-0.5">11434</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -658,9 +733,9 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
|
||||
{ollamaError && !isCheckingOllama && (
|
||||
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-xs text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2">
|
||||
<p className="flex items-center gap-1 text-xs text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{ollamaError}
|
||||
</p>
|
||||
</div>
|
||||
@@ -669,15 +744,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ollama?.model ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama!, model: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama!, model: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., llama3.2, mistral, codellama"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Pull a model with <code className="px-1 py-0.5 bg-elevated rounded">ollama pull llama3.2</code>
|
||||
Pull a model with{' '}
|
||||
<code className="rounded bg-elevated px-1 py-0.5">ollama pull llama3.2</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -694,10 +772,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
helperLink: 'https://openrouter.ai/keys',
|
||||
helperLinkLabel: 'OpenRouter Keys',
|
||||
isVisible: !!showApiKey['openrouter'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, apiKey: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('openrouter'),
|
||||
}}
|
||||
>
|
||||
@@ -705,10 +784,12 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<OpenRouterModelCombobox
|
||||
value={settings.openrouter?.model ?? ''}
|
||||
onChange={(model) => setSettings(prev => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, model }
|
||||
}))}
|
||||
onChange={(model) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
openrouter: { ...prev.openrouter!, model },
|
||||
}))
|
||||
}
|
||||
models={openRouterModels}
|
||||
isLoading={isLoadingModels}
|
||||
onLoadModels={loadOpenRouterModels}
|
||||
@@ -739,19 +820,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
helperLink: 'https://platform.minimax.io',
|
||||
helperLinkLabel: 'MiniMax Platform',
|
||||
isVisible: !!showApiKey['minimax'],
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, apiKey: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.minimax?.model ?? 'MiniMax-M2.5',
|
||||
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
|
||||
onChange: (value) => setSettings(prev => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: value }
|
||||
})),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: value },
|
||||
})),
|
||||
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
|
||||
}}
|
||||
/>
|
||||
@@ -759,29 +842,35 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
|
||||
{/* GLM Settings */}
|
||||
{settings.activeProvider === 'glm' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['glm'] ? 'text' : 'password'}
|
||||
value={settings.glm?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, apiKey: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, apiKey: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your Z.AI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('glm')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
{showApiKey['glm'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showApiKey['glm'] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
@@ -801,14 +890,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<select
|
||||
value={settings.glm?.model ?? 'GLM-5'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, model: e.target.value }
|
||||
}))}
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, model: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
{getAvailableModels('glm').map(model => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
{getAvailableModels('glm').map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -818,12 +911,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<input
|
||||
type="text"
|
||||
value={settings.glm?.baseUrl ?? 'https://api.z.ai/api/coding/paas/v4'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, baseUrl: e.target.value }
|
||||
}))}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
glm: { ...prev.glm!, baseUrl: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.z.ai/api/coding/paas/v4"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API.
|
||||
@@ -833,30 +928,33 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
)}
|
||||
|
||||
{/* Privacy Note */}
|
||||
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
|
||||
<div className="rounded-xl border border-border-subtle bg-elevated/50 p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-lg text-green-400 flex-shrink-0">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-green-500/20 text-green-400">
|
||||
🔒
|
||||
</div>
|
||||
<div className="text-xs text-text-muted leading-relaxed">
|
||||
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's session storage and are cleared when the tab closes. They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
|
||||
<div className="text-xs leading-relaxed text-text-muted">
|
||||
<span className="font-medium text-text-secondary">Privacy:</span> Your API keys are
|
||||
stored only in your browser's session storage and are cleared when the tab closes.
|
||||
They're sent directly to the LLM provider when you chat. Your code never leaves your
|
||||
machine.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border-subtle bg-elevated/30">
|
||||
<div className="flex items-center justify-between border-t border-border-subtle bg-elevated/30 px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="flex items-center gap-1.5 text-green-400 animate-fade-in">
|
||||
<Check className="w-4 h-4" />
|
||||
<span className="flex animate-fade-in items-center gap-1.5 text-green-400">
|
||||
<Check className="h-4 w-4" />
|
||||
Settings saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-red-400 animate-fade-in">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="flex animate-fade-in items-center gap-1.5 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
@@ -864,13 +962,13 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
className="px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-dim"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -880,4 +978,3 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,28 +11,29 @@ export const StatusBar = () => {
|
||||
// Detect primary language
|
||||
const primaryLanguage = useMemo(() => {
|
||||
if (!graph) return null;
|
||||
const languages = graph.nodes
|
||||
.map(n => n.properties.language)
|
||||
.filter(Boolean);
|
||||
const languages = graph.nodes.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<string, number>);
|
||||
const counts = languages.reduce(
|
||||
(acc, lang) => {
|
||||
acc[lang!] = (acc[lang!] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
}, [graph]);
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
|
||||
<footer className="flex items-center justify-between border-t border-dashed border-border-subtle bg-deep px-5 py-2 text-[11px] text-text-muted">
|
||||
{/* Left - Status */}
|
||||
<div className="flex items-center gap-4">
|
||||
{progress && progress.phase !== 'complete' ? (
|
||||
<>
|
||||
<div className="w-28 h-1 bg-elevated rounded-full overflow-hidden">
|
||||
<div className="h-1 w-28 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -40,7 +41,7 @@ export const StatusBar = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" data-testid="status-ready">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-node-function" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -51,17 +52,19 @@ export const StatusBar = () => {
|
||||
href="https://github.com/sponsors/abhigyanpatwari"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 hover:bg-pink-500/20 hover:border-pink-500/40 hover:scale-[1.02] transition-all duration-200 cursor-pointer"
|
||||
className="group flex cursor-pointer items-center gap-2 rounded-full border border-pink-500/20 bg-pink-500/10 px-3 py-1 transition-all duration-200 hover:scale-[1.02] hover:border-pink-500/40 hover:bg-pink-500/20"
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5 text-pink-500 fill-pink-500/40 group-hover:fill-pink-500 group-hover:scale-110 transition-all duration-200 animate-pulse" />
|
||||
<span className="text-[11px] font-medium text-pink-400 group-hover:text-pink-300 transition-colors">Sponsor</span>
|
||||
<span className="text-[10px] text-pink-300/50 group-hover:text-pink-300/80 italic hidden md:inline transition-colors">
|
||||
<Heart className="h-3.5 w-3.5 animate-pulse fill-pink-500/40 text-pink-500 transition-all duration-200 group-hover:scale-110 group-hover:fill-pink-500" />
|
||||
<span className="text-[11px] font-medium text-pink-400 transition-colors group-hover:text-pink-300">
|
||||
Sponsor
|
||||
</span>
|
||||
<span className="hidden text-[10px] text-pink-300/50 italic transition-colors group-hover:text-pink-300/80 md:inline">
|
||||
need to buy some API credits to run SWE-bench 😅
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Right - Stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3" data-testid="graph-stats">
|
||||
{graph && (
|
||||
<>
|
||||
<span>{nodeCount} nodes</span>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* ToolCallCard Component
|
||||
*
|
||||
*
|
||||
* Displays a tool call with expand/collapse functionality.
|
||||
* Shows the tool name, status, and when expanded, the query/args and result.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from '@/lib/lucide-icons';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from '@/lib/lucide-icons';
|
||||
import type { ToolCallInfo } from '../core/llm/types';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
@@ -49,28 +56,28 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="w-3.5 h-3.5 animate-spin" />,
|
||||
icon: <Loader2 className="h-3.5 w-3.5 animate-spin" />,
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
icon: <Check className="w-3.5 h-3.5" />,
|
||||
icon: <Check className="h-3.5 w-3.5" />,
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
icon: <AlertCircle className="w-3.5 h-3.5" />,
|
||||
icon: <AlertCircle className="h-3.5 w-3.5" />,
|
||||
color: 'text-rose-400',
|
||||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'border-rose-500/30',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Sparkles className="w-3.5 h-3.5" />,
|
||||
icon: <Sparkles className="h-3.5 w-3.5" />,
|
||||
color: 'text-text-muted',
|
||||
bgColor: 'bg-surface',
|
||||
borderColor: 'border-border-subtle',
|
||||
@@ -84,13 +91,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
||||
const getToolDisplayName = (name: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
// Current 7-tool architecture
|
||||
'search': '🔍 Search Code',
|
||||
'cypher': '🔗 Cypher Query',
|
||||
'grep': '🔎 Pattern Search',
|
||||
'read': '📄 Read File',
|
||||
'overview': '🗺️ Codebase Overview',
|
||||
'explore': '🔬 Deep Dive',
|
||||
'impact': '💥 Impact Analysis',
|
||||
search: '🔍 Search Code',
|
||||
cypher: '🔗 Cypher Query',
|
||||
grep: '🔎 Pattern Search',
|
||||
read: '📄 Read File',
|
||||
overview: '🗺️ Codebase Overview',
|
||||
explore: '🔬 Deep Dive',
|
||||
impact: '💥 Impact Analysis',
|
||||
};
|
||||
return names[name] || name;
|
||||
};
|
||||
@@ -101,18 +108,25 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
const formattedArgs = formatArgs(toolCall.args);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}>
|
||||
<div
|
||||
className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}
|
||||
>
|
||||
{/* Header - always visible */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsExpanded(!isExpanded); } }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors cursor-pointer select-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors select-none hover:bg-white/5"
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
<span className="text-text-muted">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
|
||||
{/* Tool name */}
|
||||
@@ -132,11 +146,11 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
<div className="border-t border-border-subtle/50">
|
||||
{/* Arguments/Query */}
|
||||
{formattedArgs && (
|
||||
<div className="px-3 py-2 border-b border-border-subtle/50">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
|
||||
<div className="border-b border-border-subtle/50 px-3 py-2">
|
||||
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
|
||||
{toolCall.name === 'cypher' ? 'Query' : 'Input'}
|
||||
</div>
|
||||
<pre className="text-xs text-text-secondary bg-surface/50 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
<pre className="overflow-x-auto rounded bg-surface/50 p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -145,15 +159,14 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
{/* Result */}
|
||||
{toolCall.result && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
|
||||
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
|
||||
Result
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto bg-surface/50 rounded">
|
||||
<pre className="text-xs text-text-secondary p-2 whitespace-pre-wrap font-mono">
|
||||
<div className="max-h-[400px] overflow-y-auto rounded bg-surface/50">
|
||||
<pre className="p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
|
||||
{toolCall.result.length > 3000
|
||||
? toolCall.result.slice(0, 3000) + '\n\n... (truncated)'
|
||||
: toolCall.result
|
||||
}
|
||||
: toolCall.result}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,8 +174,8 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
|
||||
{/* Loading state for in-progress */}
|
||||
{toolCall.status === 'running' && !toolCall.result && (
|
||||
<div className="px-3 py-3 flex items-center gap-2 text-xs text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,27 +41,27 @@ export const WebGPUFallbackDialog = ({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className={`relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden transition-all duration-200 ${isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}
|
||||
<div
|
||||
className={`relative mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
|
||||
>
|
||||
{/* Header with scratching emoji */}
|
||||
<div className="relative bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5 border-b border-border-subtle">
|
||||
<div className="relative border-b border-border-subtle bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
className="absolute top-4 right-4 p-1 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Animated emoji */}
|
||||
<div
|
||||
<div
|
||||
className={`text-5xl ${isAnimating ? 'animate-bounce' : ''}`}
|
||||
onAnimationEnd={() => setIsAnimating(false)}
|
||||
onClick={() => setIsAnimating(true)}
|
||||
@@ -69,10 +69,8 @@ export const WebGPUFallbackDialog = ({
|
||||
🤔
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
WebGPU said "nope"
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-0.5">
|
||||
<h2 className="text-lg font-semibold text-text-primary">WebGPU said "nope"</h2>
|
||||
<p className="mt-0.5 text-sm text-text-muted">
|
||||
Your browser doesn't support GPU acceleration
|
||||
</p>
|
||||
</div>
|
||||
@@ -80,65 +78,68 @@ export const WebGPUFallbackDialog = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<p className="text-sm text-text-secondary leading-relaxed">
|
||||
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG)
|
||||
won't be as smart. The graph still works fine though!
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<p className="text-sm leading-relaxed text-text-secondary">
|
||||
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as
|
||||
smart. The graph still works fine though!
|
||||
</p>
|
||||
|
||||
<div className="bg-elevated/50 rounded-lg p-4 border border-border-subtle">
|
||||
|
||||
<div className="rounded-lg border border-border-subtle bg-elevated/50 p-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
<span className="font-medium text-text-primary">Your options:</span>
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<Snail className="w-4 h-4 mt-0.5 text-amber-400 flex-shrink-0" />
|
||||
<Snail className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-400" />
|
||||
<span>
|
||||
<strong className="text-text-secondary">Use CPU</strong> — Works but {isSmallCodebase ? 'a bit' : 'way'} slower
|
||||
<strong className="text-text-secondary">Use CPU</strong> — Works but{' '}
|
||||
{isSmallCodebase ? 'a bit' : 'way'} slower
|
||||
{nodeCount > 0 && (
|
||||
<span className="text-text-muted"> (~{estimatedMinutes} min for {nodeCount} nodes)</span>
|
||||
<span className="text-text-muted">
|
||||
{' '}
|
||||
(~{estimatedMinutes} min for {nodeCount} nodes)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<SkipForward className="w-4 h-4 mt-0.5 text-blue-400 flex-shrink-0" />
|
||||
<SkipForward className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
|
||||
<span>
|
||||
<strong className="text-text-secondary">Skip it</strong> — Graph works, just no AI semantic search
|
||||
<strong className="text-text-secondary">Skip it</strong> — Graph works, just no AI
|
||||
semantic search
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{isSmallCodebase && (
|
||||
<p className="text-xs text-node-function flex items-center gap-1.5 bg-node-function/10 px-3 py-2 rounded-lg">
|
||||
<Rocket className="w-3.5 h-3.5" />
|
||||
<p className="flex items-center gap-1.5 rounded-lg bg-node-function/10 px-3 py-2 text-xs text-node-function">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Small codebase detected! CPU should be fine.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
💡 Tip: Try Chrome or Edge for WebGPU support
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">💡 Tip: Try Chrome or Edge for WebGPU support</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 bg-elevated/30 border-t border-border-subtle flex gap-3">
|
||||
<div className="flex gap-3 border-t border-border-subtle bg-elevated/30 px-6 py-4">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary bg-surface border border-border-subtle rounded-lg hover:bg-hover hover:text-text-primary transition-all flex items-center justify-center gap-2"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2.5 text-sm font-medium text-text-secondary transition-all hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
<SkipForward className="h-4 w-4" />
|
||||
Skip Embeddings
|
||||
</button>
|
||||
<button
|
||||
onClick={onUseCPU}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all flex items-center justify-center gap-2 ${
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
isSmallCodebase
|
||||
? 'bg-node-function text-white hover:bg-node-function/90'
|
||||
: 'bg-amber-500/20 text-amber-300 border border-amber-500/30 hover:bg-amber-500/30'
|
||||
: 'border border-amber-500/30 bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
<Snail className="w-4 h-4" />
|
||||
<Snail className="h-4 w-4" />
|
||||
Use CPU {isSmallCodebase ? '(Recommended)' : '(Slow)'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -146,4 +147,3 @@ export const WebGPUFallbackDialog = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,36 +36,34 @@ export const ProviderConfigCard = ({
|
||||
children,
|
||||
}: ProviderConfigCardProps) => {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
|
||||
{description ? (
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
) : null}
|
||||
{description ? <p className="text-xs text-text-muted">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={apiKey.isVisible ? 'text' : 'password'}
|
||||
value={apiKey.value}
|
||||
onChange={e => apiKey.onChange(e.target.value)}
|
||||
onChange={(e) => apiKey.onChange(e.target.value)}
|
||||
placeholder={apiKey.placeholder}
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={apiKey.onToggleVisibility}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
{apiKey.isVisible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{apiKey.isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{apiKey.helperText && (
|
||||
@@ -94,13 +92,11 @@ export const ProviderConfigCard = ({
|
||||
<input
|
||||
type="text"
|
||||
value={model.value}
|
||||
onChange={e => model.onChange(e.target.value)}
|
||||
onChange={(e) => model.onChange(e.target.value)}
|
||||
placeholder={model.placeholder}
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
{model.helperText ? (
|
||||
<p className="text-xs text-text-muted">{model.helperText}</p>
|
||||
) : null}
|
||||
{model.helperText ? <p className="text-xs text-text-muted">{model.helperText}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user