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:
sahalterion 2026-04-03 18:07:25 -07:00
parent 17294656b0
commit 1fd9c8c828
11 changed files with 485 additions and 105 deletions

View file

@ -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) && (

View file

@ -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}
/>
)}

View file

@ -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(() => {

View file

@ -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>
)}

View file

@ -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">

View 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>
);
};

View file

@ -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

View file

@ -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;

View file

@ -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,

View file

@ -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', () => {

View file

@ -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(() => {});
}