mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
feat(web): landing page tabs, repo-colored graph, file tree grouping, repo legend toggles
CLI: - Split `auto-discover --repos` into separate `group repos <paths...>` command Landing page: - Add Repos/Groups tab bar (Groups tab only shown when groups exist) - Group cards show name, repo count, last sync time - Scrollable card with max-h-[80vh] to prevent overflow Graph visualization: - File/Folder nodes colored by repo in multi-repo mode - Hover tooltip shows repo name in parentheses - RepoLegend in top-left with clickable toggles: amber = highlighted, grey = dimmed. Deselected repos' nodes AND edges dim to ~12% opacity File tree sidebar: - In group mode, files grouped under collapsible repo headers - Repo headers have amber dot and bold font 218 web tests + 5177 backend tests pass. Zero regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
17294656b0
commit
1fd9c8c828
11 changed files with 485 additions and 105 deletions
|
|
@ -4,6 +4,7 @@ import { DropZone } from './components/DropZone';
|
|||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
import { Header } from './components/Header';
|
||||
import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas';
|
||||
import { RepoLegend } from './components/RepoLegend';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
|
|
@ -45,6 +46,7 @@ const AppContent = () => {
|
|||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
connectToGroup,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
|
@ -228,6 +230,13 @@ const AppContent = () => {
|
|||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
}}
|
||||
onGroupSelect={(groupName, serverUrl) => {
|
||||
if (serverUrl && !serverBaseUrl) {
|
||||
const normalized = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(normalized);
|
||||
}
|
||||
connectToGroup(groupName);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -282,6 +291,7 @@ const AppContent = () => {
|
|||
{/* Graph area - takes remaining space */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<GraphCanvas ref={graphCanvasRef} />
|
||||
<RepoLegend />
|
||||
|
||||
{/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */}
|
||||
{isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { RepoLanding } from './RepoLanding';
|
|||
|
||||
interface DropZoneProps {
|
||||
onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise<void>;
|
||||
onGroupSelect?: (groupName: string, serverUrl?: string) => void;
|
||||
}
|
||||
|
||||
// ── Crossfade wrapper ───────────────────────────────────────────────────────
|
||||
|
|
@ -133,7 +134,7 @@ function LoadingCard({ message }: { message: string }) {
|
|||
|
||||
// ── DropZone ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
export const DropZone = ({ onServerConnect, onGroupSelect }: DropZoneProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Backend polling for server detection
|
||||
|
|
@ -305,6 +306,9 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
<RepoLanding
|
||||
repos={detectedRepos}
|
||||
onSelectRepo={connectToRepo}
|
||||
onSelectGroup={onGroupSelect
|
||||
? (groupName) => onGroupSelect(groupName, detectedBackendUrl ?? undefined)
|
||||
: undefined}
|
||||
onAnalyzeComplete={connectToRepo}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,12 @@ const TreeItem = ({
|
|||
)}
|
||||
|
||||
{/* Node icon */}
|
||||
{node.type === 'folder' ? (
|
||||
{node.path.startsWith('repo::') ? (
|
||||
<>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-amber-400" />
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
</>
|
||||
) : node.type === 'folder' ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
||||
) : (
|
||||
|
|
@ -152,7 +157,9 @@ const TreeItem = ({
|
|||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span className="truncate font-mono text-xs">{node.name}</span>
|
||||
<span className={`truncate font-mono text-xs ${node.path.startsWith('repo::') ? 'font-semibold' : ''}`}>
|
||||
{node.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
|
|
@ -222,6 +229,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
|||
openCodePanel,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
groupMode,
|
||||
} = useAppState();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
|
@ -229,11 +237,38 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
|||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<'files' | 'filters'>('files');
|
||||
|
||||
// Build file tree from graph
|
||||
// Build file tree from graph (with repo grouping in group mode)
|
||||
const fileTree = useMemo(() => {
|
||||
if (!graph) return [];
|
||||
return buildFileTree(graph.nodes);
|
||||
}, [graph]);
|
||||
|
||||
if (!groupMode) return buildFileTree(graph.nodes);
|
||||
|
||||
// Group nodes by repo, then build a tree per repo
|
||||
const repoGroups = new Map<string, GraphNode[]>();
|
||||
for (const node of graph.nodes) {
|
||||
const repo = (node.properties as Record<string, unknown>)._repo as string | undefined;
|
||||
const repoName = repo || 'unknown';
|
||||
const group = repoGroups.get(repoName);
|
||||
if (group) {
|
||||
group.push(node);
|
||||
} else {
|
||||
repoGroups.set(repoName, [node]);
|
||||
}
|
||||
}
|
||||
|
||||
const repoTrees: TreeNode[] = [];
|
||||
for (const [repoName, nodes] of repoGroups) {
|
||||
const children = buildFileTree(nodes);
|
||||
repoTrees.push({
|
||||
id: `repo::${repoName}`,
|
||||
name: repoName,
|
||||
type: 'folder',
|
||||
path: `repo::${repoName}`,
|
||||
children,
|
||||
});
|
||||
}
|
||||
return repoTrees;
|
||||
}, [graph, groupMode]);
|
||||
|
||||
// Auto-expand first level on initial load
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -46,8 +46,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
highlightedRepos,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
const [hoveredNodeRepo, setHoveredNodeRepo] = useState<string | null>(null);
|
||||
|
||||
const effectiveHighlightedNodeIds = useMemo(() => {
|
||||
if (!isAIHighlightsEnabled) return highlightedNodeIds;
|
||||
|
|
@ -96,10 +98,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
(nodeId: string | null) => {
|
||||
if (!nodeId || !graph) {
|
||||
setHoveredNodeName(null);
|
||||
setHoveredNodeRepo(null);
|
||||
return;
|
||||
}
|
||||
const node = nodeById.get(nodeId);
|
||||
setHoveredNodeName(node ? node.properties.name : null);
|
||||
const repo = node ? (node.properties as Record<string, unknown>)._repo as string | undefined : undefined;
|
||||
setHoveredNodeRepo(repo ?? null);
|
||||
},
|
||||
[graph, nodeById],
|
||||
);
|
||||
|
|
@ -147,6 +152,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
|
||||
animatedNodes: effectiveAnimatedNodes,
|
||||
visibleEdgeTypes,
|
||||
highlightedRepos,
|
||||
});
|
||||
|
||||
// Expose focusNode to parent via ref
|
||||
|
|
@ -253,6 +259,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
{hoveredNodeName && !sigmaSelectedNode && (
|
||||
<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>
|
||||
{hoveredNodeRepo && (
|
||||
<span className="ml-2 text-xs text-text-muted">({hoveredNodeRepo})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@
|
|||
* └─ RepoAnalyzer (variant="onboarding")
|
||||
*/
|
||||
|
||||
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
|
||||
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers, FolderOpen } from '@/lib/lucide-icons';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import type { BackendRepo } from '../services/backend-client';
|
||||
import { fetchGroups, fetchGroupStatus, type BackendRepo, type GroupStatus } from '../services/backend-client';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -83,18 +84,92 @@ function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void })
|
|||
);
|
||||
}
|
||||
|
||||
// ── Group card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({
|
||||
name,
|
||||
status,
|
||||
onClick,
|
||||
}: {
|
||||
name: string;
|
||||
status: GroupStatus | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const repoCount = status ? Object.keys(status.repos).length : 0;
|
||||
const lastSync = status?.lastSync;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-testid="landing-group-card"
|
||||
className="group w-full cursor-pointer rounded-xl border border-border-default bg-elevated p-4 text-left transition-all duration-200 hover:border-amber-500/40 hover:bg-hover hover:shadow-glow-soft"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
<h3 className="truncate text-sm font-semibold text-text-primary transition-colors group-hover:text-amber-400">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 pl-6">
|
||||
{repoCount > 0 && (
|
||||
<span className="text-xs text-text-muted">{repoCount} repos</span>
|
||||
)}
|
||||
{lastSync && (
|
||||
<span className="text-xs text-text-muted">
|
||||
synced {formatRelativeTime(lastSync)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-text-muted opacity-0 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-amber-400 group-hover:opacity-100" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RepoLanding ──────────────────────────────────────────────────────────────
|
||||
|
||||
type LandingTab = 'repos' | 'groups';
|
||||
|
||||
interface RepoLandingProps {
|
||||
repos: BackendRepo[];
|
||||
onSelectRepo: (repoName: string) => void;
|
||||
onSelectGroup?: (groupName: string) => void;
|
||||
onAnalyzeComplete: (repoName: string) => void;
|
||||
}
|
||||
|
||||
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
|
||||
export const RepoLanding = ({
|
||||
repos,
|
||||
onSelectRepo,
|
||||
onSelectGroup,
|
||||
onAnalyzeComplete,
|
||||
}: RepoLandingProps) => {
|
||||
const [activeTab, setActiveTab] = useState<LandingTab>('repos');
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
const [groupStatuses, setGroupStatuses] = useState<Record<string, GroupStatus | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups()
|
||||
.then((g) => setGroups(g))
|
||||
.catch(() => setGroups([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'groups' || groups.length === 0) return;
|
||||
for (const name of groups) {
|
||||
if (groupStatuses[name] !== undefined) continue;
|
||||
setGroupStatuses((prev) => ({ ...prev, [name]: null }));
|
||||
fetchGroupStatus(name)
|
||||
.then((s) => setGroupStatuses((prev) => ({ ...prev, [name]: s })))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [activeTab, groups, groupStatuses]);
|
||||
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
|
||||
<div className="relative max-h-[80vh] animate-fade-in overflow-y-auto rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows */}
|
||||
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
|
||||
|
||||
|
|
@ -109,34 +184,88 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
|
|||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Choose a repository
|
||||
{activeTab === 'repos' ? 'Choose a repository' : 'Choose a group'}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
Select an indexed repository to explore, or analyze a new one.
|
||||
{activeTab === 'repos'
|
||||
? 'Select an indexed repository to explore, or analyze a new one.'
|
||||
: 'Select a group to view the combined knowledge graph across repos.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repo list */}
|
||||
<div className="relative mb-5 space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
|
||||
))}
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
{groups.length > 0 && (
|
||||
<div className="relative mb-5 flex items-center justify-center gap-1 rounded-lg bg-void p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('repos')}
|
||||
className={`flex-1 rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
activeTab === 'repos'
|
||||
? 'bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
Repos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('groups')}
|
||||
className={`flex-1 rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
activeTab === 'groups'
|
||||
? 'bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
Groups
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or analyze new
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
{/* Repos tab content */}
|
||||
{activeTab === 'repos' && (
|
||||
<>
|
||||
<div className="relative mb-5 space-y-2">
|
||||
{repos.map((repo) => (
|
||||
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Analyzer form */}
|
||||
<div className="relative">
|
||||
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
|
||||
</div>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or analyze new
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Groups tab content */}
|
||||
{activeTab === 'groups' && (
|
||||
<div className="relative space-y-2">
|
||||
{groups.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<FolderOpen className="mx-auto mb-3 h-8 w-8 text-text-muted/50" />
|
||||
<p className="text-sm text-text-muted">No groups configured yet.</p>
|
||||
<p className="mt-1 text-xs text-text-muted/70">
|
||||
Run <code className="rounded bg-void px-1.5 py-0.5">gitnexus group auto-discover</code> to create one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((name) => (
|
||||
<GroupCard
|
||||
key={name}
|
||||
name={name}
|
||||
status={groupStatuses[name] ?? null}
|
||||
onClick={() => onSelectGroup?.(name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
|
||||
|
|
|
|||
69
gitnexus-web/src/components/RepoLegend.tsx
Normal file
69
gitnexus-web/src/components/RepoLegend.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
|
||||
export const RepoLegend = () => {
|
||||
const { graph, groupMode, highlightedRepos, setHighlightedRepos } = useAppState();
|
||||
|
||||
const repos = useMemo(() => {
|
||||
if (!groupMode || !graph) return [];
|
||||
|
||||
const repoMap = new Map<string, { color: string; count: number }>();
|
||||
for (const node of graph.nodes) {
|
||||
const repo = (node.properties as Record<string, unknown>)._repo as string | undefined;
|
||||
if (!repo) continue;
|
||||
const existing = repoMap.get(repo);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
repoMap.set(repo, { color: '#9ca3af', count: 1 });
|
||||
}
|
||||
}
|
||||
return [...repoMap.entries()].map(([name, info]) => ({ name, ...info }));
|
||||
}, [graph, groupMode]);
|
||||
|
||||
const toggleRepo = useCallback(
|
||||
(repoName: string) => {
|
||||
const next = new Set(highlightedRepos);
|
||||
if (next.has(repoName)) {
|
||||
next.delete(repoName);
|
||||
} else {
|
||||
next.add(repoName);
|
||||
}
|
||||
setHighlightedRepos(next);
|
||||
},
|
||||
[highlightedRepos, setHighlightedRepos],
|
||||
);
|
||||
|
||||
if (!groupMode || repos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10 rounded-lg border border-border-subtle bg-elevated/90 px-3 py-2 backdrop-blur-sm">
|
||||
<div className="mb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Repos
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{repos.map((repo) => {
|
||||
const isHighlighted = highlightedRepos.has(repo.name);
|
||||
return (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => toggleRepo(repo.name)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-hover"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full transition-colors"
|
||||
style={{ backgroundColor: isHighlighted ? '#f59e0b' : '#4b5563' }}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs transition-colors ${isHighlighted ? 'text-text-secondary' : 'text-text-muted'}`}
|
||||
>
|
||||
{repo.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">{repo.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -155,6 +155,8 @@ interface AppState {
|
|||
availableGroups: string[];
|
||||
setAvailableGroups: (groups: string[]) => void;
|
||||
connectToGroup: (groupName: string) => Promise<void>;
|
||||
highlightedRepos: Set<string>;
|
||||
setHighlightedRepos: (repos: Set<string>) => void;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runQuery: (cypher: string) => Promise<any[]>;
|
||||
|
|
@ -332,6 +334,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const [groupMode, setGroupMode] = useState(false);
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const [availableGroups, setAvailableGroups] = useState<string[]>([]);
|
||||
const [highlightedRepos, setHighlightedRepos] = useState<Set<string>>(new Set());
|
||||
|
||||
// Embedding state
|
||||
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
|
||||
|
|
@ -1188,8 +1191,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const connectToGroup = useCallback(
|
||||
async (groupName: string) => {
|
||||
if (!serverBaseUrl) return;
|
||||
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 10,
|
||||
|
|
@ -1229,6 +1230,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setActiveGroup(groupName);
|
||||
setProjectName(`Group: ${groupName}`);
|
||||
|
||||
// Populate highlightedRepos with all repos (all visible by default)
|
||||
const repoNames = new Set<string>();
|
||||
for (const node of result.nodes) {
|
||||
const repo = (node.properties as Record<string, unknown>)._repo as string | undefined;
|
||||
if (repo) repoNames.add(repo);
|
||||
}
|
||||
setHighlightedRepos(repoNames);
|
||||
|
||||
setProgress(null);
|
||||
setViewMode('exploring');
|
||||
} catch (err) {
|
||||
|
|
@ -1245,7 +1254,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
},
|
||||
[
|
||||
serverBaseUrl,
|
||||
setProgress,
|
||||
setViewMode,
|
||||
setProjectName,
|
||||
|
|
@ -1356,6 +1364,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
availableGroups,
|
||||
setAvailableGroups,
|
||||
connectToGroup,
|
||||
highlightedRepos,
|
||||
setHighlightedRepos,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
// Embedding state and methods
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ interface UseSigmaOptions {
|
|||
blastRadiusNodeIds?: Set<string>;
|
||||
animatedNodes?: Map<string, NodeAnimation>;
|
||||
visibleEdgeTypes?: EdgeType[];
|
||||
highlightedRepos?: Set<string>;
|
||||
}
|
||||
|
||||
interface UseSigmaReturn {
|
||||
|
|
@ -138,6 +139,7 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
const blastRadiusRef = useRef<Set<string>>(new Set());
|
||||
const animatedNodesRef = useRef<Map<string, NodeAnimation>>(new Map());
|
||||
const visibleEdgeTypesRef = useRef<EdgeType[] | null>(null);
|
||||
const highlightedReposRef = useRef<Set<string> | null>(null);
|
||||
const layoutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
|
|
@ -148,12 +150,14 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
blastRadiusRef.current = options.blastRadiusNodeIds || new Set();
|
||||
animatedNodesRef.current = options.animatedNodes || new Map();
|
||||
visibleEdgeTypesRef.current = options.visibleEdgeTypes || null;
|
||||
highlightedReposRef.current = options.highlightedRepos || null;
|
||||
sigmaRef.current?.refresh();
|
||||
}, [
|
||||
options.highlightedNodeIds,
|
||||
options.blastRadiusNodeIds,
|
||||
options.animatedNodes,
|
||||
options.visibleEdgeTypes,
|
||||
options.highlightedRepos,
|
||||
]);
|
||||
|
||||
// Animation loop for node effects
|
||||
|
|
@ -283,6 +287,21 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
return res;
|
||||
}
|
||||
|
||||
// Dim nodes in deselected repos (group mode repo filtering)
|
||||
const hRepos = highlightedReposRef.current;
|
||||
if (hRepos) {
|
||||
const nodeRepo = data.repoName;
|
||||
if (nodeRepo && !hRepos.has(nodeRepo)) {
|
||||
res.color = dimColor(data.color, 0.12);
|
||||
res.size = (data.size || 8) * 0.35;
|
||||
res.zIndex = -1;
|
||||
return res;
|
||||
}
|
||||
if (nodeRepo) {
|
||||
res.zIndex = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const blastRadius = blastRadiusRef.current;
|
||||
|
|
@ -402,6 +421,26 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
|
|||
}
|
||||
}
|
||||
|
||||
// Dim edges touching deselected repos
|
||||
const hReposEdge = highlightedReposRef.current;
|
||||
if (hReposEdge) {
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const [source, target] = graph.extremities(edge);
|
||||
const srcRepo = graph.getNodeAttribute(source, 'repoName') as string | undefined;
|
||||
const tgtRepo = graph.getNodeAttribute(target, 'repoName') as string | undefined;
|
||||
const srcDimmed = srcRepo && !hReposEdge.has(srcRepo);
|
||||
const tgtDimmed = tgtRepo && !hReposEdge.has(tgtRepo);
|
||||
if (srcDimmed || tgtDimmed) {
|
||||
res.color = dimColor(data.color, 0.08);
|
||||
res.size = 0.2;
|
||||
res.zIndex = -1;
|
||||
return res;
|
||||
}
|
||||
res.zIndex = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSelected = selectedNodeRef.current;
|
||||
const highlighted = highlightedRef.current;
|
||||
const blastRadius = blastRadiusRef.current;
|
||||
|
|
|
|||
|
|
@ -216,12 +216,16 @@ export const knowledgeGraphToGraphology = (
|
|||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
|
||||
// Structural nodes keep their type-based color
|
||||
// In multi-repo mode, color File/Folder nodes by repo for visual distinction
|
||||
const structColor = isMultiRepo && repoCenter
|
||||
? repoCenter.color
|
||||
: NODE_COLORS[node.label] || '#9ca3af';
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x,
|
||||
y,
|
||||
size: scaledSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
color: structColor,
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,21 @@ describe('knowledgeGraphToGraphology - multi-repo support', () => {
|
|||
expect(edgeAttr.color).toBe('#f59e0b'); // Amber
|
||||
});
|
||||
|
||||
it('colors File/Folder nodes by repo color in multi-repo mode', () => {
|
||||
const nodes = [
|
||||
makeNode('repoA::folder1', 'Folder', 'src', { _repo: 'repoA' }),
|
||||
makeNode('repoB::folder1', 'Folder', 'src', { _repo: 'repoB' }),
|
||||
];
|
||||
const graph = knowledgeGraphToGraphology(makeGraph(nodes, []));
|
||||
|
||||
const attrA = graph.getNodeAttributes('repoA::folder1') as SigmaNodeAttributes;
|
||||
const attrB = graph.getNodeAttributes('repoB::folder1') as SigmaNodeAttributes;
|
||||
// In multi-repo mode, structural nodes should use repo color, not default type color
|
||||
expect(attrA.color).toBe(attrA.repoColor);
|
||||
expect(attrB.color).toBe(attrB.repoColor);
|
||||
expect(attrA.color).not.toBe(attrB.color);
|
||||
});
|
||||
|
||||
it('works correctly in single-repo mode (no repoName assigned)', () => {
|
||||
const nodes = [
|
||||
makeNode('fn1', 'Function', 'funcA'),
|
||||
|
|
@ -187,6 +202,45 @@ describe('knowledgeGraphToGraphology - multi-repo support', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Repo highlight toggle tests ─────────────────────────────────────────────
|
||||
|
||||
describe('repo highlight toggle logic', () => {
|
||||
it('toggling a repo out of the set removes it', () => {
|
||||
const initial = new Set(['repoA', 'repoB']);
|
||||
const next = new Set(initial);
|
||||
next.delete('repoA');
|
||||
expect(next.has('repoA')).toBe(false);
|
||||
expect(next.has('repoB')).toBe(true);
|
||||
expect(next.size).toBe(1);
|
||||
});
|
||||
|
||||
it('toggling a repo back into the set adds it', () => {
|
||||
const current = new Set(['repoB']);
|
||||
const next = new Set(current);
|
||||
next.add('repoA');
|
||||
expect(next.has('repoA')).toBe(true);
|
||||
expect(next.has('repoB')).toBe(true);
|
||||
expect(next.size).toBe(2);
|
||||
});
|
||||
|
||||
it('nodes in deselected repos should be dimmable based on repoName attribute', () => {
|
||||
const highlightedRepos = new Set(['repoA']);
|
||||
const nodeRepoA = 'repoA';
|
||||
const nodeRepoB = 'repoB';
|
||||
|
||||
// repoA is highlighted — should NOT be dimmed
|
||||
expect(highlightedRepos.has(nodeRepoA)).toBe(true);
|
||||
// repoB is not highlighted — should be dimmed
|
||||
expect(highlightedRepos.has(nodeRepoB)).toBe(false);
|
||||
});
|
||||
|
||||
it('empty highlightedRepos means no dimming (all visible)', () => {
|
||||
const highlightedRepos = new Set<string>();
|
||||
// When set is empty, the dimming logic should not trigger
|
||||
expect(highlightedRepos.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backend client type tests ──────────────────────────────────────────────
|
||||
|
||||
describe('backend-client group types', () => {
|
||||
|
|
|
|||
|
|
@ -21,95 +21,112 @@ export function registerGroupCommands(program: Command): void {
|
|||
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
|
||||
});
|
||||
|
||||
// Shared result printer for auto-discover and repos commands
|
||||
async function printDiscoverResult(raw: unknown, json: boolean): Promise<void> {
|
||||
const result = raw as {
|
||||
error?: string;
|
||||
group?: string;
|
||||
groupDir?: string;
|
||||
repos?: Array<{ name: string; packageName: string | null }>;
|
||||
repoCount?: number;
|
||||
packageMappings?: Record<string, string>;
|
||||
synced?: boolean;
|
||||
contracts?: number;
|
||||
crossLinks?: number;
|
||||
};
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Group "${result.group}" created at ${result.groupDir}\n`);
|
||||
console.log(`Repos (${result.repoCount}):`);
|
||||
for (const repo of result.repos || []) {
|
||||
const pkg = repo.packageName ? ` (${repo.packageName})` : '';
|
||||
console.log(` ${repo.name}${pkg}`);
|
||||
}
|
||||
const mappings = result.packageMappings || {};
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
console.log(`\nPackage mappings:`);
|
||||
for (const [pkg, groupPath] of Object.entries(mappings)) {
|
||||
console.log(` ${pkg} → ${groupPath}`);
|
||||
}
|
||||
}
|
||||
if (result.synced) {
|
||||
console.log(
|
||||
`\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group
|
||||
.command('auto-discover [directory]')
|
||||
.description(
|
||||
'Auto-discover indexed repos in a directory, or create a group from explicit repo paths',
|
||||
)
|
||||
.description('Scan a directory for indexed repos and create a group')
|
||||
.option('--name <name>', 'Group name', 'workspace')
|
||||
.option('--repos <paths...>', 'Explicit repo paths (instead of scanning a directory)')
|
||||
.option('--force', 'Overwrite existing group')
|
||||
.option('--skip-sync', 'Create group without running sync')
|
||||
.option('--json', 'JSON output')
|
||||
.action(
|
||||
async (
|
||||
directory: string | undefined,
|
||||
opts: { name: string; repos?: string[]; force?: boolean; skipSync?: boolean; json?: boolean },
|
||||
opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean },
|
||||
) => {
|
||||
const pathMod = await import('node:path');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const resolvedDir = pathMod.resolve(directory || process.cwd());
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
console.log(`Discovering indexed repos in ${resolvedDir}...\n`);
|
||||
|
||||
let raw: unknown;
|
||||
if (opts.repos && opts.repos.length > 0) {
|
||||
// Explicit repo paths mode
|
||||
const resolvedPaths = opts.repos.map((p) => pathMod.resolve(p));
|
||||
console.log(
|
||||
`Creating group from ${resolvedPaths.length} repos...\n`,
|
||||
);
|
||||
raw = await backend.getGroupService().groupDiscover({
|
||||
repoPaths: resolvedPaths,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
} else {
|
||||
// Directory scan mode
|
||||
const resolvedDir = pathMod.resolve(directory || process.cwd());
|
||||
console.log(
|
||||
`Discovering indexed repos in ${resolvedDir}...\n`,
|
||||
);
|
||||
raw = await backend.getGroupService().groupDiscover({
|
||||
directory: resolvedDir,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
}
|
||||
const raw = await backend.getGroupService().groupDiscover({
|
||||
directory: resolvedDir,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
await printDiscoverResult(raw, Boolean(opts.json));
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const result = raw as {
|
||||
error?: string;
|
||||
group?: string;
|
||||
groupDir?: string;
|
||||
repos?: Array<{ name: string; packageName: string | null }>;
|
||||
repoCount?: number;
|
||||
packageMappings?: Record<string, string>;
|
||||
synced?: boolean;
|
||||
contracts?: number;
|
||||
crossLinks?: number;
|
||||
};
|
||||
group
|
||||
.command('repos <paths...>')
|
||||
.description('Create a group from explicit repo paths')
|
||||
.option('--name <name>', 'Group name', 'workspace')
|
||||
.option('--force', 'Overwrite existing group')
|
||||
.option('--skip-sync', 'Create group without running sync')
|
||||
.option('--json', 'JSON output')
|
||||
.action(
|
||||
async (
|
||||
paths: string[],
|
||||
opts: { name: string; force?: boolean; skipSync?: boolean; json?: boolean },
|
||||
) => {
|
||||
const pathMod = await import('node:path');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const resolvedPaths = paths.map((p) => pathMod.resolve(p));
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
console.log(`Creating group from ${resolvedPaths.length} repos...\n`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Group "${result.group}" created at ${result.groupDir}\n`);
|
||||
console.log(`Discovered repos (${result.repoCount}):`);
|
||||
for (const repo of result.repos || []) {
|
||||
const pkg = repo.packageName ? ` (${repo.packageName})` : '';
|
||||
console.log(` ${repo.name}${pkg}`);
|
||||
}
|
||||
const mappings = result.packageMappings || {};
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
console.log(`\nPackage mappings:`);
|
||||
for (const [pkg, groupPath] of Object.entries(mappings)) {
|
||||
console.log(` ${pkg} → ${groupPath}`);
|
||||
}
|
||||
}
|
||||
if (result.synced) {
|
||||
console.log(
|
||||
`\nSync: ${result.contracts} contracts, ${result.crossLinks} cross-links`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const raw = await backend.getGroupService().groupDiscover({
|
||||
repoPaths: resolvedPaths,
|
||||
name: opts.name,
|
||||
force: Boolean(opts.force),
|
||||
skipSync: Boolean(opts.skipSync),
|
||||
});
|
||||
await printDiscoverResult(raw, Boolean(opts.json));
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue