mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat: configure prettier with pre-commit hook (#563)
* feat: configure prettier with pre-commit hook integration Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo root with husky pre-commit hook integration. Moves husky from gitnexus/ to root package.json for reliable hook installation. - Root package.json with prepare/format/format:check scripts - .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4 - .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md - .gitattributes enforcing LF line endings for Windows consistency - Pre-commit hook uses direct node_modules/.bin/ paths (no npx) * style: apply prettier formatting to entire codebase One-time bulk format. No logic changes. Use .git-blame-ignore-revs to skip this commit in git blame. * chore: add .git-blame-ignore-revs for prettier format commit * perf: pre-commit hook runs only tests related to staged files Use vitest --related to scope test execution to tests that import the changed files, instead of running the full suite on every commit. * perf: remove vitest from pre-commit hook, keep in CI only Pre-commit now runs lint-staged + tsc only. Tests run in CI (ci-tests.yml) where they belong — keeps commits fast. * ci: add prettier format check to quality workflow PRs will now fail if code isn't formatted with prettier.
This commit is contained in:
parent
fd7fb5bf1f
commit
bf09eab95b
353 changed files with 26992 additions and 17009 deletions
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Prettier initial formatting (2026-03-28)
|
||||
afcc3d1523f99c77ff67c4fd1af12334660113f6
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
.husky/* text eol=lf
|
||||
2
.github/release.yml
vendored
2
.github/release.yml
vendored
|
|
@ -35,7 +35,7 @@ changelog:
|
|||
- dependencies
|
||||
- title: "\U0001F4DD Other Changes"
|
||||
labels:
|
||||
- "*"
|
||||
- '*'
|
||||
exclude:
|
||||
labels:
|
||||
- dependencies
|
||||
|
|
|
|||
13
.github/workflows/ci-quality.yml
vendored
13
.github/workflows/ci-quality.yml
vendored
|
|
@ -4,6 +4,19 @@ 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 .
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
|
|
|||
6
.github/workflows/ci-report.yml
vendored
6
.github/workflows/ci-report.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
16
.prettierignore
Normal file
16
.prettierignore
Normal file
|
|
@ -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
.prettierrc
Normal file
10
.prettierrc
Normal file
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,11 @@ export {
|
|||
REL_TYPES,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
} from './lbug/schema-constants.js';
|
||||
export type {
|
||||
NodeTableName,
|
||||
RelType,
|
||||
} 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';
|
||||
|
||||
// Pipeline progress
|
||||
export type {
|
||||
PipelinePhase,
|
||||
PipelineProgress,
|
||||
} from './pipeline.js';
|
||||
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@
|
|||
import { SupportedLanguages } from './languages.js';
|
||||
|
||||
/** Ruby extensionless filenames recognised as Ruby source */
|
||||
const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']);
|
||||
const RUBY_EXTENSIONLESS_FILES = new Set([
|
||||
'Rakefile',
|
||||
'Gemfile',
|
||||
'Guardfile',
|
||||
'Vagrantfile',
|
||||
'Brewfile',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exhaustive map: every SupportedLanguages member → its file extensions.
|
||||
|
|
@ -21,26 +27,29 @@ const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'V
|
|||
* 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.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
|
||||
[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.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 [lang, exts] of Object.entries(EXTENSION_MAP) as [
|
||||
SupportedLanguages,
|
||||
readonly string[],
|
||||
][]) {
|
||||
for (const ext of exts) {
|
||||
extToLang.set(ext, lang);
|
||||
}
|
||||
|
|
@ -75,37 +84,50 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
|
|||
* 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.Cobol]: 'cobol',
|
||||
[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.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',
|
||||
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',
|
||||
Makefile: 'makefile',
|
||||
Dockerfile: 'docker',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,25 +9,63 @@
|
|||
*/
|
||||
|
||||
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',
|
||||
'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 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', 'OVERRIDES',
|
||||
'MEMBER_OF', 'STEP_IN_PROCESS',
|
||||
'HANDLES_ROUTE', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF',
|
||||
'WRAPS', 'QUERIES',
|
||||
'CONTAINS',
|
||||
'DEFINES',
|
||||
'IMPORTS',
|
||||
'CALLS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'ACCESSES',
|
||||
'OVERRIDES',
|
||||
'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 type RelType = (typeof REL_TYPES)[number];
|
||||
|
||||
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ test.describe('Flow 1: Onboarding — no server', () => {
|
|||
// 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 });
|
||||
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows terminal window with command', async ({ page }) => {
|
||||
|
|
@ -98,7 +100,9 @@ test.describe('Flow 2: Server detected — auto-connect', () => {
|
|||
});
|
||||
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 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');
|
||||
|
|
@ -135,7 +139,11 @@ test.describe('Flow 2: Server detected — auto-connect', () => {
|
|||
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' }),
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
|
@ -158,7 +166,11 @@ test.describe('Flow 3: Analyze form', () => {
|
|||
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' }),
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -222,9 +234,15 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
|
|||
if (process.env.E2E) return;
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
if (!res.ok) { test.skip(true, SKIP_MSG); return; }
|
||||
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; }
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'Server has no indexed repos');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, SKIP_MSG);
|
||||
}
|
||||
|
|
@ -238,7 +256,10 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
|
|||
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();
|
||||
const badge = page
|
||||
.locator('header button')
|
||||
.filter({ has: page.locator('svg') })
|
||||
.first();
|
||||
await badge.click();
|
||||
|
||||
// Repo dropdown should be visible
|
||||
|
|
@ -252,7 +273,10 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
|
|||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open repo dropdown
|
||||
const badge = page.locator('header button').filter({ has: page.locator('svg') }).first();
|
||||
const badge = page
|
||||
.locator('header button')
|
||||
.filter({ has: page.locator('svg') })
|
||||
.first();
|
||||
await badge.click();
|
||||
|
||||
// Click "Analyze a new repository..."
|
||||
|
|
|
|||
|
|
@ -21,11 +21,17 @@ test.beforeAll(async () => {
|
|||
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;
|
||||
}
|
||||
|
|
@ -85,7 +91,9 @@ test.describe('Processes Panel', () => {
|
|||
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('processes-panel.png'), fullPage: true });
|
||||
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
|
|
@ -96,7 +104,10 @@ test.describe('Processes Panel', () => {
|
|||
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
|
||||
await viewBtn.click();
|
||||
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
|
||||
await page.screenshot({ path: testInfo.outputPath('process-view-clicked.png'), fullPage: true });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('process-view-clicked.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
|
||||
|
|
@ -104,7 +115,9 @@ test.describe('Processes Panel', () => {
|
|||
|
||||
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,
|
||||
});
|
||||
|
||||
const processRow = page.locator('[data-testid="process-row"]').first();
|
||||
await expect(processRow).toBeVisible({ timeout: 10_000 });
|
||||
|
|
@ -129,10 +142,14 @@ test.describe('Turn Off All Highlights', () => {
|
|||
await fileItem.click();
|
||||
|
||||
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]');
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', { timeout: 5_000 });
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await highlightToggle.click();
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000 });
|
||||
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,15 @@ import { FileTreePanel } from './components/FileTreePanel';
|
|||
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, connectHeartbeat, BackendError, type ConnectResult, type BackendRepo } from './services/backend-client';
|
||||
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 = () => {
|
||||
|
|
@ -41,36 +49,39 @@ const AppContent = () => {
|
|||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
||||
const handleServerConnect = useCallback(async (result: ConnectResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
const parts = (repoPath || '').split('/').filter(p => p && !p.startsWith('.'));
|
||||
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
|
||||
setProjectName(projectName);
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
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);
|
||||
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent with backend queries, then start embeddings
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(projectName);
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (err) {
|
||||
console.warn('Failed to initialize agent:', err);
|
||||
}
|
||||
}, [setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback]);
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent with backend queries, then start embeddings
|
||||
try {
|
||||
if (getActiveProviderConfig()) {
|
||||
await initializeAgent(projectName);
|
||||
}
|
||||
startEmbeddingsWithFallback();
|
||||
} catch (err) {
|
||||
console.warn('Failed to initialize agent:', err);
|
||||
}
|
||||
},
|
||||
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
|
||||
);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
|
|
@ -84,7 +95,12 @@ const AppContent = () => {
|
|||
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;
|
||||
|
|
@ -93,34 +109,51 @@ const AppContent = () => {
|
|||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
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` });
|
||||
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' });
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
}).then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
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');
|
||||
})
|
||||
.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) => {
|
||||
|
|
@ -176,7 +209,7 @@ const AppContent = () => {
|
|||
|
||||
// Exploring view
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-void overflow-hidden">
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-void">
|
||||
<Header
|
||||
onFocusNode={handleFocusNode}
|
||||
availableRepos={availableRepos}
|
||||
|
|
@ -200,28 +233,30 @@ const AppContent = () => {
|
|||
} 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));
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
continue;
|
||||
}
|
||||
console.error('Failed to connect after analyze:', err);
|
||||
fetchRepos().then(repos => setAvailableRepos(repos)).catch(() => {});
|
||||
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>
|
||||
)}
|
||||
|
|
@ -239,7 +274,6 @@ const AppContent = () => {
|
|||
onClose={() => setSettingsPanelOpen(false)}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,49 +25,44 @@ interface AnalyzeOnboardingProps {
|
|||
|
||||
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
return (
|
||||
<div className="p-7 bg-surface border border-border-default rounded-3xl animate-fade-in relative overflow-hidden">
|
||||
|
||||
<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="absolute -top-28 -right-28 w-72 h-72 bg-accent/6 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-56 h-56 bg-node-function/6 rounded-full blur-3xl pointer-events-none" />
|
||||
<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="inline-flex items-center gap-1.5 mb-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-accent/70" />
|
||||
<span className="text-[11px] text-accent/80 font-medium uppercase tracking-widest">
|
||||
<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 w-14 h-14 mb-4 flex items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent-dim/10 border border-accent/30 shadow-glow-soft">
|
||||
<Github className="w-7 h-7 text-accent" />
|
||||
<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 font-semibold text-text-primary leading-snug">
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Analyze your first repository
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mt-1.5 leading-relaxed max-w-xs mx-auto">
|
||||
Paste a GitHub URL and GitNexus will clone it, parse the code, and
|
||||
build a live knowledge graph — right in your browser.
|
||||
<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}
|
||||
/>
|
||||
<RepoAnalyzer variant="onboarding" onComplete={onComplete} />
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-[11px] text-text-muted text-center leading-relaxed">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -49,33 +49,26 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
|
|||
<div className="space-y-4">
|
||||
{/* Phase label + elapsed */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-secondary font-medium">{label}</span>
|
||||
<span className="text-text-muted font-mono text-xs">{formatElapsed(elapsed)}</span>
|
||||
<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 bg-elevated rounded-full overflow-hidden">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-300 ease-out"
|
||||
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="text-xs text-text-muted font-mono">{pct}%</span>
|
||||
<span className="font-mono text-xs text-text-muted">{pct}%</span>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="
|
||||
flex items-center gap-1.5
|
||||
px-3 py-1.5
|
||||
text-xs text-red-400
|
||||
bg-red-500/10 hover:bg-red-500/20
|
||||
rounded-lg
|
||||
transition-all duration-200
|
||||
"
|
||||
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="w-3.5 h-3.5" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick, Loader2 } 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';
|
||||
|
|
@ -47,7 +58,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
|
||||
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);
|
||||
|
|
@ -85,34 +96,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
|
||||
|
|
@ -126,12 +143,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;
|
||||
|
||||
|
|
@ -158,13 +172,21 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
rafIds.push(outerRafId);
|
||||
|
||||
return () => {
|
||||
rafIds.forEach(id => cancelAnimationFrame(id));
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [codeReferenceFocus?.ts, aiReferences]);
|
||||
|
||||
const refsWithSnippets = useMemo(() => {
|
||||
return aiReferences.map((ref) => {
|
||||
return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0 };
|
||||
return {
|
||||
ref,
|
||||
content: null as string | null,
|
||||
start: 0,
|
||||
end: 0,
|
||||
highlightStart: 0,
|
||||
highlightEnd: 0,
|
||||
totalLines: 0,
|
||||
};
|
||||
});
|
||||
}, [aiReferences]);
|
||||
|
||||
|
|
@ -207,20 +229,29 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
endLine: (endLine ?? startLine) + CONTEXT_LINES,
|
||||
};
|
||||
|
||||
readFile(selectedFilePath, options).then(result => {
|
||||
if (!cancelled) {
|
||||
setFileResult(result);
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setFileResult(null);
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
});
|
||||
readFile(selectedFilePath, options)
|
||||
.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]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedFilePath,
|
||||
selectedNode?.properties?.startLine,
|
||||
selectedNode?.properties?.endLine,
|
||||
selectedIsFile,
|
||||
]);
|
||||
|
||||
// Scroll to the selected node's startLine after content loads
|
||||
useEffect(() => {
|
||||
|
|
@ -234,8 +265,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
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;
|
||||
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 {
|
||||
|
|
@ -247,27 +279,30 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
rafIds.push(innerRaf);
|
||||
});
|
||||
const rafIds = [outerRaf];
|
||||
return () => { cancelled = true; rafIds.forEach(id => cancelAnimationFrame(id)); };
|
||||
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>
|
||||
)}
|
||||
|
|
@ -277,67 +312,72 @@ 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 ref={selectedViewerRef} className="flex-1 min-h-0 overflow-auto scrollbar-thin">
|
||||
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
|
||||
{isLoadingFile ? (
|
||||
<div className="flex items-center justify-center py-8 gap-2 text-text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<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 ? (
|
||||
|
|
@ -377,7 +417,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.</>
|
||||
)}
|
||||
|
|
@ -394,128 +437,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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ interface DropZoneProps {
|
|||
// ── Crossfade wrapper ───────────────────────────────────────────────────────
|
||||
// Captures the outgoing children during fade-out, then swaps to the new children on fade-in.
|
||||
|
||||
function Crossfade({ activeKey, children }: {
|
||||
activeKey: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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);
|
||||
|
|
@ -36,7 +33,9 @@ function Crossfade({ activeKey, children }: {
|
|||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
}
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [activeKey, displayedKey]);
|
||||
|
||||
return (
|
||||
|
|
@ -56,30 +55,34 @@ function Crossfade({ activeKey, children }: {
|
|||
|
||||
function SuccessCard() {
|
||||
return (
|
||||
<div className="p-7 bg-surface border border-emerald-500/20 rounded-3xl relative overflow-hidden" role="status" aria-live="polite">
|
||||
<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="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-emerald-500/8 rounded-full blur-3xl pointer-events-none" />
|
||||
<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 w-16 h-16 mb-5 flex items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/30 shadow-[0_0_30px_rgba(16,185,129,0.15)]">
|
||||
<Check className="w-8 h-8 text-emerald-400" />
|
||||
<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="text-lg font-semibold text-emerald-400 text-center mb-2">
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-emerald-400">
|
||||
Server Connected
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center leading-relaxed">
|
||||
<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 => (
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-emerald-400/60 animate-pulse"
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400/60"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -92,26 +95,30 @@ function SuccessCard() {
|
|||
|
||||
function LoadingCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="p-7 bg-surface border border-accent/20 rounded-3xl relative overflow-hidden" role="status" aria-live="polite">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-accent/20 bg-surface p-7"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Loading glow */}
|
||||
<div className="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-accent/8 rounded-full blur-3xl pointer-events-none" />
|
||||
<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 w-16 h-16 mb-5 flex items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent-dim/10 border border-accent/30 shadow-glow-soft">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
<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="text-lg font-semibold text-text-primary text-center mb-2">
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-text-primary">
|
||||
{message || 'Connecting...'}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center leading-relaxed">
|
||||
<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="w-4 h-4 text-accent/30" />
|
||||
<Sparkles className="h-4 w-4 text-accent/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,14 +131,23 @@ 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 {
|
||||
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
|
||||
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>('onboarding');
|
||||
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>(
|
||||
'onboarding',
|
||||
);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
|
@ -279,17 +295,17 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
const displayPhase = !initialProbeComplete ? null : phase;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
|
||||
<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">
|
||||
{/* 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 animate-fade-in">
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -297,12 +313,8 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
{/* Crossfade between phases */}
|
||||
{displayPhase && (
|
||||
<Crossfade activeKey={displayPhase}>
|
||||
{displayPhase === 'onboarding' && (
|
||||
<OnboardingGuide isPolling={isPolling} />
|
||||
)}
|
||||
{displayPhase === 'analyze' && (
|
||||
<AnalyzeOnboarding onComplete={handleAnalyzeComplete} />
|
||||
)}
|
||||
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
|
||||
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={handleAnalyzeComplete} />}
|
||||
{displayPhase === 'success' && <SuccessCard />}
|
||||
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
|
||||
</Crossfade>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,8 @@ import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
|||
* Shows in header when graph is loaded
|
||||
*/
|
||||
export const EmbeddingStatus = () => {
|
||||
const {
|
||||
embeddingStatus,
|
||||
embeddingProgress,
|
||||
startEmbeddings,
|
||||
graph,
|
||||
viewMode,
|
||||
serverBaseUrl,
|
||||
} = useAppState();
|
||||
const { embeddingStatus, embeddingProgress, startEmbeddings, graph, viewMode, serverBaseUrl } =
|
||||
useAppState();
|
||||
|
||||
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
|
||||
|
||||
|
|
@ -29,8 +23,10 @@ export const EmbeddingStatus = () => {
|
|||
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);
|
||||
|
|
@ -47,7 +43,7 @@ export const EmbeddingStatus = () => {
|
|||
setShowFallbackDialog(false);
|
||||
// Just close - user can try again later if they want
|
||||
};
|
||||
|
||||
|
||||
// WebGPU fallback dialog - rendered independently of state
|
||||
const fallbackDialog = (
|
||||
<WebGPUFallbackDialog
|
||||
|
|
@ -66,12 +62,12 @@ export const EmbeddingStatus = () => {
|
|||
<div className="flex items-center gap-2">
|
||||
<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}
|
||||
|
|
@ -84,13 +80,13 @@ export const EmbeddingStatus = () => {
|
|||
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>
|
||||
|
|
@ -106,17 +102,17 @@ export const EmbeddingStatus = () => {
|
|||
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>
|
||||
|
|
@ -128,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>
|
||||
);
|
||||
|
|
@ -138,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>
|
||||
);
|
||||
|
|
@ -154,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"
|
||||
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}
|
||||
|
|
@ -167,4 +163,3 @@ export const EmbeddingStatus = () => {
|
|||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ 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 {
|
||||
FILTERABLE_LABELS,
|
||||
NODE_COLORS,
|
||||
ALL_EDGE_TYPES,
|
||||
EDGE_INFO,
|
||||
type EdgeType,
|
||||
} from '../lib/constants';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
// Tree node structure
|
||||
|
|
@ -38,12 +44,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 +113,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 +132,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 +149,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 +164,7 @@ const TreeItem = ({
|
|||
{/* Children */}
|
||||
{isExpanded && filteredChildren.length > 0 && (
|
||||
<div>
|
||||
{filteredChildren.map(child => (
|
||||
{filteredChildren.map((child) => (
|
||||
<TreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
|
|
@ -184,18 +185,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 +217,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 +244,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 +266,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 +286,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 +412,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 +431,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 +453,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 +470,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 +491,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 +511,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 +523,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 +562,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 +572,3 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
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 {
|
||||
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,6 +1,25 @@
|
|||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, FolderOpen, ChevronDown, Trash2, RefreshCw, Loader2 } 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 { deleteRepo, fetchRepos, startAnalyze, streamAnalyzeProgress, type BackendRepo, type JobProgress } from '../services/backend-client';
|
||||
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';
|
||||
|
|
@ -29,7 +48,13 @@ interface HeaderProps {
|
|||
onReposChanged?: (repos: BackendRepo[]) => void;
|
||||
}
|
||||
|
||||
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnalyzeComplete, onReposChanged }: HeaderProps) => {
|
||||
export const Header = ({
|
||||
onFocusNode,
|
||||
availableRepos = [],
|
||||
onSwitchRepo,
|
||||
onAnalyzeComplete,
|
||||
onReposChanged,
|
||||
}: HeaderProps) => {
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
|
|
@ -37,7 +62,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
setHelpDialogBoxOpen
|
||||
setHelpDialogBoxOpen,
|
||||
} = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
|
|
@ -60,7 +85,7 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
|
||||
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]);
|
||||
|
||||
|
|
@ -81,7 +106,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
|
||||
// Cleanup re-analyze SSE on unmount
|
||||
useEffect(() => {
|
||||
return () => { reanalyzeSseRef.current?.abort(); };
|
||||
return () => {
|
||||
reanalyzeSseRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut (Cmd+K / Ctrl+K)
|
||||
|
|
@ -107,10 +134,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
|
||||
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];
|
||||
|
|
@ -129,37 +156,40 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
};
|
||||
|
||||
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 dropdown */}
|
||||
{projectName && (
|
||||
<div className="relative" ref={repoDropdownRef}>
|
||||
<button
|
||||
onClick={() => { setIsRepoDropdownOpen(prev => !prev); setShowAnalyzer(false); }}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 border rounded-lg text-sm transition-all cursor-pointer
|
||||
${isRepoDropdownOpen
|
||||
? 'bg-accent/10 border-accent/40 text-text-primary'
|
||||
: 'bg-surface border-border-subtle text-text-secondary hover:bg-hover hover:border-border-default'
|
||||
}
|
||||
`}
|
||||
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-[160px]">{projectName}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-text-muted transition-transform duration-200 ${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>
|
||||
|
||||
{isRepoDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1.5 w-80 bg-surface border border-border-subtle rounded-xl shadow-xl overflow-hidden z-50 animate-slide-up">
|
||||
<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
|
||||
|
|
@ -177,15 +207,15 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
{/* Repo list */}
|
||||
{availableRepos.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Repositories
|
||||
</div>
|
||||
{availableRepos.map(repo => (
|
||||
{availableRepos.map((repo) => (
|
||||
<div
|
||||
key={repo.name}
|
||||
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
|
||||
repo.name === projectName
|
||||
? 'bg-accent/10 border-l-2 border-accent'
|
||||
? 'border-l-2 border-accent bg-accent/10'
|
||||
: 'hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -194,12 +224,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
if (repo.name !== projectName) onSwitchRepo?.(repo.name);
|
||||
setIsRepoDropdownOpen(false);
|
||||
}}
|
||||
className="flex-1 flex items-center gap-3 text-left cursor-pointer min-w-0"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5 text-node-folder shrink-0" />
|
||||
<span className="flex-1 truncate text-sm text-text-primary font-mono">{repo.name}</span>
|
||||
<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="text-[10px] text-accent font-mono shrink-0">active</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-accent">
|
||||
active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Re-analyze */}
|
||||
|
|
@ -208,9 +242,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
e.stopPropagation();
|
||||
if (reanalyzing) return; // already running
|
||||
setReanalyzing(repo.name);
|
||||
setReanalyzeProgress({ phase: 'queued', percent: 0, message: 'Starting...' });
|
||||
setReanalyzeProgress({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Starting...',
|
||||
});
|
||||
try {
|
||||
const { jobId } = await startAnalyze({ path: repo.path, force: true });
|
||||
const { jobId } = await startAnalyze({
|
||||
path: repo.path,
|
||||
force: true,
|
||||
});
|
||||
reanalyzeSseRef.current = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setReanalyzeProgress(p),
|
||||
|
|
@ -234,14 +275,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
}
|
||||
}}
|
||||
disabled={!!reanalyzing}
|
||||
className={`p-1 rounded transition-all cursor-pointer ${
|
||||
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}`}
|
||||
title={
|
||||
reanalyzing === repo.name
|
||||
? 'Re-analyzing...'
|
||||
: `Re-analyze ${repo.name}`
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{/* Delete */}
|
||||
<button
|
||||
|
|
@ -269,10 +316,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
console.error('Failed to delete repo:', err);
|
||||
}
|
||||
}}
|
||||
className="p-1 text-text-muted/0 group-hover:text-text-muted hover:!text-red-400 rounded transition-all cursor-pointer"
|
||||
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="w-3.5 h-3.5" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -281,16 +328,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
|
||||
{/* Re-analyze progress bar */}
|
||||
{reanalyzing && reanalyzeProgress && (
|
||||
<div className="px-4 py-2.5 border-t border-border-subtle bg-accent/5">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Loader2 className="w-3 h-3 text-accent animate-spin shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">
|
||||
<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 bg-elevated rounded-full overflow-hidden">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-300"
|
||||
className="h-full rounded-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${Math.max(2, reanalyzeProgress.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -298,14 +345,22 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
)}
|
||||
|
||||
{/* Analyze new */}
|
||||
<div className={availableRepos.length > 0 || reanalyzing ? 'border-t border-border-subtle' : ''}>
|
||||
<div
|
||||
className={
|
||||
availableRepos.length > 0 || reanalyzing
|
||||
? 'border-t border-border-subtle'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAnalyzer(true)}
|
||||
disabled={!!reanalyzing}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-hover transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span className="text-sm text-text-secondary">Analyze a new repository...</span>
|
||||
<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>
|
||||
</>
|
||||
|
|
@ -317,9 +372,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
</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"
|
||||
|
|
@ -332,16 +387,16 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
}}
|
||||
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-xl 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}”
|
||||
|
|
@ -352,19 +407,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleSelectNode(node)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors cursor-pointer ${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'
|
||||
}`}
|
||||
>
|
||||
<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' }}
|
||||
/>
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{node.properties.name}
|
||||
</span>
|
||||
<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>
|
||||
|
|
@ -382,17 +438,17 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
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>
|
||||
|
|
@ -404,34 +460,32 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnaly
|
|||
{/* 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 cursor-pointer"
|
||||
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 cursor-pointer">
|
||||
<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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,11 +71,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 +96,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 +130,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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ function CopyButton({ text }: { text: string }) {
|
|||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
|
|
@ -31,20 +33,13 @@ function CopyButton({ text }: { text: string }) {
|
|||
<button
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
className={`
|
||||
shrink-0 px-2 py-1 rounded-md cursor-pointer
|
||||
transition-all duration-200
|
||||
focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none
|
||||
${copied
|
||||
? 'text-emerald-400 bg-emerald-400/10'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
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="w-3.5 h-3.5" />
|
||||
: <Copy className="w-3.5 h-3.5" />
|
||||
}
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,28 +57,28 @@ function TerminalWindow({
|
|||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-xl overflow-hidden border transition-all duration-300
|
||||
${isActive
|
||||
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 px-4 py-2.5 bg-deep border-b border-border-subtle">
|
||||
<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="w-2.5 h-2.5 rounded-full bg-red-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/60" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/60" />
|
||||
<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-[11px] text-text-muted text-center font-mono">{label}</span>
|
||||
<span className="flex-1 text-center font-mono text-[11px] text-text-muted">{label}</span>
|
||||
<CopyButton text={command} />
|
||||
</div>
|
||||
{/* Command body */}
|
||||
<div className="px-4 py-3.5 bg-void font-mono text-sm flex items-center gap-3">
|
||||
<span className="text-accent/60 select-none" aria-hidden="true">$</span>
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap text-text-primary tracking-wide">
|
||||
<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>
|
||||
|
|
@ -98,24 +93,24 @@ type StepState = 'waiting' | 'active' | 'done';
|
|||
function StepDot({ state, number }: { state: StepState; number: number }) {
|
||||
if (state === 'done') {
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-500/20 border border-emerald-500/50 flex items-center justify-center shrink-0">
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<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 w-6 h-6 shrink-0 flex items-center justify-center">
|
||||
<div className="absolute inset-0 rounded-full border border-accent/30 animate-ping" />
|
||||
<div className="w-6 h-6 rounded-full bg-accent/20 border border-accent/60 flex items-center justify-center">
|
||||
<span className="text-[10px] font-semibold text-accent leading-none">{number}</span>
|
||||
<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="w-6 h-6 rounded-full bg-elevated border border-border-subtle flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-semibold text-text-muted leading-none">{number}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,38 +132,33 @@ function StepRow({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300
|
||||
${state === 'waiting' ? 'opacity-40' : 'opacity-100'}
|
||||
`}
|
||||
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="flex-1 min-w-0 pt-0.5">
|
||||
<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'
|
||||
? 'text-text-primary'
|
||||
: 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{state === 'done' && (
|
||||
<span className="text-[10px] text-emerald-400/60 font-mono uppercase tracking-wider animate-fade-in">
|
||||
<span className="animate-fade-in font-mono text-[10px] tracking-wider text-emerald-400/60 uppercase">
|
||||
done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{description}</p>
|
||||
)}
|
||||
{isVisible && children && (
|
||||
<div className="mt-3 animate-slide-up">{children}</div>
|
||||
<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>
|
||||
|
|
@ -180,27 +170,25 @@ function StepRow({
|
|||
function PollingBar() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-accent/5 border border-accent/15 animate-fade-in"
|
||||
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="w-4 h-4 text-accent/70" />
|
||||
<Zap className="h-4 w-4 text-accent/70" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full border border-accent/25 animate-pulse" />
|
||||
<div className="h-5 w-5 animate-pulse rounded-full border border-accent/25" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
Listening for server
|
||||
<span className="inline-flex ml-0.5 text-text-muted">
|
||||
<span className="ml-0.5 inline-flex text-text-muted">
|
||||
<span className="animate-pulse">...</span>
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Will auto-connect when detected
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">Will auto-connect when detected</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -213,8 +201,8 @@ interface OnboardingGuideProps {
|
|||
}
|
||||
|
||||
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
const primary = isDev ? 'cd gitnexus && npm run serve' : 'npx gitnexus@latest serve';
|
||||
const termLabel = isDev ? 'Start backend' : 'Terminal';
|
||||
const primary = isDev ? 'cd gitnexus && npm run 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.
|
||||
|
|
@ -223,25 +211,24 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
|||
const step3State: StepState = 'waiting';
|
||||
|
||||
return (
|
||||
<div className="p-7 bg-surface border border-border-default rounded-3xl animate-fade-in relative overflow-hidden">
|
||||
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient background glows */}
|
||||
<div className="absolute -top-28 -right-28 w-72 h-72 bg-accent/6 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-56 h-56 bg-node-function/6 rounded-full blur-3xl pointer-events-none" />
|
||||
<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="inline-flex items-center gap-1.5 mb-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-accent/70" />
|
||||
<span className="text-[11px] text-accent/80 font-medium uppercase tracking-widest">
|
||||
<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 font-semibold text-text-primary leading-snug">
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Start your local server
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mt-1 leading-relaxed max-w-xs mx-auto">
|
||||
<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.'}
|
||||
|
|
@ -251,10 +238,9 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
|||
|
||||
{/* ── Step-by-step flow ───────────────────────────────────────── */}
|
||||
<div className="relative space-y-5">
|
||||
|
||||
{/* Vertical connector line behind the dots */}
|
||||
<div
|
||||
className="absolute left-[11px] top-6 bottom-6 w-px bg-border-subtle pointer-events-none"
|
||||
className="pointer-events-none absolute top-6 bottom-6 left-[11px] w-px bg-border-subtle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
|
@ -265,19 +251,17 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
|||
title="Copy the command"
|
||||
description={isPolling ? undefined : 'Click the icon in the terminal to copy.'}
|
||||
>
|
||||
<TerminalWindow
|
||||
command={primary}
|
||||
label={termLabel}
|
||||
isActive={step1State === 'active'}
|
||||
/>
|
||||
<TerminalWindow command={primary} label={termLabel} isActive={step1State === 'active'} />
|
||||
|
||||
{/* Secondary global-install option — production only */}
|
||||
{!isDev && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-3">
|
||||
<div className="flex-1 h-px bg-border-subtle" />
|
||||
<span className="text-[11px] text-text-muted uppercase tracking-widest">or install globally</span>
|
||||
<div className="flex-1 h-px bg-border-subtle" />
|
||||
<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"
|
||||
|
|
@ -293,11 +277,7 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
|||
state={step2State}
|
||||
number={2}
|
||||
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
|
||||
description={
|
||||
isPolling
|
||||
? undefined
|
||||
: 'Open a new terminal window, paste, and hit Enter.'
|
||||
}
|
||||
description={isPolling ? undefined : 'Open a new terminal window, paste, and hit Enter.'}
|
||||
>
|
||||
{isPolling && <PollingBar />}
|
||||
</StepRow>
|
||||
|
|
@ -312,21 +292,21 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
|||
</div>
|
||||
|
||||
{/* ── Prerequisite footnote ────────────────────────────────────── */}
|
||||
<div className="mt-6 pt-5 border-t border-border-subtle flex items-center justify-center gap-1.5 text-xs text-text-muted">
|
||||
<Server className="w-3 h-3 shrink-0" />
|
||||
<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 hover:text-accent/80 hover:underline transition-colors"
|
||||
className="text-accent transition-colors hover:text-accent/80 hover:underline"
|
||||
>
|
||||
Node.js {REQUIRED_NODE_VERSION}+
|
||||
</a>
|
||||
</span>
|
||||
<span className="text-border-default mx-1">·</span>
|
||||
<Terminal className="w-3 h-3 shrink-0" />
|
||||
<span className="mx-1 text-border-default">·</span>
|
||||
<Terminal className="h-3 w-3 shrink-0" />
|
||||
<span>Port 4747</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Process Flow Modal
|
||||
*
|
||||
*
|
||||
* Displays a Mermaid flowchart for a process in a centered modal popup.
|
||||
*/
|
||||
|
||||
|
|
@ -11,295 +11,312 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -39,39 +39,31 @@ function isValidGithubUrl(value: string): boolean {
|
|||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 p-1 bg-elevated rounded-lg" role="tablist" aria-label="Input type">
|
||||
<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-1 flex items-center justify-center gap-1.5
|
||||
px-3 py-1.5 text-xs font-medium rounded-md
|
||||
transition-all duration-150 cursor-pointer
|
||||
${mode === '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="w-3 h-3" />
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'local'}
|
||||
onClick={() => onChange('local')}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-1.5
|
||||
px-3 py-1.5 text-xs font-medium rounded-md
|
||||
transition-all duration-150 cursor-pointer
|
||||
${mode === '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="w-3 h-3" />
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Local Folder
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -80,28 +72,32 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
|||
|
||||
// ── Analyze button ───────────────────────────────────────────────────────────
|
||||
|
||||
function AnalyzeButton({ canSubmit, isLoading, onClick, variant }: {
|
||||
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';
|
||||
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
|
||||
? 'bg-accent hover:bg-accent/90 text-white shadow-glow-soft hover:shadow-glow hover:-translate-y-0.5 cursor-pointer'
|
||||
: 'bg-elevated border border-border-subtle text-text-muted cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
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="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{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="w-3.5 h-3.5" />}
|
||||
{canSubmit && !isLoading && <ArrowRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,13 +106,17 @@ function AnalyzeButton({ canSubmit, isLoading, onClick, variant }: {
|
|||
|
||||
function DoneState({ repoName }: { repoName: string }) {
|
||||
return (
|
||||
<div className="py-4 flex flex-col items-center gap-3 animate-fade-in" role="status" aria-live="polite">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center shadow-[0_0_20px_rgba(16,185,129,0.15)]">
|
||||
<Check className="w-6 h-6 text-emerald-400" />
|
||||
<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="text-xs text-text-muted mt-0.5 font-mono">{repoName}</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>
|
||||
|
|
@ -141,7 +141,11 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
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 [progress, setProgress] = useState<JobProgress>({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Queued',
|
||||
});
|
||||
const [completedRepoName, setCompletedRepoName] = useState('');
|
||||
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
|
|
@ -168,9 +172,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
// 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 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)) {
|
||||
|
|
@ -186,9 +191,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
setPhase('starting');
|
||||
|
||||
try {
|
||||
const request = mode === 'github'
|
||||
? { url: githubUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
|
|
@ -198,9 +201,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name = data.repoName
|
||||
?? nameSource.split(/[/\\]/).filter(Boolean).at(-1)
|
||||
?? 'repository';
|
||||
const name =
|
||||
data.repoName ?? nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? 'repository';
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
|
|
@ -225,7 +227,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
if (jobIdRef.current) {
|
||||
try { await cancelAnalyze(jobIdRef.current); } catch {}
|
||||
try {
|
||||
await cancelAnalyze(jobIdRef.current);
|
||||
} catch {}
|
||||
jobIdRef.current = null;
|
||||
}
|
||||
setPhase('input');
|
||||
|
|
@ -244,37 +248,49 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
{/* GitHub URL input */}
|
||||
{showInput && mode === 'github' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={inputId} className="block text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
<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 px-4 py-3.5 bg-void border rounded-xl 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="w-4 h-4 text-text-muted shrink-0" />
|
||||
<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(); } }}
|
||||
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 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted disabled:opacity-50 font-mono"
|
||||
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="w-3.5 h-3.5 text-emerald-400" />
|
||||
: <AlertCircle className="w-3.5 h-3.5 text-text-muted" />
|
||||
}
|
||||
{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>
|
||||
|
|
@ -284,33 +300,44 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${inputId}-local`} className="block text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
<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 px-4 py-3.5 bg-void border rounded-xl 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="w-4 h-4 text-text-muted shrink-0" />
|
||||
<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(); } }}
|
||||
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 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted disabled:opacity-50 font-mono"
|
||||
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="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Native folder picker + Browse button — below the input */}
|
||||
|
|
@ -320,7 +347,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
|
||||
webkitdirectory=""
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const rel = files[0].webkitRelativePath;
|
||||
|
|
@ -337,9 +364,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
type="button"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium text-text-secondary hover:text-text-primary bg-elevated hover:bg-hover border border-border-subtle rounded-lg transition-all duration-150 cursor-pointer disabled:opacity-50"
|
||||
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="w-3.5 h-3.5" />
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Browse for folder
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -347,8 +374,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
|
||||
{/* Error message */}
|
||||
{(phase === 'error' || (phase === 'input' && validationError)) && validationError && (
|
||||
<p className="text-xs text-red-400 animate-fade-in flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />{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>
|
||||
)}
|
||||
|
||||
|
|
@ -364,20 +392,31 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
|
||||
{/* CTA button */}
|
||||
{(phase === 'input' || phase === 'starting') && (
|
||||
<AnalyzeButton canSubmit={canSubmit} isLoading={isLoading} onClick={handleAnalyze} variant={variant} />
|
||||
<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 px-4 py-2.5 bg-elevated border border-border-subtle text-sm text-text-secondary hover:text-text-primary hover:bg-hover rounded-xl transition-all duration-200 cursor-pointer"
|
||||
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="px-4 py-2.5 text-sm text-text-muted hover:text-text-secondary transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer px-4 py-2.5 text-sm text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -386,7 +425,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
|
||||
{/* Dismiss for sheet variant while analyzing */}
|
||||
{phase === 'analyzing' && variant === 'sheet' && onCancel && (
|
||||
<button onClick={onCancel} className="w-full text-xs text-text-muted hover:text-text-secondary transition-colors py-1 cursor-pointer">
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -42,102 +48,119 @@ export const RightPanel = () => {
|
|||
return null;
|
||||
}, []);
|
||||
|
||||
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 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 handleGroundingClick = useCallback((inner: string) => {
|
||||
const raw = inner.trim();
|
||||
if (!raw) return;
|
||||
const handleGroundingClick = useCallback(
|
||||
(inner: string) => {
|
||||
const raw = inner.trim();
|
||||
if (!raw) return;
|
||||
|
||||
let rawPath = raw;
|
||||
let startLine1: number | undefined;
|
||||
let endLine1: number | undefined;
|
||||
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);
|
||||
}
|
||||
// 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 resolvedPath = resolveFilePathForUI(rawPath);
|
||||
if (!resolvedPath) return;
|
||||
|
||||
const nodeId = findFileNodeIdForUI(resolvedPath);
|
||||
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(() => {
|
||||
|
|
@ -189,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>
|
||||
|
|
@ -224,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>
|
||||
|
|
@ -259,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>
|
||||
|
|
@ -295,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}
|
||||
|
|
@ -338,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 && (
|
||||
|
|
@ -365,8 +389,6 @@ export const RightPanel = () => {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll anchor for auto-scroll */}
|
||||
|
|
@ -374,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}
|
||||
|
|
@ -383,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
|
||||
|
|
@ -396,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...'
|
||||
|
|
@ -427,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,11 +52,13 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,191 +1,270 @@
|
|||
const DEFAULT_IGNORE_LIST = new Set([
|
||||
// Version Control
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.bzr',
|
||||
|
||||
// IDEs & Editors
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.vs',
|
||||
'.eclipse',
|
||||
'.settings',
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'bower_components',
|
||||
'jspm_packages',
|
||||
'vendor', // PHP/Go
|
||||
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
|
||||
'venv',
|
||||
'.venv',
|
||||
'env',
|
||||
'.env',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'site-packages',
|
||||
'.tox',
|
||||
'eggs',
|
||||
'.eggs',
|
||||
'lib64',
|
||||
'parts',
|
||||
'sdist',
|
||||
'wheels',
|
||||
|
||||
// Build Outputs
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'output',
|
||||
'bin',
|
||||
'obj',
|
||||
'target', // Java/Rust
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.vercel',
|
||||
'.netlify',
|
||||
'.serverless',
|
||||
'_build',
|
||||
'public/build',
|
||||
'.parcel-cache',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
|
||||
// Test & Coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'htmlcov',
|
||||
'.coverage',
|
||||
'__tests__', // Often just test files
|
||||
'__mocks__',
|
||||
'.jest',
|
||||
|
||||
// Logs & Temp
|
||||
'logs',
|
||||
'log',
|
||||
'tmp',
|
||||
'temp',
|
||||
'cache',
|
||||
'.cache',
|
||||
'.tmp',
|
||||
'.temp',
|
||||
|
||||
// Generated/Compiled
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
||||
// Documentation (optional - might want to keep)
|
||||
// 'docs',
|
||||
// 'documentation',
|
||||
|
||||
// Misc
|
||||
'.husky',
|
||||
'.github', // GitHub config, not code
|
||||
'.circleci',
|
||||
'.gitlab',
|
||||
'fixtures', // Test fixtures
|
||||
'snapshots', // Jest snapshots
|
||||
'__snapshots__',
|
||||
// Version Control
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'.bzr',
|
||||
|
||||
// IDEs & Editors
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.vs',
|
||||
'.eclipse',
|
||||
'.settings',
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'bower_components',
|
||||
'jspm_packages',
|
||||
'vendor', // PHP/Go
|
||||
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
|
||||
'venv',
|
||||
'.venv',
|
||||
'env',
|
||||
'.env',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'site-packages',
|
||||
'.tox',
|
||||
'eggs',
|
||||
'.eggs',
|
||||
'lib64',
|
||||
'parts',
|
||||
'sdist',
|
||||
'wheels',
|
||||
|
||||
// Build Outputs
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'output',
|
||||
'bin',
|
||||
'obj',
|
||||
'target', // Java/Rust
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.vercel',
|
||||
'.netlify',
|
||||
'.serverless',
|
||||
'_build',
|
||||
'public/build',
|
||||
'.parcel-cache',
|
||||
'.turbo',
|
||||
'.svelte-kit',
|
||||
|
||||
// Test & Coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'htmlcov',
|
||||
'.coverage',
|
||||
'__tests__', // Often just test files
|
||||
'__mocks__',
|
||||
'.jest',
|
||||
|
||||
// Logs & Temp
|
||||
'logs',
|
||||
'log',
|
||||
'tmp',
|
||||
'temp',
|
||||
'cache',
|
||||
'.cache',
|
||||
'.tmp',
|
||||
'.temp',
|
||||
|
||||
// Generated/Compiled
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
||||
// Documentation (optional - might want to keep)
|
||||
// 'docs',
|
||||
// 'documentation',
|
||||
|
||||
// Misc
|
||||
'.husky',
|
||||
'.github', // GitHub config, not code
|
||||
'.circleci',
|
||||
'.gitlab',
|
||||
'fixtures', // Test fixtures
|
||||
'snapshots', // Jest snapshots
|
||||
'__snapshots__',
|
||||
]);
|
||||
|
||||
const IGNORED_EXTENSIONS = new Set([
|
||||
// Images
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp', '.tiff', '.tif',
|
||||
'.psd', '.ai', '.sketch', '.fig', '.xd',
|
||||
|
||||
// Archives
|
||||
'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz', '.tgz',
|
||||
|
||||
// Binary/Compiled
|
||||
'.exe', '.dll', '.so', '.dylib', '.a', '.lib', '.o', '.obj',
|
||||
'.class', '.jar', '.war', '.ear',
|
||||
'.pyc', '.pyo', '.pyd',
|
||||
'.beam', // Erlang
|
||||
'.wasm', // WebAssembly - important!
|
||||
'.node', // Native Node addons
|
||||
|
||||
// Documents
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.odt', '.ods', '.odp',
|
||||
|
||||
// Media
|
||||
'.mp4', '.mp3', '.wav', '.mov', '.avi', '.mkv', '.flv', '.wmv',
|
||||
'.ogg', '.webm', '.flac', '.aac', '.m4a',
|
||||
|
||||
// Fonts
|
||||
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
||||
|
||||
// Databases
|
||||
'.db', '.sqlite', '.sqlite3', '.mdb', '.accdb',
|
||||
|
||||
// Minified/Bundled files
|
||||
'.min.js', '.min.css', '.bundle.js', '.chunk.js',
|
||||
|
||||
// Source maps (debug files, not source)
|
||||
'.map',
|
||||
|
||||
// Lock files (handled separately, but also here)
|
||||
'.lock',
|
||||
|
||||
// Certificates & Keys (security - don't index!)
|
||||
'.pem', '.key', '.crt', '.cer', '.p12', '.pfx',
|
||||
|
||||
// Data files (often large/binary)
|
||||
'.csv', '.tsv', '.parquet', '.avro', '.feather',
|
||||
'.npy', '.npz', '.pkl', '.pickle', '.h5', '.hdf5',
|
||||
|
||||
// Misc binary
|
||||
'.bin', '.dat', '.data', '.raw',
|
||||
'.iso', '.img', '.dmg',
|
||||
// Images
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.tif',
|
||||
'.psd',
|
||||
'.ai',
|
||||
'.sketch',
|
||||
'.fig',
|
||||
'.xd',
|
||||
|
||||
// Archives
|
||||
'.zip',
|
||||
'.tar',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.bz2',
|
||||
'.xz',
|
||||
'.tgz',
|
||||
|
||||
// Binary/Compiled
|
||||
'.exe',
|
||||
'.dll',
|
||||
'.so',
|
||||
'.dylib',
|
||||
'.a',
|
||||
'.lib',
|
||||
'.o',
|
||||
'.obj',
|
||||
'.class',
|
||||
'.jar',
|
||||
'.war',
|
||||
'.ear',
|
||||
'.pyc',
|
||||
'.pyo',
|
||||
'.pyd',
|
||||
'.beam', // Erlang
|
||||
'.wasm', // WebAssembly - important!
|
||||
'.node', // Native Node addons
|
||||
|
||||
// Documents
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.odt',
|
||||
'.ods',
|
||||
'.odp',
|
||||
|
||||
// Media
|
||||
'.mp4',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.mov',
|
||||
'.avi',
|
||||
'.mkv',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
'.ogg',
|
||||
'.webm',
|
||||
'.flac',
|
||||
'.aac',
|
||||
'.m4a',
|
||||
|
||||
// Fonts
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.eot',
|
||||
'.otf',
|
||||
|
||||
// Databases
|
||||
'.db',
|
||||
'.sqlite',
|
||||
'.sqlite3',
|
||||
'.mdb',
|
||||
'.accdb',
|
||||
|
||||
// Minified/Bundled files
|
||||
'.min.js',
|
||||
'.min.css',
|
||||
'.bundle.js',
|
||||
'.chunk.js',
|
||||
|
||||
// Source maps (debug files, not source)
|
||||
'.map',
|
||||
|
||||
// Lock files (handled separately, but also here)
|
||||
'.lock',
|
||||
|
||||
// Certificates & Keys (security - don't index!)
|
||||
'.pem',
|
||||
'.key',
|
||||
'.crt',
|
||||
'.cer',
|
||||
'.p12',
|
||||
'.pfx',
|
||||
|
||||
// Data files (often large/binary)
|
||||
'.csv',
|
||||
'.tsv',
|
||||
'.parquet',
|
||||
'.avro',
|
||||
'.feather',
|
||||
'.npy',
|
||||
'.npz',
|
||||
'.pkl',
|
||||
'.pickle',
|
||||
'.h5',
|
||||
'.hdf5',
|
||||
|
||||
// Misc binary
|
||||
'.bin',
|
||||
'.dat',
|
||||
'.data',
|
||||
'.raw',
|
||||
'.iso',
|
||||
'.img',
|
||||
'.dmg',
|
||||
]);
|
||||
|
||||
// Files to ignore by exact name
|
||||
const IGNORED_FILES = new Set([
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'composer.lock',
|
||||
'Gemfile.lock',
|
||||
'poetry.lock',
|
||||
'Cargo.lock',
|
||||
'go.sum',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.npmrc',
|
||||
'.yarnrc',
|
||||
'.editorconfig',
|
||||
'.prettierrc',
|
||||
'.prettierignore',
|
||||
'.eslintignore',
|
||||
'.dockerignore',
|
||||
'Thumbs.db',
|
||||
'.DS_Store',
|
||||
'LICENSE',
|
||||
'LICENSE.md',
|
||||
'LICENSE.txt',
|
||||
'CHANGELOG.md',
|
||||
'CHANGELOG',
|
||||
'CONTRIBUTING.md',
|
||||
'CODE_OF_CONDUCT.md',
|
||||
'SECURITY.md',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.development',
|
||||
'.env.production',
|
||||
'.env.test',
|
||||
'.env.example',
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'composer.lock',
|
||||
'Gemfile.lock',
|
||||
'poetry.lock',
|
||||
'Cargo.lock',
|
||||
'go.sum',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.npmrc',
|
||||
'.yarnrc',
|
||||
'.editorconfig',
|
||||
'.prettierrc',
|
||||
'.prettierignore',
|
||||
'.eslintignore',
|
||||
'.dockerignore',
|
||||
'Thumbs.db',
|
||||
'.DS_Store',
|
||||
'LICENSE',
|
||||
'LICENSE.md',
|
||||
'LICENSE.txt',
|
||||
'CHANGELOG.md',
|
||||
'CHANGELOG',
|
||||
'CONTRIBUTING.md',
|
||||
'CODE_OF_CONDUCT.md',
|
||||
'SECURITY.md',
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.development',
|
||||
'.env.production',
|
||||
'.env.test',
|
||||
'.env.example',
|
||||
]);
|
||||
|
||||
|
||||
|
||||
export const shouldIgnorePath = (filePath: string): boolean => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
|
|
@ -209,7 +288,7 @@ export const shouldIgnorePath = (filePath: string): boolean => {
|
|||
if (lastDotIndex !== -1) {
|
||||
const ext = fileNameLower.substring(lastDotIndex);
|
||||
if (IGNORED_EXTENSIONS.has(ext)) return true;
|
||||
|
||||
|
||||
// Handle compound extensions like .min.js, .bundle.js
|
||||
const secondLastDot = fileNameLower.lastIndexOf('.', lastDotIndex - 1);
|
||||
if (secondLastDot !== -1) {
|
||||
|
|
@ -227,13 +306,15 @@ export const shouldIgnorePath = (filePath: string): boolean => {
|
|||
}
|
||||
|
||||
// Ignore files that look like generated/bundled code
|
||||
if (fileNameLower.includes('.bundle.') ||
|
||||
fileNameLower.includes('.chunk.') ||
|
||||
fileNameLower.includes('.generated.') ||
|
||||
fileNameLower.endsWith('.d.ts')) { // TypeScript declaration files
|
||||
if (
|
||||
fileNameLower.includes('.bundle.') ||
|
||||
fileNameLower.includes('.chunk.') ||
|
||||
fileNameLower.includes('.generated.') ||
|
||||
fileNameLower.endsWith('.d.ts')
|
||||
) {
|
||||
// TypeScript declaration files
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
const relationshipMap = new Map<string, GraphRelationship>();
|
||||
|
||||
const addNode = (node: GraphNode) => {
|
||||
if(!nodeMap.has(node.id)) {
|
||||
if (!nodeMap.has(node.id)) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
};
|
||||
|
|
@ -17,13 +17,13 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
}
|
||||
};
|
||||
|
||||
return{
|
||||
get nodes(){
|
||||
return Array.from(nodeMap.values())
|
||||
return {
|
||||
get nodes() {
|
||||
return Array.from(nodeMap.values());
|
||||
},
|
||||
|
||||
get relationships(){
|
||||
return Array.from(relationshipMap.values())
|
||||
|
||||
get relationships() {
|
||||
return Array.from(relationshipMap.values());
|
||||
},
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
|
|
@ -37,6 +37,5 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
|
||||
addNode,
|
||||
addRelationship,
|
||||
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Cluster Enricher
|
||||
*
|
||||
*
|
||||
* LLM-based enrichment for community clusters.
|
||||
* Generates semantic names, keywords, and descriptions using an LLM.
|
||||
*/
|
||||
|
|
@ -42,43 +42,35 @@ export interface ClusterMemberInfo {
|
|||
// PROMPT TEMPLATE
|
||||
// ============================================================================
|
||||
|
||||
const buildEnrichmentPrompt = (
|
||||
members: ClusterMemberInfo[],
|
||||
heuristicLabel: string
|
||||
): string => {
|
||||
const buildEnrichmentPrompt = (members: ClusterMemberInfo[], heuristicLabel: string): string => {
|
||||
// Limit to first 20 members to control token usage
|
||||
const limitedMembers = members.slice(0, 20);
|
||||
|
||||
const memberList = limitedMembers
|
||||
.map(m => `${m.name} (${m.type})`)
|
||||
.join(', ');
|
||||
|
||||
|
||||
const memberList = limitedMembers.map((m) => `${m.name} (${m.type})`).join(', ');
|
||||
|
||||
return `Analyze this code cluster and provide a semantic name and short description.
|
||||
|
||||
Heuristic: "${heuristicLabel}"
|
||||
Members: ${memberList}${members.length > 20 ? ` (+${members.length - 20} more)` : ''}
|
||||
|
||||
Reply with JSON only:
|
||||
{"name": "2-4 word semantic name", "description": "One sentence describing purpose"}`
|
||||
{"name": "2-4 word semantic name", "description": "One sentence describing purpose"}`;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PARSE LLM RESPONSE
|
||||
// ============================================================================
|
||||
|
||||
const parseEnrichmentResponse = (
|
||||
response: string,
|
||||
fallbackLabel: string
|
||||
): ClusterEnrichment => {
|
||||
const parseEnrichmentResponse = (response: string, fallbackLabel: string): ClusterEnrichment => {
|
||||
try {
|
||||
// Extract JSON from response (handles markdown code blocks)
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No JSON found in response');
|
||||
}
|
||||
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
|
||||
return {
|
||||
name: parsed.name || fallbackLabel,
|
||||
keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [],
|
||||
|
|
@ -100,7 +92,7 @@ const parseEnrichmentResponse = (
|
|||
|
||||
/**
|
||||
* Enrich clusters with LLM-generated names, keywords, and descriptions
|
||||
*
|
||||
*
|
||||
* @param communities - Community nodes to enrich
|
||||
* @param memberMap - Map of communityId -> member info
|
||||
* @param llmClient - LLM client for generation
|
||||
|
|
@ -110,17 +102,17 @@ export const enrichClusters = async (
|
|||
communities: CommunityNode[],
|
||||
memberMap: Map<string, ClusterMemberInfo[]>,
|
||||
llmClient: LLMClient,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<EnrichmentResult> => {
|
||||
const enrichments = new Map<string, ClusterEnrichment>();
|
||||
let tokensUsed = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < communities.length; i++) {
|
||||
const community = communities[i];
|
||||
const members = memberMap.get(community.id) || [];
|
||||
|
||||
|
||||
onProgress?.(i + 1, communities.length);
|
||||
|
||||
|
||||
if (members.length === 0) {
|
||||
// No members, use heuristic
|
||||
enrichments.set(community.id, {
|
||||
|
|
@ -130,14 +122,14 @@ export const enrichClusters = async (
|
|||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const prompt = buildEnrichmentPrompt(members, community.heuristicLabel);
|
||||
const response = await llmClient.generate(prompt);
|
||||
|
||||
|
||||
// Rough token estimate
|
||||
tokensUsed += prompt.length / 4 + response.length / 4;
|
||||
|
||||
|
||||
const enrichment = parseEnrichmentResponse(response, community.heuristicLabel);
|
||||
enrichments.set(community.id, enrichment);
|
||||
} catch (error) {
|
||||
|
|
@ -150,7 +142,7 @@ export const enrichClusters = async (
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { enrichments, tokensUsed };
|
||||
};
|
||||
|
||||
|
|
@ -167,30 +159,30 @@ export const enrichClustersBatch = async (
|
|||
memberMap: Map<string, ClusterMemberInfo[]>,
|
||||
llmClient: LLMClient,
|
||||
batchSize: number = 5,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<EnrichmentResult> => {
|
||||
const enrichments = new Map<string, ClusterEnrichment>();
|
||||
let tokensUsed = 0;
|
||||
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < communities.length; i += batchSize) {
|
||||
// Report progress
|
||||
onProgress?.(Math.min(i + batchSize, communities.length), communities.length);
|
||||
|
||||
const batch = communities.slice(i, i + batchSize);
|
||||
|
||||
const batchPrompt = batch.map((community, idx) => {
|
||||
const members = memberMap.get(community.id) || [];
|
||||
const limitedMembers = members.slice(0, 15);
|
||||
const memberList = limitedMembers
|
||||
.map(m => `${m.name} (${m.type})`)
|
||||
.join(', ');
|
||||
|
||||
return `Cluster ${idx + 1} (id: ${community.id}):
|
||||
|
||||
const batchPrompt = batch
|
||||
.map((community, idx) => {
|
||||
const members = memberMap.get(community.id) || [];
|
||||
const limitedMembers = members.slice(0, 15);
|
||||
const memberList = limitedMembers.map((m) => `${m.name} (${m.type})`).join(', ');
|
||||
|
||||
return `Cluster ${idx + 1} (id: ${community.id}):
|
||||
Heuristic: "${community.heuristicLabel}"
|
||||
Members: ${memberList}`;
|
||||
}).join('\n\n');
|
||||
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `Analyze these code clusters and generate semantic names, keywords, and descriptions.
|
||||
|
||||
${batchPrompt}
|
||||
|
|
@ -200,11 +192,11 @@ Output JSON array:
|
|||
{"id": "comm_X", "name": "...", "keywords": [...], "description": "..."},
|
||||
...
|
||||
]`;
|
||||
|
||||
|
||||
try {
|
||||
const response = await llmClient.generate(prompt);
|
||||
tokensUsed += prompt.length / 4 + response.length / 4;
|
||||
|
||||
|
||||
// Parse batch response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
|
|
@ -214,7 +206,7 @@ Output JSON array:
|
|||
keywords: string[];
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
|
||||
for (const item of parsed) {
|
||||
enrichments.set(item.id, {
|
||||
name: item.name,
|
||||
|
|
@ -235,7 +227,7 @@ Output JSON array:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fill in any missing communities
|
||||
for (const community of communities) {
|
||||
if (!enrichments.has(community.id)) {
|
||||
|
|
@ -246,6 +238,6 @@ Output JSON array:
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { enrichments, tokensUsed };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Graph RAG Agent Factory
|
||||
*
|
||||
*
|
||||
* Creates a LangChain agent configured for code graph analysis.
|
||||
* Supports Azure OpenAI and Google Gemini providers.
|
||||
*/
|
||||
|
|
@ -25,15 +25,12 @@ import type {
|
|||
GLMConfig,
|
||||
AgentStreamChunk,
|
||||
} from './types';
|
||||
import {
|
||||
type CodebaseContext,
|
||||
buildDynamicSystemPrompt,
|
||||
} from './context-builder';
|
||||
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
|
||||
/**
|
||||
* System prompt for the Graph RAG agent
|
||||
*
|
||||
*
|
||||
* Design principles (based on Aider/Cline research):
|
||||
* - Short, punchy directives > long explanations
|
||||
* - No template-inducing examples
|
||||
|
|
@ -43,7 +40,7 @@ import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../conf
|
|||
*/
|
||||
/**
|
||||
* Base system prompt - exported so it can be used with dynamic context injection
|
||||
*
|
||||
*
|
||||
* Structure (optimized for instruction following):
|
||||
* 1. Identity + GROUNDING mandate (most important)
|
||||
* 2. Core protocol (how to work)
|
||||
|
|
@ -131,11 +128,11 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
switch (config.provider) {
|
||||
case 'openai': {
|
||||
const openaiConfig = config as OpenAIConfig;
|
||||
|
||||
|
||||
if (!openaiConfig.apiKey || openaiConfig.apiKey.trim() === '') {
|
||||
throw new Error('OpenAI API key is required but was not provided');
|
||||
}
|
||||
|
||||
|
||||
return new ChatOpenAI({
|
||||
apiKey: openaiConfig.apiKey,
|
||||
modelName: openaiConfig.model,
|
||||
|
|
@ -148,7 +145,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'azure-openai': {
|
||||
const azureConfig = config as AzureOpenAIConfig;
|
||||
return new AzureChatOpenAI({
|
||||
|
|
@ -160,7 +157,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'gemini': {
|
||||
const geminiConfig = config as GeminiConfig;
|
||||
return new ChatGoogleGenerativeAI({
|
||||
|
|
@ -171,7 +168,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'anthropic': {
|
||||
const anthropicConfig = config as AnthropicConfig;
|
||||
return new ChatAnthropic({
|
||||
|
|
@ -182,7 +179,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'ollama': {
|
||||
const ollamaConfig = config as OllamaConfig;
|
||||
return new ChatOllama({
|
||||
|
|
@ -197,7 +194,7 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
numCtx: 32768,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
case 'openrouter': {
|
||||
const openRouterConfig = config as OpenRouterConfig;
|
||||
|
||||
|
|
@ -298,27 +295,27 @@ const extractInstanceName = (endpoint: string): string => {
|
|||
export const createGraphRAGAgent = (
|
||||
config: ProviderConfig,
|
||||
backend: GraphRAGBackend,
|
||||
codebaseContext?: CodebaseContext
|
||||
codebaseContext?: CodebaseContext,
|
||||
) => {
|
||||
const model = createChatModel(config);
|
||||
const tools = createGraphRAGTools(backend);
|
||||
|
||||
|
||||
// Use dynamic prompt if context is provided, otherwise use base prompt
|
||||
const systemPrompt = codebaseContext
|
||||
const systemPrompt = codebaseContext
|
||||
? buildDynamicSystemPrompt(BASE_SYSTEM_PROMPT, codebaseContext)
|
||||
: BASE_SYSTEM_PROMPT;
|
||||
|
||||
|
||||
// Log the full prompt for debugging
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🤖 AGENT SYSTEM PROMPT:\n', systemPrompt);
|
||||
}
|
||||
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: model as any,
|
||||
tools: tools as any,
|
||||
messageModifier: new SystemMessage(systemPrompt) as any,
|
||||
});
|
||||
|
||||
|
||||
return agent;
|
||||
};
|
||||
|
||||
|
|
@ -335,29 +332,26 @@ export interface AgentMessage {
|
|||
* Uses BOTH streamModes for best of both worlds:
|
||||
* - 'values' for state transitions (tool calls, results) in proper order
|
||||
* - 'messages' for token-by-token text streaming
|
||||
*
|
||||
*
|
||||
* This preserves the natural progression: reasoning → tool → reasoning → tool → answer
|
||||
*/
|
||||
export async function* streamAgentResponse(
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[]
|
||||
messages: AgentMessage[],
|
||||
): AsyncGenerator<AgentStreamChunk> {
|
||||
try {
|
||||
const formattedMessages = messages.map(m => ({
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
|
||||
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
|
||||
const stream = await agent.stream(
|
||||
{ messages: formattedMessages },
|
||||
{
|
||||
streamMode: ['values', 'messages'] as any,
|
||||
// Allow longer tool/reasoning loops (more Cursor-like persistence)
|
||||
recursionLimit: 50,
|
||||
} as any
|
||||
);
|
||||
|
||||
const stream = await agent.stream({ messages: formattedMessages }, {
|
||||
streamMode: ['values', 'messages'] as any,
|
||||
// Allow longer tool/reasoning loops (more Cursor-like persistence)
|
||||
recursionLimit: 50,
|
||||
} as any);
|
||||
|
||||
// Track what we've yielded to avoid duplicates
|
||||
const yieldedToolCalls = new Set<string>();
|
||||
const yieldedToolResults = new Set<string>();
|
||||
|
|
@ -368,13 +362,13 @@ export async function* streamAgentResponse(
|
|||
// Anything before the first tool call should be treated as "reasoning/narration"
|
||||
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
|
||||
let hasSeenToolCallThisTurn = false;
|
||||
|
||||
|
||||
for await (const event of stream) {
|
||||
// Events come as [streamMode, data] tuples when using multiple modes
|
||||
// or just data when using single mode
|
||||
let mode: string;
|
||||
let data: any;
|
||||
|
||||
|
||||
if (Array.isArray(event) && event.length === 2 && typeof event[0] === 'string') {
|
||||
[mode, data] = event;
|
||||
} else if (Array.isArray(event) && event[0]?._getType) {
|
||||
|
|
@ -386,10 +380,10 @@ export async function* streamAgentResponse(
|
|||
mode = 'values';
|
||||
data = event;
|
||||
}
|
||||
|
||||
|
||||
// DEBUG: Enhanced logging
|
||||
if (import.meta.env.DEV) {
|
||||
const msgType = mode === 'messages' && data?.[0]?._getType?.() || 'n/a';
|
||||
const msgType = (mode === 'messages' && data?.[0]?._getType?.()) || 'n/a';
|
||||
const hasContent = mode === 'messages' && data?.[0]?.content;
|
||||
const hasToolCalls = mode === 'messages' && data?.[0]?.tool_calls?.length > 0;
|
||||
console.log(`🔄 [${mode}] type:${msgType} content:${!!hasContent} tools:${hasToolCalls}`);
|
||||
|
|
@ -398,14 +392,14 @@ export async function* streamAgentResponse(
|
|||
if (mode === 'messages') {
|
||||
const [msg] = Array.isArray(data) ? data : [data];
|
||||
if (!msg) continue;
|
||||
|
||||
|
||||
const msgType = msg._getType?.() || msg.type || msg.constructor?.name || 'unknown';
|
||||
|
||||
|
||||
// AIMessageChunk - streaming text tokens
|
||||
if (msgType === 'ai' || msgType === 'AIMessage' || msgType === 'AIMessageChunk') {
|
||||
const rawContent = msg.content;
|
||||
const toolCalls = msg.tool_calls || [];
|
||||
|
||||
|
||||
// Handle content that can be string or array of content blocks
|
||||
let content: string = '';
|
||||
if (typeof rawContent === 'string') {
|
||||
|
|
@ -414,10 +408,10 @@ export async function* streamAgentResponse(
|
|||
// Content blocks format: [{type: 'text', text: '...'}, ...]
|
||||
content = rawContent
|
||||
.filter((block: any) => block.type === 'text' || typeof block === 'string')
|
||||
.map((block: any) => typeof block === 'string' ? block : block.text || '')
|
||||
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
|
||||
.join('');
|
||||
}
|
||||
|
||||
|
||||
// If chunk has content, stream it
|
||||
if (content && content.length > 0) {
|
||||
// Determine if this is reasoning/narration vs final answer content.
|
||||
|
|
@ -425,15 +419,13 @@ export async function* streamAgentResponse(
|
|||
// - Between tool calls/results: treat as reasoning
|
||||
// - After all tools are done: treat as final content
|
||||
const isReasoning =
|
||||
!hasSeenToolCallThisTurn ||
|
||||
toolCalls.length > 0 ||
|
||||
pendingToolCalls > 0;
|
||||
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
|
||||
yield {
|
||||
type: isReasoning ? 'reasoning' : 'content',
|
||||
[isReasoning ? 'reasoning' : 'content']: content,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Track tool calls from message chunks
|
||||
if (toolCalls.length > 0) {
|
||||
hasSeenToolCallThisTurn = true;
|
||||
|
|
@ -461,13 +453,14 @@ export async function* streamAgentResponse(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ToolMessage in messages mode
|
||||
if (msgType === 'tool' || msgType === 'ToolMessage') {
|
||||
const toolCallId = msg.tool_call_id || '';
|
||||
if (toolCallId && !yieldedToolResults.has(toolCallId)) {
|
||||
yieldedToolResults.add(toolCallId);
|
||||
const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
const result =
|
||||
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolCall: {
|
||||
|
|
@ -483,16 +476,16 @@ export async function* streamAgentResponse(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle 'values' mode - state snapshots for structure
|
||||
if (mode === 'values' && data?.messages) {
|
||||
const stepMessages = data.messages || [];
|
||||
|
||||
|
||||
// Process new messages for tool calls/results we might have missed
|
||||
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
|
||||
const msg = stepMessages[i];
|
||||
const msgType = msg._getType?.() || msg.type || 'unknown';
|
||||
|
||||
|
||||
// Catch tool calls from values mode (backup)
|
||||
if ((msgType === 'ai' || msgType === 'AIMessage') && !yieldedToolCalls.size) {
|
||||
const toolCalls = msg.tool_calls || [];
|
||||
|
|
@ -513,13 +506,14 @@ export async function* streamAgentResponse(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Catch tool results from values mode (backup)
|
||||
if (msgType === 'tool' || msgType === 'ToolMessage') {
|
||||
const toolCallId = msg.tool_call_id || '';
|
||||
if (toolCallId && !yieldedToolResults.has(toolCallId)) {
|
||||
yieldedToolResults.add(toolCallId);
|
||||
const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
const result =
|
||||
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolCall: {
|
||||
|
|
@ -534,11 +528,11 @@ export async function* streamAgentResponse(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lastProcessedMsgCount = stepMessages.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DEBUG: Stream completed normally
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Stream completed normally, yielding done');
|
||||
|
|
@ -550,8 +544,8 @@ export async function* streamAgentResponse(
|
|||
if (import.meta.env.DEV) {
|
||||
console.error('❌ Stream error:', message, error);
|
||||
}
|
||||
yield {
|
||||
type: 'error',
|
||||
yield {
|
||||
type: 'error',
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
|
|
@ -563,17 +557,16 @@ export async function* streamAgentResponse(
|
|||
*/
|
||||
export const invokeAgent = async (
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[]
|
||||
messages: AgentMessage[],
|
||||
): Promise<string> => {
|
||||
const formattedMessages = messages.map(m => ({
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
|
||||
const result = await agent.invoke({ messages: formattedMessages });
|
||||
|
||||
|
||||
// result.messages is the full conversation state
|
||||
const lastMessage = result.messages[result.messages.length - 1];
|
||||
return lastMessage?.content?.toString() ?? 'No response generated.';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Context Builder for Graph RAG Agent
|
||||
*
|
||||
*
|
||||
* Generates dynamic context about the loaded codebase to inject into the system prompt.
|
||||
* This helps the LLM understand the project structure, scale, and key entry points
|
||||
* without needing to explore from scratch.
|
||||
|
|
@ -54,7 +54,7 @@ export interface CodebaseContext {
|
|||
*/
|
||||
export async function getCodebaseStats(
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
projectName: string
|
||||
projectName: string,
|
||||
): Promise<CodebaseStats> {
|
||||
try {
|
||||
// Count each node type
|
||||
|
|
@ -67,7 +67,7 @@ export async function getCodebaseStats(
|
|||
];
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
|
||||
for (const { type, query } of countQueries) {
|
||||
try {
|
||||
const result = await executeQuery(query);
|
||||
|
|
@ -100,13 +100,12 @@ export async function getCodebaseStats(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find hotspots - nodes with the most connections
|
||||
*/
|
||||
export async function getHotspots(
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
limit: number = 8
|
||||
limit: number = 8,
|
||||
): Promise<Hotspot[]> {
|
||||
try {
|
||||
// Find nodes with most edges (both directions)
|
||||
|
|
@ -118,25 +117,27 @@ export async function getHotspots(
|
|||
LIMIT ${limit}
|
||||
RETURN n.name AS name, LABEL(n) AS type, n.filePath AS filePath, connections
|
||||
`;
|
||||
|
||||
|
||||
const results = await executeQuery(query);
|
||||
|
||||
return results.map(row => {
|
||||
if (Array.isArray(row)) {
|
||||
|
||||
return results
|
||||
.map((row) => {
|
||||
if (Array.isArray(row)) {
|
||||
return {
|
||||
name: row[0],
|
||||
type: row[1],
|
||||
filePath: row[2],
|
||||
connections: row[3],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: row[0],
|
||||
type: row[1],
|
||||
filePath: row[2],
|
||||
connections: row[3],
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
filePath: row.filePath,
|
||||
connections: row.connections,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
filePath: row.filePath,
|
||||
connections: row.connections,
|
||||
};
|
||||
}).filter(h => h.name && h.type);
|
||||
})
|
||||
.filter((h) => h.name && h.type);
|
||||
} catch (error) {
|
||||
console.error('Failed to get hotspots:', error);
|
||||
return [];
|
||||
|
|
@ -149,17 +150,19 @@ export async function getHotspots(
|
|||
*/
|
||||
export async function getFolderTree(
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
maxDepth: number = 10
|
||||
maxDepth: number = 10,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get all file paths
|
||||
const query = 'MATCH (f:File) RETURN f.filePath AS path ORDER BY path';
|
||||
const results = await executeQuery(query);
|
||||
|
||||
const paths = results.map(row => {
|
||||
if (Array.isArray(row)) return row[0];
|
||||
return row.path;
|
||||
}).filter(Boolean);
|
||||
|
||||
const paths = results
|
||||
.map((row) => {
|
||||
if (Array.isArray(row)) return row[0];
|
||||
return row.path;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (paths.length === 0) return '';
|
||||
|
||||
|
|
@ -175,7 +178,7 @@ export async function getFolderTree(
|
|||
* Format paths as indented tree (TOON-style, no ASCII box chars)
|
||||
* Uses indentation only for hierarchy - more token efficient than ASCII tree
|
||||
* Shows complete structure with no truncation
|
||||
*
|
||||
*
|
||||
* Example output:
|
||||
* src/
|
||||
* components/ (45 files)
|
||||
|
|
@ -192,22 +195,22 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
children: Map<string, TreeNode>;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
|
||||
const root: TreeNode = { isFile: false, children: new Map(), fileCount: 0 };
|
||||
|
||||
|
||||
for (const path of paths) {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
|
||||
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { isFile, children: new Map(), fileCount: 0 });
|
||||
}
|
||||
|
||||
|
||||
current = current.children.get(part)!;
|
||||
if (isFile) {
|
||||
// Count files in parent directories
|
||||
|
|
@ -219,10 +222,10 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Render tree with indentation only (no ASCII box chars)
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
function renderNode(node: TreeNode, indent: string, depth: number): void {
|
||||
const entries = [...node.children.entries()];
|
||||
// Sort: folders first (by file count desc), then files alphabetically
|
||||
|
|
@ -231,7 +234,7 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
if (!aNode.isFile && !bNode.isFile) return bNode.fileCount - aNode.fileCount;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
|
||||
for (const [name, childNode] of entries) {
|
||||
if (childNode.isFile) {
|
||||
// File
|
||||
|
|
@ -240,7 +243,7 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
// Directory
|
||||
const childCount = childNode.children.size;
|
||||
const fileCount = childNode.fileCount;
|
||||
|
||||
|
||||
// Only collapse if beyond maxDepth
|
||||
if (depth >= maxDepth) {
|
||||
lines.push(`${indent}${name}/ (${fileCount} files)`);
|
||||
|
|
@ -251,9 +254,9 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
renderNode(root, '', 0);
|
||||
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -262,23 +265,23 @@ function formatAsHybridAscii(paths: string[], maxDepth: number): string {
|
|||
*/
|
||||
function buildTreeFromPaths(paths: string[], maxDepth: number): Map<string, any> {
|
||||
const root = new Map<string, any>();
|
||||
|
||||
|
||||
for (const fullPath of paths) {
|
||||
// Normalize path separators
|
||||
const normalizedPath = fullPath.replace(/\\/g, '/');
|
||||
const parts = normalizedPath.split('/').filter(Boolean);
|
||||
|
||||
|
||||
let current = root;
|
||||
const depth = Math.min(parts.length, maxDepth + 1); // +1 to include files at maxDepth
|
||||
|
||||
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const part = parts[i];
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
|
||||
if (!current.has(part)) {
|
||||
current.set(part, isFile ? null : new Map<string, any>());
|
||||
}
|
||||
|
||||
|
||||
const next = current.get(part);
|
||||
if (next instanceof Map) {
|
||||
current = next;
|
||||
|
|
@ -287,21 +290,17 @@ function buildTreeFromPaths(paths: string[], maxDepth: number): Map<string, any>
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tree as ASCII (like VS Code sidebar)
|
||||
*/
|
||||
function formatTreeAsAscii(
|
||||
tree: Map<string, any>,
|
||||
prefix: string,
|
||||
isLast: boolean = true
|
||||
): string {
|
||||
function formatTreeAsAscii(tree: Map<string, any>, prefix: string, isLast: boolean = true): string {
|
||||
const lines: string[] = [];
|
||||
const entries = Array.from(tree.entries());
|
||||
|
||||
|
||||
// Sort: folders first, then files, alphabetically
|
||||
entries.sort(([a, aVal], [b, bVal]) => {
|
||||
const aIsDir = aVal instanceof Map;
|
||||
|
|
@ -309,12 +308,12 @@ function formatTreeAsAscii(
|
|||
if (aIsDir !== bIsDir) return bIsDir ? 1 : -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
|
||||
entries.forEach(([name, subtree], index) => {
|
||||
const isLastItem = index === entries.length - 1;
|
||||
const connector = isLastItem ? '└── ' : '├── ';
|
||||
const childPrefix = prefix + (isLastItem ? ' ' : '│ ');
|
||||
|
||||
|
||||
if (subtree instanceof Map && subtree.size > 0) {
|
||||
// Folder with children
|
||||
const childCount = countItems(subtree);
|
||||
|
|
@ -329,7 +328,7 @@ function formatTreeAsAscii(
|
|||
lines.push(`${prefix}${connector}${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return lines.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +352,7 @@ function countItems(tree: Map<string, any>): number {
|
|||
*/
|
||||
export async function buildCodebaseContext(
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
projectName: string
|
||||
projectName: string,
|
||||
): Promise<CodebaseContext> {
|
||||
// Run all queries in parallel for speed
|
||||
const [stats, hotspots, folderTree] = await Promise.all([
|
||||
|
|
@ -374,12 +373,12 @@ export async function buildCodebaseContext(
|
|||
*/
|
||||
export function formatContextForPrompt(context: CodebaseContext): string {
|
||||
const { stats, hotspots, folderTree } = context;
|
||||
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
// Project header with stats
|
||||
lines.push(`### 📊 CODEBASE: ${stats.projectName}`);
|
||||
|
||||
|
||||
const statParts = [
|
||||
`Files: ${stats.fileCount}`,
|
||||
`Functions: ${stats.functionCount}`,
|
||||
|
|
@ -388,16 +387,16 @@ export function formatContextForPrompt(context: CodebaseContext): string {
|
|||
].filter(Boolean);
|
||||
lines.push(statParts.join(' | '));
|
||||
lines.push('');
|
||||
|
||||
|
||||
// Hotspots
|
||||
if (hotspots.length > 0) {
|
||||
lines.push('**Hotspots** (most connected):');
|
||||
hotspots.slice(0, 5).forEach(h => {
|
||||
hotspots.slice(0, 5).forEach((h) => {
|
||||
lines.push(`- \`${h.name}\` (${h.type}) — ${h.connections} edges`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
||||
// Folder tree
|
||||
if (folderTree) {
|
||||
lines.push('### 📁 STRUCTURE');
|
||||
|
|
@ -406,7 +405,7 @@ export function formatContextForPrompt(context: CodebaseContext): string {
|
|||
lines.push(folderTree);
|
||||
lines.push('```');
|
||||
}
|
||||
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -414,12 +413,9 @@ export function formatContextForPrompt(context: CodebaseContext): string {
|
|||
* Build the complete dynamic system prompt
|
||||
* Context is appended at the END so core instructions remain at the top
|
||||
*/
|
||||
export function buildDynamicSystemPrompt(
|
||||
basePrompt: string,
|
||||
context: CodebaseContext
|
||||
): string {
|
||||
export function buildDynamicSystemPrompt(basePrompt: string, context: CodebaseContext): string {
|
||||
const contextSection = formatContextForPrompt(context);
|
||||
|
||||
|
||||
// Append context at the END - keeps core instructions at top for better adherence
|
||||
return `${basePrompt}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* LLM Module Exports
|
||||
*
|
||||
*
|
||||
* Provides Graph RAG agent capabilities for code analysis.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Settings Service
|
||||
*
|
||||
*
|
||||
* Handles localStorage persistence for LLM provider settings.
|
||||
* All API keys are stored locally - never sent to any server except the LLM provider.
|
||||
*/
|
||||
|
|
@ -127,16 +127,24 @@ export const saveSettings = (settings: LLMSettings): void => {
|
|||
export const updateProviderSettings = <T extends LLMProvider>(
|
||||
provider: T,
|
||||
updates: Partial<
|
||||
T extends 'openai' ? Partial<Omit<OpenAIConfig, 'provider'>> :
|
||||
T extends 'azure-openai' ? Partial<Omit<AzureOpenAIConfig, 'provider'>> :
|
||||
T extends 'gemini' ? Partial<Omit<GeminiConfig, 'provider'>> :
|
||||
T extends 'anthropic' ? Partial<Omit<AnthropicConfig, 'provider'>> :
|
||||
T extends 'ollama' ? Partial<Omit<OllamaConfig, 'provider'>> :
|
||||
T extends 'openrouter' ? Partial<Omit<OpenRouterConfig, 'provider'>> :
|
||||
T extends 'minimax' ? Partial<Omit<MiniMaxConfig, 'provider'>> :
|
||||
T extends 'glm' ? Partial<Omit<GLMConfig, 'provider'>> :
|
||||
never
|
||||
>
|
||||
T extends 'openai'
|
||||
? Partial<Omit<OpenAIConfig, 'provider'>>
|
||||
: T extends 'azure-openai'
|
||||
? Partial<Omit<AzureOpenAIConfig, 'provider'>>
|
||||
: T extends 'gemini'
|
||||
? Partial<Omit<GeminiConfig, 'provider'>>
|
||||
: T extends 'anthropic'
|
||||
? Partial<Omit<AnthropicConfig, 'provider'>>
|
||||
: T extends 'ollama'
|
||||
? Partial<Omit<OllamaConfig, 'provider'>>
|
||||
: T extends 'openrouter'
|
||||
? Partial<Omit<OpenRouterConfig, 'provider'>>
|
||||
: T extends 'minimax'
|
||||
? Partial<Omit<MiniMaxConfig, 'provider'>>
|
||||
: T extends 'glm'
|
||||
? Partial<Omit<GLMConfig, 'provider'>>
|
||||
: never
|
||||
>,
|
||||
): LLMSettings => {
|
||||
const current = loadSettings();
|
||||
|
||||
|
|
@ -377,7 +385,12 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
|||
case 'gemini':
|
||||
return ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-1.0-pro'];
|
||||
case 'anthropic':
|
||||
return ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'];
|
||||
return [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-opus-20240229',
|
||||
];
|
||||
case 'ollama':
|
||||
return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder'];
|
||||
case 'minimax':
|
||||
|
|
@ -406,4 +419,3 @@ export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name:
|
|||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* LLM Provider Types
|
||||
*
|
||||
*
|
||||
* Type definitions for multi-provider LLM support.
|
||||
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
|
||||
*/
|
||||
|
|
@ -9,7 +9,15 @@
|
|||
* Supported LLM providers
|
||||
*/
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter' | 'minimax' | 'glm';
|
||||
export type LLMProvider =
|
||||
| 'openai'
|
||||
| 'azure-openai'
|
||||
| 'gemini'
|
||||
| 'anthropic'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'minimax'
|
||||
| 'glm';
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
|
|
@ -27,8 +35,8 @@ export interface BaseProviderConfig {
|
|||
export interface OpenAIConfig extends BaseProviderConfig {
|
||||
provider: 'openai';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'
|
||||
baseUrl?: string; // optional, for custom endpoints or proxies
|
||||
model: string; // e.g., 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'
|
||||
baseUrl?: string; // optional, for custom endpoints or proxies
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,9 +45,9 @@ export interface OpenAIConfig extends BaseProviderConfig {
|
|||
export interface AzureOpenAIConfig extends BaseProviderConfig {
|
||||
provider: 'azure-openai';
|
||||
apiKey: string;
|
||||
endpoint: string; // e.g., https://your-resource.openai.azure.com
|
||||
endpoint: string; // e.g., https://your-resource.openai.azure.com
|
||||
deploymentName: string;
|
||||
apiVersion?: string; // defaults to '2024-08-01-preview'
|
||||
apiVersion?: string; // defaults to '2024-08-01-preview'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,7 +56,7 @@ export interface AzureOpenAIConfig extends BaseProviderConfig {
|
|||
export interface GeminiConfig extends BaseProviderConfig {
|
||||
provider: 'gemini';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro'
|
||||
model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +65,7 @@ export interface GeminiConfig extends BaseProviderConfig {
|
|||
export interface AnthropicConfig extends BaseProviderConfig {
|
||||
provider: 'anthropic';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022'
|
||||
model: string; // e.g., 'claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,7 +73,7 @@ export interface AnthropicConfig extends BaseProviderConfig {
|
|||
*/
|
||||
export interface OllamaConfig extends BaseProviderConfig {
|
||||
provider: 'ollama';
|
||||
baseUrl?: string; // defaults to http://localhost:11434
|
||||
baseUrl?: string; // defaults to http://localhost:11434
|
||||
model: string;
|
||||
}
|
||||
|
||||
|
|
@ -75,8 +83,8 @@ export interface OllamaConfig extends BaseProviderConfig {
|
|||
export interface OpenRouterConfig extends BaseProviderConfig {
|
||||
provider: 'openrouter';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
|
||||
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
|
||||
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
|
||||
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,7 +93,7 @@ export interface OpenRouterConfig extends BaseProviderConfig {
|
|||
export interface MiniMaxConfig extends BaseProviderConfig {
|
||||
provider: 'minimax';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
|
||||
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,14 +102,22 @@ export interface MiniMaxConfig extends BaseProviderConfig {
|
|||
export interface GLMConfig extends BaseProviderConfig {
|
||||
provider: 'glm';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'GLM-4.7', 'GLM-4.5', 'GLM-4.5-Air', 'GLM-5'
|
||||
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
|
||||
model: string; // e.g., 'GLM-4.7', 'GLM-4.5', 'GLM-4.5-Air', 'GLM-5'
|
||||
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig | MiniMaxConfig | GLMConfig;
|
||||
export type ProviderConfig =
|
||||
| OpenAIConfig
|
||||
| AzureOpenAIConfig
|
||||
| GeminiConfig
|
||||
| AnthropicConfig
|
||||
| OllamaConfig
|
||||
| OpenRouterConfig
|
||||
| MiniMaxConfig
|
||||
| GLMConfig;
|
||||
|
||||
/**
|
||||
* Stored settings (what goes to localStorage)
|
||||
|
|
@ -380,4 +396,3 @@ NOTES:
|
|||
- For vector search, join CodeEmbedding.nodeId to the appropriate table's id
|
||||
- Use LIMIT to avoid returning too many results
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -29,37 +29,36 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels(prev =>
|
||||
prev.includes(label) ? prev.filter(l => l !== label) : [...prev, label]
|
||||
setVisibleLabels((prev) =>
|
||||
prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
|
||||
setVisibleEdgeTypes(prev =>
|
||||
prev.includes(edgeType) ? prev.filter(e => e !== edgeType) : [...prev, edgeType]
|
||||
setVisibleEdgeTypes((prev) =>
|
||||
prev.includes(edgeType) ? prev.filter((e) => e !== edgeType) : [...prev, edgeType],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<GraphStateContextValue>(() => ({
|
||||
graph,
|
||||
setGraph,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
}), [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds]);
|
||||
|
||||
return (
|
||||
<GraphStateContext.Provider value={value}>
|
||||
{children}
|
||||
</GraphStateContext.Provider>
|
||||
const value = useMemo<GraphStateContextValue>(
|
||||
() => ({
|
||||
graph,
|
||||
setGraph,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
visibleLabels,
|
||||
toggleLabelVisibility,
|
||||
visibleEdgeTypes,
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
}),
|
||||
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
|
||||
);
|
||||
|
||||
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
|
||||
};
|
||||
|
||||
export const useGraphState = (): GraphStateContextValue => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
probeBackend,
|
||||
setBackendUrl as setServiceUrl,
|
||||
} from '../services/backend-client';
|
||||
import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
|
||||
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
|
||||
|
||||
// ── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
|
@ -117,7 +114,7 @@ export function useBackend(): UseBackendResult {
|
|||
pollingTimerRef.current = null;
|
||||
}
|
||||
// Probe immediately, then restart the polling chain if still disconnected
|
||||
void probeRef.current().then(ok => {
|
||||
void probeRef.current().then((ok) => {
|
||||
if (!ok && isPolling) {
|
||||
// Restart the setTimeout chain — schedule is captured in startPolling's closure,
|
||||
// so we re-call startPolling which clears+restarts cleanly.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { useAppState } from './useAppState';
|
|||
|
||||
export const useSettings = () => {
|
||||
const { llmSettings, updateLLMSettings } = useAppState();
|
||||
|
||||
|
||||
return {
|
||||
settings: llmSettings,
|
||||
updateSettings: updateLLMSettings
|
||||
updateSettings: updateLLMSettings,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
|||
|
||||
// Helper: RGB to hex
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return '#' + [r, g, b].map(x => {
|
||||
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Dim a color by mixing with dark background (keeps color hint)
|
||||
|
|
@ -35,7 +40,7 @@ const dimColor = (hex: string, amount: number): string => {
|
|||
return rgbToHex(
|
||||
darkBg.r + (rgb.r - darkBg.r) * amount,
|
||||
darkBg.g + (rgb.g - darkBg.g) * amount,
|
||||
darkBg.b + (rgb.b - darkBg.b) * amount
|
||||
darkBg.b + (rgb.b - darkBg.b) * amount,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -43,9 +48,9 @@ const dimColor = (hex: string, amount: number): string => {
|
|||
const brightenColor = (hex: string, factor: number): string => {
|
||||
const rgb = hexToRgb(hex);
|
||||
return rgbToHex(
|
||||
rgb.r + (255 - rgb.r) * (factor - 1) / factor,
|
||||
rgb.g + (255 - rgb.g) * (factor - 1) / factor,
|
||||
rgb.b + (255 - rgb.b) * (factor - 1) / factor
|
||||
rgb.r + ((255 - rgb.r) * (factor - 1)) / factor,
|
||||
rgb.g + ((255 - rgb.g) * (factor - 1)) / factor,
|
||||
rgb.b + ((255 - rgb.b) * (factor - 1)) / factor,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -77,7 +82,7 @@ interface UseSigmaReturn {
|
|||
|
||||
// Noverlap for final cleanup - minimal since it starts with good positions
|
||||
const NOVERLAP_SETTINGS = {
|
||||
maxIterations: 20, // Reduced - less cleanup needed
|
||||
maxIterations: 20, // Reduced - less cleanup needed
|
||||
ratio: 1.1,
|
||||
margin: 10,
|
||||
expansion: 1.05,
|
||||
|
|
@ -88,21 +93,21 @@ const getFA2Settings = (nodeCount: number) => {
|
|||
const isSmall = nodeCount < 500;
|
||||
const isMedium = nodeCount >= 500 && nodeCount < 2000;
|
||||
const isLarge = nodeCount >= 2000 && nodeCount < 10000;
|
||||
|
||||
|
||||
return {
|
||||
// Lower gravity allows folders to stay spread out
|
||||
gravity: isSmall ? 0.8 : isMedium ? 0.5 : isLarge ? 0.3 : 0.15,
|
||||
|
||||
|
||||
// Higher scaling ratio = more spread out overall
|
||||
scalingRatio: isSmall ? 15 : isMedium ? 30 : isLarge ? 60 : 100,
|
||||
|
||||
|
||||
// LOW slowDown = FASTER movement (converges quicker)
|
||||
slowDown: isSmall ? 1 : isMedium ? 2 : isLarge ? 3 : 5,
|
||||
|
||||
|
||||
// Barnes-Hut for performance - use it even on smaller graphs
|
||||
barnesHutOptimize: nodeCount > 200,
|
||||
barnesHutTheta: isLarge ? 0.8 : 0.6, // Higher = faster but less accurate
|
||||
|
||||
barnesHutTheta: isLarge ? 0.8 : 0.6, // Higher = faster but less accurate
|
||||
|
||||
// These help with clustering while keeping spread
|
||||
strongGravityMode: false,
|
||||
outboundAttractionDistribution: true,
|
||||
|
|
@ -115,12 +120,12 @@ const getFA2Settings = (nodeCount: number) => {
|
|||
// Layout duration - let it run longer for better results
|
||||
// Web Worker + WebGL means minimal system impact
|
||||
const getLayoutDuration = (nodeCount: number): number => {
|
||||
if (nodeCount > 10000) return 45000; // 45s for huge graphs
|
||||
if (nodeCount > 5000) return 35000; // 35s
|
||||
if (nodeCount > 2000) return 30000; // 30s
|
||||
if (nodeCount > 1000) return 30000; // 30s
|
||||
if (nodeCount > 500) return 25000; // 25s
|
||||
return 20000; // 20s for small graphs
|
||||
if (nodeCount > 10000) return 45000; // 45s for huge graphs
|
||||
if (nodeCount > 5000) return 35000; // 35s
|
||||
if (nodeCount > 2000) return 30000; // 30s
|
||||
if (nodeCount > 1000) return 30000; // 30s
|
||||
if (nodeCount > 500) return 25000; // 25s
|
||||
return 20000; // 20s for small graphs
|
||||
};
|
||||
|
||||
export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
||||
|
|
@ -144,7 +149,12 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
animatedNodesRef.current = options.animatedNodes || new Map();
|
||||
visibleEdgeTypesRef.current = options.visibleEdgeTypes || null;
|
||||
sigmaRef.current?.refresh();
|
||||
}, [options.highlightedNodeIds, options.blastRadiusNodeIds, options.animatedNodes, options.visibleEdgeTypes]);
|
||||
}, [
|
||||
options.highlightedNodeIds,
|
||||
options.blastRadiusNodeIds,
|
||||
options.animatedNodes,
|
||||
options.visibleEdgeTypes,
|
||||
]);
|
||||
|
||||
// Animation loop for node effects
|
||||
useEffect(() => {
|
||||
|
|
@ -174,19 +184,16 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
const setSelectedNode = useCallback((nodeId: string | null) => {
|
||||
selectedNodeRef.current = nodeId;
|
||||
setSelectedNodeState(nodeId);
|
||||
|
||||
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
|
||||
// Tiny camera nudge to force edge refresh (workaround for Sigma edge caching)
|
||||
const camera = sigma.getCamera();
|
||||
const currentRatio = camera.ratio;
|
||||
// Imperceptible zoom change that triggers re-render
|
||||
camera.animate(
|
||||
{ ratio: currentRatio * 1.0001 },
|
||||
{ duration: 50 }
|
||||
);
|
||||
|
||||
camera.animate({ ratio: currentRatio * 1.0001 }, { duration: 50 });
|
||||
|
||||
sigma.refresh();
|
||||
}, []);
|
||||
|
||||
|
|
@ -206,27 +213,27 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
labelRenderedSizeThreshold: 8,
|
||||
labelDensity: 0.1,
|
||||
labelGridCellSize: 70,
|
||||
|
||||
|
||||
defaultNodeColor: '#6b7280',
|
||||
defaultEdgeColor: '#2a2a3a',
|
||||
|
||||
|
||||
defaultEdgeType: 'curved',
|
||||
edgeProgramClasses: {
|
||||
curved: EdgeCurveProgram,
|
||||
},
|
||||
|
||||
|
||||
// Custom hover renderer - dark background instead of white
|
||||
defaultDrawNodeHover: (context, data, settings) => {
|
||||
const label = data.label;
|
||||
if (!label) return;
|
||||
|
||||
|
||||
const size = settings.labelSize || 11;
|
||||
const font = settings.labelFont || 'JetBrains Mono, monospace';
|
||||
const weight = settings.labelWeight || '500';
|
||||
|
||||
|
||||
context.font = `${weight} ${size}px ${font}`;
|
||||
const textWidth = context.measureText(label).width;
|
||||
|
||||
|
||||
const nodeSize = data.size || 8;
|
||||
const x = data.x;
|
||||
const y = data.y - nodeSize - 10;
|
||||
|
|
@ -235,24 +242,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
const height = size + paddingY * 2;
|
||||
const width = textWidth + paddingX * 2;
|
||||
const radius = 4;
|
||||
|
||||
|
||||
// Dark background pill
|
||||
context.fillStyle = '#12121c';
|
||||
context.beginPath();
|
||||
context.roundRect(x - width / 2, y - height / 2, width, height, radius);
|
||||
context.fill();
|
||||
|
||||
|
||||
// Border matching node color
|
||||
context.strokeStyle = data.color || '#6366f1';
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
|
||||
|
||||
// Label text - light color
|
||||
context.fillStyle = '#f5f5f7';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(label, x, y);
|
||||
|
||||
|
||||
// Also draw a subtle glow ring around the node
|
||||
context.beginPath();
|
||||
context.arc(data.x, data.y, nodeSize + 4, 0, Math.PI * 2);
|
||||
|
|
@ -262,20 +269,20 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
context.stroke();
|
||||
context.globalAlpha = 1;
|
||||
},
|
||||
|
||||
|
||||
minCameraRatio: 0.002,
|
||||
maxCameraRatio: 50,
|
||||
hideEdgesOnMove: true,
|
||||
zIndex: true,
|
||||
|
||||
|
||||
nodeReducer: (node, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
|
||||
if (data.hidden) {
|
||||
res.hidden = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const blastRadius = blastRadiusRef.current;
|
||||
|
|
@ -284,17 +291,17 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
const hasBlastRadius = blastRadius.size > 0;
|
||||
const isQueryHighlighted = highlighted.has(node);
|
||||
const isBlastRadiusNode = blastRadius.has(node);
|
||||
|
||||
|
||||
// Apply animation effects FIRST (before other highlighting)
|
||||
const animation = animatedNodes.get(node);
|
||||
if (animation) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - animation.startTime;
|
||||
const progress = Math.min(elapsed / animation.duration, 1);
|
||||
|
||||
|
||||
// Calculate animation phase (0-1-0-1... oscillation)
|
||||
const phase = (Math.sin(progress * Math.PI * 4) + 1) / 2;
|
||||
|
||||
|
||||
if (animation.type === 'pulse') {
|
||||
// Cyan pulse for search results
|
||||
const sizeMultiplier = 1.5 + phase * 0.8;
|
||||
|
|
@ -317,10 +324,10 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
res.zIndex = 5;
|
||||
res.highlighted = true;
|
||||
}
|
||||
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
// Blast radius takes priority (red highlighting)
|
||||
if (hasBlastRadius && !currentSelected) {
|
||||
if (isBlastRadiusNode) {
|
||||
|
|
@ -341,7 +348,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
if (hasHighlights && !currentSelected) {
|
||||
if (isQueryHighlighted) {
|
||||
res.color = '#06b6d4';
|
||||
|
|
@ -355,13 +362,14 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
if (currentSelected) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const isSelected = node === currentSelected;
|
||||
const isNeighbor = graph.hasEdge(node, currentSelected) || graph.hasEdge(currentSelected, node);
|
||||
|
||||
const isNeighbor =
|
||||
graph.hasEdge(node, currentSelected) || graph.hasEdge(currentSelected, node);
|
||||
|
||||
if (isSelected) {
|
||||
res.color = data.color;
|
||||
res.size = (data.size || 8) * 1.8;
|
||||
|
|
@ -378,13 +386,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return res;
|
||||
},
|
||||
|
||||
|
||||
edgeReducer: (edge, data) => {
|
||||
const res = { ...data };
|
||||
|
||||
|
||||
// Check edge type visibility first
|
||||
const visibleTypes = visibleEdgeTypesRef.current;
|
||||
if (visibleTypes && data.relationType) {
|
||||
|
|
@ -393,24 +401,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const blastRadius = blastRadiusRef.current;
|
||||
const hasHighlights = highlighted.size > 0 || blastRadius.size > 0; // Check BOTH sets
|
||||
|
||||
|
||||
if (hasHighlights && !currentSelected) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const [source, target] = graph.extremities(edge);
|
||||
|
||||
|
||||
// Check if nodes are in EITHER set
|
||||
const isSourceActive = highlighted.has(source) || blastRadius.has(source);
|
||||
const isTargetActive = highlighted.has(target) || blastRadius.has(target);
|
||||
|
||||
|
||||
const bothHighlighted = isSourceActive && isTargetActive;
|
||||
const oneHighlighted = isSourceActive || isTargetActive;
|
||||
|
||||
|
||||
if (bothHighlighted) {
|
||||
// If both nodes are in blast radius, use red edge
|
||||
if (blastRadius.has(source) && blastRadius.has(target)) {
|
||||
|
|
@ -432,13 +440,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
if (currentSelected) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const [source, target] = graph.extremities(edge);
|
||||
const isConnected = source === currentSelected || target === currentSelected;
|
||||
|
||||
|
||||
if (isConnected) {
|
||||
res.color = brightenColor(data.color, 1.5);
|
||||
res.size = Math.max(3, (data.size || 1) * 4);
|
||||
|
|
@ -450,7 +458,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
|
@ -511,49 +519,52 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
const inferredSettings = forceAtlas2.inferSettings(graph);
|
||||
const customSettings = getFA2Settings(nodeCount);
|
||||
const settings = { ...inferredSettings, ...customSettings };
|
||||
|
||||
|
||||
const layout = new FA2Layout(graph, { settings });
|
||||
|
||||
|
||||
layoutRef.current = layout;
|
||||
layout.start();
|
||||
setIsLayoutRunning(true);
|
||||
|
||||
const duration = getLayoutDuration(nodeCount);
|
||||
|
||||
|
||||
layoutTimeoutRef.current = setTimeout(() => {
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.stop();
|
||||
layoutRef.current = null;
|
||||
|
||||
|
||||
// Light noverlap cleanup
|
||||
noverlap.assign(graph, NOVERLAP_SETTINGS);
|
||||
sigmaRef.current?.refresh();
|
||||
|
||||
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
const setGraph = useCallback((newGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
const setGraph = useCallback(
|
||||
(newGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>) => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.kill();
|
||||
layoutRef.current = null;
|
||||
}
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
layoutTimeoutRef.current = null;
|
||||
}
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.kill();
|
||||
layoutRef.current = null;
|
||||
}
|
||||
if (layoutTimeoutRef.current) {
|
||||
clearTimeout(layoutTimeoutRef.current);
|
||||
layoutTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
graphRef.current = newGraph;
|
||||
sigma.setGraph(newGraph);
|
||||
setSelectedNode(null);
|
||||
graphRef.current = newGraph;
|
||||
sigma.setGraph(newGraph);
|
||||
setSelectedNode(null);
|
||||
|
||||
runLayout(newGraph);
|
||||
sigma.getCamera().animatedReset({ duration: 500 });
|
||||
}, [runLayout, setSelectedNode]);
|
||||
runLayout(newGraph);
|
||||
sigma.getCamera().animatedReset({ duration: 500 });
|
||||
},
|
||||
[runLayout, setSelectedNode],
|
||||
);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
|
|
@ -562,20 +573,17 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
|
||||
// Skip if already focused on this node (prevents double-click issues)
|
||||
const alreadySelected = selectedNodeRef.current === nodeId;
|
||||
|
||||
|
||||
// Set selection state directly (without the camera nudge from setSelectedNode)
|
||||
selectedNodeRef.current = nodeId;
|
||||
setSelectedNodeState(nodeId);
|
||||
|
||||
|
||||
// Only animate camera if selecting a new node
|
||||
if (!alreadySelected) {
|
||||
const nodeAttrs = graph.getNodeAttributes(nodeId);
|
||||
sigma.getCamera().animate(
|
||||
{ x: nodeAttrs.x, y: nodeAttrs.y, ratio: 0.15 },
|
||||
{ duration: 400 }
|
||||
);
|
||||
sigma.getCamera().animate({ x: nodeAttrs.x, y: nodeAttrs.y, ratio: 0.15 }, { duration: 400 });
|
||||
}
|
||||
|
||||
|
||||
sigma.refresh();
|
||||
}, []);
|
||||
|
||||
|
|
@ -606,13 +614,13 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
if (layoutRef.current) {
|
||||
layoutRef.current.stop();
|
||||
layoutRef.current = null;
|
||||
|
||||
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
noverlap.assign(graph, NOVERLAP_SETTINGS);
|
||||
sigmaRef.current?.refresh();
|
||||
}
|
||||
|
||||
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TAILWIND V4 THEME CONFIGURATION
|
||||
|
|
@ -51,7 +51,6 @@
|
|||
|
||||
/* Keyframes */
|
||||
@keyframes breathe {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
border-color: #2a2a3a;
|
||||
|
|
@ -65,7 +64,6 @@
|
|||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
|
|
@ -116,7 +114,6 @@
|
|||
REDUCED MOTION — respect OS-level motion preferences (WCAG 2.3.3)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
|
@ -242,10 +239,10 @@ body {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.chat-prose>h1:first-child,
|
||||
.chat-prose>h2:first-child,
|
||||
.chat-prose>h3:first-child,
|
||||
.chat-prose>h4:first-child {
|
||||
.chat-prose > h1:first-child,
|
||||
.chat-prose > h2:first-child,
|
||||
.chat-prose > h3:first-child,
|
||||
.chat-prose > h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
|
@ -273,7 +270,7 @@ body {
|
|||
}
|
||||
|
||||
/* Inline code - VS Code style */
|
||||
.chat-prose code:not([class*="language-"]) {
|
||||
.chat-prose code:not([class*='language-']) {
|
||||
padding: 0.2em 0.5em;
|
||||
background: rgba(110, 118, 129, 0.2);
|
||||
border-radius: 6px;
|
||||
|
|
@ -285,9 +282,9 @@ body {
|
|||
}
|
||||
|
||||
/* Ensure inline code keeps its color inside links and other elements */
|
||||
.chat-prose a code:not([class*="language-"]),
|
||||
.chat-prose strong code:not([class*="language-"]),
|
||||
.chat-prose em code:not([class*="language-"]) {
|
||||
.chat-prose a code:not([class*='language-']),
|
||||
.chat-prose strong code:not([class*='language-']),
|
||||
.chat-prose em code:not([class*='language-']) {
|
||||
color: #e6b450 !important;
|
||||
}
|
||||
|
||||
|
|
@ -365,4 +362,4 @@ body {
|
|||
|
||||
.sigma-container canvas {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,83 +2,83 @@ import type { NodeLabel } from 'gitnexus-shared';
|
|||
|
||||
// Node colors by type - slightly muted for less visual noise
|
||||
export const NODE_COLORS: Record<NodeLabel, string> = {
|
||||
Project: '#a855f7', // Purple - prominent
|
||||
Package: '#8b5cf6', // Violet
|
||||
Module: '#7c3aed', // Violet darker
|
||||
Folder: '#6366f1', // Indigo
|
||||
File: '#3b82f6', // Blue
|
||||
Class: '#f59e0b', // Amber - stands out
|
||||
Function: '#10b981', // Emerald
|
||||
Method: '#14b8a6', // Teal
|
||||
Variable: '#64748b', // Slate - muted (less important)
|
||||
Interface: '#ec4899', // Pink
|
||||
Enum: '#f97316', // Orange
|
||||
Decorator: '#eab308', // Yellow
|
||||
Import: '#475569', // Slate darker - very muted
|
||||
Type: '#a78bfa', // Violet light
|
||||
Project: '#a855f7', // Purple - prominent
|
||||
Package: '#8b5cf6', // Violet
|
||||
Module: '#7c3aed', // Violet darker
|
||||
Folder: '#6366f1', // Indigo
|
||||
File: '#3b82f6', // Blue
|
||||
Class: '#f59e0b', // Amber - stands out
|
||||
Function: '#10b981', // Emerald
|
||||
Method: '#14b8a6', // Teal
|
||||
Variable: '#64748b', // Slate - muted (less important)
|
||||
Interface: '#ec4899', // Pink
|
||||
Enum: '#f97316', // Orange
|
||||
Decorator: '#eab308', // Yellow
|
||||
Import: '#475569', // Slate darker - very muted
|
||||
Type: '#a78bfa', // Violet light
|
||||
CodeElement: '#64748b', // Slate - muted
|
||||
Community: '#818cf8', // Indigo light - cluster indicator
|
||||
Process: '#f43f5e', // Rose - execution flow indicator
|
||||
Section: '#60a5fa', // Blue light - structural section
|
||||
Struct: '#f59e0b', // Amber - like Class
|
||||
Trait: '#ec4899', // Pink - like Interface
|
||||
Impl: '#14b8a6', // Teal - like Method
|
||||
TypeAlias: '#a78bfa', // Violet light - like Type
|
||||
Const: '#64748b', // Slate - like Variable
|
||||
Static: '#64748b', // Slate - like Variable
|
||||
Namespace: '#7c3aed', // Violet - like Module
|
||||
Union: '#f97316', // Orange - like Enum
|
||||
Typedef: '#a78bfa', // Violet light - like Type
|
||||
Macro: '#eab308', // Yellow - like Decorator
|
||||
Property: '#64748b', // Slate - like Variable
|
||||
Record: '#f59e0b', // Amber - like Class
|
||||
Delegate: '#14b8a6', // Teal - like Method
|
||||
Community: '#818cf8', // Indigo light - cluster indicator
|
||||
Process: '#f43f5e', // Rose - execution flow indicator
|
||||
Section: '#60a5fa', // Blue light - structural section
|
||||
Struct: '#f59e0b', // Amber - like Class
|
||||
Trait: '#ec4899', // Pink - like Interface
|
||||
Impl: '#14b8a6', // Teal - like Method
|
||||
TypeAlias: '#a78bfa', // Violet light - like Type
|
||||
Const: '#64748b', // Slate - like Variable
|
||||
Static: '#64748b', // Slate - like Variable
|
||||
Namespace: '#7c3aed', // Violet - like Module
|
||||
Union: '#f97316', // Orange - like Enum
|
||||
Typedef: '#a78bfa', // Violet light - like Type
|
||||
Macro: '#eab308', // Yellow - like Decorator
|
||||
Property: '#64748b', // Slate - like Variable
|
||||
Record: '#f59e0b', // Amber - like Class
|
||||
Delegate: '#14b8a6', // Teal - like Method
|
||||
Annotation: '#eab308', // Yellow - like Decorator
|
||||
Constructor: '#10b981', // Emerald - like Function
|
||||
Template: '#a78bfa', // Violet light - like Type
|
||||
Route: '#f43f5e', // Rose - like Process
|
||||
Tool: '#a855f7', // Purple - like Project
|
||||
Template: '#a78bfa', // Violet light - like Type
|
||||
Route: '#f43f5e', // Rose - like Process
|
||||
Tool: '#a855f7', // Purple - like Project
|
||||
};
|
||||
|
||||
// Node sizes by type - clear visual hierarchy with dramatic size differences
|
||||
// Structural nodes are MUCH larger to make hierarchy obvious
|
||||
export const NODE_SIZES: Record<NodeLabel, number> = {
|
||||
Project: 20, // Largest - root of everything
|
||||
Package: 16, // Major structural element
|
||||
Module: 13, // Important container
|
||||
Folder: 10, // Structural - clearly bigger than files
|
||||
File: 6, // Common element - smaller than folders
|
||||
Class: 8, // Important code structure
|
||||
Function: 4, // Common code element - small
|
||||
Method: 3, // Smaller than function
|
||||
Variable: 2, // Tiny - leaf node
|
||||
Interface: 7, // Important type definition
|
||||
Enum: 5, // Type definition
|
||||
Decorator: 2, // Tiny modifier
|
||||
Import: 1.5, // Very small - usually hidden anyway
|
||||
Type: 3, // Type alias - small
|
||||
CodeElement: 2, // Generic small
|
||||
Community: 0, // Hidden by default - metadata node
|
||||
Process: 0, // Hidden by default - metadata node
|
||||
Section: 8, // Structural section - similar to Folder
|
||||
Struct: 8, // Like Class
|
||||
Trait: 7, // Like Interface
|
||||
Impl: 3, // Like Method
|
||||
TypeAlias: 3, // Like Type
|
||||
Const: 2, // Like Variable
|
||||
Static: 2, // Like Variable
|
||||
Namespace: 13, // Like Module
|
||||
Union: 5, // Like Enum
|
||||
Typedef: 3, // Like Type
|
||||
Macro: 2, // Like Decorator
|
||||
Property: 2, // Like Variable
|
||||
Record: 8, // Like Class
|
||||
Delegate: 3, // Like Method
|
||||
Annotation: 2, // Like Decorator
|
||||
Constructor: 4, // Like Function
|
||||
Template: 3, // Like Type
|
||||
Route: 5, // Like Enum
|
||||
Tool: 5, // Like Enum
|
||||
Project: 20, // Largest - root of everything
|
||||
Package: 16, // Major structural element
|
||||
Module: 13, // Important container
|
||||
Folder: 10, // Structural - clearly bigger than files
|
||||
File: 6, // Common element - smaller than folders
|
||||
Class: 8, // Important code structure
|
||||
Function: 4, // Common code element - small
|
||||
Method: 3, // Smaller than function
|
||||
Variable: 2, // Tiny - leaf node
|
||||
Interface: 7, // Important type definition
|
||||
Enum: 5, // Type definition
|
||||
Decorator: 2, // Tiny modifier
|
||||
Import: 1.5, // Very small - usually hidden anyway
|
||||
Type: 3, // Type alias - small
|
||||
CodeElement: 2, // Generic small
|
||||
Community: 0, // Hidden by default - metadata node
|
||||
Process: 0, // Hidden by default - metadata node
|
||||
Section: 8, // Structural section - similar to Folder
|
||||
Struct: 8, // Like Class
|
||||
Trait: 7, // Like Interface
|
||||
Impl: 3, // Like Method
|
||||
TypeAlias: 3, // Like Type
|
||||
Const: 2, // Like Variable
|
||||
Static: 2, // Like Variable
|
||||
Namespace: 13, // Like Module
|
||||
Union: 5, // Like Enum
|
||||
Typedef: 3, // Like Type
|
||||
Macro: 2, // Like Decorator
|
||||
Property: 2, // Like Variable
|
||||
Record: 8, // Like Class
|
||||
Delegate: 3, // Like Method
|
||||
Annotation: 2, // Like Decorator
|
||||
Constructor: 4, // Like Function
|
||||
Template: 3, // Like Type
|
||||
Route: 5, // Like Enum
|
||||
Tool: 5, // Like Enum
|
||||
};
|
||||
|
||||
// Community color palette for cluster-based coloring
|
||||
|
|
|
|||
|
|
@ -51,43 +51,43 @@ const getScaledNodeSize = (baseSize: number, nodeCount: number): number => {
|
|||
const getNodeMass = (nodeType: NodeLabel, nodeCount: number): number => {
|
||||
// Scale mass based on graph size
|
||||
const baseMassMultiplier = nodeCount > 5000 ? 2 : nodeCount > 1000 ? 1.5 : 1;
|
||||
|
||||
|
||||
switch (nodeType) {
|
||||
case 'Project':
|
||||
return 50 * baseMassMultiplier; // Heaviest - anchors everything
|
||||
return 50 * baseMassMultiplier; // Heaviest - anchors everything
|
||||
case 'Package':
|
||||
return 30 * baseMassMultiplier; // Very heavy
|
||||
return 30 * baseMassMultiplier; // Very heavy
|
||||
case 'Module':
|
||||
return 20 * baseMassMultiplier; // Heavy
|
||||
return 20 * baseMassMultiplier; // Heavy
|
||||
case 'Folder':
|
||||
return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
|
||||
return 15 * baseMassMultiplier; // Heavy - blasts folders apart!
|
||||
case 'File':
|
||||
return 3 * baseMassMultiplier; // Medium - follows folders
|
||||
return 3 * baseMassMultiplier; // Medium - follows folders
|
||||
case 'Class':
|
||||
case 'Interface':
|
||||
return 5 * baseMassMultiplier; // Medium-heavy
|
||||
return 5 * baseMassMultiplier; // Medium-heavy
|
||||
case 'Function':
|
||||
case 'Method':
|
||||
return 2 * baseMassMultiplier; // Light
|
||||
return 2 * baseMassMultiplier; // Light
|
||||
default:
|
||||
return 1; // Default mass
|
||||
return 1; // Default mass
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the KnowledgeGraph to a graphology Graph for Sigma.js
|
||||
* Folders are positioned in a wide spread, children positioned NEAR their parents
|
||||
*
|
||||
*
|
||||
* @param knowledgeGraph - The knowledge graph to convert
|
||||
* @param communityMemberships - Optional map of nodeId -> communityIndex for community coloring
|
||||
*/
|
||||
export const knowledgeGraphToGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
communityMemberships?: Map<string, number>
|
||||
communityMemberships?: Map<string, number>,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
|
||||
|
||||
// Build parent-child map from hierarchy relationships
|
||||
// CONTAINS: Folder -> File
|
||||
// DEFINES: File -> Function/Class/Interface/Method
|
||||
|
|
@ -96,10 +96,10 @@ export const knowledgeGraphToGraphology = (
|
|||
const parentToChildren = new Map<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach(rel => {
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
|
|
@ -110,14 +110,14 @@ export const knowledgeGraphToGraphology = (
|
|||
childToParent.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Create node lookup
|
||||
const nodeMap = new Map(knowledgeGraph.nodes.map(n => [n.id, n]));
|
||||
|
||||
const nodeMap = new Map(knowledgeGraph.nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Separate structural nodes (folders, packages) from content nodes
|
||||
const structuralTypes = new Set(['Project', 'Package', 'Module', 'Folder']);
|
||||
const structuralNodes = knowledgeGraph.nodes.filter(n => structuralTypes.has(n.label));
|
||||
|
||||
const structuralNodes = knowledgeGraph.nodes.filter((n) => structuralTypes.has(n.label));
|
||||
|
||||
// Much wider spread for structural nodes - this is the key!
|
||||
const structuralSpread = Math.sqrt(nodeCount) * 40;
|
||||
// Small jitter for children around their parent
|
||||
|
|
@ -131,11 +131,11 @@ export const knowledgeGraphToGraphology = (
|
|||
const communities = new Set(communityMemberships.values());
|
||||
const communityCount = communities.size;
|
||||
const clusterSpread = structuralSpread * 0.8; // Clusters spread across 80% of graph
|
||||
|
||||
|
||||
// Position cluster centers using golden angle for even distribution
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
let idx = 0;
|
||||
communities.forEach(communityId => {
|
||||
communities.forEach((communityId) => {
|
||||
const angle = idx * goldenAngle;
|
||||
const radius = clusterSpread * Math.sqrt((idx + 1) / communityCount);
|
||||
clusterCenters.set(communityId, {
|
||||
|
|
@ -157,17 +157,17 @@ export const knowledgeGraphToGraphology = (
|
|||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
const radius = structuralSpread * Math.sqrt((index + 1) / Math.max(structuralNodes.length, 1));
|
||||
|
||||
|
||||
// Add some randomness to prevent perfect patterns
|
||||
const jitter = structuralSpread * 0.15;
|
||||
const x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
|
||||
const y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
|
||||
|
||||
|
||||
nodePositions.set(node.id, { x, y });
|
||||
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
|
||||
|
||||
// Structural nodes keep their type-based color
|
||||
graph.addNode(node.id, {
|
||||
x,
|
||||
|
|
@ -188,17 +188,17 @@ export const knowledgeGraphToGraphology = (
|
|||
// Use BFS starting from structural nodes to ensure parents are positioned first
|
||||
const addNodeWithPosition = (nodeId: string) => {
|
||||
if (graph.hasNode(nodeId)) return;
|
||||
|
||||
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
|
||||
let x: number, y: number;
|
||||
|
||||
|
||||
// Check if this is a symbol node with a community assignment
|
||||
const communityIndex = communityMemberships?.get(nodeId);
|
||||
const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']);
|
||||
const clusterCenter = communityIndex !== undefined ? clusterCenters.get(communityIndex) : null;
|
||||
|
||||
|
||||
if (clusterCenter && symbolTypes.has(node.label)) {
|
||||
// CLUSTER-BASED POSITIONING: Position near cluster center with tight jitter
|
||||
x = clusterCenter.x + (Math.random() - 0.5) * clusterJitter;
|
||||
|
|
@ -207,7 +207,7 @@ export const knowledgeGraphToGraphology = (
|
|||
// HIERARCHY-BASED POSITIONING: Position near parent
|
||||
const parentId = childToParent.get(nodeId);
|
||||
const parentPos = parentId ? nodePositions.get(parentId) : null;
|
||||
|
||||
|
||||
if (parentPos) {
|
||||
x = parentPos.x + (Math.random() - 0.5) * childJitter;
|
||||
y = parentPos.y + (Math.random() - 0.5) * childJitter;
|
||||
|
|
@ -217,21 +217,21 @@ export const knowledgeGraphToGraphology = (
|
|||
y = (Math.random() - 0.5) * structuralSpread * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
nodePositions.set(nodeId, { x, y });
|
||||
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
|
||||
|
||||
// Check if this node has a community assignment (reuse communityIndex from above)
|
||||
const hasCommunity = communityIndex !== undefined;
|
||||
|
||||
|
||||
// Symbol nodes get colored by community if available
|
||||
const usesCommunityColor = hasCommunity && symbolTypes.has(node.label);
|
||||
const nodeColor = usesCommunityColor
|
||||
const nodeColor = usesCommunityColor
|
||||
? getCommunityColor(communityIndex!)
|
||||
: NODE_COLORS[node.label] || '#9ca3af';
|
||||
|
||||
|
||||
graph.addNode(nodeId, {
|
||||
x,
|
||||
y,
|
||||
|
|
@ -248,14 +248,14 @@ export const knowledgeGraphToGraphology = (
|
|||
communityColor: hasCommunity ? getCommunityColor(communityIndex!) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// BFS from structural nodes - this ensures parent is ALWAYS positioned before child
|
||||
const queue: string[] = [...structuralNodes.map(n => n.id)];
|
||||
const queue: string[] = [...structuralNodes.map((n) => n.id)];
|
||||
const visited = new Set<string>(queue);
|
||||
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
|
||||
|
||||
// Get children of current node and add them
|
||||
const children = parentToChildren.get(currentId) || [];
|
||||
for (const childId of children) {
|
||||
|
|
@ -266,7 +266,7 @@ export const knowledgeGraphToGraphology = (
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add any orphan nodes that weren't reached (no parent relationship)
|
||||
knowledgeGraph.nodes.forEach((node) => {
|
||||
if (!graph.hasNode(node.id)) {
|
||||
|
|
@ -276,33 +276,33 @@ export const knowledgeGraphToGraphology = (
|
|||
|
||||
// Add edges with distinct colors per relationship type
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
|
||||
// Edge styles - each relationship type has a DISTINCT color for clarity
|
||||
// Using varied hues so relationships are easily distinguishable
|
||||
const EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
// STRUCTURAL - Greens (folder/file hierarchy)
|
||||
CONTAINS: { color: '#2d5a3d', sizeMultiplier: 0.4 }, // Forest green - folder contains
|
||||
|
||||
CONTAINS: { color: '#2d5a3d', sizeMultiplier: 0.4 }, // Forest green - folder contains
|
||||
|
||||
// DEFINITIONS - Cyan/Teal (code definitions)
|
||||
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
|
||||
|
||||
// DEPENDENCIES - Blue (imports between files)
|
||||
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
|
||||
|
||||
DEFINES: { color: '#0e7490', sizeMultiplier: 0.5 }, // Cyan - file defines function/class
|
||||
|
||||
// DEPENDENCIES - Blue (imports between files)
|
||||
IMPORTS: { color: '#1d4ed8', sizeMultiplier: 0.6 }, // Blue - file imports file
|
||||
|
||||
// FUNCTION FLOW - Purple (call graph)
|
||||
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
|
||||
|
||||
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
|
||||
|
||||
// TYPE RELATIONSHIPS - Warm colors (OOP)
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
};
|
||||
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + (Math.random() * 0.08);
|
||||
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
|
|
@ -322,7 +322,7 @@ export const knowledgeGraphToGraphology = (
|
|||
*/
|
||||
export const filterGraphByLabels = (
|
||||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
visibleLabels: NodeLabel[]
|
||||
visibleLabels: NodeLabel[],
|
||||
): void => {
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const isVisible = visibleLabels.includes(attributes.nodeType);
|
||||
|
|
@ -336,17 +336,17 @@ export const filterGraphByLabels = (
|
|||
export const getNodesWithinHops = (
|
||||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
startNodeId: string,
|
||||
maxHops: number
|
||||
maxHops: number,
|
||||
): Set<string> => {
|
||||
const visited = new Set<string>();
|
||||
const queue: { nodeId: string; depth: number }[] = [{ nodeId: startNodeId, depth: 0 }];
|
||||
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeId, depth } = queue.shift()!;
|
||||
|
||||
|
||||
if (visited.has(nodeId)) continue;
|
||||
visited.add(nodeId);
|
||||
|
||||
|
||||
if (depth < maxHops) {
|
||||
graph.forEachNeighbor(nodeId, (neighborId) => {
|
||||
if (!visited.has(neighborId)) {
|
||||
|
|
@ -355,7 +355,7 @@ export const getNodesWithinHops = (
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return visited;
|
||||
};
|
||||
|
||||
|
|
@ -366,20 +366,20 @@ export const filterGraphByDepth = (
|
|||
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>,
|
||||
selectedNodeId: string | null,
|
||||
maxHops: number | null,
|
||||
visibleLabels: NodeLabel[]
|
||||
visibleLabels: NodeLabel[],
|
||||
): void => {
|
||||
if (maxHops === null) {
|
||||
filterGraphByLabels(graph, visibleLabels);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (selectedNodeId === null || !graph.hasNode(selectedNodeId)) {
|
||||
filterGraphByLabels(graph, visibleLabels);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const nodesInRange = getNodesWithinHops(graph, selectedNodeId, maxHops);
|
||||
|
||||
|
||||
graph.forEachNode((nodeId, attributes) => {
|
||||
const isLabelVisible = visibleLabels.includes(attributes.nodeType);
|
||||
const isInRange = nodesInRange.has(nodeId);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// Shared regex patterns for grounding references in chat/markdown.
|
||||
// Pattern 1: File refs - [[path/file.ext]] or [[path/file.ext:line]] or [[path/file.ext:line-line]]
|
||||
// Line numbers are optional.
|
||||
export const FILE_REF_REGEX = /\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-–](\d+))?)?\]\]/g;
|
||||
export const FILE_REF_REGEX =
|
||||
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)(?::(\d+)(?:[-–](\d+))?)?\]\]/g;
|
||||
|
||||
// Pattern 2: Node refs - [[Type:Name]] or [[graph:Type:Name]]
|
||||
export const NODE_REF_REGEX = /\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
|
||||
export const NODE_REF_REGEX =
|
||||
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Mermaid Diagram Generator for Processes
|
||||
*
|
||||
*
|
||||
* Generates Mermaid flowchart syntax from Process step data.
|
||||
* Designed to show branching/merging when CALLS edges exist between steps.
|
||||
*/
|
||||
|
|
@ -24,9 +24,9 @@ export interface ProcessData {
|
|||
label: string;
|
||||
processType: 'intra_community' | 'cross_community';
|
||||
steps: ProcessStep[];
|
||||
edges?: ProcessEdge[]; // CALLS edges between steps for branching
|
||||
edges?: ProcessEdge[]; // CALLS edges between steps for branching
|
||||
clusters?: string[];
|
||||
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
|
||||
rawMermaid?: string; // AI-generated mermaid code (sanitized before rendering)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,7 +34,7 @@ export interface ProcessData {
|
|||
*/
|
||||
export function generateProcessMermaid(process: ProcessData): string {
|
||||
const { steps, edges, clusters } = process;
|
||||
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
return 'graph TD\n A[No steps found]';
|
||||
}
|
||||
|
|
@ -43,16 +43,26 @@ export function generateProcessMermaid(process: ProcessData): string {
|
|||
|
||||
// Add class definitions for styling (rounded corners + colors)
|
||||
lines.push(' %% Styles');
|
||||
lines.push(' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
|
||||
lines.push(' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
|
||||
lines.push(' classDef step fill:#1e293b,stroke:#22d3ee,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
|
||||
lines.push(' classDef terminal fill:#1e293b,stroke:#f472b6,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
|
||||
lines.push(' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;');
|
||||
lines.push(
|
||||
' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
|
||||
);
|
||||
lines.push(
|
||||
' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
|
||||
);
|
||||
lines.push(
|
||||
' classDef step fill:#1e293b,stroke:#22d3ee,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
|
||||
);
|
||||
lines.push(
|
||||
' classDef terminal fill:#1e293b,stroke:#f472b6,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;',
|
||||
);
|
||||
lines.push(
|
||||
' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;',
|
||||
);
|
||||
|
||||
// Track clusters for subgraph grouping
|
||||
const clusterGroups = new Map<string, ProcessStep[]>();
|
||||
const noCluster: ProcessStep[] = [];
|
||||
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.cluster) {
|
||||
const group = clusterGroups.get(step.cluster) || [];
|
||||
|
|
@ -88,10 +98,12 @@ export function generateProcessMermaid(process: ProcessData): string {
|
|||
if (useClusters) {
|
||||
// Generate subgraphs for each cluster
|
||||
let clusterIndex = 0;
|
||||
|
||||
|
||||
for (const [clusterName, clusterSteps] of clusterGroups) {
|
||||
lines.push(` subgraph ${sanitizeLabel(clusterName)}["${sanitizeLabel(clusterName)}"]:::cluster`);
|
||||
|
||||
lines.push(
|
||||
` subgraph ${sanitizeLabel(clusterName)}["${sanitizeLabel(clusterName)}"]:::cluster`,
|
||||
);
|
||||
|
||||
for (const step of clusterSteps) {
|
||||
const id = nodeId(step);
|
||||
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
|
||||
|
|
@ -102,7 +114,7 @@ export function generateProcessMermaid(process: ProcessData): string {
|
|||
lines.push(' end');
|
||||
clusterIndex++;
|
||||
}
|
||||
|
||||
|
||||
// Add unclustered steps
|
||||
for (const step of noCluster) {
|
||||
const id = nodeId(step);
|
||||
|
|
@ -125,7 +137,7 @@ export function generateProcessMermaid(process: ProcessData): string {
|
|||
// Generate edges
|
||||
if (edges && edges.length > 0) {
|
||||
// Use actual CALLS edges for branching
|
||||
const stepById = new Map(steps.map(s => [s.id, s]));
|
||||
const stepById = new Map(steps.map((s) => [s.id, s]));
|
||||
for (const edge of edges) {
|
||||
const fromStep = stepById.get(edge.from);
|
||||
const toStep = stepById.get(edge.to);
|
||||
|
|
@ -150,8 +162,8 @@ export function generateProcessMermaid(process: ProcessData): string {
|
|||
* Simple linear mermaid for quick preview
|
||||
*/
|
||||
export function generateSimpleMermaid(processLabel: string, stepCount: number): string {
|
||||
const [entry, terminal] = processLabel.split(' → ').map(s => s.trim());
|
||||
|
||||
const [entry, terminal] = processLabel.split(' → ').map((s) => s.trim());
|
||||
|
||||
return `graph LR
|
||||
classDef entry fill:#059669,stroke:#34d399,stroke-width:2px,color:#ffffff,rx:10,ry:10;
|
||||
classDef terminal fill:#be185d,stroke:#f472b6,stroke-width:2px,color:#ffffff,rx:10,ry:10;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export const normalizePath = (p: string): string => {
|
|||
* Follows the same heuristics previously embedded in useAppState:
|
||||
* 1) exact match, 2) ends-with match (prefers shorter paths), 3) segment containment.
|
||||
*/
|
||||
export const resolveFilePath = (fileContents: Map<string, string>, requestedPath: string): string | null => {
|
||||
export const resolveFilePath = (
|
||||
fileContents: Map<string, string>,
|
||||
requestedPath: string,
|
||||
): string | null => {
|
||||
const req = normalizePath(requestedPath).toLowerCase();
|
||||
if (!req) return null;
|
||||
|
||||
|
|
@ -35,7 +38,10 @@ export const resolveFilePath = (fileContents: Map<string, string>, requestedPath
|
|||
let idx = 0;
|
||||
for (const s of segs) {
|
||||
const found = normSegs.findIndex((x, i) => i >= idx && x.includes(s));
|
||||
if (found === -1) { idx = -1; break; }
|
||||
if (found === -1) {
|
||||
idx = -1;
|
||||
break;
|
||||
}
|
||||
idx = found + 1;
|
||||
}
|
||||
if (idx !== -1) return key;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export const generateId = (label: string, name: string): string => {
|
||||
return `${label}:${name}`
|
||||
}
|
||||
return `${label}:${name}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ import './index.css';
|
|||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
|||
export interface BackendRepo {
|
||||
name: string;
|
||||
path: string;
|
||||
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
|
||||
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
|
||||
indexedAt: string;
|
||||
lastCommit?: string;
|
||||
stats?: {
|
||||
|
|
@ -251,7 +251,8 @@ const fetchWithTimeout = async (
|
|||
if (error instanceof TypeError) {
|
||||
throw new BackendError(
|
||||
`Network error reaching GitNexus backend at ${_backendUrl}: ${error.message}`,
|
||||
0, 'network',
|
||||
0,
|
||||
'network',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
|
@ -273,14 +274,16 @@ const assertOk = async (response: Response): Promise<void> => {
|
|||
// Response body was not JSON
|
||||
}
|
||||
|
||||
const code = response.status === 404 ? 'not_found'
|
||||
: response.status >= 400 && response.status < 500 ? 'client'
|
||||
: 'server';
|
||||
const code =
|
||||
response.status === 404
|
||||
? 'not_found'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
throw new BackendError(message, response.status, code);
|
||||
};
|
||||
|
||||
const repoParam = (repo?: string): string =>
|
||||
repo ? `repo=${encodeURIComponent(repo)}` : '';
|
||||
const repoParam = (repo?: string): string => (repo ? `repo=${encodeURIComponent(repo)}` : '');
|
||||
|
||||
// ── API Methods ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -303,10 +306,7 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
|||
* server goes down (after one retry to avoid false positives from transient
|
||||
* network hiccups). Returns a cleanup function.
|
||||
*/
|
||||
export const connectHeartbeat = (
|
||||
onConnect: () => void,
|
||||
onDisconnect: () => void,
|
||||
): (() => void) => {
|
||||
export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void): (() => void) => {
|
||||
let closed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let es: EventSource | null = null;
|
||||
|
|
@ -316,7 +316,12 @@ export const connectHeartbeat = (
|
|||
const connect = () => {
|
||||
if (closed) return;
|
||||
es = new EventSource(`${_backendUrl}/api/heartbeat`);
|
||||
es.onopen = () => { if (!closed) { attempt = 0; onConnect(); } };
|
||||
es.onopen = () => {
|
||||
if (!closed) {
|
||||
attempt = 0;
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
|
|
@ -342,9 +347,12 @@ export const connectHeartbeat = (
|
|||
|
||||
/** Delete a repo's index and unregister it. */
|
||||
export const deleteRepo = async (repoName: string): Promise<void> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/repo?repo=${encodeURIComponent(repoName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/repo?repo=${encodeURIComponent(repoName)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
await assertOk(response);
|
||||
};
|
||||
|
||||
|
|
@ -377,9 +385,15 @@ export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
|
|||
/** Fetch the graph (nodes + relationships). Content stripped by default. */
|
||||
export const fetchGraph = async (
|
||||
repo?: string,
|
||||
opts?: { includeContent?: boolean; signal?: AbortSignal; onProgress?: (downloaded: number, total: number | null) => void },
|
||||
opts?: {
|
||||
includeContent?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (downloaded: number, total: number | null) => void;
|
||||
},
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : ''].filter(Boolean).join('&');
|
||||
const params = [repoParam(repo), opts?.includeContent ? 'includeContent=true' : '']
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const url = `${_backendUrl}/api/graph${params ? `?${params}` : ''}`;
|
||||
const response = await fetchWithTimeout(url, { signal: opts?.signal }, 60_000);
|
||||
await assertOk(response);
|
||||
|
|
@ -458,7 +472,9 @@ export const grep = async (
|
|||
`pattern=${encodeURIComponent(pattern)}`,
|
||||
repoParam(repo),
|
||||
limit ? `limit=${limit}` : '',
|
||||
].filter(Boolean).join('&');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
|
||||
await assertOk(response);
|
||||
const body = await response.json();
|
||||
|
|
@ -483,7 +499,9 @@ export const readFile = async (
|
|||
repoParam(options?.repo),
|
||||
options?.startLine !== undefined ? `startLine=${options.startLine}` : '',
|
||||
options?.endLine !== undefined ? `endLine=${options.endLine}` : '',
|
||||
].filter(Boolean).join('&');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/file?${params}`);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<ReadFileResult>;
|
||||
|
|
@ -491,7 +509,9 @@ export const readFile = async (
|
|||
|
||||
/** Fetch all processes for a repo. */
|
||||
export const fetchProcesses = async (repo?: string): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/processes${repo ? `?${repoParam(repo)}` : ''}`);
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/processes${repo ? `?${repoParam(repo)}` : ''}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
|
@ -507,7 +527,9 @@ export const fetchProcessDetail = async (repo: string, name: string): Promise<un
|
|||
|
||||
/** Fetch all clusters for a repo. */
|
||||
export const fetchClusters = async (repo?: string): Promise<unknown> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/clusters${repo ? `?${repoParam(repo)}` : ''}`);
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/clusters${repo ? `?${repoParam(repo)}` : ''}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
};
|
||||
|
|
@ -524,21 +546,30 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
|
|||
// ── Analyze API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Start a server-side analysis job. */
|
||||
export const startAnalyze = async (
|
||||
request: { url?: string; path?: string; force?: boolean; embeddings?: boolean },
|
||||
): Promise<{ jobId: string; status: string }> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}, 30_000);
|
||||
export const startAnalyze = async (request: {
|
||||
url?: string;
|
||||
path?: string;
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
}): Promise<{ jobId: string; status: string }> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/analyze`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<{ jobId: string; status: string }>;
|
||||
};
|
||||
|
||||
/** Poll analysis job status. */
|
||||
export const getAnalyzeStatus = async (jobId: string): Promise<JobStatus> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/analyze/${encodeURIComponent(jobId)}`);
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/analyze/${encodeURIComponent(jobId)}`,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<JobStatus>;
|
||||
};
|
||||
|
|
@ -573,11 +604,15 @@ export const streamAnalyzeProgress = (
|
|||
|
||||
/** Start server-side embedding generation. */
|
||||
export const startEmbeddings = async (repo: string): Promise<{ jobId: string; status: string }> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/embed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repo }),
|
||||
}, 30_000);
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/embed`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repo }),
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<{ jobId: string; status: string }>;
|
||||
};
|
||||
|
|
@ -591,10 +626,9 @@ export const getEmbedStatus = async (jobId: string): Promise<JobStatus> => {
|
|||
|
||||
/** Cancel a running embedding job. */
|
||||
export const cancelEmbeddings = async (jobId: string): Promise<void> => {
|
||||
const response = await fetchWithTimeout(
|
||||
`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await assertOk(response);
|
||||
};
|
||||
|
||||
|
|
@ -605,14 +639,11 @@ export const streamEmbeddingProgress = (
|
|||
onComplete: (data: { repoName?: string }) => void,
|
||||
onError: (error: string) => void,
|
||||
): AbortController => {
|
||||
return streamSSE<JobProgress>(
|
||||
`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}/progress`,
|
||||
{
|
||||
onMessage: onProgress,
|
||||
onComplete: onComplete as (data: unknown) => void,
|
||||
onError,
|
||||
},
|
||||
);
|
||||
return streamSSE<JobProgress>(`${_backendUrl}/api/embed/${encodeURIComponent(jobId)}/progress`, {
|
||||
onMessage: onProgress,
|
||||
onComplete: onComplete as (data: unknown) => void,
|
||||
onError,
|
||||
});
|
||||
};
|
||||
|
||||
// ── Convenience: connect to server ─────────────────────────────────────────
|
||||
|
|
|
|||
441
gitnexus-web/src/vendor/leiden/index.js
vendored
441
gitnexus-web/src/vendor/leiden/index.js
vendored
|
|
@ -29,305 +29,292 @@ var createRandomIndex = randomIndexModule.createRandomIndex || randomIndexModule
|
|||
var UndirectedLouvainIndex = indices.UndirectedLouvainIndex;
|
||||
|
||||
var DEFAULTS = {
|
||||
attributes: {
|
||||
community: 'community',
|
||||
weight: 'weight'
|
||||
},
|
||||
randomness: 0.01,
|
||||
randomWalk: true,
|
||||
resolution: 1,
|
||||
rng: Math.random,
|
||||
weighted: false
|
||||
attributes: {
|
||||
community: 'community',
|
||||
weight: 'weight',
|
||||
},
|
||||
randomness: 0.01,
|
||||
randomWalk: true,
|
||||
resolution: 1,
|
||||
rng: Math.random,
|
||||
weighted: false,
|
||||
};
|
||||
|
||||
var EPSILON = 1e-10;
|
||||
|
||||
function tieBreaker(
|
||||
bestCommunity,
|
||||
currentCommunity,
|
||||
targetCommunity,
|
||||
delta,
|
||||
bestDelta
|
||||
) {
|
||||
if (Math.abs(delta - bestDelta) < EPSILON) {
|
||||
if (bestCommunity === currentCommunity) {
|
||||
return false;
|
||||
} else {
|
||||
return targetCommunity > bestCommunity;
|
||||
}
|
||||
} else if (delta > bestDelta) {
|
||||
return true;
|
||||
}
|
||||
function tieBreaker(bestCommunity, currentCommunity, targetCommunity, delta, bestDelta) {
|
||||
if (Math.abs(delta - bestDelta) < EPSILON) {
|
||||
if (bestCommunity === currentCommunity) {
|
||||
return false;
|
||||
} else {
|
||||
return targetCommunity > bestCommunity;
|
||||
}
|
||||
} else if (delta > bestDelta) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function undirectedLeiden(detailed, graph, options) {
|
||||
var index = new UndirectedLouvainIndex(graph, {
|
||||
attributes: {
|
||||
weight: options.attributes.weight
|
||||
},
|
||||
keepDendrogram: detailed,
|
||||
resolution: options.resolution,
|
||||
weighted: options.weighted
|
||||
});
|
||||
var index = new UndirectedLouvainIndex(graph, {
|
||||
attributes: {
|
||||
weight: options.attributes.weight,
|
||||
},
|
||||
keepDendrogram: detailed,
|
||||
resolution: options.resolution,
|
||||
weighted: options.weighted,
|
||||
});
|
||||
|
||||
var addenda = new UndirectedLeidenAddenda(index, {
|
||||
randomness: options.randomness,
|
||||
rng: options.rng
|
||||
});
|
||||
var addenda = new UndirectedLeidenAddenda(index, {
|
||||
randomness: options.randomness,
|
||||
rng: options.rng,
|
||||
});
|
||||
|
||||
var randomIndex = createRandomIndex(options.rng);
|
||||
var randomIndex = createRandomIndex(options.rng);
|
||||
|
||||
// Communities
|
||||
var currentCommunity, targetCommunity;
|
||||
var communities = new SparseMap(Float64Array, index.C);
|
||||
// Communities
|
||||
var currentCommunity, targetCommunity;
|
||||
var communities = new SparseMap(Float64Array, index.C);
|
||||
|
||||
// Traversal
|
||||
var queue = new SparseQueueSet(index.C),
|
||||
start,
|
||||
end,
|
||||
weight,
|
||||
ci,
|
||||
ri,
|
||||
s,
|
||||
i,
|
||||
j,
|
||||
l;
|
||||
// Traversal
|
||||
var queue = new SparseQueueSet(index.C),
|
||||
start,
|
||||
end,
|
||||
weight,
|
||||
ci,
|
||||
ri,
|
||||
s,
|
||||
i,
|
||||
j,
|
||||
l;
|
||||
|
||||
// Metrics
|
||||
var degree, targetCommunityDegree;
|
||||
// Metrics
|
||||
var degree, targetCommunityDegree;
|
||||
|
||||
// Moves
|
||||
var bestCommunity, bestDelta, deltaIsBetter, delta;
|
||||
// Moves
|
||||
var bestCommunity, bestDelta, deltaIsBetter, delta;
|
||||
|
||||
// Details
|
||||
var deltaComputations = 0,
|
||||
nodesVisited = 0,
|
||||
moves = [],
|
||||
currentMoves;
|
||||
// Details
|
||||
var deltaComputations = 0,
|
||||
nodesVisited = 0,
|
||||
moves = [],
|
||||
currentMoves;
|
||||
|
||||
while (true) {
|
||||
l = index.C;
|
||||
while (true) {
|
||||
l = index.C;
|
||||
|
||||
currentMoves = 0;
|
||||
currentMoves = 0;
|
||||
|
||||
// Traversal of the graph
|
||||
ri = options.randomWalk ? randomIndex(l) : 0;
|
||||
// Traversal of the graph
|
||||
ri = options.randomWalk ? randomIndex(l) : 0;
|
||||
|
||||
for (s = 0; s < l; s++, ri++) {
|
||||
i = ri % l;
|
||||
queue.enqueue(i);
|
||||
}
|
||||
for (s = 0; s < l; s++, ri++) {
|
||||
i = ri % l;
|
||||
queue.enqueue(i);
|
||||
}
|
||||
|
||||
while (queue.size !== 0) {
|
||||
i = queue.dequeue();
|
||||
nodesVisited++;
|
||||
while (queue.size !== 0) {
|
||||
i = queue.dequeue();
|
||||
nodesVisited++;
|
||||
|
||||
degree = 0;
|
||||
communities.clear();
|
||||
degree = 0;
|
||||
communities.clear();
|
||||
|
||||
currentCommunity = index.belongings[i];
|
||||
currentCommunity = index.belongings[i];
|
||||
|
||||
start = index.starts[i];
|
||||
end = index.starts[i + 1];
|
||||
start = index.starts[i];
|
||||
end = index.starts[i + 1];
|
||||
|
||||
// Traversing neighbors
|
||||
for (; start < end; start++) {
|
||||
j = index.neighborhood[start];
|
||||
weight = index.weights[start];
|
||||
// Traversing neighbors
|
||||
for (; start < end; start++) {
|
||||
j = index.neighborhood[start];
|
||||
weight = index.weights[start];
|
||||
|
||||
targetCommunity = index.belongings[j];
|
||||
targetCommunity = index.belongings[j];
|
||||
|
||||
// Incrementing metrics
|
||||
degree += weight;
|
||||
addWeightToCommunity(communities, targetCommunity, weight);
|
||||
}
|
||||
// Incrementing metrics
|
||||
degree += weight;
|
||||
addWeightToCommunity(communities, targetCommunity, weight);
|
||||
}
|
||||
|
||||
// Finding best community to move to
|
||||
bestDelta = index.fastDeltaWithOwnCommunity(
|
||||
i,
|
||||
degree,
|
||||
communities.get(currentCommunity) || 0,
|
||||
currentCommunity
|
||||
);
|
||||
bestCommunity = currentCommunity;
|
||||
// Finding best community to move to
|
||||
bestDelta = index.fastDeltaWithOwnCommunity(
|
||||
i,
|
||||
degree,
|
||||
communities.get(currentCommunity) || 0,
|
||||
currentCommunity,
|
||||
);
|
||||
bestCommunity = currentCommunity;
|
||||
|
||||
for (ci = 0; ci < communities.size; ci++) {
|
||||
targetCommunity = communities.dense[ci];
|
||||
for (ci = 0; ci < communities.size; ci++) {
|
||||
targetCommunity = communities.dense[ci];
|
||||
|
||||
if (targetCommunity === currentCommunity) continue;
|
||||
if (targetCommunity === currentCommunity) continue;
|
||||
|
||||
targetCommunityDegree = communities.vals[ci];
|
||||
targetCommunityDegree = communities.vals[ci];
|
||||
|
||||
deltaComputations++;
|
||||
deltaComputations++;
|
||||
|
||||
delta = index.fastDelta(
|
||||
i,
|
||||
degree,
|
||||
targetCommunityDegree,
|
||||
targetCommunity
|
||||
);
|
||||
delta = index.fastDelta(i, degree, targetCommunityDegree, targetCommunity);
|
||||
|
||||
deltaIsBetter = tieBreaker(
|
||||
bestCommunity,
|
||||
currentCommunity,
|
||||
targetCommunity,
|
||||
delta,
|
||||
bestDelta
|
||||
);
|
||||
deltaIsBetter = tieBreaker(
|
||||
bestCommunity,
|
||||
currentCommunity,
|
||||
targetCommunity,
|
||||
delta,
|
||||
bestDelta,
|
||||
);
|
||||
|
||||
if (deltaIsBetter) {
|
||||
bestDelta = delta;
|
||||
bestCommunity = targetCommunity;
|
||||
}
|
||||
}
|
||||
if (deltaIsBetter) {
|
||||
bestDelta = delta;
|
||||
bestCommunity = targetCommunity;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDelta < 0) {
|
||||
bestCommunity = index.isolate(i, degree);
|
||||
if (bestDelta < 0) {
|
||||
bestCommunity = index.isolate(i, degree);
|
||||
|
||||
if (bestCommunity === currentCommunity) continue;
|
||||
} else {
|
||||
if (bestCommunity === currentCommunity) {
|
||||
continue;
|
||||
} else {
|
||||
index.move(i, degree, bestCommunity);
|
||||
}
|
||||
}
|
||||
if (bestCommunity === currentCommunity) continue;
|
||||
} else {
|
||||
if (bestCommunity === currentCommunity) {
|
||||
continue;
|
||||
} else {
|
||||
index.move(i, degree, bestCommunity);
|
||||
}
|
||||
}
|
||||
|
||||
currentMoves++;
|
||||
currentMoves++;
|
||||
|
||||
// Adding neighbors from other communities to the queue
|
||||
start = index.starts[i];
|
||||
end = index.starts[i + 1];
|
||||
// Adding neighbors from other communities to the queue
|
||||
start = index.starts[i];
|
||||
end = index.starts[i + 1];
|
||||
|
||||
for (; start < end; start++) {
|
||||
j = index.neighborhood[start];
|
||||
targetCommunity = index.belongings[j];
|
||||
for (; start < end; start++) {
|
||||
j = index.neighborhood[start];
|
||||
targetCommunity = index.belongings[j];
|
||||
|
||||
if (targetCommunity !== bestCommunity) queue.enqueue(j);
|
||||
}
|
||||
}
|
||||
if (targetCommunity !== bestCommunity) queue.enqueue(j);
|
||||
}
|
||||
}
|
||||
|
||||
moves.push(currentMoves);
|
||||
moves.push(currentMoves);
|
||||
|
||||
if (currentMoves === 0) {
|
||||
index.zoomOut();
|
||||
break;
|
||||
}
|
||||
if (currentMoves === 0) {
|
||||
index.zoomOut();
|
||||
break;
|
||||
}
|
||||
|
||||
if (!addenda.onlySingletons()) {
|
||||
// We continue working on the induced graph
|
||||
addenda.zoomOut();
|
||||
continue;
|
||||
}
|
||||
if (!addenda.onlySingletons()) {
|
||||
// We continue working on the induced graph
|
||||
addenda.zoomOut();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var results = {
|
||||
index: index,
|
||||
deltaComputations: deltaComputations,
|
||||
nodesVisited: nodesVisited,
|
||||
moves: moves
|
||||
};
|
||||
var results = {
|
||||
index: index,
|
||||
deltaComputations: deltaComputations,
|
||||
nodesVisited: nodesVisited,
|
||||
moves: moves,
|
||||
};
|
||||
|
||||
return results;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function returning the communities mapping of the graph.
|
||||
*/
|
||||
function leiden(assign, detailed, graph, options) {
|
||||
if (!isGraph(graph))
|
||||
throw new Error(
|
||||
'graphology-communities-leiden: the given graph is not a valid graphology instance.'
|
||||
);
|
||||
if (!isGraph(graph))
|
||||
throw new Error(
|
||||
'graphology-communities-leiden: the given graph is not a valid graphology instance.',
|
||||
);
|
||||
|
||||
var type = inferType(graph);
|
||||
var type = inferType(graph);
|
||||
|
||||
if (type === 'mixed')
|
||||
throw new Error(
|
||||
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.'
|
||||
);
|
||||
if (type === 'mixed')
|
||||
throw new Error(
|
||||
'graphology-communities-leiden: cannot run the algorithm on a true mixed graph.',
|
||||
);
|
||||
|
||||
if (type === 'directed')
|
||||
throw new Error(
|
||||
'graphology-communities-leiden: not yet implemented for directed graphs.'
|
||||
);
|
||||
if (type === 'directed')
|
||||
throw new Error('graphology-communities-leiden: not yet implemented for directed graphs.');
|
||||
|
||||
// Attributes name
|
||||
options = resolveDefaults(options, DEFAULTS);
|
||||
// Attributes name
|
||||
options = resolveDefaults(options, DEFAULTS);
|
||||
|
||||
// Empty graph case
|
||||
var c = 0;
|
||||
// Empty graph case
|
||||
var c = 0;
|
||||
|
||||
if (graph.size === 0) {
|
||||
if (assign) {
|
||||
graph.forEachNode(function (node) {
|
||||
graph.setNodeAttribute(node, options.attributes.communities, c++);
|
||||
});
|
||||
if (graph.size === 0) {
|
||||
if (assign) {
|
||||
graph.forEachNode(function (node) {
|
||||
graph.setNodeAttribute(node, options.attributes.communities, c++);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var communities = {};
|
||||
var communities = {};
|
||||
|
||||
graph.forEachNode(function (node) {
|
||||
communities[node] = c++;
|
||||
});
|
||||
graph.forEachNode(function (node) {
|
||||
communities[node] = c++;
|
||||
});
|
||||
|
||||
if (!detailed) return communities;
|
||||
if (!detailed) return communities;
|
||||
|
||||
return {
|
||||
communities: communities,
|
||||
count: graph.order,
|
||||
deltaComputations: 0,
|
||||
dendrogram: null,
|
||||
level: 0,
|
||||
modularity: NaN,
|
||||
moves: null,
|
||||
nodesVisited: 0,
|
||||
resolution: options.resolution
|
||||
};
|
||||
}
|
||||
return {
|
||||
communities: communities,
|
||||
count: graph.order,
|
||||
deltaComputations: 0,
|
||||
dendrogram: null,
|
||||
level: 0,
|
||||
modularity: NaN,
|
||||
moves: null,
|
||||
nodesVisited: 0,
|
||||
resolution: options.resolution,
|
||||
};
|
||||
}
|
||||
|
||||
var fn = undirectedLeiden;
|
||||
var fn = undirectedLeiden;
|
||||
|
||||
var results = fn(detailed, graph, options);
|
||||
var results = fn(detailed, graph, options);
|
||||
|
||||
var index = results.index;
|
||||
var index = results.index;
|
||||
|
||||
// Standard output
|
||||
if (!detailed) {
|
||||
if (assign) {
|
||||
index.assign(options.attributes.community);
|
||||
return;
|
||||
}
|
||||
// Standard output
|
||||
if (!detailed) {
|
||||
if (assign) {
|
||||
index.assign(options.attributes.community);
|
||||
return;
|
||||
}
|
||||
|
||||
return index.collect();
|
||||
}
|
||||
return index.collect();
|
||||
}
|
||||
|
||||
// Detailed output
|
||||
var output = {
|
||||
count: index.C,
|
||||
deltaComputations: results.deltaComputations,
|
||||
dendrogram: index.dendrogram,
|
||||
level: index.level,
|
||||
modularity: index.modularity(),
|
||||
moves: results.moves,
|
||||
nodesVisited: results.nodesVisited,
|
||||
resolution: options.resolution
|
||||
};
|
||||
// Detailed output
|
||||
var output = {
|
||||
count: index.C,
|
||||
deltaComputations: results.deltaComputations,
|
||||
dendrogram: index.dendrogram,
|
||||
level: index.level,
|
||||
modularity: index.modularity(),
|
||||
moves: results.moves,
|
||||
nodesVisited: results.nodesVisited,
|
||||
resolution: options.resolution,
|
||||
};
|
||||
|
||||
if (assign) {
|
||||
index.assign(options.attributes.community);
|
||||
return output;
|
||||
}
|
||||
if (assign) {
|
||||
index.assign(options.attributes.community);
|
||||
return output;
|
||||
}
|
||||
|
||||
output.communities = index.collect();
|
||||
output.communities = index.collect();
|
||||
|
||||
return output;
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
504
gitnexus-web/src/vendor/leiden/utils.js
vendored
504
gitnexus-web/src/vendor/leiden/utils.js
vendored
|
|
@ -14,379 +14,369 @@ import randomModule from 'pandemonium/random';
|
|||
var createRandom = randomModule.createRandom || randomModule;
|
||||
|
||||
export function addWeightToCommunity(map, community, weight) {
|
||||
var currentWeight = map.get(community);
|
||||
var currentWeight = map.get(community);
|
||||
|
||||
if (typeof currentWeight === 'undefined') currentWeight = 0;
|
||||
if (typeof currentWeight === 'undefined') currentWeight = 0;
|
||||
|
||||
currentWeight += weight;
|
||||
currentWeight += weight;
|
||||
|
||||
map.set(community, currentWeight);
|
||||
map.set(community, currentWeight);
|
||||
}
|
||||
|
||||
export function UndirectedLeidenAddenda(index, options) {
|
||||
options = options || {};
|
||||
options = options || {};
|
||||
|
||||
var rng = options.rng || Math.random;
|
||||
var randomness = 'randomness' in options ? options.randomness : 0.01;
|
||||
var rng = options.rng || Math.random;
|
||||
var randomness = 'randomness' in options ? options.randomness : 0.01;
|
||||
|
||||
this.index = index;
|
||||
this.random = createRandom(rng);
|
||||
this.randomness = randomness;
|
||||
this.rng = rng;
|
||||
this.index = index;
|
||||
this.random = createRandom(rng);
|
||||
this.randomness = randomness;
|
||||
this.rng = rng;
|
||||
|
||||
var NodesPointerArray = index.counts.constructor;
|
||||
var WeightsArray = index.weights.constructor;
|
||||
var NodesPointerArray = index.counts.constructor;
|
||||
var WeightsArray = index.weights.constructor;
|
||||
|
||||
var order = index.C;
|
||||
this.resolution = index.resolution;
|
||||
var order = index.C;
|
||||
this.resolution = index.resolution;
|
||||
|
||||
// Used to group nodes by communities
|
||||
this.B = index.C;
|
||||
this.C = 0;
|
||||
this.communitiesOffsets = new NodesPointerArray(order);
|
||||
this.nodesSortedByCommunities = new NodesPointerArray(order);
|
||||
this.communitiesBounds = new NodesPointerArray(order + 1);
|
||||
// Used to group nodes by communities
|
||||
this.B = index.C;
|
||||
this.C = 0;
|
||||
this.communitiesOffsets = new NodesPointerArray(order);
|
||||
this.nodesSortedByCommunities = new NodesPointerArray(order);
|
||||
this.communitiesBounds = new NodesPointerArray(order + 1);
|
||||
|
||||
// Used to merge nodes subsets
|
||||
this.communityWeights = new WeightsArray(order);
|
||||
this.degrees = new WeightsArray(order);
|
||||
this.nonSingleton = new Uint8Array(order);
|
||||
this.externalEdgeWeightPerCommunity = new WeightsArray(order);
|
||||
this.belongings = new NodesPointerArray(order);
|
||||
this.neighboringCommunities = new SparseMap(WeightsArray, order);
|
||||
this.cumulativeIncrement = new Float64Array(order);
|
||||
this.macroCommunities = null;
|
||||
// Used to merge nodes subsets
|
||||
this.communityWeights = new WeightsArray(order);
|
||||
this.degrees = new WeightsArray(order);
|
||||
this.nonSingleton = new Uint8Array(order);
|
||||
this.externalEdgeWeightPerCommunity = new WeightsArray(order);
|
||||
this.belongings = new NodesPointerArray(order);
|
||||
this.neighboringCommunities = new SparseMap(WeightsArray, order);
|
||||
this.cumulativeIncrement = new Float64Array(order);
|
||||
this.macroCommunities = null;
|
||||
}
|
||||
|
||||
UndirectedLeidenAddenda.prototype.groupByCommunities = function () {
|
||||
var index = this.index;
|
||||
var index = this.index;
|
||||
|
||||
var n, i, c, b, o;
|
||||
var n, i, c, b, o;
|
||||
|
||||
n = 0;
|
||||
o = 0;
|
||||
n = 0;
|
||||
o = 0;
|
||||
|
||||
for (i = 0; i < index.C; i++) {
|
||||
c = index.counts[i];
|
||||
for (i = 0; i < index.C; i++) {
|
||||
c = index.counts[i];
|
||||
|
||||
if (c !== 0) {
|
||||
this.communitiesBounds[o++] = n;
|
||||
n += c;
|
||||
this.communitiesOffsets[i] = n;
|
||||
}
|
||||
}
|
||||
if (c !== 0) {
|
||||
this.communitiesBounds[o++] = n;
|
||||
n += c;
|
||||
this.communitiesOffsets[i] = n;
|
||||
}
|
||||
}
|
||||
|
||||
this.communitiesBounds[o] = n;
|
||||
this.communitiesBounds[o] = n;
|
||||
|
||||
o = 0;
|
||||
o = 0;
|
||||
|
||||
for (i = 0; i < index.C; i++) {
|
||||
b = index.belongings[i];
|
||||
o = --this.communitiesOffsets[b];
|
||||
this.nodesSortedByCommunities[o] = i;
|
||||
}
|
||||
for (i = 0; i < index.C; i++) {
|
||||
b = index.belongings[i];
|
||||
o = --this.communitiesOffsets[b];
|
||||
this.nodesSortedByCommunities[o] = i;
|
||||
}
|
||||
|
||||
this.B = index.C - index.U;
|
||||
this.C = index.C;
|
||||
this.B = index.C - index.U;
|
||||
this.C = index.C;
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.communities = function () {
|
||||
var communities = new Array(this.B);
|
||||
var communities = new Array(this.B);
|
||||
|
||||
var i, j, community, start, stop;
|
||||
var i, j, community, start, stop;
|
||||
|
||||
for (i = 0; i < this.B; i++) {
|
||||
start = this.communitiesBounds[i];
|
||||
stop = this.communitiesBounds[i + 1];
|
||||
community = [];
|
||||
for (i = 0; i < this.B; i++) {
|
||||
start = this.communitiesBounds[i];
|
||||
stop = this.communitiesBounds[i + 1];
|
||||
community = [];
|
||||
|
||||
for (j = start; j < stop; j++) {
|
||||
community.push(j);
|
||||
}
|
||||
for (j = start; j < stop; j++) {
|
||||
community.push(j);
|
||||
}
|
||||
|
||||
communities[i] = community;
|
||||
}
|
||||
communities[i] = community;
|
||||
}
|
||||
|
||||
return communities;
|
||||
return communities;
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.mergeNodesSubset = function (start, stop) {
|
||||
var index = this.index;
|
||||
var currentMacroCommunity =
|
||||
index.belongings[this.nodesSortedByCommunities[start]];
|
||||
var neighboringCommunities = this.neighboringCommunities;
|
||||
var index = this.index;
|
||||
var currentMacroCommunity = index.belongings[this.nodesSortedByCommunities[start]];
|
||||
var neighboringCommunities = this.neighboringCommunities;
|
||||
|
||||
var totalNodeWeight = 0;
|
||||
var totalNodeWeight = 0;
|
||||
|
||||
var i, j, w;
|
||||
var ei, el, et;
|
||||
var i, j, w;
|
||||
var ei, el, et;
|
||||
|
||||
// Initializing singletons
|
||||
for (j = start; j < stop; j++) {
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
// Initializing singletons
|
||||
for (j = start; j < stop; j++) {
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
|
||||
this.belongings[i] = i;
|
||||
this.nonSingleton[i] = 0;
|
||||
this.degrees[i] = 0;
|
||||
totalNodeWeight += index.loops[i] / 2;
|
||||
this.belongings[i] = i;
|
||||
this.nonSingleton[i] = 0;
|
||||
this.degrees[i] = 0;
|
||||
totalNodeWeight += index.loops[i] / 2;
|
||||
|
||||
this.communityWeights[i] = index.loops[i];
|
||||
this.externalEdgeWeightPerCommunity[i] = 0;
|
||||
this.communityWeights[i] = index.loops[i];
|
||||
this.externalEdgeWeightPerCommunity[i] = 0;
|
||||
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
w = index.weights[ei];
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
w = index.weights[ei];
|
||||
|
||||
this.degrees[i] += w;
|
||||
this.degrees[i] += w;
|
||||
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
|
||||
totalNodeWeight += w;
|
||||
this.externalEdgeWeightPerCommunity[i] += w;
|
||||
this.communityWeights[i] += w;
|
||||
}
|
||||
}
|
||||
totalNodeWeight += w;
|
||||
this.externalEdgeWeightPerCommunity[i] += w;
|
||||
this.communityWeights[i] += w;
|
||||
}
|
||||
}
|
||||
|
||||
var microDegrees = this.externalEdgeWeightPerCommunity.slice();
|
||||
var microDegrees = this.externalEdgeWeightPerCommunity.slice();
|
||||
|
||||
var s, ri, ci;
|
||||
var order = stop - start;
|
||||
var s, ri, ci;
|
||||
var order = stop - start;
|
||||
|
||||
var degree,
|
||||
bestCommunity,
|
||||
qualityValueIncrement,
|
||||
maxQualityValueIncrement,
|
||||
totalTransformedQualityValueIncrement,
|
||||
targetCommunity,
|
||||
targetCommunityDegree,
|
||||
targetCommunityWeight;
|
||||
var degree,
|
||||
bestCommunity,
|
||||
qualityValueIncrement,
|
||||
maxQualityValueIncrement,
|
||||
totalTransformedQualityValueIncrement,
|
||||
targetCommunity,
|
||||
targetCommunityDegree,
|
||||
targetCommunityWeight;
|
||||
|
||||
var r, lo, hi, mid, chosenCommunity;
|
||||
var r, lo, hi, mid, chosenCommunity;
|
||||
|
||||
ri = this.random(start, stop - 1);
|
||||
ri = this.random(start, stop - 1);
|
||||
|
||||
for (s = start; s < stop; s++, ri++) {
|
||||
j = start + (ri % order);
|
||||
for (s = start; s < stop; s++, ri++) {
|
||||
j = start + (ri % order);
|
||||
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
|
||||
if (this.nonSingleton[i] === 1) {
|
||||
continue;
|
||||
}
|
||||
if (this.nonSingleton[i] === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.externalEdgeWeightPerCommunity[i] <
|
||||
this.communityWeights[i] *
|
||||
(totalNodeWeight / 2 - this.communityWeights[i]) *
|
||||
this.resolution
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
this.externalEdgeWeightPerCommunity[i] <
|
||||
this.communityWeights[i] * (totalNodeWeight / 2 - this.communityWeights[i]) * this.resolution
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.communityWeights[i] = 0;
|
||||
this.externalEdgeWeightPerCommunity[i] = 0;
|
||||
this.communityWeights[i] = 0;
|
||||
this.externalEdgeWeightPerCommunity[i] = 0;
|
||||
|
||||
neighboringCommunities.clear();
|
||||
neighboringCommunities.set(i, 0);
|
||||
neighboringCommunities.clear();
|
||||
neighboringCommunities.set(i, 0);
|
||||
|
||||
degree = 0;
|
||||
degree = 0;
|
||||
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
|
||||
w = index.weights[ei];
|
||||
w = index.weights[ei];
|
||||
|
||||
degree += w;
|
||||
degree += w;
|
||||
|
||||
addWeightToCommunity(neighboringCommunities, this.belongings[et], w);
|
||||
}
|
||||
addWeightToCommunity(neighboringCommunities, this.belongings[et], w);
|
||||
}
|
||||
|
||||
bestCommunity = i;
|
||||
maxQualityValueIncrement = 0;
|
||||
totalTransformedQualityValueIncrement = 0;
|
||||
bestCommunity = i;
|
||||
maxQualityValueIncrement = 0;
|
||||
totalTransformedQualityValueIncrement = 0;
|
||||
|
||||
for (ci = 0; ci < neighboringCommunities.size; ci++) {
|
||||
targetCommunity = neighboringCommunities.dense[ci];
|
||||
targetCommunityDegree = neighboringCommunities.vals[ci];
|
||||
targetCommunityWeight = this.communityWeights[targetCommunity];
|
||||
for (ci = 0; ci < neighboringCommunities.size; ci++) {
|
||||
targetCommunity = neighboringCommunities.dense[ci];
|
||||
targetCommunityDegree = neighboringCommunities.vals[ci];
|
||||
targetCommunityWeight = this.communityWeights[targetCommunity];
|
||||
|
||||
if (
|
||||
this.externalEdgeWeightPerCommunity[targetCommunity] >=
|
||||
targetCommunityWeight *
|
||||
(totalNodeWeight / 2 - targetCommunityWeight) *
|
||||
this.resolution
|
||||
) {
|
||||
qualityValueIncrement =
|
||||
targetCommunityDegree -
|
||||
((degree + index.loops[i]) *
|
||||
targetCommunityWeight *
|
||||
this.resolution) /
|
||||
totalNodeWeight;
|
||||
if (
|
||||
this.externalEdgeWeightPerCommunity[targetCommunity] >=
|
||||
targetCommunityWeight * (totalNodeWeight / 2 - targetCommunityWeight) * this.resolution
|
||||
) {
|
||||
qualityValueIncrement =
|
||||
targetCommunityDegree -
|
||||
((degree + index.loops[i]) * targetCommunityWeight * this.resolution) / totalNodeWeight;
|
||||
|
||||
if (qualityValueIncrement > maxQualityValueIncrement) {
|
||||
bestCommunity = targetCommunity;
|
||||
maxQualityValueIncrement = qualityValueIncrement;
|
||||
}
|
||||
if (qualityValueIncrement > maxQualityValueIncrement) {
|
||||
bestCommunity = targetCommunity;
|
||||
maxQualityValueIncrement = qualityValueIncrement;
|
||||
}
|
||||
|
||||
if (qualityValueIncrement >= 0)
|
||||
totalTransformedQualityValueIncrement += Math.exp(
|
||||
qualityValueIncrement / this.randomness
|
||||
);
|
||||
}
|
||||
if (qualityValueIncrement >= 0)
|
||||
totalTransformedQualityValueIncrement += Math.exp(
|
||||
qualityValueIncrement / this.randomness,
|
||||
);
|
||||
}
|
||||
|
||||
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
|
||||
}
|
||||
this.cumulativeIncrement[ci] = totalTransformedQualityValueIncrement;
|
||||
}
|
||||
|
||||
if (
|
||||
totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
|
||||
totalTransformedQualityValueIncrement < Infinity
|
||||
) {
|
||||
r = totalTransformedQualityValueIncrement * this.rng();
|
||||
lo = -1;
|
||||
hi = neighboringCommunities.size + 1;
|
||||
if (
|
||||
totalTransformedQualityValueIncrement < Number.MAX_VALUE &&
|
||||
totalTransformedQualityValueIncrement < Infinity
|
||||
) {
|
||||
r = totalTransformedQualityValueIncrement * this.rng();
|
||||
lo = -1;
|
||||
hi = neighboringCommunities.size + 1;
|
||||
|
||||
while (lo < hi - 1) {
|
||||
mid = (lo + hi) >>> 1;
|
||||
while (lo < hi - 1) {
|
||||
mid = (lo + hi) >>> 1;
|
||||
|
||||
if (this.cumulativeIncrement[mid] >= r) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
if (this.cumulativeIncrement[mid] >= r) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
|
||||
chosenCommunity = neighboringCommunities.dense[hi];
|
||||
} else {
|
||||
chosenCommunity = bestCommunity;
|
||||
}
|
||||
chosenCommunity = neighboringCommunities.dense[hi];
|
||||
} else {
|
||||
chosenCommunity = bestCommunity;
|
||||
}
|
||||
|
||||
this.communityWeights[chosenCommunity] += degree + index.loops[i];
|
||||
this.communityWeights[chosenCommunity] += degree + index.loops[i];
|
||||
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
ei = index.starts[i];
|
||||
el = index.starts[i + 1];
|
||||
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
for (; ei < el; ei++) {
|
||||
et = index.neighborhood[ei];
|
||||
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
if (index.belongings[et] !== currentMacroCommunity) continue;
|
||||
|
||||
targetCommunity = this.belongings[et];
|
||||
targetCommunity = this.belongings[et];
|
||||
|
||||
if (targetCommunity === chosenCommunity) {
|
||||
this.externalEdgeWeightPerCommunity[chosenCommunity] -=
|
||||
microDegrees[et];
|
||||
} else {
|
||||
this.externalEdgeWeightPerCommunity[chosenCommunity] +=
|
||||
microDegrees[et];
|
||||
}
|
||||
}
|
||||
if (targetCommunity === chosenCommunity) {
|
||||
this.externalEdgeWeightPerCommunity[chosenCommunity] -= microDegrees[et];
|
||||
} else {
|
||||
this.externalEdgeWeightPerCommunity[chosenCommunity] += microDegrees[et];
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenCommunity !== i) {
|
||||
this.belongings[i] = chosenCommunity;
|
||||
this.nonSingleton[chosenCommunity] = 1;
|
||||
this.C--;
|
||||
}
|
||||
}
|
||||
if (chosenCommunity !== i) {
|
||||
this.belongings[i] = chosenCommunity;
|
||||
this.nonSingleton[chosenCommunity] = 1;
|
||||
this.C--;
|
||||
}
|
||||
}
|
||||
|
||||
var microCommunities = this.neighboringCommunities;
|
||||
microCommunities.clear();
|
||||
var microCommunities = this.neighboringCommunities;
|
||||
microCommunities.clear();
|
||||
|
||||
for (j = start; j < stop; j++) {
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
microCommunities.set(this.belongings[i], 1);
|
||||
}
|
||||
for (j = start; j < stop; j++) {
|
||||
i = this.nodesSortedByCommunities[j];
|
||||
microCommunities.set(this.belongings[i], 1);
|
||||
}
|
||||
|
||||
return microCommunities.dense.slice(0, microCommunities.size);
|
||||
return microCommunities.dense.slice(0, microCommunities.size);
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.refinePartition = function () {
|
||||
this.groupByCommunities();
|
||||
this.groupByCommunities();
|
||||
|
||||
this.macroCommunities = new Array(this.B);
|
||||
this.macroCommunities = new Array(this.B);
|
||||
|
||||
var i, start, stop, mapping;
|
||||
var i, start, stop, mapping;
|
||||
|
||||
var bounds = this.communitiesBounds;
|
||||
var bounds = this.communitiesBounds;
|
||||
|
||||
for (i = 0; i < this.B; i++) {
|
||||
start = bounds[i];
|
||||
stop = bounds[i + 1];
|
||||
for (i = 0; i < this.B; i++) {
|
||||
start = bounds[i];
|
||||
stop = bounds[i + 1];
|
||||
|
||||
mapping = this.mergeNodesSubset(start, stop);
|
||||
this.macroCommunities[i] = mapping;
|
||||
}
|
||||
mapping = this.mergeNodesSubset(start, stop);
|
||||
this.macroCommunities[i] = mapping;
|
||||
}
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.split = function () {
|
||||
var index = this.index;
|
||||
var isolates = this.neighboringCommunities;
|
||||
var index = this.index;
|
||||
var isolates = this.neighboringCommunities;
|
||||
|
||||
isolates.clear();
|
||||
isolates.clear();
|
||||
|
||||
var i, community, isolated;
|
||||
var i, community, isolated;
|
||||
|
||||
for (i = 0; i < index.C; i++) {
|
||||
community = this.belongings[i];
|
||||
for (i = 0; i < index.C; i++) {
|
||||
community = this.belongings[i];
|
||||
|
||||
if (i !== community) continue;
|
||||
if (i !== community) continue;
|
||||
|
||||
isolated = index.isolate(i, this.degrees[i]);
|
||||
isolates.set(community, isolated);
|
||||
}
|
||||
isolated = index.isolate(i, this.degrees[i]);
|
||||
isolates.set(community, isolated);
|
||||
}
|
||||
|
||||
for (i = 0; i < index.C; i++) {
|
||||
community = this.belongings[i];
|
||||
for (i = 0; i < index.C; i++) {
|
||||
community = this.belongings[i];
|
||||
|
||||
if (i === community) continue;
|
||||
if (i === community) continue;
|
||||
|
||||
isolated = isolates.get(community);
|
||||
index.move(i, this.degrees[i], isolated);
|
||||
}
|
||||
isolated = isolates.get(community);
|
||||
index.move(i, this.degrees[i], isolated);
|
||||
}
|
||||
|
||||
var j, macro;
|
||||
var j, macro;
|
||||
|
||||
for (i = 0; i < this.macroCommunities.length; i++) {
|
||||
macro = this.macroCommunities[i];
|
||||
for (i = 0; i < this.macroCommunities.length; i++) {
|
||||
macro = this.macroCommunities[i];
|
||||
|
||||
for (j = 0; j < macro.length; j++) macro[j] = isolates.get(macro[j]);
|
||||
}
|
||||
for (j = 0; j < macro.length; j++) macro[j] = isolates.get(macro[j]);
|
||||
}
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.zoomOut = function () {
|
||||
var index = this.index;
|
||||
this.refinePartition();
|
||||
this.split();
|
||||
var index = this.index;
|
||||
this.refinePartition();
|
||||
this.split();
|
||||
|
||||
var newLabels = index.zoomOut();
|
||||
var newLabels = index.zoomOut();
|
||||
|
||||
var macro, leader, follower;
|
||||
var macro, leader, follower;
|
||||
|
||||
var i, j;
|
||||
var i, j;
|
||||
|
||||
for (i = 0; i < this.macroCommunities.length; i++) {
|
||||
macro = this.macroCommunities[i];
|
||||
leader = newLabels[macro[0]];
|
||||
for (i = 0; i < this.macroCommunities.length; i++) {
|
||||
macro = this.macroCommunities[i];
|
||||
leader = newLabels[macro[0]];
|
||||
|
||||
for (j = 1; j < macro.length; j++) {
|
||||
follower = newLabels[macro[j]];
|
||||
index.expensiveMove(follower, leader);
|
||||
}
|
||||
}
|
||||
for (j = 1; j < macro.length; j++) {
|
||||
follower = newLabels[macro[j]];
|
||||
index.expensiveMove(follower, leader);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UndirectedLeidenAddenda.prototype.onlySingletons = function () {
|
||||
var index = this.index;
|
||||
var index = this.index;
|
||||
|
||||
var i;
|
||||
var i;
|
||||
|
||||
for (i = 0; i < index.C; i++) {
|
||||
if (index.counts[i] > 1) return false;
|
||||
}
|
||||
for (i = 0; i < index.C; i++) {
|
||||
if (index.counts[i] > 1) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
};
|
||||
|
|
|
|||
6
gitnexus-web/test/fixtures/graph.ts
vendored
6
gitnexus-web/test/fixtures/graph.ts
vendored
|
|
@ -29,7 +29,11 @@ export function createClassNode(name: string, filePath: string): GraphNode {
|
|||
};
|
||||
}
|
||||
|
||||
export function createProcessNode(id: string, label: string, type: 'cross_community' | 'intra_community' = 'cross_community'): GraphNode {
|
||||
export function createProcessNode(
|
||||
id: string,
|
||||
label: string,
|
||||
type: 'cross_community' | 'intra_community' = 'cross_community',
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: 'Process',
|
||||
|
|
|
|||
|
|
@ -110,11 +110,24 @@ describe('loadServerGraph — data flow validation', () => {
|
|||
it('reconstructs graph from server node/relationship arrays', () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const serverNodes = [
|
||||
{ id: 'File:src/app.ts', label: 'File' as const, properties: { name: 'app.ts', filePath: 'src/app.ts' } },
|
||||
{ id: 'Function:src/app.ts:main', label: 'Function' as const, properties: { name: 'main', filePath: 'src/app.ts', startLine: 1, endLine: 20 } },
|
||||
{
|
||||
id: 'File:src/app.ts',
|
||||
label: 'File' as const,
|
||||
properties: { name: 'app.ts', filePath: 'src/app.ts' },
|
||||
},
|
||||
{
|
||||
id: 'Function:src/app.ts:main',
|
||||
label: 'Function' as const,
|
||||
properties: { name: 'main', filePath: 'src/app.ts', startLine: 1, endLine: 20 },
|
||||
},
|
||||
];
|
||||
const serverRels = [
|
||||
{ sourceId: 'File:src/app.ts', targetId: 'Function:src/app.ts:main', type: 'CONTAINS' as const, properties: {} },
|
||||
{
|
||||
sourceId: 'File:src/app.ts',
|
||||
targetId: 'Function:src/app.ts:main',
|
||||
type: 'CONTAINS' as const,
|
||||
properties: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const node of serverNodes) graph.addNode(node);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ describe('NODE_COLORS', () => {
|
|||
|
||||
describe('NODE_SIZES', () => {
|
||||
it('gives Project the largest size', () => {
|
||||
const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => a[1] > b[1] ? a : b);
|
||||
const maxLabel = Object.entries(NODE_SIZES).reduce((a, b) => (a[1] > b[1] ? a : b));
|
||||
expect(maxLabel[0]).toBe('Project');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,16 @@ import type { NodeLabel } from '../../src/core/graph/types';
|
|||
import * as lucideIcons from '../../src/lib/lucide-icons';
|
||||
|
||||
const LEGEND_LABELS: NodeLabel[] = [
|
||||
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type',
|
||||
'Function', 'Method', 'Variable', 'Decorator',
|
||||
'Folder',
|
||||
'File',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Decorator',
|
||||
];
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
|
|
@ -33,7 +41,9 @@ describe('filter panel icon mappings', () => {
|
|||
const exportedNames = new Set(Object.keys(lucideIcons));
|
||||
const requiredIcons = new Set(Object.values(ICON_MAP));
|
||||
for (const iconName of requiredIcons) {
|
||||
expect(exportedNames.has(iconName), `${iconName} should be exported from lucide-icons`).toBe(true);
|
||||
expect(exportedNames.has(iconName), `${iconName} should be exported from lucide-icons`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -61,8 +71,16 @@ describe('color legend', () => {
|
|||
|
||||
it('legend labels match the order used in FileTreePanel', () => {
|
||||
const expected: NodeLabel[] = [
|
||||
'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type',
|
||||
'Function', 'Method', 'Variable', 'Decorator',
|
||||
'Folder',
|
||||
'File',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Decorator',
|
||||
];
|
||||
expect(LEGEND_LABELS).toEqual(expected);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph';
|
||||
import { createFileNode, createFunctionNode, createCallsRelationship, createContainsRelationship } from '../fixtures/graph';
|
||||
import {
|
||||
createFileNode,
|
||||
createFunctionNode,
|
||||
createCallsRelationship,
|
||||
createContainsRelationship,
|
||||
} from '../fixtures/graph';
|
||||
|
||||
describe('createKnowledgeGraph', () => {
|
||||
it('starts empty', () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ describe('path-resolution utilities', () => {
|
|||
});
|
||||
|
||||
it('prefers exact matches', () => {
|
||||
expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe('src/components/Header.tsx');
|
||||
expect(resolveFilePath(contents, 'src/components/Header.tsx')).toBe(
|
||||
'src/components/Header.tsx',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves ends-with partials', () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe('nodeById Map — O(1) lookup correctness', () => {
|
|||
{ id: 'Class:b.ts:Bar', label: 'Class', name: 'Bar' },
|
||||
{ id: 'File:c.ts', label: 'File', name: 'c.ts' },
|
||||
];
|
||||
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
expect(nodeById.get('Function:a.ts:foo')?.name).toBe('foo');
|
||||
expect(nodeById.get('Class:b.ts:Bar')?.name).toBe('Bar');
|
||||
|
|
@ -26,7 +26,7 @@ describe('nodeById Map — O(1) lookup correctness', () => {
|
|||
{ id: 'File:a.ts', label: 'File', name: 'first' },
|
||||
{ id: 'File:a.ts', label: 'File', name: 'second' },
|
||||
];
|
||||
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
expect(nodeById.get('File:a.ts')?.name).toBe('second');
|
||||
expect(nodeById.size).toBe(1);
|
||||
|
|
|
|||
|
|
@ -11,14 +11,11 @@ import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
|
|||
// readOnly guard (regex) -- gitnexus-web/src/core/lbug/lbug-adapter.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const validLabel = (label: string): boolean =>
|
||||
(NODE_TABLES as readonly string[]).includes(label);
|
||||
const validLabel = (label: string): boolean => (NODE_TABLES as readonly string[]).includes(label);
|
||||
|
||||
const validRelType = (t: string): boolean =>
|
||||
(REL_TYPES as readonly string[]).includes(t);
|
||||
const validRelType = (t: string): boolean => (REL_TYPES as readonly string[]).includes(t);
|
||||
|
||||
const isSafeId = (id: string): boolean =>
|
||||
/^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
|
||||
const isWriteQuery = (cypher: string): boolean => {
|
||||
const stripped = cypher.replace(/'[^']*'|"[^"]*"/g, '').toUpperCase();
|
||||
|
|
@ -29,17 +26,32 @@ const isWriteQuery = (cypher: string): boolean => {
|
|||
// validLabel
|
||||
// ===========================================================================
|
||||
describe('validLabel – NODE_TABLES membership', () => {
|
||||
it.each([
|
||||
'Function', 'Class', 'File', 'Process', 'Community',
|
||||
])('accepts known label "%s"', (label) => {
|
||||
expect(validLabel(label)).toBe(true);
|
||||
});
|
||||
it.each(['Function', 'Class', 'File', 'Process', 'Community'])(
|
||||
'accepts known label "%s"',
|
||||
(label) => {
|
||||
expect(validLabel(label)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'Struct', 'Enum', 'Trait', 'Impl', 'Macro', 'Typedef',
|
||||
'Union', 'Namespace', 'TypeAlias', 'Const', 'Static',
|
||||
'Property', 'Record', 'Delegate', 'Annotation',
|
||||
'Constructor', 'Template', 'Module',
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Trait',
|
||||
'Impl',
|
||||
'Macro',
|
||||
'Typedef',
|
||||
'Union',
|
||||
'Namespace',
|
||||
'TypeAlias',
|
||||
'Const',
|
||||
'Static',
|
||||
'Property',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Constructor',
|
||||
'Template',
|
||||
'Module',
|
||||
])('accepts multi-language label "%s"', (label) => {
|
||||
expect(validLabel(label)).toBe(true);
|
||||
});
|
||||
|
|
@ -59,7 +71,17 @@ describe('validLabel – NODE_TABLES membership', () => {
|
|||
});
|
||||
|
||||
it('NODE_TABLES contains all expected core labels', () => {
|
||||
const core = ['File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement', 'Community', 'Process'];
|
||||
const core = [
|
||||
'File',
|
||||
'Folder',
|
||||
'Function',
|
||||
'Class',
|
||||
'Interface',
|
||||
'Method',
|
||||
'CodeElement',
|
||||
'Community',
|
||||
'Process',
|
||||
];
|
||||
for (const label of core) {
|
||||
expect((NODE_TABLES as readonly string[]).includes(label)).toBe(true);
|
||||
}
|
||||
|
|
@ -70,9 +92,7 @@ describe('validLabel – NODE_TABLES membership', () => {
|
|||
// validRelType
|
||||
// ===========================================================================
|
||||
describe('validRelType – REL_TYPES membership', () => {
|
||||
it.each(
|
||||
[...REL_TYPES]
|
||||
)('accepts known relation type "%s"', (relType) => {
|
||||
it.each([...REL_TYPES])('accepts known relation type "%s"', (relType) => {
|
||||
expect(validRelType(relType)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -112,11 +132,12 @@ describe('isSafeId – identifier allowlist regex', () => {
|
|||
expect(isSafeId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['with spaces', 'Process:my process'],
|
||||
])('rejects ID with unsafe chars: %s', (_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(false);
|
||||
});
|
||||
it.each([['with spaces', 'Process:my process']])(
|
||||
'rejects ID with unsafe chars: %s',
|
||||
(_desc, id) => {
|
||||
expect(isSafeId(id)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isSafeId('')).toBe(false);
|
||||
|
|
|
|||
|
|
@ -19,10 +19,13 @@ describe('loadSettings', () => {
|
|||
});
|
||||
|
||||
it('merges stored values with defaults', () => {
|
||||
sessionStorage.setItem('gitnexus-llm-settings', JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'qwen3-coder:30b' },
|
||||
}));
|
||||
sessionStorage.setItem(
|
||||
'gitnexus-llm-settings',
|
||||
JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'qwen3-coder:30b' },
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.activeProvider).toBe('ollama');
|
||||
|
|
@ -38,10 +41,13 @@ describe('loadSettings', () => {
|
|||
});
|
||||
|
||||
it('migrates legacy localStorage to sessionStorage', () => {
|
||||
localStorage.setItem('gitnexus-llm-settings', JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'migrated-model' },
|
||||
}));
|
||||
localStorage.setItem(
|
||||
'gitnexus-llm-settings',
|
||||
JSON.stringify({
|
||||
activeProvider: 'ollama',
|
||||
ollama: { model: 'migrated-model' },
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.ollama.model).toBe('migrated-model');
|
||||
|
|
@ -111,7 +117,11 @@ describe('getActiveProviderConfig', () => {
|
|||
describe('isProviderConfigured', () => {
|
||||
it('returns false when provider requires API key and none is set', () => {
|
||||
// Manually build a clean openai config with no API key
|
||||
saveSettings({ ...loadSettings(), activeProvider: 'openai', openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 } });
|
||||
saveSettings({
|
||||
...loadSettings(),
|
||||
activeProvider: 'openai',
|
||||
openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 },
|
||||
});
|
||||
expect(isProviderConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ const _require = createRequire(import.meta.url);
|
|||
const gitnexusPkg = _require('../gitnexus/package.json');
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
__REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')),
|
||||
},
|
||||
|
|
@ -20,9 +17,12 @@ export default defineConfig({
|
|||
'@': path.resolve(__dirname, './src'),
|
||||
'@shared': path.resolve(__dirname, '../shared'),
|
||||
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
|
||||
__dirname,
|
||||
'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs',
|
||||
),
|
||||
// Fix for mermaid d3-color prototype crash on Vercel (known issue with mermaid 10.9.0+ and Vite)
|
||||
'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
|
||||
mermaid: path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(__dirname, 'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs'),
|
||||
'mermaid': path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
|
||||
__dirname,
|
||||
'node_modules/@anthropic-ai/sdk/lib/transform-json-schema.mjs',
|
||||
),
|
||||
mermaid: path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.esm.min.mjs'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
|
@ -28,12 +31,12 @@ export default defineConfig({
|
|||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/workers/**', // Web workers (require worker env)
|
||||
'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
|
||||
'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
|
||||
'src/core/embeddings/**', // WASM (requires ML model)
|
||||
'src/main.tsx', // Entry point
|
||||
'src/vite-env.d.ts', // Type declarations
|
||||
'src/workers/**', // Web workers (require worker env)
|
||||
'src/core/lbug/**', // WASM (requires SharedArrayBuffer)
|
||||
'src/core/tree-sitter/**', // WASM (requires tree-sitter binaries)
|
||||
'src/core/embeddings/**', // WASM (requires ML model)
|
||||
'src/main.tsx', // Entry point
|
||||
'src/vite-env.d.ts', // Type declarations
|
||||
],
|
||||
thresholds: {
|
||||
statements: 10,
|
||||
|
|
@ -44,4 +47,3 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_claude-mem_mcp-search__get_observations"
|
||||
]
|
||||
"allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -110,18 +126,20 @@ function resolveCliPath() {
|
|||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
if (cliPath) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, ...args],
|
||||
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
// On Windows, invoke npx.cmd directly (no shell needed)
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,7 +165,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());
|
||||
|
|
@ -158,9 +178,11 @@ function handlePreToolUse(input) {
|
|||
* Emit a PostToolUse 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 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -192,10 +214,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;
|
||||
|
||||
|
|
@ -204,16 +231,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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@
|
|||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"husky": "^9.1.7",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.18"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* AI Context Generator
|
||||
*
|
||||
*
|
||||
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
|
||||
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
|
||||
* CLAUDE.md is for Claude Code which only reads that file.
|
||||
|
|
@ -20,7 +20,7 @@ interface RepoStats {
|
|||
nodes?: number;
|
||||
edges?: number;
|
||||
communities?: number;
|
||||
clusters?: number; // Aggregated cluster count (what tools show)
|
||||
clusters?: number; // Aggregated cluster count (what tools show)
|
||||
processes?: number;
|
||||
}
|
||||
|
||||
|
|
@ -38,12 +38,20 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
|||
* - Exact tool commands with parameters — vague directives get ignored
|
||||
* - Self-review checklist — forces model to verify its own work
|
||||
*/
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[]): string {
|
||||
const generatedRows = (generatedSkills && generatedSkills.length > 0)
|
||||
? generatedSkills.map(s =>
|
||||
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`
|
||||
).join('\n')
|
||||
: '';
|
||||
function generateGitNexusContent(
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
): string {
|
||||
const generatedRows =
|
||||
generatedSkills && generatedSkills.length > 0
|
||||
? generatedSkills
|
||||
.map(
|
||||
(s) =>
|
||||
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`,
|
||||
)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const skillsTable = `| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
|
|
@ -150,7 +158,6 @@ ${skillsTable}
|
|||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
|
|
@ -171,7 +178,7 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
*/
|
||||
async function upsertGitNexusSection(
|
||||
filePath: string,
|
||||
content: string
|
||||
content: string,
|
||||
): Promise<'created' | 'updated' | 'appended'> {
|
||||
const exists = await fileExists(filePath);
|
||||
|
||||
|
|
@ -213,27 +220,33 @@ async function installSkills(repoPath: string): Promise<string[]> {
|
|||
const skills = [
|
||||
{
|
||||
name: 'gitnexus-exploring',
|
||||
description: 'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
|
||||
description:
|
||||
'Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: "How does X work?", "What calls this function?", "Show me the auth flow"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-debugging',
|
||||
description: 'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
|
||||
description:
|
||||
'Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: "Why is X failing?", "Where does this error come from?", "Trace this bug"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-impact-analysis',
|
||||
description: 'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
|
||||
description:
|
||||
'Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: "Is it safe to change X?", "What depends on this?", "What will break?"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-refactoring',
|
||||
description: 'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
|
||||
description:
|
||||
'Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: "Rename this function", "Extract this into a module", "Refactor this class", "Move this to a separate file"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-guide',
|
||||
description: 'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
|
||||
description:
|
||||
'Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: "What GitNexus tools are available?", "How do I use GitNexus?"',
|
||||
},
|
||||
{
|
||||
name: 'gitnexus-cli',
|
||||
description: 'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
|
||||
description:
|
||||
'Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: "Index this repo", "Reanalyze the codebase", "Generate a wiki"',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -285,7 +298,7 @@ export async function generateAIContextFiles(
|
|||
_storagePath: string,
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[]
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
): Promise<{ files: string[] }> {
|
||||
const content = generateGitNexusContent(projectName, stats, generatedSkills);
|
||||
const createdFiles: string[] = [];
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { getGitRoot, hasGitDir } from '../storage/git.js';
|
|||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
|
||||
|
|
@ -50,10 +49,7 @@ export interface AnalyzeOptions {
|
|||
skipGit?: boolean;
|
||||
}
|
||||
|
||||
export const analyzeCommand = async (
|
||||
inputPath?: string,
|
||||
options?: AnalyzeOptions
|
||||
) => {
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
if (options?.verbose) {
|
||||
|
|
@ -69,7 +65,9 @@ export const analyzeCommand = async (
|
|||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
if (!options?.skipGit) {
|
||||
console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
||||
console.log(
|
||||
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
|
@ -82,32 +80,41 @@ export const analyzeCommand = async (
|
|||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
if (!repoHasGit && !options?.skipGit) {
|
||||
console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
||||
console.log(
|
||||
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!repoHasGit) {
|
||||
console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
|
||||
console.log(
|
||||
' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
|
||||
);
|
||||
}
|
||||
|
||||
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
||||
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
||||
|
||||
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
||||
console.log(' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n');
|
||||
console.log(
|
||||
' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n',
|
||||
);
|
||||
}
|
||||
|
||||
// ── CLI progress bar setup ─────────────────────────────────────────
|
||||
const bar = new cliProgress.SingleBar({
|
||||
format: ' {bar} {percentage}% | {phase}',
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
barGlue: '',
|
||||
autopadding: true,
|
||||
clearOnComplete: false,
|
||||
stopOnComplete: false,
|
||||
}, cliProgress.Presets.shades_grey);
|
||||
const bar = new cliProgress.SingleBar(
|
||||
{
|
||||
format: ' {bar} {percentage}% | {phase}',
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
barGlue: '',
|
||||
autopadding: true,
|
||||
clearOnComplete: false,
|
||||
stopOnComplete: false,
|
||||
},
|
||||
cliProgress.Presets.shades_grey,
|
||||
);
|
||||
|
||||
bar.start(100, 0, { phase: 'Initializing...' });
|
||||
|
||||
|
|
@ -118,7 +125,9 @@ export const analyzeCommand = async (
|
|||
aborted = true;
|
||||
bar.stop();
|
||||
console.log('\n Interrupted — cleaning up...');
|
||||
closeLbug().catch(() => {}).finally(() => process.exit(130));
|
||||
closeLbug()
|
||||
.catch(() => {})
|
||||
.finally(() => process.exit(130));
|
||||
};
|
||||
process.on('SIGINT', sigintHandler);
|
||||
|
||||
|
|
@ -128,7 +137,7 @@ export const analyzeCommand = async (
|
|||
const origError = console.error.bind(console);
|
||||
const barLog = (...args: any[]) => {
|
||||
process.stdout.write('\x1b[2K\r');
|
||||
origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
};
|
||||
console.log = barLog;
|
||||
console.warn = barLog;
|
||||
|
|
@ -139,7 +148,10 @@ export const analyzeCommand = async (
|
|||
let phaseStart = Date.now();
|
||||
|
||||
const updateBar = (value: number, phaseLabel: string) => {
|
||||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||||
if (phaseLabel !== lastPhaseLabel) {
|
||||
lastPhaseLabel = phaseLabel;
|
||||
phaseStart = Date.now();
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||||
bar.update(value, { phase: display });
|
||||
|
|
@ -190,7 +202,11 @@ export const analyzeCommand = async (
|
|||
try {
|
||||
const { generateSkillFiles } = await import('./skill-gen.js');
|
||||
const { generateAIContextFiles } = await import('./ai-context.js');
|
||||
const skillResult = await generateSkillFiles(repoPath, result.repoName, result.pipelineResult);
|
||||
const skillResult = await generateSkillFiles(
|
||||
repoPath,
|
||||
result.repoName,
|
||||
result.pipelineResult,
|
||||
);
|
||||
if (skillResult.skills.length > 0) {
|
||||
barLog(` Generated ${skillResult.skills.length} skill files`);
|
||||
// Re-generate AI context files now that we have skill info
|
||||
|
|
@ -203,19 +219,29 @@ export const analyzeCommand = async (
|
|||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||||
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
||||
}
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter((count: number) => count >= 5).length;
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter(
|
||||
(count: number) => count >= 5,
|
||||
).length;
|
||||
}
|
||||
const { storagePath: sp } = getStoragePaths(repoPath);
|
||||
await generateAIContextFiles(repoPath, sp, result.repoName, {
|
||||
files: s.files ?? 0,
|
||||
nodes: s.nodes ?? 0,
|
||||
edges: s.edges ?? 0,
|
||||
communities: s.communities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: s.processes,
|
||||
}, skillResult.skills);
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
sp,
|
||||
result.repoName,
|
||||
{
|
||||
files: s.files ?? 0,
|
||||
nodes: s.nodes ?? 0,
|
||||
edges: s.edges ?? 0,
|
||||
communities: s.communities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: s.processes,
|
||||
},
|
||||
skillResult.skills,
|
||||
);
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
|
|
@ -233,7 +259,9 @@ export const analyzeCommand = async (
|
|||
// ── Summary ────────────────────────────────────────────────────
|
||||
const s = result.stats;
|
||||
console.log(`\n Repository indexed successfully (${totalTime}s)\n`);
|
||||
console.log(` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${(s.communities ?? 0)} clusters | ${(s.processes ?? 0)} flows`);
|
||||
console.log(
|
||||
` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
|
||||
);
|
||||
console.log(` ${repoPath}`);
|
||||
|
||||
try {
|
||||
|
|
@ -243,7 +271,6 @@ export const analyzeCommand = async (
|
|||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
} catch (err: any) {
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Augment CLI Command
|
||||
*
|
||||
*
|
||||
* Fast-path command for platform hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
|
||||
*
|
||||
*
|
||||
* Usage: gitnexus augment <pattern>
|
||||
* Returns enriched text to stdout.
|
||||
*
|
||||
*
|
||||
* Performance: Must cold-start fast (<500ms).
|
||||
* Skips unnecessary initialization (no web server, no full DB warmup).
|
||||
*/
|
||||
|
|
@ -17,10 +17,10 @@ export async function augmentCommand(pattern: string): Promise<void> {
|
|||
if (!pattern || pattern.length < 3) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const result = await augment(pattern, process.cwd());
|
||||
|
||||
|
||||
if (result) {
|
||||
// IMPORTANT: Write to stderr, NOT stdout.
|
||||
// LadybugDB's native module captures stdout fd at OS level during init,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Clean Command
|
||||
*
|
||||
*
|
||||
* Removes the .gitnexus index from the current repository.
|
||||
* Also unregisters it from the global registry.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
/**
|
||||
* Eval Server — Lightweight HTTP server for SWE-bench evaluation
|
||||
*
|
||||
*
|
||||
* Keeps LadybugDB warm in memory so tool calls from the agent are near-instant.
|
||||
* Designed to run inside Docker containers during SWE-bench evaluation.
|
||||
*
|
||||
*
|
||||
* KEY DESIGN: Returns LLM-friendly text, not raw JSON.
|
||||
* Raw JSON wastes tokens and is hard for models to parse. The text formatter
|
||||
* converts structured results into compact, readable output that models
|
||||
* can immediately act on. Next-step hints guide the agent through a
|
||||
* productive tool-chaining workflow (query → context → impact → fix).
|
||||
*
|
||||
*
|
||||
* Architecture:
|
||||
* Agent bash cmd → curl localhost:PORT/tool/query → eval-server → LocalBackend → format → text
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* gitnexus eval-server # default port 4848
|
||||
* gitnexus eval-server --port 4848 # explicit port
|
||||
* gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle
|
||||
*
|
||||
*
|
||||
* API:
|
||||
* POST /tool/:name — Call a tool. Body is JSON arguments. Returns formatted text.
|
||||
* GET /health — Health check. Returns {"status":"ok","repos":[...]}
|
||||
|
|
@ -82,7 +82,9 @@ export function formatContextResult(result: any): string {
|
|||
if (result.error) return `Error: ${result.error}`;
|
||||
|
||||
if (result.status === 'ambiguous') {
|
||||
const lines = [`Multiple symbols named '${result.candidates?.[0]?.name || '?'}'. Disambiguate with file path:\n`];
|
||||
const lines = [
|
||||
`Multiple symbols named '${result.candidates?.[0]?.name || '?'}'. Disambiguate with file path:\n`,
|
||||
];
|
||||
for (const c of result.candidates || []) {
|
||||
lines.push(` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} (uid: ${c.uid})`);
|
||||
}
|
||||
|
|
@ -100,7 +102,10 @@ export function formatContextResult(result: any): string {
|
|||
|
||||
// Incoming refs (who calls/imports/extends this)
|
||||
const incoming = result.incoming || {};
|
||||
const incomingCount = Object.values(incoming).reduce((sum: number, arr: any) => sum + arr.length, 0) as number;
|
||||
const incomingCount = Object.values(incoming).reduce(
|
||||
(sum: number, arr: any) => sum + arr.length,
|
||||
0,
|
||||
) as number;
|
||||
if (incomingCount > 0) {
|
||||
lines.push(`Called/imported by (${incomingCount}):`);
|
||||
for (const [relType, refs] of Object.entries(incoming)) {
|
||||
|
|
@ -113,7 +118,10 @@ export function formatContextResult(result: any): string {
|
|||
|
||||
// Outgoing refs (what this calls/imports)
|
||||
const outgoing = result.outgoing || {};
|
||||
const outgoingCount = Object.values(outgoing).reduce((sum: number, arr: any) => sum + arr.length, 0) as number;
|
||||
const outgoingCount = Object.values(outgoing).reduce(
|
||||
(sum: number, arr: any) => sum + arr.length,
|
||||
0,
|
||||
) as number;
|
||||
if (outgoingCount > 0) {
|
||||
lines.push(`Calls/imports (${outgoingCount}):`);
|
||||
for (const [relType, refs] of Object.entries(outgoing)) {
|
||||
|
|
@ -158,8 +166,11 @@ export function formatImpactResult(result: any): string {
|
|||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
|
||||
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`);
|
||||
const dirLabel =
|
||||
direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
|
||||
lines.push(
|
||||
`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`,
|
||||
);
|
||||
if (result.partial) {
|
||||
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
|
||||
}
|
||||
|
|
@ -198,7 +209,7 @@ export function formatCypherResult(result: any): string {
|
|||
const keys = Object.keys(result[0]);
|
||||
const lines: string[] = [`${result.length} row(s):\n`];
|
||||
for (const row of result.slice(0, 30)) {
|
||||
const parts = keys.map(k => `${k}: ${row[k]}`);
|
||||
const parts = keys.map((k) => `${k}: ${row[k]}`);
|
||||
lines.push(` ${parts.join(' | ')}`);
|
||||
}
|
||||
if (result.length > 30) {
|
||||
|
|
@ -254,7 +265,9 @@ export function formatListReposResult(result: any): string {
|
|||
const lines = ['Indexed repositories:\n'];
|
||||
for (const r of result) {
|
||||
const stats = r.stats || {};
|
||||
lines.push(` ${r.name} — ${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`);
|
||||
lines.push(
|
||||
` ${r.name} — ${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`,
|
||||
);
|
||||
lines.push(` Path: ${r.path}`);
|
||||
lines.push(` Indexed: ${r.indexedAt}`);
|
||||
}
|
||||
|
|
@ -266,13 +279,20 @@ export function formatListReposResult(result: any): string {
|
|||
*/
|
||||
function formatToolResult(toolName: string, result: any): string {
|
||||
switch (toolName) {
|
||||
case 'query': return formatQueryResult(result);
|
||||
case 'context': return formatContextResult(result);
|
||||
case 'impact': return formatImpactResult(result);
|
||||
case 'cypher': return formatCypherResult(result);
|
||||
case 'detect_changes': return formatDetectChangesResult(result);
|
||||
case 'list_repos': return formatListReposResult(result);
|
||||
default: return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
case 'query':
|
||||
return formatQueryResult(result);
|
||||
case 'context':
|
||||
return formatContextResult(result);
|
||||
case 'impact':
|
||||
return formatImpactResult(result);
|
||||
case 'cypher':
|
||||
return formatCypherResult(result);
|
||||
case 'detect_changes':
|
||||
return formatDetectChangesResult(result);
|
||||
case 'list_repos':
|
||||
return formatListReposResult(result);
|
||||
default:
|
||||
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +337,9 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
}
|
||||
|
||||
const repos = await backend.listRepos();
|
||||
console.error(`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map(r => r.name).join(', ')}`);
|
||||
console.error(
|
||||
`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map((r) => r.name).join(', ')}`,
|
||||
);
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
|
@ -339,7 +361,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
if (req.method === 'GET' && req.url === '/health') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ status: 'ok', repos: repos.map(r => r.name) }));
|
||||
res.end(JSON.stringify({ status: 'ok', repos: repos.map((r) => r.name) }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -389,7 +411,6 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(404);
|
||||
res.end('Not found. Use POST /tool/:name or GET /health');
|
||||
|
||||
} catch (err: any) {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(500);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue