Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions gitnexus-web/e2e/tree-view.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test, expect } from '@playwright/test';

/**
* E2E tests for graph layout mode switching (Sequential / Radial layouts).
*
* Requires:
* - gitnexus serve running on localhost:4747 with at least one indexed repo
* - gitnexus-web dev server running on localhost:5173
*
* Skipped when servers aren't available (CI without services, etc.).
* Set E2E=1 to force-run even without the availability check.
*/

const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';

test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available on :4747');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available on :5173');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos — run gitnexus analyze first');
return;
}
}
} catch {
test.skip(true, 'servers not available');
}
});

async function waitForGraphLoaded(page: import('@playwright/test').Page) {
await page.goto(`${FRONTEND_URL}?lng=en`);

// The app starts on the landing/onboarding screen. Pick the first repo card
// (preferring a known repo name) and click it to load the graph.
const landingCards = page.locator('[data-testid="landing-repo-card"]');
const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first();
try {
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first();
await card.click();
} catch {
// Landing screen may not appear (e.g. when ?server auto-connects)
}

// Wait until the status bar confirms the graph is ready.
const statusBar = page.getByRole('contentinfo');
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 });

// Finally confirm the sigma canvas is present.
await page.waitForSelector('.sigma-container', { timeout: 10_000 });
}

test.describe('Graph Layout Modes', () => {
test.beforeEach(async ({ page }) => {
await waitForGraphLoaded(page);
});

test('should switch between force, sequential, and radial layouts', async ({ page }) => {
const forceTab = page.locator('button:has-text("Force Graph")');
const sequentialTab = page.locator('button:has-text("Sequential Layout")');
const radialTab = page.locator('button:has-text("Radial Layout")');

// Force Graph is the default active tab
await expect(forceTab).toHaveClass(/bg-accent/);
await expect(sequentialTab).not.toHaveClass(/bg-accent/);

// Switch to Sequential Layout
await sequentialTab.click();
await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
await expect(forceTab).not.toHaveClass(/bg-accent/);

// All three layout tabs should be present in the tab bar
await expect(radialTab).toBeVisible();

// Switch back to Force Graph
await forceTab.click();
await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
await expect(sequentialTab).not.toHaveClass(/bg-accent/);
});

test('should interact with nodes in sequential layout', async ({ page }) => {
await page.locator('button:has-text("Sequential Layout")').click();

// Click the first file-tree item in the sidebar (more reliable than a
// blind canvas click, which may land on empty space). The FileTreePanel
// renders node names as <span class="truncate font-mono text-xs">.
// Clicking any of them calls setSelectedNode, which shows the selection
// bar with the "Clear" button — the same mechanism used in
// server-connect.spec.ts's "Turn Off All Highlights" test.
const firstTreeItem = page.locator('span.truncate.font-mono').first();
await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 });
await firstTreeItem.click();

await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 });
});
});
3 changes: 3 additions & 0 deletions gitnexus-web/src/components/FileTreePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => {
case 'Import':
return FileCode;
case 'Variable':
case 'Property':
return Variable;
case 'Const':
Comment thread
hugogu marked this conversation as resolved.
return Target;
default:
return Variable;
}
Expand Down
107 changes: 90 additions & 17 deletions gitnexus-web/src/components/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import {
Pause,
Lightbulb,
LightbulbOff,
Network,
GitBranch,
Target,
} from '@/lib/lucide-icons';
import { useSigma } from '../hooks/useSigma';
import { useAppState } from '../hooks/useAppState';
import {
knowledgeGraphToGraphology,
knowledgeGraphToTreeGraphology,
knowledgeGraphToCirclesGraphology,
filterGraphByDepth,
SigmaNodeAttributes,
SigmaEdgeAttributes,
Expand Down Expand Up @@ -48,6 +53,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
clearAICitationHighlights,
clearBlastRadius,
animatedNodes,
graphViewMode,
setGraphViewMode,
} = useAppState();
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);

Expand Down Expand Up @@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
animatedNodes: effectiveAnimatedNodes,
visibleEdgeTypes,
layoutMode: graphViewMode,
});

const handleViewModeChange = useCallback(
(mode: 'force' | 'tree' | 'circles') => {
if (mode === graphViewMode) return;
setSelectedNode(null);
setSigmaSelectedNode(null);
setHoveredNodeName(null);
setGraphViewMode(mode);
// Reset zoom when switching views
resetZoom();
},
[graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode],
);

// Expose focusNode to parent via ref
useImperativeHandle(
ref,
Expand All @@ -174,25 +195,30 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
useEffect(() => {
if (!graph) return;

// 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) => {
if (rel.type === 'MEMBER_OF') {
// Find the community node to get its index
const communityNode = nodeById.get(rel.targetId);
if (communityNode && communityNode.label === 'Community') {
// Extract community index from id (e.g., "comm_5" -> 5)
const numericPart = rel.targetId.replace('comm_', '');
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
communityMemberships.set(rel.sourceId, communityIdx);
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;

if (graphViewMode === 'tree') {
sigmaGraph = knowledgeGraphToTreeGraphology(graph);
} else if (graphViewMode === 'circles') {
sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
} else {
// Build community memberships map from MEMBER_OF relationships
const communityMemberships = new Map<string, number>();
graph.relationships.forEach((rel) => {
if (rel.type === 'MEMBER_OF') {
const communityNode = nodeById.get(rel.targetId);
if (communityNode && communityNode.label === 'Community') {
const numericPart = rel.targetId.replace('comm_', '');
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
communityMemberships.set(rel.sourceId, communityIdx);
}
}
}
});
});
sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
}

const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
setSigmaGraph(sigmaGraph);
}, [graph, nodeById, setSigmaGraph]);
}, [graph, nodeById, setSigmaGraph, graphViewMode]);

// Update node visibility when filters change
useEffect(() => {
Expand All @@ -205,7 +231,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
}, [visibleLabels, depthFilter, appSelectedNode]);
}, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]);

// Sync app selected node with sigma
useEffect(() => {
Expand Down Expand Up @@ -245,6 +271,53 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
/>
</div>

{/* View Mode Tabs */}
<div
role="tablist"
aria-label={t('canvas.viewModes.label')}
className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 gap-1 rounded-lg border border-border-subtle bg-elevated/90 p-1 backdrop-blur-sm"
>
<button
role="tab"
aria-selected={graphViewMode === 'force'}
onClick={() => handleViewModeChange('force')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'force'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<Network className="h-3.5 w-3.5" />
{t('canvas.viewModes.force')}
</button>
Comment thread
hugogu marked this conversation as resolved.
<button
role="tab"
aria-selected={graphViewMode === 'tree'}
onClick={() => handleViewModeChange('tree')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'tree'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<GitBranch className="h-3.5 w-3.5" />
{t('canvas.viewModes.tree')}
</button>
<button
role="tab"
aria-selected={graphViewMode === 'circles'}
onClick={() => handleViewModeChange('circles')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'circles'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<Target className="h-3.5 w-3.5" />
{t('canvas.viewModes.circles')}
</button>
</div>

{/* Sigma container */}
<div
ref={containerRef}
Expand Down
22 changes: 22 additions & 0 deletions gitnexus-web/src/hooks/app-state/graph.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { GraphStateProvider, useGraphState } from './graph';

function wrapper({ children }: { children: React.ReactNode }) {
return <GraphStateProvider>{children}</GraphStateProvider>;
}

describe('GraphState', () => {
it('should have default graphViewMode as "force"', () => {
const { result } = renderHook(() => useGraphState(), { wrapper });
expect(result.current.graphViewMode).toBe('force');
});

it('should toggle graphViewMode', () => {
const { result } = renderHook(() => useGraphState(), { wrapper });
act(() => {
result.current.setGraphViewMode('tree');
});
expect(result.current.graphViewMode).toBe('tree');
});
});
15 changes: 14 additions & 1 deletion gitnexus-web/src/hooks/app-state/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface GraphStateContextValue {
setDepthFilter: (depth: number | null) => void;
highlightedNodeIds: Set<string>;
setHighlightedNodeIds: (ids: Set<string>) => void;
graphViewMode: 'force' | 'tree' | 'circles';
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
}

const GraphStateContext = createContext<GraphStateContextValue | null>(null);
Expand All @@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
const [depthFilter, setDepthFilter] = useState<number | null>(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');

const toggleLabelVisibility = useCallback((label: NodeLabel) => {
setVisibleLabels((prev) =>
Expand Down Expand Up @@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
graphViewMode,
setGraphViewMode,
}),
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
[
graph,
selectedNode,
visibleLabels,
visibleEdgeTypes,
depthFilter,
highlightedNodeIds,
graphViewMode,
],
);

return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
Expand Down
8 changes: 8 additions & 0 deletions gitnexus-web/src/hooks/useAppState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ interface AppState {
depthFilter: number | null;
setDepthFilter: (depth: number | null) => void;

// Graph view mode
graphViewMode: 'force' | 'tree' | 'circles';
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;

// Query state
highlightedNodeIds: Set<string>;
setHighlightedNodeIds: (ids: Set<string>) => void;
Expand Down Expand Up @@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setDepthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
graphViewMode,
setGraphViewMode,
} = useGraphState();

// Right Panel
Expand Down Expand Up @@ -1266,6 +1272,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
graphViewMode,
setGraphViewMode,
highlightedNodeIds,
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,
Expand Down
Loading
Loading