Skip to content

Commit 9cf2397

Browse files
aksOpsclaude
andcommitted
perf(treemap): on-demand subtree fetch + visible directory labels
Two related improvements to the dashboard treemap, building on the depth-8 cap shipped in v0.2.1: **Phase 2 — backend path-rooted subtree.** Adds an optional `path` query parameter to `GET /api/file-tree` so the frontend can fetch just a focused subtree instead of the whole tree. The Cypher query filters by `STARTS WITH path/`, the tree builder strips the prefix during walk so the local root represents the focused directory, and `@Cacheable` is keyed on path so different anchors are cached separately. Path inputs are normalized + validated in the controller (no `..`, no leading slash, ≤1024 chars) before reaching the query layer. **Phase 3 — frontend on-demand drill.** When the user clicks a directory rendered as a depth-cap leaf (type=directory, no children, nodeCount > 0), Dashboard fetches its subtree via the new path endpoint, splices the response into the in-memory tree at that node, and drills into it in the same gesture. A small loading indicator appears in the breadcrumb during the fetch. Concurrent clicks on the same path dedupe via a ref-tracked in-flight set; failures clear the flag so retries work. **UX fix — labels on directory cells.** The design-system Treemap canvas only paints names on `isLeaf` cells, so directories at the visible drill level rendered as unlabelled rectangles (names only appeared on hover). Dashboard now feeds the treemap a render-only flat copy of the focused level — directories are stripped of their children and given a pre-aggregated value so the canvas treats them as leaves and paints the label, while the original TreemapNode (with intact children) stays in `focusedRoot` and is recovered via a `renderToOriginal` WeakMap in the click handler so drill-down keeps working. Test plan: - mvn test (3706 / 3706 pass; 32 expected skips) - npm run build (197.47 kB / 63.02 kB gzip — +0.5 kB vs main) - 4 new GraphControllerTest cases covering path passthrough, trailing-slash normalization, `..` traversal rejection, overlong path rejection - Manual: deploy + verify treemap shows directory names by default, drill into a depth-8 truncated directory triggers a fetch and shows children Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d6e34ea commit 9cf2397

7 files changed

Lines changed: 241 additions & 48 deletions

File tree

