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
9 changes: 6 additions & 3 deletions src/main/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ export const api = {
traceImpact: (id: string, depth = 3) =>
fetchJson<Record<string, unknown>>(`${BASE}/triage/impact/${encodeURIComponent(id)}?depth=${depth}`),

getFileTree: (depth?: number) => {
const params = depth !== undefined ? `?depth=${depth}` : '';
return fetchJson<FileTreeResponse>(`${BASE}/file-tree${params}`);
getFileTree: (depth?: number, path?: string) => {
const qs = new URLSearchParams();
if (depth !== undefined) qs.set('depth', String(depth));
if (path !== undefined && path !== '') qs.set('path', path);
const suffix = qs.toString() ? `?${qs}` : '';
return fetchJson<FileTreeResponse>(`${BASE}/file-tree${suffix}`);
},

getTopology: () =>
Expand Down
163 changes: 144 additions & 19 deletions src/main/frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef, Fragment } from 'react';
import {
Card, Spin, Alert, Modal, Drawer, Stat, Table, ScrollDiv, Space,
} from '@ossrandom/design-system';
Expand Down Expand Up @@ -132,6 +132,7 @@ function buildTreemapTree(
nodes: FileTreeNode[],
parentPath: string,
pathMap: WeakMap<TreemapNode, string>,
truncatedDirMap: WeakMap<TreemapNode, boolean>,
): TreemapNode[] {
// Sort children by name so the treemap layout is stable across page
// loads regardless of API result ordering. d3-hierarchy's squarified
Expand All @@ -143,7 +144,7 @@ function buildTreemapTree(
const fullPath = parentPath ? `${parentPath}/${n.name}` : n.name;
if (n.nodeCount <= 0 && (!n.children || n.children.length === 0)) continue;
if (n.type === 'directory' && n.children && n.children.length > 0) {
const children = buildTreemapTree(n.children, fullPath, pathMap);
const children = buildTreemapTree(n.children, fullPath, pathMap, truncatedDirMap);
if (children.length === 0) continue;
const node: TreemapNode = {
name: n.name,
Expand All @@ -153,18 +154,60 @@ function buildTreemapTree(
pathMap.set(node, fullPath);
out.push(node);
} else {
// A directory with no children but nodeCount > 0 is the backend's depth-cap
// marker — descendants exist but weren't fetched. Render as a leaf and
// flag for lazy expansion on click (Phase 2/3 path-rooted refetch).
const isTruncatedDir = n.type === 'directory' && n.nodeCount > 0;
const node: TreemapNode = {
name: n.name,
value: Math.max(n.nodeCount, 1),
color: LANG_COLORS[inferLang(n.name)] ?? '#666',
color: LANG_COLORS[isTruncatedDir ? 'other' : inferLang(n.name)] ?? '#666',
};
pathMap.set(node, fullPath);
if (isTruncatedDir) truncatedDirMap.set(node, true);
out.push(node);
}
}
return out;
}

/**
* Walk down to leaves and sum their values. Used when flattening the visible
* level for label rendering: the design-system Treemap canvas only paints
* names on leaf cells (its `isLeaf` check guards label drawing), so
* directories at the focused level have to be rendered as leaves with a
* pre-aggregated value to keep the cell-sizing accurate.
*/
function aggregateLeafValue(node: TreemapNode): number {
if (!node.children || node.children.length === 0) return node.value ?? 1;
let sum = 0;
for (const c of node.children) sum += aggregateLeafValue(c);
return sum;
}

/**
* Splice a freshly-fetched subtree into the right slot of the existing tree.
* Matches by absolute filesystem path (which the backend emits unchanged in
* `path` for both root and path-rooted responses), then replaces the
* directory's children. Returns a new tree array — the caller should treat
* the result as immutable.
*/
function mergeSubtree(
tree: FileTreeNode[],
fsPath: string,
subtree: FileTreeNode[],
): FileTreeNode[] {
return tree.map(n => {
if (n.path === fsPath && n.type === 'directory') {
return { ...n, children: subtree };
}
if (n.children && n.children.length > 0 && fsPath.startsWith(n.path + '/')) {
return { ...n, children: mergeSubtree(n.children, fsPath, subtree) };
}
return n;
});
}

function useViewportHeight(offset: number): number {
const [h, setH] = useState(() => (typeof window === 'undefined' ? 600 : window.innerHeight - offset));
useEffect(() => {
Expand Down Expand Up @@ -208,27 +251,63 @@ export default function Dashboard() {
// breadcrumb(38) + gaps(24)
const treemapHeight = useViewportHeight(56 + 32 + 110 + 38 + 24);

// Treemap. Cap initial fetch at depth 8 — enough for a fully-qualified Java
// Treemap. Initial fetch caps at depth 8 — enough for a fully-qualified Java
// path (src/main/java/io/github/<org>/<pkg>/<sub>/File.java = 8 segments)
// and most other languages, but spares the 200 K-node case from shipping
// the full tree for paths the user will never drill into. Past depth 8
// the directory renders as a leaf with its aggregate node count; on-demand
// subtree fetching is a follow-up (Phase 2).
const { data: treeData, loading: treeLoading } = useApi<FileTreeResponse>(() => api.getFileTree(8), []);
const { treemapRoot, pathMap } = useMemo(() => {
const map = new WeakMap<TreemapNode, string>();
const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', map);
// and the typical TS/Python/Go layouts. Directories beyond depth 8 come
// back as truncation markers (type=directory, children=[], nodeCount>0);
// when the user clicks one, we fetch its subtree on demand via the
// path-rooted /api/file-tree?path=… endpoint and splice it into place.
const [treeData, setTreeData] = useState<FileTreeResponse | null>(null);
const [treeLoading, setTreeLoading] = useState(true);
const [subtreeLoading, setSubtreeLoading] = useState(false);
const loadedPathsRef = useRef<Set<string>>(new Set());

useEffect(() => {
let cancelled = false;
setTreeLoading(true);
api.getFileTree(8)
.then(r => { if (!cancelled) setTreeData(r); })
.catch(() => { /* surface via empty-tree state */ })
.finally(() => { if (!cancelled) setTreeLoading(false); });
return () => { cancelled = true; };
}, []);

const ensureSubtreeLoaded = useCallback(async (fsPath: string) => {
if (loadedPathsRef.current.has(fsPath)) return;
// Mark eagerly to dedupe concurrent clicks; revert on failure so the
// user can retry by clicking again.
loadedPathsRef.current.add(fsPath);
setSubtreeLoading(true);
try {
const sub = await api.getFileTree(8, fsPath);
setTreeData(prev => prev
? { ...prev, tree: mergeSubtree(prev.tree, fsPath, sub.tree) }
: prev);
} catch {
loadedPathsRef.current.delete(fsPath);
} finally {
setSubtreeLoading(false);
}
}, []);

const { treemapRoot, pathMap, truncatedDirMap } = useMemo(() => {
const pMap = new WeakMap<TreemapNode, string>();
const tMap = new WeakMap<TreemapNode, boolean>();
const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', pMap, tMap);
const root: TreemapNode = { name: 'root', children };
return { treemapRoot: root, pathMap: map };
return { treemapRoot: root, pathMap: pMap, truncatedDirMap: tMap };
}, [treeData]);

// Drill state — names of the directories we've drilled into, in order.
// Empty = full tree. Single-click on a directory pushes; clicking a
// breadcrumb segment slices back to that depth.
const [focusPath, setFocusPath] = useState<string[]>([]);

// Reset focus when the underlying tree changes (e.g., re-fetch after enrich).
useEffect(() => { setFocusPath([]); }, [treemapRoot]);
// Reset focus only on a fresh full-tree load (total_files indicates the
// initial fetch landed). Subtree-merge updates also bump treeData but keep
// total_files unchanged, so the user's drill position survives lazy loads.
const totalFiles = treeData?.total_files;
useEffect(() => { setFocusPath([]); }, [totalFiles]);

// Walk treemapRoot along focusPath. Falls back to root if any segment is
// missing (defensive — shouldn't happen since focusPath only ever holds
Expand All @@ -243,15 +322,56 @@ export default function Dashboard() {
return cur;
}, [treemapRoot, focusPath]);

// Render-only flat copy of the focused level. The design-system Treemap
// only paints names on leaf cells (its canvas path checks `!n.children?.length`
// before drawing the label), so directories at the visible level — which
// legitimately have children for drill-down — appear as unlabelled
// rectangles. Strip children for the render pass to satisfy the leaf
// check; the original TreemapNode (with intact children) stays in
// `focusedRoot` and is recovered via `renderToOriginal` in the click
// handler so drill-down still works.
const { focusedRootForRender, renderToOriginal } = useMemo(() => {
const map = new WeakMap<TreemapNode, TreemapNode>();
const flat = (focusedRoot.children ?? []).map(c => {
if (c.children && c.children.length > 0) {
const rendered: TreemapNode = {
name: c.name,
value: aggregateLeafValue(c),
color: c.color,
};
map.set(rendered, c);
return rendered;
}
// Files and truncated-directory markers are already leaves; preserve
// identity so existing pathMap / truncatedDirMap lookups keep working.
return c;
});
return {
focusedRootForRender: { name: focusedRoot.name, children: flat },
renderToOriginal: map,
};
}, [focusedRoot]);

// File viewer
const [fileDrawer, setFileDrawer] = useState<{ path: string; content: string } | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const onTreemapNodeClick = useCallback(async (node: TreemapNode) => {
// Directory — drill down one level.
const onTreemapNodeClick = useCallback(async (clicked: TreemapNode) => {
// Resolve the original TreemapNode behind the rendered (children-stripped)
// cell so pathMap / truncatedDirMap / drill-down keep working.
const node = renderToOriginal.get(clicked) ?? clicked;
// Directory with children — drill down one level.
if (node.children && node.children.length > 0) {
setFocusPath(prev => [...prev, node.name]);
return;
}
// Truncated directory (depth-cap marker) — fetch its subtree, then drill in.
if (truncatedDirMap.get(node)) {
const fsPath = pathMap.get(node);
if (!fsPath) return;
await ensureSubtreeLoaded(fsPath);
setFocusPath(prev => [...prev, node.name]);
return;
}
// Leaf — open file in drawer.
const filePath = pathMap.get(node);
if (!filePath) return;
Expand All @@ -260,7 +380,7 @@ export default function Dashboard() {
try { setFileDrawer({ path: filePath, content: await api.readFile(filePath) }); }
catch { setFileDrawer({ path: filePath, content: '// Could not load file' }); }
finally { setFileLoading(false); }
}, [pathMap]);
}, [pathMap, truncatedDirMap, renderToOriginal, ensureSubtreeLoaded]);

if (statsLoading || treeLoading) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="lg" /></div>;
Expand Down Expand Up @@ -307,6 +427,11 @@ export default function Dashboard() {
</button>
</Fragment>
))}
{subtreeLoading && (
<span style={{ marginLeft: 8, opacity: 0.7 }} aria-live="polite">
<Spin size="sm" /> loading subtree…
</span>
)}
</div>

<div>
Expand All @@ -316,7 +441,7 @@ export default function Dashboard() {
// design-system Treemap caches layout on `data` identity, and a
// remount is the simplest way to ensure a clean redraw.
key={focusPath.join('/') || 'root'}
data={focusedRoot}
data={focusedRootForRender}
height={treemapHeight}
engine="canvas"
// One level at a time — each cell maps 1:1 to a direct child of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,43 @@ public List<Map<String, Object>> searchGraph(
public Map<String, Object> getFileTree(
@RequestParam(required = false) Integer depth,
@RequestParam(required = false) Integer maxFiles,
@RequestParam(required = false) String path,
@RequestParam(defaultValue = "true") boolean excludeTests) {
requireQueryService();
// depth=null means unlimited (full tree for treemap). Otherwise cap at maxDepth.
Integer cappedDepth = (depth != null) ? Math.min(depth, config.getMaxDepth()) : null;
// Default unlimited for treemap
int limit = (maxFiles != null) ? maxFiles : Integer.MAX_VALUE;
return queryService.getFileTree(cappedDepth, limit, excludeTests);
String normalizedPath = normalizeFileTreePath(path);
return queryService.getFileTree(cappedDepth, limit, excludeTests, normalizedPath);
}

/**
* Validate + normalize a user-supplied filesystem path before it reaches the query
* layer. Rejects {@code ..} traversal, leading slashes, and overlong inputs;
* trims trailing slashes. {@code null} and blank inputs collapse to {@code null}
* which means "root tree" downstream.
*/
private static String normalizeFileTreePath(String raw) {
if (raw == null || raw.isBlank()) return null;
String p = raw.trim();
while (p.endsWith("/")) p = p.substring(0, p.length() - 1);
while (p.startsWith("/")) p = p.substring(1);
if (p.length() > 1024) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.BAD_REQUEST,
"path too long");
}
// Reject path-traversal segments. Splitting first prevents false positives on
// legitimate filenames that happen to contain ".." as a substring.
for (String seg : p.split("/")) {
if ("..".equals(seg)) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.BAD_REQUEST,
"path must not contain '..' segments");
}
}
return p.isEmpty() ? null : p;
}

@GetMapping("/capabilities")
Expand Down
32 changes: 23 additions & 9 deletions src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -678,20 +678,34 @@ public record FilePathResult(List<Map<String, Object>> rows, boolean truncated)
* exist the {@link FilePathResult#truncated()} flag is set to {@code true}.
*/
public FilePathResult getFilePathsWithCounts(int maxFiles) {
return getFilePathsWithCounts(maxFiles, null);
}

/**
* Path-rooted variant: when {@code pathPrefix} is non-null/non-blank, limits results
* to files whose {@code filePath} is a descendant of the prefix (matches
* {@code prefix + "/*"}). Used by the file-tree REST endpoint to fetch subtrees on
* demand instead of shipping the full tree on every page load.
*/
public FilePathResult getFilePathsWithCounts(int maxFiles, String pathPrefix) {
List<Map<String, Object>> rows = new ArrayList<>();
try (Transaction tx = graphDb.beginTx()) {
StringBuilder query = new StringBuilder(
"MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL");
Map<String, Object> params = new HashMap<>();
if (pathPrefix != null && !pathPrefix.isBlank()) {
// Append "/" so "src/main" matches "src/main/Foo.java" but not "src/main2/...".
query.append(" AND n.filePath STARTS WITH $prefix");
params.put("prefix", pathPrefix + "/");
}
query.append(" RETURN n.filePath AS filePath, count(n) AS nodeCount ORDER BY n.filePath");
// When maxFiles is very large (e.g., Integer.MAX_VALUE for unlimited treemap),
// skip the LIMIT clause entirely to avoid integer overflow
String query = "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL "
+ "RETURN n.filePath AS filePath, count(n) AS nodeCount "
+ "ORDER BY n.filePath";
Result result;
// skip the LIMIT clause entirely to avoid integer overflow.
if (maxFiles < 1_000_000) {
result = tx.execute(query + " LIMIT $limit",
Map.of(PROP_LIMIT, (long) (maxFiles + 1)));
} else {
result = tx.execute(query);
query.append(" LIMIT $limit");
params.put(PROP_LIMIT, (long) (maxFiles + 1));
}
Result result = tx.execute(query.toString(), params);
while (result.hasNext()) {
var row = result.next();
Map<String, Object> m = new LinkedHashMap<>();
Expand Down
Loading
Loading