Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ function ContainerNodeComponent({ data, width, height }: NodeProps<ContainerFlow
const borderColor = data.isDiffAffected
? "var(--color-diff-changed)"
: data.isExpanded || data.isFocusedViaChild
? "rgba(212,165,116,0.6)"
: "rgba(212,165,116,0.25)";
const borderWidth = data.isExpanded || data.isFocusedViaChild ? 1.5 : 1;
? "rgba(212,165,116,0.85)"
: "rgba(212,165,116,0.55)";
const borderWidth = 1.5;

const labelDimmed = data.name === "~";
const labelText = labelDimmed ? "(root)" : data.name;
Expand All @@ -46,7 +46,9 @@ function ContainerNodeComponent({ data, width, height }: NodeProps<ContainerFlow
style={{
width,
height,
background: "rgba(255,255,255,0.02)",
background: data.isExpanded
? "rgba(255,255,255,0.04)"
: "rgba(212,165,116,0.06)",
border: `${borderWidth}px solid ${borderColor}`,
position: "relative",
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -663,8 +663,25 @@ function useLayerDetailTopology(): LayerDetailTopology & {
const [topology, setTopology] = useState<LayerDetailTopology>(EMPTY_TOPOLOGY);
const [layoutStatus, setLayoutStatus] = useState<"computing" | "ready">("ready");

// Synchronous flag: true while a new-layer ELK layout is in flight.
// Read by the nodes-watch fitView effect in GraphViewInner to prevent a
// premature fitView firing on the previous layer's stale nodes before the
// new layout arrives. A ref (not state) so it's visible to effects in the
// same flush without causing an extra render.
const newLayoutRef = useRef(false);
const prevBuiltRef = useRef<unknown>(null);

useEffect(() => {
// Mark "new layout in flight" only when `built` itself changes (new layer
// or structural filter change), NOT when only stage1Tick changes (which is
// a same-layer container-size refinement after Stage 2 expansion).
if (built !== prevBuiltRef.current && built) {
newLayoutRef.current = true;
}
prevBuiltRef.current = built;

if (!built) {
newLayoutRef.current = false;
setTopology(EMPTY_TOPOLOGY);
setLayoutStatus("ready");
return;
Expand Down Expand Up @@ -748,6 +765,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
...(portalNodes as unknown as Node[]),
];
const positionedNodes = mergeElkPositions(allBaseNodes, positioned);
newLayoutRef.current = false;
setTopology({
nodes: positionedNodes,
edges: aggEdges,
Expand All @@ -763,6 +781,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
})
.catch((err) => {
if (cancelled) return;
newLayoutRef.current = false;
console.error("[layer-detail Stage 1 ELK] layout failed:", err);
setLayoutStatus("ready");
});
Expand Down Expand Up @@ -898,7 +917,7 @@ function useLayerDetailTopology(): LayerDetailTopology & {
bumpStage1Tick,
]);

return { ...topology, layoutStatus };
return { ...topology, layoutStatus, newLayoutRef };
}

/**
Expand Down Expand Up @@ -1289,8 +1308,10 @@ function useLayerDetailGraph() {
nodes,
edges,
nodeToContainer: topo.nodeToContainer,
containers: topo.containers,
containerIds,
layoutStatus: topo.layoutStatus,
newLayoutRef: topo.newLayoutRef,
};
}

Expand All @@ -1311,6 +1332,7 @@ function GraphViewInner() {
const pendingFocusContainer = useDashboardStore((s) => s.pendingFocusContainer);
const setPendingFocusContainer = useDashboardStore((s) => s.setPendingFocusContainer);
const tourFitPending = useDashboardStore((s) => s.tourFitPending);
const stage1Tick = useDashboardStore((s) => s.stage1Tick);
const { preset } = useTheme();

const overviewGraph = useOverviewGraph();
Expand All @@ -1320,10 +1342,12 @@ function GraphViewInner() {
nodes: initialNodes,
edges: initialEdges,
nodeToContainer,
containers,
containerIds,
layoutStatus,
newLayoutRef,
} = navigationLevel === "overview"
? { ...overviewGraph, nodeToContainer: undefined, containerIds: undefined }
? { ...overviewGraph, nodeToContainer: undefined, containers: undefined, containerIds: undefined, newLayoutRef: undefined }
: detailGraph;

const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
Expand Down Expand Up @@ -1351,13 +1375,20 @@ function GraphViewInner() {
useEffect(() => {
if (!pendingFitRef.current) return;
if (nodes.length === 0) return;
// Bail if a new-layer ELK layout is still in flight. The nodes we see are
// stale (previous layer) — fitting them would leave the new layer's nodes
// off-screen. newLayoutRef.current is set synchronously in the ELK Stage 1
// effect (which fires before this effect in the same flush) and cleared
// when ELK completes. When ELK completes, setTopology triggers a re-render
// → nodes changes → this effect re-runs with newLayoutRef.current = false.
if (newLayoutRef?.current) return;
pendingFitRef.current = false;
// One frame so React Flow has positioned the nodes before fit.
const raf = requestAnimationFrame(() => {
fitView({ duration: 400, padding: 0.2 });
fitView({ duration: 400, padding: 0.15, minZoom: 0.12 });
});
return () => cancelAnimationFrame(raf);
}, [nodes, fitView]);
}, [nodes, fitView, newLayoutRef]);

// Lock viewport onto a container the user just manually expanded so it
// appears to expand in place rather than getting yanked off-screen by
Expand Down Expand Up @@ -1435,6 +1466,53 @@ function GraphViewInner() {
tourBorrowedContainersRef.current = stillBorrowed;
}, [tourHighlightedNodeIds, nodeToContainer, expandContainer, collapseContainer]);

// Initial auto-expand: on first visit to a layer, expand only "small"
// containers (≤ AUTO_EXPAND_MAX_CHILDREN files). Large containers stay
// collapsed to avoid cascading ELK layouts and visual overwhelm.
// Per-layer Set ensures we don't re-expand on return visits, respecting
// any containers the user manually collapsed.
const AUTO_EXPAND_MAX_CHILDREN = 10;
const autoExpandedLayersRef = useRef<Set<string>>(new Set());
const pendingExpandFitRef = useRef<number | null>(null);

useEffect(() => {
if (navigationLevel !== "layer-detail" || !activeLayerId) return;
if (!containerIds || containerIds.length === 0 || !containers) return;
// Bail while a new-layer ELK layout is in flight: containers/containerIds
// come from the same `topo` topology state as `nodes`, so on a direct
// layer→layer jump they still hold the PREVIOUS layer until setTopology
// lands. Marking the layer auto-expanded here (and expanding stale ids)
// would make the guard below skip it once fresh topology arrives, so the
// new layer's small containers never expand. Same guard the fitView effect
// uses; newLayoutRef clears when ELK completes, re-triggering this effect.
if (newLayoutRef?.current) return;
if (autoExpandedLayersRef.current.has(activeLayerId)) return;
autoExpandedLayersRef.current.add(activeLayerId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for fresh topology before marking the layer expanded

When navigating directly from one detail layer to another (for example via a portal or tour step), topo.containers still holds the previous layer until the async Stage 1 layout calls setTopology. This effect runs immediately on the activeLayerId change, records the new layer as already auto-expanded, and then filters/expands stale container ids; once the real topology arrives the guard skips the layer, so small containers are not auto-expanded, and colliding ids can even expand a large container based on the previous layer’s child count. Gate this on fresh topology/layout for the current layer before adding it to autoExpandedLayersRef.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 33b42d8.

Root cause: containers / containerIds / nodes all derive from the same topo topology state, so on a direct layer→layer jump (portal/tour, where navigationLevel stays "layer-detail") they hold the previous layer for one flush until the async Stage 1 ELK calls setTopology. The effect ran in that window, added the new activeLayerId to autoExpandedLayersRef, and expanded stale ids — so once fresh topology arrived the has(activeLayerId) guard skipped the layer and its small containers never expanded.

This staleness window was already known: the sibling fitView effect bails on it via newLayoutRef. The fix applies the same guard before mutating the ref:

if (navigationLevel !== "layer-detail" || !activeLayerId) return;
if (!containerIds || containerIds.length === 0 || !containers) return;
if (newLayoutRef?.current) return;        // stale topology — wait for fresh layout
if (autoExpandedLayersRef.current.has(activeLayerId)) return;
autoExpandedLayersRef.current.add(activeLayerId);

When ELK completes, newLayoutRef clears and setTopology re-renders → the effect re-runs with fresh containers and marks/expands the layer correctly.


const store = useDashboardStore.getState();
const toExpand = containerIds.filter((cid) => {
if (store.expandedContainers.has(cid)) return false;
const c = containers.find((cc) => cc.id === cid);
return (c?.nodeIds.length ?? Infinity) <= AUTO_EXPAND_MAX_CHILDREN;
});
if (toExpand.length === 0) return;

pendingExpandFitRef.current = store.stage1Tick;
for (const cid of toExpand) expandContainer(cid);
}, [navigationLevel, activeLayerId, containerIds, containers, expandContainer]);

// Re-fit after the expansion-triggered Stage 1 re-layout settles.
useEffect(() => {
if (pendingExpandFitRef.current === null) return;
if (layoutStatus !== "ready") return;
if (stage1Tick === pendingExpandFitRef.current) return;
pendingExpandFitRef.current = null;
const raf = requestAnimationFrame(() => {
fitView({ duration: 300, padding: 0.15, minZoom: 0.08 });
});
return () => cancelAnimationFrame(raf);
}, [stage1Tick, layoutStatus, fitView]);

// Zoom: debounced auto-expand when the user has zoomed in past 1.0.
// Hysteresis: zoom < 0.6 = no auto-expand AND no auto-collapse (v1, the
// user collapses manually). The handler reads expandedContainers via
Expand Down