src/main/frontend/src/lib/api.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,12 @@ export const api = {
9595
traceImpact: (id: string, depth = 3) =>
9696
fetchJson<Record<string, unknown>>(`${BASE}/triage/impact/${encodeURIComponent(id)}?depth=${depth}`),
9797

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

103106
getTopology: () =>

src/main/frontend/src/pages/Dashboard.tsx

Lines changed: 144 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
1+
import { useState, useMemo, useCallback, useEffect, useRef, Fragment } from 'react';
22
import {
33
Card, Spin, Alert, Modal, Drawer, Stat, Table, ScrollDiv, Space,
44
} from '@ossrandom/design-system';
@@ -132,6 +132,7 @@ function buildTreemapTree(
132132
nodes: FileTreeNode[],
133133
parentPath: string,
134134
pathMap: WeakMap<TreemapNode, string>,
135+
truncatedDirMap: WeakMap<TreemapNode, boolean>,
135136
): TreemapNode[] {
136137
// Sort children by name so the treemap layout is stable across page
137138
// loads regardless of API result ordering. d3-hierarchy's squarified
@@ -143,7 +144,7 @@ function buildTreemapTree(
143144
const fullPath = parentPath ? `${parentPath}/${n.name}` : n.name;
144145
if (n.nodeCount <= 0 && (!n.children || n.children.length === 0)) continue;
145146
if (n.type === 'directory' && n.children && n.children.length > 0) {
146-
const children = buildTreemapTree(n.children, fullPath, pathMap);
147+
const children = buildTreemapTree(n.children, fullPath, pathMap, truncatedDirMap);
147148
if (children.length === 0) continue;
148149
const node: TreemapNode = {
149150
name: n.name,
@@ -153,18 +154,60 @@ function buildTreemapTree(
153154
pathMap.set(node, fullPath);
154155
out.push(node);
155156
} else {
157+
// A directory with no children but nodeCount > 0 is the backend's depth-cap
158+
// marker — descendants exist but weren't fetched. Render as a leaf and
159+
// flag for lazy expansion on click (Phase 2/3 path-rooted refetch).
160+
const isTruncatedDir = n.type === 'directory' && n.nodeCount > 0;
156161
const node: TreemapNode = {
157162
name: n.name,
158163
value: Math.max(n.nodeCount, 1),
159-
color: LANG_COLORS[inferLang(n.name)] ?? '#666',
164+
color: LANG_COLORS[isTruncatedDir ? 'other' : inferLang(n.name)] ?? '#666',
160165
};
161166
pathMap.set(node, fullPath);
167+
if (isTruncatedDir) truncatedDirMap.set(node, true);
162168
out.push(node);
163169
}
164170
}
165171
return out;
166172
}
167173

174+
/**
175+
* Walk down to leaves and sum their values. Used when flattening the visible
176+
* level for label rendering: the design-system Treemap canvas only paints
177+
* names on leaf cells (its `isLeaf` check guards label drawing), so
178+
* directories at the focused level have to be rendered as leaves with a
179+
* pre-aggregated value to keep the cell-sizing accurate.
180+
*/
181+
function aggregateLeafValue(node: TreemapNode): number {
182+
if (!node.children || node.children.length === 0) return node.value ?? 1;
183+
let sum = 0;
184+
for (const c of node.children) sum += aggregateLeafValue(c);
185+
return sum;
186+
}
187+
188+
/**
189+
* Splice a freshly-fetched subtree into the right slot of the existing tree.
190+
* Matches by absolute filesystem path (which the backend emits unchanged in
191+
* `path` for both root and path-rooted responses), then replaces the
192+
* directory's children. Returns a new tree array — the caller should treat
193+
* the result as immutable.
194+
*/
195+
function mergeSubtree(
196+
tree: FileTreeNode[],
197+
fsPath: string,
198+
subtree: FileTreeNode[],
199+
): FileTreeNode[] {
200+
return tree.map(n => {
201+
if (n.path === fsPath && n.type === 'directory') {
202+
return { ...n, children: subtree };
203+
}
204+
if (n.children && n.children.length > 0 && fsPath.startsWith(n.path + '/')) {
205+
return { ...n, children: mergeSubtree(n.children, fsPath, subtree) };
206+
}
207+
return n;
208+
});
209+
}
210+
168211
function useViewportHeight(offset: number): number {
169212
const [h, setH] = useState(() => (typeof window === 'undefined' ? 600 : window.innerHeight - offset));
170213
useEffect(() => {
@@ -208,27 +251,63 @@ export default function Dashboard() {
208251
// breadcrumb(38) + gaps(24)
209252
const treemapHeight = useViewportHeight(56 + 32 + 110 + 38 + 24);
210253

211-
// Treemap. Cap initial fetch at depth 8 — enough for a fully-qualified Java
254+
// Treemap. Initial fetch caps at depth 8 — enough for a fully-qualified Java
212255
// path (src/main/java/io/github/<org>/<pkg>/<sub>/File.java = 8 segments)
213-
// and most other languages, but spares the 200 K-node case from shipping
214-
// the full tree for paths the user will never drill into. Past depth 8
215-
// the directory renders as a leaf with its aggregate node count; on-demand
216-
// subtree fetching is a follow-up (Phase 2).
217-
const { data: treeData, loading: treeLoading } = useApi<FileTreeResponse>(() => api.getFileTree(8), []);
218-
const { treemapRoot, pathMap } = useMemo(() => {
219-
const map = new WeakMap<TreemapNode, string>();
220-
const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', map);
256+
// and the typical TS/Python/Go layouts. Directories beyond depth 8 come
257+
// back as truncation markers (type=directory, children=[], nodeCount>0);
258+
// when the user clicks one, we fetch its subtree on demand via the
259+
// path-rooted /api/file-tree?path=… endpoint and splice it into place.
260+
const [treeData, setTreeData] = useState<FileTreeResponse | null>(null);
261+
const [treeLoading, setTreeLoading] = useState(true);
262+
const [subtreeLoading, setSubtreeLoading] = useState(false);
263+
const loadedPathsRef = useRef<Set<string>>(new Set());
264+
265+
useEffect(() => {
266+
let cancelled = false;
267+
setTreeLoading(true);
268+
api.getFileTree(8)
269+
.then(r => { if (!cancelled) setTreeData(r); })
270+
.catch(() => { /* surface via empty-tree state */ })
271+
.finally(() => { if (!cancelled) setTreeLoading(false); });
272+
return () => { cancelled = true; };
273+
}, []);
274+
275+
const ensureSubtreeLoaded = useCallback(async (fsPath: string) => {
276+
if (loadedPathsRef.current.has(fsPath)) return;
277+
// Mark eagerly to dedupe concurrent clicks; revert on failure so the
278+
// user can retry by clicking again.
279+
loadedPathsRef.current.add(fsPath);
280+
setSubtreeLoading(true);
281+
try {
282+
const sub = await api.getFileTree(8, fsPath);
283+
setTreeData(prev => prev
284+
? { ...prev, tree: mergeSubtree(prev.tree, fsPath, sub.tree) }
285+
: prev);
286+
} catch {
287+
loadedPathsRef.current.delete(fsPath);
288+
} finally {
289+
setSubtreeLoading(false);
290+
}
291+
}, []);
292+
293+
const { treemapRoot, pathMap, truncatedDirMap } = useMemo(() => {
294+
const pMap = new WeakMap<TreemapNode, string>();
295+
const tMap = new WeakMap<TreemapNode, boolean>();
296+
const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', pMap, tMap);
221297
const root: TreemapNode = { name: 'root', children };
222-
return { treemapRoot: root, pathMap: map };
298+
return { treemapRoot: root, pathMap: pMap, truncatedDirMap: tMap };
223299
}, [treeData]);
224300

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

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

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

325+
// Render-only flat copy of the focused level. The design-system Treemap
326+
// only paints names on leaf cells (its canvas path checks `!n.children?.length`
327+
// before drawing the label), so directories at the visible level — which
328+
// legitimately have children for drill-down — appear as unlabelled
329+
// rectangles. Strip children for the render pass to satisfy the leaf
330+
// check; the original TreemapNode (with intact children) stays in
331+
// `focusedRoot` and is recovered via `renderToOriginal` in the click
332+
// handler so drill-down still works.
333+
const { focusedRootForRender, renderToOriginal } = useMemo(() => {
334+
const map = new WeakMap<TreemapNode, TreemapNode>();
335+
const flat = (focusedRoot.children ?? []).map(c => {
336+
if (c.children && c.children.length > 0) {
337+
const rendered: TreemapNode = {
338+
name: c.name,
339+
value: aggregateLeafValue(c),
340+
color: c.color,
341+
};
342+
map.set(rendered, c);
343+
return rendered;
344+
}
345+
// Files and truncated-directory markers are already leaves; preserve
346+
// identity so existing pathMap / truncatedDirMap lookups keep working.
347+
return c;
348+
});
349+
return {
350+
focusedRootForRender: { name: focusedRoot.name, children: flat },
351+
renderToOriginal: map,
352+
};
353+
}, [focusedRoot]);
354+
246355
// File viewer
247356
const [fileDrawer, setFileDrawer] = useState<{ path: string; content: string } | null>(null);
248357
const [fileLoading, setFileLoading] = useState(false);
249-
const onTreemapNodeClick = useCallback(async (node: TreemapNode) => {
250-
// Directory — drill down one level.
358+
const onTreemapNodeClick = useCallback(async (clicked: TreemapNode) => {
359+
// Resolve the original TreemapNode behind the rendered (children-stripped)
360+
// cell so pathMap / truncatedDirMap / drill-down keep working.
361+
const node = renderToOriginal.get(clicked) ?? clicked;
362+
// Directory with children — drill down one level.
251363
if (node.children && node.children.length > 0) {
252364
setFocusPath(prev => [...prev, node.name]);
253365
return;
254366
}
367+
// Truncated directory (depth-cap marker) — fetch its subtree, then drill in.
368+
if (truncatedDirMap.get(node)) {
369+
const fsPath = pathMap.get(node);
370+
if (!fsPath) return;
371+
await ensureSubtreeLoaded(fsPath);
372+
setFocusPath(prev => [...prev, node.name]);
373+
return;
374+
}
255375
// Leaf — open file in drawer.
256376
const filePath = pathMap.get(node);
257377
if (!filePath) return;
@@ -260,7 +380,7 @@ export default function Dashboard() {
260380
try { setFileDrawer({ path: filePath, content: await api.readFile(filePath) }); }
261381
catch { setFileDrawer({ path: filePath, content: '// Could not load file' }); }
262382
finally { setFileLoading(false); }
263-
}, [pathMap]);
383+
}, [pathMap, truncatedDirMap, renderToOriginal, ensureSubtreeLoaded]);
264384

265385
if (statsLoading || treeLoading) {
266386
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="lg" /></div>;
@@ -307,6 +427,11 @@ export default function Dashboard() {
307427
</button>
308428
</Fragment>
309429
))}
430+
{subtreeLoading && (
431+
<span style={{ marginLeft: 8, opacity: 0.7 }} aria-live="polite">
432+
<Spin size="sm" /> loading subtree…
433+
</span>
434+
)}
310435
</div>
311436

312437
<div>
@@ -316,7 +441,7 @@ export default function Dashboard() {
316441
// design-system Treemap caches layout on `data` identity, and a
317442
// remount is the simplest way to ensure a clean redraw.
318443
key={focusPath.join('/') || 'root'}
319-
data={focusedRoot}
444+
data={focusedRootForRender}
320445
height={treemapHeight}
321446
engine="canvas"
322447
// One level at a time — each cell maps 1:1 to a direct child of

src/main/java/io/github/randomcodespace/iq/api/GraphController.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,43 @@ public List<Map<String, Object>> searchGraph(
211211
public Map<String, Object> getFileTree(
212212
@RequestParam(required = false) Integer depth,
213213
@RequestParam(required = false) Integer maxFiles,
214+
@RequestParam(required = false) String path,
214215
@RequestParam(defaultValue = "true") boolean excludeTests) {
215216
requireQueryService();
216217
// depth=null means unlimited (full tree for treemap). Otherwise cap at maxDepth.
217218
Integer cappedDepth = (depth != null) ? Math.min(depth, config.getMaxDepth()) : null;
218219
// Default unlimited for treemap
219220
int limit = (maxFiles != null) ? maxFiles : Integer.MAX_VALUE;
220-
return queryService.getFileTree(cappedDepth, limit, excludeTests);
221+
String normalizedPath = normalizeFileTreePath(path);
222+
return queryService.getFileTree(cappedDepth, limit, excludeTests, normalizedPath);
223+
}
224+
225+
/**
226+
* Validate + normalize a user-supplied filesystem path before it reaches the query
227+
* layer. Rejects {@code ..} traversal, leading slashes, and overlong inputs;
228+
* trims trailing slashes. {@code null} and blank inputs collapse to {@code null}
229+
* which means "root tree" downstream.
230+
*/
231+
private static String normalizeFileTreePath(String raw) {
232+
if (raw == null || raw.isBlank()) return null;
233+
String p = raw.trim();
234+
while (p.endsWith("/")) p = p.substring(0, p.length() - 1);
235+
while (p.startsWith("/")) p = p.substring(1);
236+
if (p.length() > 1024) {
237+
throw new org.springframework.web.server.ResponseStatusException(
238+
org.springframework.http.HttpStatus.BAD_REQUEST,
239+
"path too long");
240+
}
241+
// Reject path-traversal segments. Splitting first prevents false positives on
242+
// legitimate filenames that happen to contain ".." as a substring.
243+
for (String seg : p.split("/")) {
244+
if ("..".equals(seg)) {
245+
throw new org.springframework.web.server.ResponseStatusException(
246+
org.springframework.http.HttpStatus.BAD_REQUEST,
247+
"path must not contain '..' segments");
248+
}
249+
}
250+
return p.isEmpty() ? null : p;
221251
}
222252

223253
@GetMapping("/capabilities")

src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -678,20 +678,34 @@ public record FilePathResult(List<Map<String, Object>> rows, boolean truncated)
678678
* exist the {@link FilePathResult#truncated()} flag is set to {@code true}.
679679
*/
680680
public FilePathResult getFilePathsWithCounts(int maxFiles) {
681+
return getFilePathsWithCounts(maxFiles, null);
682+
}
683+
684+
/**
685+
* Path-rooted variant: when {@code pathPrefix} is non-null/non-blank, limits results
686+
* to files whose {@code filePath} is a descendant of the prefix (matches
687+
* {@code prefix + "/*"}). Used by the file-tree REST endpoint to fetch subtrees on
688+
* demand instead of shipping the full tree on every page load.
689+
*/
690+
public FilePathResult getFilePathsWithCounts(int maxFiles, String pathPrefix) {
681691
List<Map<String, Object>> rows = new ArrayList<>();
682692
try (Transaction tx = graphDb.beginTx()) {
693+
StringBuilder query = new StringBuilder(
694+
"MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL");
695+
Map<String, Object> params = new HashMap<>();
696+
if (pathPrefix != null && !pathPrefix.isBlank()) {
697+
// Append "/" so "src/main" matches "src/main/Foo.java" but not "src/main2/...".
698+
query.append(" AND n.filePath STARTS WITH $prefix");
699+
params.put("prefix", pathPrefix + "/");
700+
}
701+
query.append(" RETURN n.filePath AS filePath, count(n) AS nodeCount ORDER BY n.filePath");
683702
// When maxFiles is very large (e.g., Integer.MAX_VALUE for unlimited treemap),
684-
// skip the LIMIT clause entirely to avoid integer overflow
685-
String query = "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL "
686-
+ "RETURN n.filePath AS filePath, count(n) AS nodeCount "
687-
+ "ORDER BY n.filePath";
688-
Result result;
703+
// skip the LIMIT clause entirely to avoid integer overflow.
689704
if (maxFiles < 1_000_000) {
690-
result = tx.execute(query + " LIMIT $limit",
691-
Map.of(PROP_LIMIT, (long) (maxFiles + 1)));
692-
} else {
693-
result = tx.execute(query);
705+
query.append(" LIMIT $limit");
706+
params.put(PROP_LIMIT, (long) (maxFiles + 1));
694707
}
708+
Result result = tx.execute(query.toString(), params);
695709
while (result.hasNext()) {
696710
var row = result.next();
697711
Map<String, Object> m = new LinkedHashMap<>();

0 commit comments

Comments
 (0)