diff --git a/crates/engine/src/git_diff.rs b/crates/engine/src/git_diff.rs index 4d5fec0..5644140 100644 --- a/crates/engine/src/git_diff.rs +++ b/crates/engine/src/git_diff.rs @@ -21,6 +21,11 @@ pub fn changed_swift_files(workspace_root: &Path) -> Vec { &["diff", "--name-only", "HEAD", "--", "*.swift"], ) { rel_paths.extend(lines.into_iter().map(PathBuf::from)); + } else { + // Fallback for commit-less repositories: get staged files from the index. + if let Ok(lines) = git_lines(workspace_root, &["ls-files", "--", "*.swift"]) { + rel_paths.extend(lines.into_iter().map(PathBuf::from)); + } } // Newly created, untracked Swift files. @@ -169,4 +174,21 @@ mod tests { let result = head_content(tmp.path(), &file); assert!(result.is_none()); } + + #[test] + fn test_changed_swift_files_in_repo_without_head_includes_staged_swift() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + git_ok(root, &["init"]); + git_ok(root, &["config", "user.email", "flowmap-test@example.com"]); + git_ok(root, &["config", "user.name", "FlowMap Test"]); + + let file = root.join("StagedNew.swift"); + fs::write(&file, "func stagedNew() {}\n").unwrap(); + git_ok(root, &["add", "StagedNew.swift"]); + + let result = changed_swift_files(root); + assert!(result.contains(&file)); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4e600a9..43a7fe9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -253,9 +253,25 @@ fn build_resolution_context( } fn is_changed_file_uri(uri: &str, changed_files: &[PathBuf]) -> bool { - let uri_path = Path::new(uri); + let uri_clean = uri.strip_prefix("file://").unwrap_or(uri); + let uri_path = Path::new(uri_clean); + changed_files.iter().any(|changed| { - uri_path == changed.as_path() || uri_path.ends_with(changed) || changed.ends_with(uri_path) + let changed_clean = changed.to_string_lossy(); + let changed_clean = changed_clean + .strip_prefix("file://") + .unwrap_or(&changed_clean); + let changed_path = Path::new(changed_clean); + + if uri_path == changed_path { + return true; + } + + match (uri_path.is_absolute(), changed_path.is_absolute()) { + (true, false) => uri_path.ends_with(changed_path), + (false, true) => changed_path.ends_with(uri_path), + _ => false, + } }) } diff --git a/docs/releases/v0.1.0-beta.1.md b/docs/releases/v0.1.0-beta.1.md index 30a2ff5..ebc9463 100644 --- a/docs/releases/v0.1.0-beta.1.md +++ b/docs/releases/v0.1.0-beta.1.md @@ -38,6 +38,11 @@ Validation artifacts: GitHub Actions minutes were exhausted during this cycle. This release was validated with local reproducible checks and pushed directly without full PR checks. +## Artifact Checksum + +- `flowmap-0.1.0-public-beta.vsix` + SHA-256: `6597807d2693a8a14abb6eb266a72e943b0561c9ca10be75005663117ef6ed13` + ## License (Beta) Final licensing terms will be announced after the beta period. diff --git a/editor/vscode/webview/graph.js b/editor/vscode/webview/graph.js index 7eb324e..bd13de5 100644 --- a/editor/vscode/webview/graph.js +++ b/editor/vscode/webview/graph.js @@ -109,14 +109,14 @@ // ── Spacing constants (PR8.6) ───────────────────────────────────────────── // Hard minimum gaps used by all detail and calls layout code. // No layout may place nodes closer than these values. - const GROUP_GAP = 140; // vertical gap between type-group blocks (file-detail) - const CARD_GAP_X = 56; // horizontal gap between func/type cards in a row - const CARD_GAP_Y = 28; // vertical gap between card rows - const COMPONENT_GAP = 180; // gap between component bounding boxes (calls mode) - const DETAIL_PADDING = 40; // outer viewport padding after detail/calls layout - const PANEL_MIN_W = 280; // minimum panel slot width per component (calls mode) - const PANEL_MIN_H = 180; // minimum panel slot height per component (calls mode) - const TILE_GAP = 10; // minimum gap between component tiles (calls mode) + const GROUP_GAP = 180; // vertical gap between type-group blocks (file-detail) + const CARD_GAP_X = 90; // horizontal gap between func/type cards in a row + const CARD_GAP_Y = 48; // vertical gap between card rows + const COMPONENT_GAP = 220; // gap between component bounding boxes (calls mode) + const DETAIL_PADDING = 60; // outer viewport padding after detail/calls layout + const PANEL_MIN_W = 320; // minimum panel slot width per component (calls mode) + const PANEL_MIN_H = 220; // minimum panel slot height per component (calls mode) + const TILE_GAP = 90; // minimum gap between component tiles (calls mode) // ── Parse embedded payload ─────────────────────────────────────────────── const raw = document.getElementById('graph-data').textContent ?? '{}'; @@ -143,19 +143,23 @@ selector: 'node', style: { label: 'data(label)', - color: '#c0c8d8', + color: '#e2e8f0', 'text-valign': 'center', 'text-halign': 'center', - 'font-size': '11px', + 'font-size': '10px', + 'font-family': 'Inter, system-ui, -apple-system, sans-serif', width: 'label', height: 'label', - padding: '8px', + padding: '10px', shape: 'roundrectangle', - 'background-color': 'rgba(180, 120, 55, 0.42)', + 'background-color': 'rgba(30, 41, 59, 0.7)', 'background-opacity': 1, 'border-width': 1, - 'border-color': 'rgba(255, 255, 255, 0.09)', + 'border-color': 'rgba(255, 255, 255, 0.08)', 'border-opacity': 1, + 'transition-property': 'background-color, border-color, border-width, opacity', + 'transition-duration': '0.3s', + 'transition-timing-function': 'ease-in-out', }, }, // ── Hidden elements (overview initial view) ───────────────────────── @@ -163,33 +167,33 @@ { selector: 'edge.hidden-edge', style: { display: 'none' } }, // ── Kind-based colours ────────────────────────────────────────────── // File cards: polished pill — centered label, generous horizontal padding, - // soft border, neutral blue-gray base (folder tint overrides bg in JS). + // soft border, base navy-blue. { selector: 'node[kind = "file"]', style: { - 'background-color': 'rgba(52, 72, 105, 0.52)', + 'background-color': 'rgba(30, 58, 138, 0.35)', 'background-opacity': 1, - 'font-size': '12px', + 'font-size': '11px', 'font-weight': '600', - color: '#bccce0', + color: '#cbd5e1', 'text-valign': 'center', 'text-halign': 'center', 'text-margin-y': '0px', - 'border-color': 'rgba(255, 255, 255, 0.11)', + 'border-color': 'rgba(96, 165, 250, 0.25)', 'border-opacity': 1, 'border-width': 1, - padding: '10px', - 'min-width': 100, - 'min-height': 28, + padding: '12px', + 'min-width': 110, + 'min-height': 32, }, }, // Hover glow: added via mouseover event, removed on mouseout { selector: 'node[kind = "file"].file-hover', style: { - 'border-color': 'rgba(77, 163, 255, 0.45)', + 'border-color': 'rgba(96, 165, 250, 0.7)', 'border-width': 1.5, - color: '#d0e0ff', + color: '#f8fafc', }, }, // ── Overview mindmap nodes ────────────────────────────────────────── @@ -198,20 +202,20 @@ selector: 'node[kind = "root"]', style: { shape: 'ellipse', - 'background-color': 'rgba(28, 45, 85, 0.90)', + 'background-color': 'rgba(15, 23, 42, 0.95)', 'background-opacity': 1, - 'border-color': 'rgba(90, 140, 230, 0.42)', + 'border-color': 'rgba(56, 189, 248, 0.5)', 'border-width': 2, 'border-opacity': 1, - color: '#c0d0f5', - 'font-size': '14px', + color: '#f8fafc', + 'font-size': '13px', 'font-weight': '700', 'text-valign': 'center', 'text-halign': 'center', - width: 80, - height: 80, + width: 86, + height: 86, 'text-wrap': 'ellipsis', - 'text-max-width': '68px', + 'text-max-width': '74px', }, }, // Folder nodes: medium cards, distinct from file pills @@ -219,20 +223,20 @@ selector: 'node[kind = "folder"]', style: { shape: 'roundrectangle', - 'background-color': 'rgba(35, 46, 72, 0.82)', + 'background-color': 'rgba(15, 23, 42, 0.85)', 'background-opacity': 1, - 'border-color': 'rgba(80, 110, 195, 0.30)', + 'border-color': 'rgba(148, 163, 184, 0.15)', 'border-width': 1.5, 'border-opacity': 1, - color: '#90a8cc', - 'font-size': '11px', + color: '#94a3b8', + 'font-size': '10px', 'font-weight': '600', 'text-valign': 'center', 'text-halign': 'center', 'text-margin-y': '0px', - padding: '10px', - 'min-width': 72, - 'min-height': 28, + padding: '12px', + 'min-width': 80, + 'min-height': 30, }, }, // Folder collapsed: dashed border + muted text @@ -240,28 +244,26 @@ selector: 'node[kind = "folder"].folder-collapsed', style: { 'border-style': 'dashed', - 'border-color': 'rgba(80, 110, 195, 0.18)', - color: '#607090', + 'border-color': 'rgba(148, 163, 184, 0.3)', + color: '#64748b', }, }, // Folder hover glow (via mouseover/mouseout events) { selector: 'node[kind = "folder"].folder-hover', style: { - 'border-color': 'rgba(90, 140, 230, 0.50)', + 'border-color': 'rgba(56, 189, 248, 0.6)', 'border-width': 2, - color: '#b0c8e8', + color: '#e2e8f0', }, }, // ── Branch edges (overview hierarchy only) ────────────────────────── - // Thin, subtle arrows: project→folder, folder→file. - // Not call edges — exist only in overview mode. { selector: 'edge[kind = "branch"]', style: { width: 1, - 'line-color': 'rgba(75, 105, 165, 0.22)', - 'target-arrow-color': 'rgba(75, 105, 165, 0.22)', + 'line-color': 'rgba(148, 163, 184, 0.15)', + 'target-arrow-color': 'rgba(148, 163, 184, 0.15)', 'target-arrow-shape': 'triangle', 'curve-style': 'straight', opacity: 0.90, @@ -270,11 +272,11 @@ { selector: 'node[kind = "type"]', style: { - 'background-color': 'rgba(38, 108, 76, 0.44)', + 'background-color': 'rgba(6, 78, 59, 0.4)', 'background-opacity': 1, - 'font-size': '11px', - color: '#a8d0b8', - 'border-color': 'rgba(255, 255, 255, 0.09)', + 'font-size': '10px', + color: '#a7f3d0', + 'border-color': 'rgba(52, 211, 153, 0.25)', 'border-opacity': 1, 'border-width': 1, }, @@ -282,11 +284,11 @@ { selector: 'node[kind = "func"]', style: { - 'background-color': 'rgba(175, 110, 45, 0.44)', + 'background-color': 'rgba(120, 53, 4, 0.4)', 'background-opacity': 1, - 'font-size': '10px', - color: '#d0b890', - 'border-color': 'rgba(255, 255, 255, 0.09)', + 'font-size': '9px', + color: '#fde68a', + 'border-color': 'rgba(251, 191, 36, 0.25)', 'border-opacity': 1, 'border-width': 1, }, @@ -294,15 +296,21 @@ // ── Diff-state overrides ──────────────────────────────────────────── { selector: 'node[diffState = "added"]', - style: { 'background-color': 'rgba(22, 62, 22, 0.90)', 'background-opacity': 1 }, + style: { + 'background-color': 'rgba(16, 185, 129, 0.25)', + 'border-color': 'rgba(52, 211, 153, 0.6)', + color: '#a7f3d0', + 'background-opacity': 1 + }, }, { selector: 'node[diffState = "removed"]', style: { - 'background-color': 'rgba(62, 20, 20, 0.90)', + 'background-color': 'rgba(239, 68, 68, 0.25)', 'background-opacity': 1, 'border-style': 'dashed', - 'border-color': 'rgba(190, 50, 50, 0.80)', + 'border-color': 'rgba(248, 113, 113, 0.6)', + color: '#fca5a5', 'border-width': 1.5, 'border-opacity': 1, opacity: 0.70, @@ -310,13 +318,18 @@ }, { selector: 'node[diffState = "changed"]', - style: { 'background-color': 'rgba(62, 58, 18, 0.90)', 'background-opacity': 1 }, + style: { + 'background-color': 'rgba(245, 158, 11, 0.25)', + 'border-color': 'rgba(251, 191, 36, 0.6)', + color: '#fde68a', + 'background-opacity': 1 + }, }, // ── Impacted node: warm orange outline ────────────────────────────── { selector: 'node[?impacted]', style: { - 'border-color': 'rgba(215, 115, 50, 0.90)', + 'border-color': '#f97316', 'border-width': 2.5, 'border-style': 'solid', 'border-opacity': 1, @@ -326,25 +339,26 @@ { selector: ':parent', style: { - 'background-opacity': 0.12, + 'background-opacity': 0.08, 'border-width': 1, 'border-color': '#ffffff', - 'border-opacity': 0.1, + 'border-opacity': 0.08, 'text-valign': 'top', 'text-halign': 'center', 'text-margin-y': '8px', - padding: '20px', + padding: '24px', }, }, // Contains edge for detail view hierarchy { selector: 'edge[kind = "contains"]', style: { - width: 1.5, - 'line-color': 'rgba(100, 100, 100, 0.4)', - 'target-arrow-color': 'rgba(100, 100, 100, 0.4)', + width: 1, + 'line-color': 'rgba(255, 255, 255, 0.12)', + 'target-arrow-color': 'rgba(255, 255, 255, 0.12)', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', + 'line-style': 'dashed', }, }, // ── Base edge ────────────────────────────────────────────────────── @@ -352,19 +366,21 @@ selector: 'edge', style: { width: 1.5, - 'line-color': '#e07b39', - 'target-arrow-color': '#e07b39', - 'target-arrow-shape': 'triangle', + 'line-color': '#f97316', + 'target-arrow-color': '#f97316', + 'target-arrow-shape': 'triangle-backcurve', 'curve-style': 'bezier', opacity: 0.7, + 'transition-property': 'line-color, target-arrow-color, width, opacity', + 'transition-duration': '0.3s', }, }, // ── Diff-state edge overrides ─────────────────────────────────────── { selector: 'edge[diffState = "added"]', style: { - 'line-color': '#33cc33', - 'target-arrow-color': '#33cc33', + 'line-color': '#10b981', + 'target-arrow-color': '#10b981', 'line-style': 'dashed', opacity: 0.85, }, @@ -372,8 +388,8 @@ { selector: 'edge[diffState = "removed"]', style: { - 'line-color': '#cc3333', - 'target-arrow-color': '#cc3333', + 'line-color': '#ef4444', + 'target-arrow-color': '#ef4444', 'line-style': 'dashed', opacity: 0.6, }, @@ -381,25 +397,25 @@ // ── Muted unchanged ───────────────────────────────────────────────── { selector: 'node.muted-bg', - style: { opacity: 0.4, color: '#777777' }, + style: { opacity: 0.4, color: '#64748b' }, }, // ── Search highlight ──────────────────────────────────────────────── { selector: 'node.search-highlight', style: { - 'border-color': 'rgba(77, 163, 255, 0.90)', + 'border-color': '#38bdf8', 'border-width': 2, 'border-style': 'solid', 'border-opacity': 1, - color: '#d8ecff', + color: '#f0f9ff', }, }, // ── Highlight state (click) ───────────────────────────────────────── { selector: 'node.highlighted', style: { - 'border-color': '#ffdd00', - 'border-width': 4, + 'border-color': '#0ea5e9', + 'border-width': 3, 'border-style': 'solid', 'border-opacity': 1, }, @@ -411,8 +427,8 @@ { selector: 'edge.highlighted', style: { - 'line-color': '#ffdd00', - 'target-arrow-color': '#ffdd00', + 'line-color': '#0ea5e9', + 'target-arrow-color': '#0ea5e9', opacity: 1, width: 2.5, }, @@ -422,7 +438,7 @@ selector: 'node:selected', style: { 'border-width': 3, - 'border-color': '#e07b39', + 'border-color': '#f97316', 'border-opacity': 1, }, }, @@ -667,5 +683,8 @@ // Register interaction and toolbar/message handlers from graph.events.js registerFlowMapEvents(); + // Start the dynamic floating and collision repulsion loop + startFloatingAnimation(); + diff --git a/editor/vscode/webview/graph.layouts.js b/editor/vscode/webview/graph.layouts.js index c1a5507..f10b0f7 100644 --- a/editor/vscode/webview/graph.layouts.js +++ b/editor/vscode/webview/graph.layouts.js @@ -7,32 +7,17 @@ // ── deferredFit ────────────────────────────────────────────────────────── // Fits the viewport to `eles` (filtered to :visible) after two animation // frames so that Cytoscape has fully flushed display:none style changes. -// -// Root cause this fixes: -// addClass('hidden-node') sets display:none but Cytoscape may not have -// computed the new compound bounding boxes synchronously. If cy.fit() is -// called in the same synchronous tick, compound parents that still have -// visible children in their cached bbox (from a previous search-reveal -// layout) produce an inflated bounding box → the fit zooms out so far -// that visible file nodes appear invisible. -// -// The double-rAF guarantees two render cycles have completed before fit, -// so all display:none calculations are stable and bbox is correct. function deferredFit(eles, padding) { const pad = (typeof padding === 'number') ? padding : 80; requestAnimationFrame(function () { requestAnimationFrame(function () { - // Re-evaluate :visible at rAF time (after style flush) const target = eles ? eles.filter(':visible') : cy.nodes(':visible'); if (target.length === 0) { - // In file-detail mode: type/func nodes are the intended view — never fall back - // to files-only, because doing so would hide the very nodes we're showing. if (state.mode === 'file-detail') { console.warn('[FlowMapDebug] deferredFit: no visible targets in file-detail mode — skipping overview fallback'); return; } - // Defensive fallback for overview/calls: nothing visible → force file-card view const files = cy.nodes('[kind = "file"]'); if (files.length > 0) { cy.nodes('[kind = "type"], [kind = "func"]').addClass('hidden-node'); @@ -47,11 +32,151 @@ function deferredFit(eles, padding) { }); } +// ── animateNodes ───────────────────────────────────────────────────────── +// Animates multiple nodes to their calculated target positions in a single +// batch, and triggers a callback when all animations complete. +function animateNodes(targets, duration, callback) { + const dur = duration || 500; + let completed = 0; + if (targets.length === 0) { + resetBasePositions(); + if (callback) callback(); + return; + } + + targets.forEach(function (t) { + t.node.animate({ + position: t.position + }, { + duration: dur, + easing: 'ease-out-cubic', + complete: function () { + completed++; + if (completed === targets.length) { + resetBasePositions(); + if (callback) callback(); + } + } + }); + }); +} + +// ── resetBasePositions ─────────────────────────────────────────────────── +// Updates the physics simulation base positions for all nodes after a layout. +function resetBasePositions() { + resetHiddenPositions(); + cy.nodes().forEach(function (node) { + node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + }); +} + +// ── startFloatingAnimation ─────────────────────────────────────────────── +// Runs a physics loop using requestAnimationFrame that automatically repels +// nodes if they get too close and floats them gently like they are in water. +let floatAnimationId = null; +function startFloatingAnimation() { + if (floatAnimationId) cancelAnimationFrame(floatAnimationId); + + // Clean up any old listeners to prevent duplicates + cy.off('free', 'node'); + + cy.on('free', 'node', function (evt) { + const node = evt.target; + node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + }); + + const startTime = Date.now(); + + function step() { + const elapsed = (Date.now() - startTime) / 1000; + + // Ensure all visible nodes have base_pos + cy.nodes(':visible').forEach(function (node) { + if (!node.scratch('base_pos')) { + node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + } + }); + + // Mutual repulsion logic to prevent overlap + const nodes = cy.nodes(':visible'); + const forces = {}; + nodes.forEach(function (n) { forces[n.id()] = { x: 0, y: 0 }; }); + + // Calculate repulsion (nodes push each other away if closer than 150px center-to-center) + const minDistance = 150; + const forceFactor = 0.08; + + for (let i = 0; i < nodes.length; i++) { + const n1 = nodes[i]; + if (n1.grabbed()) continue; + const p1 = n1.position(); + + for (let j = i + 1; j < nodes.length; j++) { + const n2 = nodes[j]; + const p2 = n2.position(); + + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + if (dist < minDistance) { + const overlap = minDistance - dist; + const rx = (dx / dist) * overlap * forceFactor; + const ry = (dy / dist) * overlap * forceFactor; + + if (!n1.grabbed()) { + forces[n1.id()].x += rx; + forces[n1.id()].y += ry; + } + if (!n2.grabbed()) { + forces[n2.id()].x -= rx; + forces[n2.id()].y -= ry; + } + } + } + } + + // Apply repulsion to base position, then add gentle floating wave offset + nodes.forEach(function (node) { + if (node.grabbed()) return; + + const id = node.id(); + const base = node.scratch('base_pos'); + if (!base) return; + + const f = forces[id]; + base.x += f.x; + base.y += f.y; + + // Hash node ID for deterministic unique wave parameters + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) & 0xffff; + } + + // Small gentle float (amplitude 3-5px) + const speedX = 0.6 + (hash % 5) * 0.12; + const speedY = 0.7 + (hash % 7) * 0.15; + const ampX = 3 + (hash % 3) * 1.0; + const ampY = 4 + (hash % 4) * 1.5; + + const waveX = Math.sin(elapsed * speedX + hash) * ampX; + const waveY = Math.cos(elapsed * speedY + hash) * ampY; + + node.position({ + x: base.x + waveX, + y: base.y + waveY + }); + }); + + floatAnimationId = requestAnimationFrame(step); + } + + floatAnimationId = requestAnimationFrame(step); +} + // ── resetHiddenPositions ───────────────────────────────────────────────── -// After a grid layout repositions file nodes, move all hidden type/func -// children to their parent's new position. This prevents compound bounding -// boxes from including stale far-away child positions when cy.fit() runs, -// which would inflate the bbox and cause an incorrect viewport zoom. +// Snap all hidden type/func children to their parent's new position. function resetHiddenPositions() { cy.nodes('[kind = "type"], [kind = "func"]').forEach(function (n) { const par = n.parent(); @@ -65,10 +190,7 @@ function resetHiddenPositions() { } // ── runGridLayout ──────────────────────────────────────────────────────── -// Places file nodes in a non-overlapping grid. -// Uses [kind="file"] selector — never :visible — for determinism. -// Resets hidden children positions AFTER layout to minimise bbox inflation. -// Calls deferredFit so the fit runs after Cytoscape has flushed styles. +// Places file nodes in a non-overlapping grid with animations. function runGridLayout() { const files = cy.nodes('[kind = "file"]'); const emptyEl = document.getElementById('empty-state'); @@ -83,50 +205,41 @@ function runGridLayout() { files.layout({ name: 'grid', - padding: 60, + padding: 80, avoidOverlap: true, condense: false, - animate: false, - fit: false, + animate: true, + animationDuration: 500, + animationEasing: 'ease-in-out-cubic', + fit: true, + stop: function () { + resetBasePositions(); + } }).run(); - - // After file nodes have new grid positions, snap hidden children to those - // positions so they don't inflate the compound parent's bounding box. - resetHiddenPositions(); - - // Fit after two rAFs: ensures display:none is fully computed before fit. - console.log('[FlowMapDebug] runGridLayout: calling deferredFit(files, 120)'); - deferredFit(files, 120); } // ── runOverviewLayout ───────────────────────────────────────────────────── -// Positions nodes in a radial mindmap layout: -// - root at the origin (0, 0) -// - folder nodes evenly spaced in a ring around root -// - file nodes fanned outward from their folder, away from root -// Positions are set directly (no Cytoscape layout engine), giving precise -// control over spacing and fan angles. Then deferred-fit to viewport. +// Positions nodes in a radial mindmap layout with smooth animations. function runOverviewLayout() { var root = cy.getElementById('__root__'); if (!root || root.length === 0) { deferredFit(cy.nodes(), 60); return; } - root.position({ x: 0, y: 0 }); + var targets = []; + targets.push({ node: root, position: { x: 0, y: 0 } }); var folderNodes = cy.nodes('[kind = "folder"]'); var nFolders = folderNodes.length; if (nFolders === 0) { deferredFit(cy.nodes(), 60); return; } - // Folder ring: radius grows with folder count - var FOLDER_R = Math.max(260, nFolders * 95); + // Folder ring: radius grows with folder count (spacious default) + var FOLDER_R = Math.max(340, nFolders * 110); folderNodes.forEach(function (folder, i) { - // Evenly spaced angles, starting at top (−π/2) var angle = (2 * Math.PI * i / nFolders) - Math.PI / 2; var fx = Math.round(FOLDER_R * Math.cos(angle)); var fy = Math.round(FOLDER_R * Math.sin(angle)); - folder.position({ x: fx, y: fy }); + targets.push({ node: folder, position: { x: fx, y: fy } }); - // Files attached to this folder via branch edges var files = cy.edges('[kind = "branch"]').filter(function (e) { return e.source().id() === folder.id(); }).targets(); @@ -134,74 +247,74 @@ function runOverviewLayout() { var nFiles = files.length; if (nFiles === 0) { return; } - // File ring: radius scales with file count (min 160, per-file 50) - var FILE_R = Math.max(160, nFiles * 50); - // Fan spread: up to 75% of a half-circle, 38° per file + // File ring: radius scales with file count (spacious defaults) + var FILE_R = Math.max(200, nFiles * 65); var spread = nFiles === 1 ? 0 : Math.min(Math.PI * 0.75, (nFiles - 1) * 0.38); files.forEach(function (file, j) { var fa = angle + (nFiles > 1 ? (j / (nFiles - 1) - 0.5) * 2 * spread : 0); - file.position({ - x: Math.round(fx + FILE_R * Math.cos(fa)), - y: Math.round(fy + FILE_R * Math.sin(fa)), + targets.push({ + node: file, + position: { + x: Math.round(fx + FILE_R * Math.cos(fa)), + y: Math.round(fy + FILE_R * Math.sin(fa)), + } }); }); }); - deferredFit(cy.nodes(), 60); + animateNodes(targets, 500, function () { + deferredFit(cy.nodes(), 60); + }); } -// ── layoutChildrenOf ───────────────────────────────────────────────────── -// Positions the visible children of a compound node in a 1–2 column grid -// directly below the parent, using the hard-minimum CARD_GAP constants. -// No two children overlap; every row is neatly aligned. -// -// Column count rule: ≤3 children → 1 column, 4+ → 2 columns. -// -// Geometry (centred on parent's x): -// startX = parentCentreX − blockWidth/2 + childW/2 -// startY = parentBottom + CARD_GAP_Y + childH/2 -// -// After positioning immediate children, recurse into any revealed type nodes -// so their func children are also placed immediately. -function layoutChildrenOf(parentNode) { +// ── collectChildTargets ─────────────────────────────────────────────────── +// Recursively collects position targets for type/func nodes inside a file. +function collectChildTargets(parentNode, px, py, ph, targets) { var visibleChildren = parentNode.children().not('.hidden-node'); if (visibleChildren.length === 0) { return; } - var px = parentNode.position('x') || 0; - var py = parentNode.position('y') || 0; - var ph = Math.max(28, parentNode.height() || 28); - var n = visibleChildren.length; var nCols = n <= 3 ? 1 : 2; - // Measure max child card dimensions from live Cytoscape style. var childW = 80, childH = 28; visibleChildren.forEach(function (c) { childW = Math.max(childW, c.width() || 80); childH = Math.max(childH, c.height() || 28); }); - // Block starts immediately below the parent node. var blockW = nCols * childW + (nCols - 1) * CARD_GAP_X; var startX = px - blockW / 2 + childW / 2; var startY = py + ph / 2 + CARD_GAP_Y + childH / 2; visibleChildren.forEach(function (child, i) { - child.position({ - x: startX + (i % nCols) * (childW + CARD_GAP_X), - y: startY + Math.floor(i / nCols) * (childH + CARD_GAP_Y), - }); - }); + var tx = startX + (i % nCols) * (childW + CARD_GAP_X); + var ty = startY + Math.floor(i / nCols) * (childH + CARD_GAP_Y); + targets.push({ node: child, position: { x: tx, y: ty } }); - // Recurse: lay out func children of any newly revealed type nodes. - visibleChildren.filter('[kind = "type"]').forEach(function (typeNode) { - if (typeNode.children().not('.hidden-node').length > 0) { - layoutChildrenOf(typeNode); + if (child.data('kind') === 'type') { + var ch = Math.max(28, child.height() || 28); + collectChildTargets(child, tx, ty, ch, targets); } }); } +// ── layoutChildrenOf ───────────────────────────────────────────────────── +// Positions the visible children of a compound node in a 1–2 column grid +// directly below the parent with smooth animations. +function layoutChildrenOf(parentNode) { + var px = parentNode.position('x') || 0; + var py = parentNode.position('y') || 0; + var ph = Math.max(28, parentNode.height() || 28); + + var targets = []; + collectChildTargets(parentNode, px, py, ph, targets); + + if (targets.length > 0) { + animateNodes(targets, 400); + } +} + // ── syncCallsEdges ─────────────────────────────────────────────────────── function syncCallsEdges() { cy.edges('[kind = "calls"]').forEach(function (e) { @@ -219,25 +332,12 @@ function syncCallsEdges() { // Calls Layout // ═══════════════════════════════════════════════════════════════════════════ // ── layoutComponentBFS ─────────────────────────────────────────────────── -// Positions func nodes within a component using compact vertical stacking. -// -// Algorithm: -// 1. Build callee adjacency from internal calls edges; compute in-degree. -// 2. BFS traversal from roots (in-degree 0 nodes) to produce a stable -// ordering — callers appear before their callees in the list. -// Cycle-only components seed all nodes at the start. -// 3. Place nodes in BFS order, top-to-bottom in 1 or 2 columns: -// – ≤8 func nodes → 1 column (prefer narrow) -// – >8 func nodes → 2 columns (still compact, not wide) -// GAP_Y = 20 px vertical | GAP_X = 36 px horizontal (2-col only) -// -// Positions are centred at x=0, starting at y=0. The caller translates -// the resulting bounding box into its panel slot. +// Returns a map of node positions stack within a component. function layoutComponentBFS(compNodes) { var funcNodes = compNodes.filter('[kind = "func"]'); - if (funcNodes.length === 0) { return; } + var positionsMap = {}; + if (funcNodes.length === 0) { return { posMap: positionsMap, nodeW: 80, nodeH: 28 }; } - // Build callee adjacency and in-degree within this component only. var callees = {}; var inDeg = {}; funcNodes.forEach(function (n) { callees[n.id()] = []; inDeg[n.id()] = 0; }); @@ -249,14 +349,12 @@ function layoutComponentBFS(compNodes) { } }); - // BFS traversal to build a stable ordering (callers before callees). var orderedIds = []; var visited = {}; var queue = []; funcNodes.forEach(function (n) { if (inDeg[n.id()] === 0) { queue.push(n.id()); visited[n.id()] = true; } }); - // Cycle-only component: seed every node so none are skipped. if (queue.length === 0) { funcNodes.forEach(function (n) { queue.push(n.id()); visited[n.id()] = true; }); } @@ -269,18 +367,16 @@ function layoutComponentBFS(compNodes) { if (!visited[tid]) { visited[tid] = true; queue.push(tid); } }); } - // Any nodes unreachable from roots (isolated within component). funcNodes.forEach(function (n) { if (!visited[n.id()]) { orderedIds.push(n.id()); } }); - // Compact vertical stacking: narrow over wide. + // Spacious vertical stacking: narrow over wide. var total = orderedIds.length; - var nCols = total > 8 ? 2 : 1; // 2 columns only for large components - var GAP_Y = 20; // compact vertical gap (~18–22 px) - var GAP_X = 36; // small horizontal gap (2-col only) + var nCols = total > 8 ? 2 : 1; + var GAP_Y = 40; // spacious vertical gap + var GAP_X = 80; // spacious horizontal gap - // Measure max card dimensions across all func nodes in this component. var nodeW = 80, nodeH = 28; orderedIds.forEach(function (id) { var n = cy.getElementById(id); @@ -288,42 +384,23 @@ function layoutComponentBFS(compNodes) { nodeH = Math.max(nodeH, n.height() || 28); }); - // Block centred at x=0; nodes fill top-to-bottom, left-to-right. var blockW = nCols * nodeW + (nCols - 1) * GAP_X; var startX = -blockW / 2 + nodeW / 2; orderedIds.forEach(function (id, i) { var c = i % nCols; var r = Math.floor(i / nCols); - cy.getElementById(id).position({ + positionsMap[id] = { x: startX + c * (nodeW + GAP_X), y: r * (nodeH + GAP_Y) + nodeH / 2, - }); + }; }); + + return { posMap: positionsMap, nodeW: nodeW, nodeH: nodeH }; } // ── runSpacedCallsLayout ───────────────────────────────────────────────── -// Calls-mode layout: irregular dense skyline (bottom-left) packing. -// -// Algorithm: -// 1. Detect file-level connected components via union-find over calls edges. -// 2. Run layoutComponentBFS() on each component (compact vertical stacking). -// Immediately capture the component's bounding box dimensions. -// 3. Skyline packing (irregular, non-grid): -// a. Sort tiles by area descending — largest tiles first, better fill. -// b. Compute target row width = max(widest tile, sqrt(totalArea) × 1.2). -// c. Maintain a "skyline" — a sorted list of {x, y} left-edge segments. -// Segment i covers [skyline[i].x, skyline[i+1].x) at height y. -// d. For each tile: try placing its left edge at every skyline breakpoint -// where x ≤ maxRowW. The effective placement y is the maximum skyline -// height over the tile's footprint. Choose minimum y; tiebreak: min x. -// e. Translate the component so its bbox top-left aligns to (bestX, bestY). -// f. Raise the skyline over [bestX, bestX + tileW + GAP) to bestY + tileH + GAP. -// 4. Fit all visible nodes with DETAIL_PADDING. -// -// Irregular placement arises naturally: tall components create high "peaks" in -// the skyline; shorter components fill the "valleys" beside them, producing a -// dense, tetris-like composition rather than a uniform grid. +// Calls-mode layout: Skyline packing with animations. function runSpacedCallsLayout() { var visibleNodes = cy.nodes(':visible'); if (visibleNodes.length === 0) { deferredFit(cy.nodes(), DETAIL_PADDING); return; } @@ -344,7 +421,6 @@ function runSpacedCallsLayout() { if (px !== py) { uf[px] = py; } } - // Traverse compound parent chain to find the owning file node. function fileAncestorOf(node) { var cur = node; while (cur && cur.length > 0) { @@ -364,7 +440,6 @@ function runSpacedCallsLayout() { } }); - // Group file IDs by their union-find root. var compGroups = {}; fileNodes.forEach(function (n) { var root = ufFind(n.id()); @@ -374,7 +449,6 @@ function runSpacedCallsLayout() { var groupArr = Object.values(compGroups); - // Helper: collect all cy nodes for a list of file IDs. function nodesForFiles(fileIds) { var col = cy.collection(); fileIds.forEach(function (fid) { @@ -391,31 +465,41 @@ function runSpacedCallsLayout() { groupArr.forEach(function (fileIds) { var compNodes = nodesForFiles(fileIds); - layoutComponentBFS(compNodes); - var bb = compNodes.boundingBox({ includeLabels: false }); - if (!bb || bb.w === 0) { return; } + var layoutResult = layoutComponentBFS(compNodes); + if (!layoutResult) { return; } + var posMap = layoutResult.posMap; + var nodeW = layoutResult.nodeW; + var nodeH = layoutResult.nodeH; + + var x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + var hasNodes = false; + Object.keys(posMap).forEach(function (id) { + hasNodes = true; + var pos = posMap[id]; + x1 = Math.min(x1, pos.x - nodeW / 2); + x2 = Math.max(x2, pos.x + nodeW / 2); + y1 = Math.min(y1, pos.y - nodeH / 2); + y2 = Math.max(y2, pos.y + nodeH / 2); + }); + + if (!hasNodes) { return; } + + var bb = { x1: x1, y1: y1, x2: x2, y2: y2, w: x2 - x1, h: y2 - y1 }; maxTileW = Math.max(maxTileW, bb.w); totalArea += (bb.w + TILE_GAP) * (bb.h + TILE_GAP); - tiles.push({ compNodes: compNodes, bb: bb }); + tiles.push({ compNodes: compNodes, posMap: posMap, bb: bb }); }); if (tiles.length === 0) { deferredFit(cy.nodes(), DETAIL_PADDING); return; } // ── Step 3: Skyline (bottom-left) packing ───────────────────────────── - // Largest area tiles first — harder to place later, better fill now. tiles.sort(function (a, b) { return (b.bb.w * b.bb.h) - (a.bb.w * a.bb.h); }); - // Target row width for a near-square, landscape-friendly composition. var maxRowW = Math.max(maxTileW, Math.sqrt(totalArea) * 1.2); - - // skyline: [{x, y}] sorted by x. - // Segment i covers the horizontal range [skyline[i].x, skyline[i+1].x) - // at height skyline[i].y. The final segment extends to +Infinity. var skyline = [{ x: 0, y: 0 }]; - // Max skyline height over the range [x1, x1 + w). function skyGetY(x1, w) { var x2 = x1 + w, maxY = 0; for (var si = 0; si < skyline.length; si++) { @@ -426,7 +510,6 @@ function runSpacedCallsLayout() { return maxY; } - // Skyline height at a single x point. function skyYAt(px) { for (var si = skyline.length - 1; si >= 0; si--) { if (skyline[si].x <= px) { return skyline[si].y; } @@ -434,10 +517,8 @@ function runSpacedCallsLayout() { return 0; } - // Raise the skyline over [x1, x1 + w) to newY. function skyRaise(x1, w, newY) { var x2 = x1 + w; - // Ensure segment boundaries exist at x1 and x2. [x1, x2].forEach(function (px) { if (skyline.every(function (s) { return s.x !== px; })) { var py = skyYAt(px), ins = false; @@ -450,11 +531,9 @@ function runSpacedCallsLayout() { if (!ins) { skyline.push({ x: px, y: py }); } } }); - // Raise all segments fully inside [x1, x2). for (var si = 0; si < skyline.length; si++) { if (skyline[si].x >= x1 && skyline[si].x < x2) { skyline[si].y = newY; } } - // Merge consecutive segments at the same height. var si = 0; while (si < skyline.length - 1) { if (skyline[si].y === skyline[si + 1].y) { @@ -463,13 +542,13 @@ function runSpacedCallsLayout() { } } + var targets = []; + tiles.forEach(function (t) { - var tw = t.bb.w + TILE_GAP; // footprint width (tile + trailing gap) - var th = t.bb.h + TILE_GAP; // footprint height (tile + trailing gap) + var tw = t.bb.w + TILE_GAP; + var th = t.bb.h + TILE_GAP; var bestX = 0, bestY = Infinity; - // Evaluate every skyline breakpoint as a candidate left-edge position. - // Skip positions beyond maxRowW to keep the composition bounded. for (var si = 0; si < skyline.length; si++) { var tryX = skyline[si].x; if (tryX > maxRowW) { break; } @@ -480,16 +559,20 @@ function runSpacedCallsLayout() { } if (bestY === Infinity) { bestX = 0; bestY = skyGetY(0, tw); } - // Align tile's bbox top-left to (bestX, bestY) via a uniform translation. var dx = bestX - t.bb.x1; var dy = bestY - t.bb.y1; - t.compNodes.positions(function (node) { - return { x: node.position('x') + dx, y: node.position('y') + dy }; + + Object.keys(t.posMap).forEach(function (id) { + var node = cy.getElementById(id); + var rx = t.posMap[id].x + dx; + var ry = t.posMap[id].y + dy; + targets.push({ node: node, position: { x: rx, y: ry } }); }); - // Raise the skyline over this tile's footprint (including trailing gap). skyRaise(bestX, tw, bestY + th); }); - deferredFit(cy.nodes(), 16); + animateNodes(targets, 500, function () { + deferredFit(cy.nodes(), DETAIL_PADDING); + }); } diff --git a/editor/vscode/webview/graph.modes.js b/editor/vscode/webview/graph.modes.js index d6ef58b..572fb60 100644 --- a/editor/vscode/webview/graph.modes.js +++ b/editor/vscode/webview/graph.modes.js @@ -326,13 +326,16 @@ function buildDetailElements(fileNodeId) { // Layout uses breadthfirst to build a proper tree structure function layoutDetailTypeNodes() { - cy.layout({ + cy.elements().filter('node, edge[kind = "contains"]').layout({ name: 'breadthfirst', directed: true, spacingFactor: 1.5, fit: true, padding: 60, - roots: cy.nodes('[kind = "file"]') + roots: cy.nodes('[kind = "file"]'), + animate: true, + animationDuration: 500, + animationEasing: 'ease-in-out-cubic' }).run(); } diff --git a/editor/vscode/webview/graph.state.js b/editor/vscode/webview/graph.state.js index ec00fe9..e340f48 100644 --- a/editor/vscode/webview/graph.state.js +++ b/editor/vscode/webview/graph.state.js @@ -93,6 +93,13 @@ function applyAnalysisData(a) { } }); + // Build parent mapping for removed nodes from removed contains edges + (diff.removed_edges ?? []).forEach(function (e) { + if (e.kind === 'contains') { + parentMap[e.to] = e.from; + } + }); + // Bubble up impact to all ancestor nodes (types, files) var newImpacts = []; impactIds.forEach(function (id) { @@ -156,17 +163,19 @@ function buildCyElements() { return !(graph.nodes ?? []).some(function (gn) { return gn.id === n.id; }); }) .map(function (n) { - return { - data: { - id: n.id, - label: (n.name ?? n.id) + ' ✕', - kind: n.kind ?? 'func', - uri: n.uri ?? '', - line: typeof n.line === 'number' ? n.line : 0, - diffState: 'removed', - impacted: false, - }, + const data = { + id: n.id, + label: (n.name ?? n.id) + ' ✕', + kind: n.kind ?? 'func', + uri: n.uri ?? '', + line: typeof n.line === 'number' ? n.line : 0, + diffState: 'removed', + impacted: false, }; + if (parentMap[n.id]) { + data.parent = parentMap[n.id]; + } + return { data: data }; }); // Non-contains edges → cytoscape edges