diff --git a/editor/vscode/src/graphView.ts b/editor/vscode/src/graphView.ts index 41a75cc..cbb8c25 100644 --- a/editor/vscode/src/graphView.ts +++ b/editor/vscode/src/graphView.ts @@ -249,6 +249,18 @@ export class GraphView { const jsUri = webview.asWebviewUri( vscode.Uri.file(path.join(context.extensionPath, 'webview', 'graph.js')) ); + const stateJsUri = webview.asWebviewUri( + vscode.Uri.file(path.join(context.extensionPath, 'webview', 'graph.state.js')) + ); + const layoutsJsUri = webview.asWebviewUri( + vscode.Uri.file(path.join(context.extensionPath, 'webview', 'graph.layouts.js')) + ); + const modesJsUri = webview.asWebviewUri( + vscode.Uri.file(path.join(context.extensionPath, 'webview', 'graph.modes.js')) + ); + const eventsJsUri = webview.asWebviewUri( + vscode.Uri.file(path.join(context.extensionPath, 'webview', 'graph.events.js')) + ); const cyUri = webview.asWebviewUri( vscode.Uri.file(path.join(context.extensionPath, 'node_modules', 'cytoscape', 'dist', 'cytoscape.min.js')) @@ -263,6 +275,10 @@ export class GraphView { .readFileSync(htmlPath, 'utf8') .replace('{{CSP_SOURCE}}', webview.cspSource) .replace('{{GRAPH_JS_URI}}', jsUri.toString()) + .replace('{{GRAPH_STATE_JS_URI}}', stateJsUri.toString()) + .replace('{{GRAPH_LAYOUTS_JS_URI}}', layoutsJsUri.toString()) + .replace('{{GRAPH_MODES_JS_URI}}', modesJsUri.toString()) + .replace('{{GRAPH_EVENTS_JS_URI}}', eventsJsUri.toString()) .replace('{{CY_JS_URI}}', cyUri.toString()) .replace('{{GRAPH_DATA}}', safeJson); } diff --git a/editor/vscode/webview/graph.events.js b/editor/vscode/webview/graph.events.js new file mode 100644 index 0000000..385ab82 --- /dev/null +++ b/editor/vscode/webview/graph.events.js @@ -0,0 +1,348 @@ +'use strict'; + +function registerFlowMapEvents() { + // ── Tap handler ────────────────────────────────────────────────────────── + cy.on('tap', 'node', function (evt) { + const node = evt.target; + const kind = node.data('kind'); + + cy.elements().removeClass('highlighted dimmed'); + + if (state.mode === 'overview') { + // File card: drill into file-detail mode + if (kind === 'file') { + // Use the stored raw graph ID — not the display node id which may differ + const rawId = node.data('rawFileNodeId') || node.id(); + console.log( + '[FlowMapDebug] file card clicked: nodeId=' + node.id() + + ' rawFileNodeId=' + rawId + + ' label=' + node.data('fullLabel') + ); + showFileDetail(rawId); + return; // navigation handled inside showFileDetail + } + // Folder: toggle collapse/expand of its file children + if (kind === 'folder') { + toggleFolderExpand(node.id()); + return; + } + // Root: re-fit the whole overview + if (kind === 'root') { + deferredFit(cy.nodes(':visible'), 60); + return; + } + + } else if (state.mode === 'file-detail') { + // Detail view: type expands/collapses its func children. + // Func highlights its outgoing call edges (if any). + if (kind === 'type') { + toggleExpand(node.id()); + } else if (kind === 'func') { + const callEdges = node.outgoers('edge').filter('[kind = "calls"]'); + const callTargets = callEdges.targets(); + if (callEdges.length > 0) { + cy.elements().addClass('dimmed'); + node.removeClass('dimmed').addClass('highlighted'); + callTargets.removeClass('dimmed').addClass('highlighted'); + callEdges.removeClass('dimmed').addClass('highlighted'); + } + } + + } else { + // Calls mode: full compound graph — file/type toggle; func highlights calls. + if (kind === 'file' || kind === 'type') { + toggleExpand(node.id()); + } else if (kind === 'func') { + const callEdges = node.outgoers('edge').filter('[kind = "calls"]'); + const callTargets = callEdges.targets(); + if (callEdges.length > 0) { + cy.elements().addClass('dimmed'); + node.removeClass('dimmed').addClass('highlighted'); + callTargets.removeClass('dimmed').addClass('highlighted'); + callEdges.removeClass('dimmed').addClass('highlighted'); + } + } + } + + // Navigate to source for all modes (except files mode which returns early) + const uri = node.data('uri'); + const line = node.data('line'); + if (uri) { + vscodeApi.postMessage({ command: 'openFile', uri: uri, line: line }); + } + }); + + cy.on('tap', function (evt) { + if (evt.target === cy) { + cy.elements().removeClass('highlighted dimmed search-highlight'); + } + }); + + // ── Hover glow for file cards and folder nodes ────────────────────────── + cy.on('mouseover', 'node[kind = "file"]', function (evt) { + evt.target.addClass('file-hover'); + }); + cy.on('mouseout', 'node[kind = "file"]', function (evt) { + evt.target.removeClass('file-hover'); + }); + cy.on('mouseover', 'node[kind = "folder"]', function (evt) { + evt.target.addClass('folder-hover'); + }); + cy.on('mouseout', 'node[kind = "folder"]', function (evt) { + evt.target.removeClass('folder-hover'); + }); + + // ── Tooltip: show full label when the display label was truncated ──────── + (function initTooltip() { + var tooltipEl = document.getElementById('cy-tooltip'); + if (!tooltipEl) { return; } + + cy.on('mouseover', 'node', function (evt) { + var node = evt.target; + var full = node.data('fullLabel'); + var shown = node.data('label'); + // Only show tooltip when the label was truncated + if (full && shown && full !== shown) { + tooltipEl.textContent = full; + tooltipEl.style.display = 'block'; + } + }); + + cy.on('mouseout', 'node', function () { + tooltipEl.style.display = 'none'; + }); + + // Track the mouse position so the tooltip follows the cursor + document.getElementById('cy').addEventListener('mousemove', function (e) { + if (tooltipEl.style.display === 'block') { + tooltipEl.style.left = (e.clientX + 14) + 'px'; + tooltipEl.style.top = (e.clientY - 30) + 'px'; + } + }); + })(); + + (function initToolbar() { + const overviewBtn = document.getElementById('btn-overview'); + const backBtn = document.getElementById('btn-back'); + const callsBtn = document.getElementById('btn-calls'); + const fitBtn = document.getElementById('btn-fit'); + const searchInput = document.getElementById('search-input'); + + // ── Overview button ───────────────────────────────────────────────── + // Returns to the top-level flat file-card overview. + // Works from any mode (file-detail, calls) — rebuilds cy from scratch. + if (overviewBtn) { + overviewBtn.addEventListener('click', function () { + state.searchQuery = ''; + if (searchInput) { searchInput.value = ''; } + cy.elements().removeClass('highlighted dimmed search-highlight'); + renderOverview(); + }); + } + + // ── Back button ───────────────────────────────────────────────────── + // Navigates back from file-detail to overview. + // Only visible when state.mode === 'file-detail'. + if (backBtn) { + backBtn.addEventListener('click', function () { + state.searchQuery = ''; + if (searchInput) { searchInput.value = ''; } + cy.elements().removeClass('highlighted dimmed search-highlight'); + renderOverview(); + }); + } + + // ── Calls button ──────────────────────────────────────────────────── + // Shows the full compound graph (file→type→func) with call edges. + // Rebuilds cy from the raw analysis data so the compound structure is + // always present, regardless of which mode was active before. + // All children are visible in calls mode — compound bbox cannot collapse. + if (callsBtn) { + callsBtn.addEventListener('click', function () { + state.mode = 'calls'; + updateToolbarForMode('calls'); + state.searchQuery = ''; + if (searchInput) { searchInput.value = ''; } + + // Rebuild the full compound structure (type/func as compound children). + const elements = buildCyElements(); + cy.elements().remove(); + cy.add({ + nodes: [...elements.cyNodes, ...elements.phantomNodes], + edges: [...elements.cyEdges, ...elements.phantomEdges], + }); + + // Show everything — no hidden nodes in calls mode. + cy.nodes().removeClass('hidden-node'); + cy.edges().removeClass('hidden-edge'); + cy.elements().removeClass('highlighted dimmed search-highlight'); + + // Spacious force-directed layout: detects connected components and + // arranges them in a grid with generous spacing between components. + runSpacedCallsLayout(); + }); + } + + // ── Fit button ────────────────────────────────────────────────────── + // Fits the viewport to all currently visible nodes. + // + // Bug this fixes (PR8.2): + // cy.fit() was called synchronously; if called during a search or + // immediately after a mode switch, styles may not be flushed yet and + // the computed bbox can be wrong → fit appears to do nothing or + // actually zooms out to an empty region. + // + // Fix: double-rAF guarantees style flush before fit. + if (fitBtn) { + fitBtn.addEventListener('click', function () { + deferredFit(cy.nodes(), 80); + }); + } + + // ── Search ────────────────────────────────────────────────────────── + // Substring match on node labels (case-insensitive). + // Behaviour is mode-aware: + // files mode: highlight matching file cards; also scan raw graph + // data so type/func name matches highlight their + // parent file card. + // file-detail mode: reveal hidden func children if they match. + // calls mode: reveal hidden ancestors (existing compound logic). + if (searchInput) { + searchInput.addEventListener('input', function () { + const query = searchInput.value.trim().toLowerCase(); + state.searchQuery = query; + cy.nodes().removeClass('search-highlight'); + + if (!query) { return; } + + if (state.mode === 'overview') { + // ── Overview mode: file label, folder name, type/func → file ──── + var matchIds = new Set(); + + // File node matches (check fullLabel for truncated display labels) + cy.nodes('[kind = "file"]').forEach(function (n) { + var lbl = (n.data('fullLabel') || n.data('label') || '').toLowerCase(); + if (lbl.includes(query)) { matchIds.add(n.id()); } + }); + + // Folder name matches → highlight folder + all its child file nodes + cy.nodes('[kind = "folder"]').forEach(function (n) { + var key = (n.data('folderKey') || '').toLowerCase(); + var lbl = (n.data('fullLabel') || n.data('label') || '').toLowerCase(); + if (key.includes(query) || lbl.includes(query)) { + matchIds.add(n.id()); + cy.edges('[kind = "branch"]').filter(function (e) { + return e.source().id() === n.id(); + }).targets().forEach(function (f) { matchIds.add(f.id()); }); + } + }); + + // Indirect: type/func label matches in raw data → highlight parent file + (graph.nodes ?? []).forEach(function (rawNode) { + if (rawNode.kind !== 'type' && rawNode.kind !== 'func') { return; } + if (!(rawNode.name ?? rawNode.id).toLowerCase().includes(query)) { return; } + var curr = rawNode.id; + for (var hop = 0; hop < 4; hop++) { + var par = parentMap[curr]; + if (!par) { break; } + var parRaw = (graph.nodes ?? []).find(function (pn) { return pn.id === par; }); + if (parRaw && parRaw.kind === 'file') { matchIds.add(par); break; } + curr = par; + } + }); + + if (matchIds.size === 0) { return; } + + var fileMatches = cy.nodes().filter(function (n) { + return matchIds.has(n.id()); + }); + fileMatches.addClass('search-highlight'); + deferredFit(fileMatches, 80); + + } else { + // ── File-detail / Calls mode: reveal hidden ancestors ──────────── + const matches = cy.nodes().filter(function (n) { + return n.data('label').toLowerCase().includes(query); + }); + + if (matches.length === 0) { return; } + + // Track which compound parents had children revealed + const affectedParents = new Set(); + + matches.forEach(function (n) { + n.removeClass('hidden-node'); + + // Walk up the parent chain (func → type, max 3 hops) + let curr = n; + for (let depth = 0; depth < 3; depth++) { + const par = curr.parent(); + if (!par || par.length === 0) { break; } + par.removeClass('hidden-node'); + affectedParents.add(par.id()); + curr = par; + } + }); + + // Position newly revealed children near their parent + affectedParents.forEach(function (parentId) { + layoutChildrenOf(cy.getElementById(parentId)); + }); + + matches.addClass('search-highlight'); + deferredFit(matches, 80); + } + }); + } + + // ── Analyze button (in empty-state panel) ─────────────────────────── + // Clicking this tells the extension to run the analyzeWorkspace command, + // which will call GraphView.show() and rebuild the HTML with real data. + const analyzeBtn = document.getElementById('btn-analyze'); + if (analyzeBtn) { + analyzeBtn.addEventListener('click', function () { + vscodeApi.postMessage({ command: 'flowmap.runAnalyze' }); + }); + } + })(); + + // ═══════════════════════════════════════════════════════════════════════════ + // Message handler + // ═══════════════════════════════════════════════════════════════════════════ + window.addEventListener('message', function (event) { + const msg = event.data; + if (!msg) { return; } + + // ── Live license badge update ──────────────────────────────────────── + if (msg.command === 'updateLicenseStatus') { + applyLicenseBadge(msg.status ?? 'free'); + } + + // ── Analysis handshake response ────────────────────────────────────── + // Extension responds to our flowmap.requestAnalysisState postMessage. + // analysis non-null → render graph with cached data + // analysis null → show the analyze prompt (no analysis exists yet) + if (msg.command === 'flowmap.analysisState') { + const analysis = msg.analysis; + console.log( + '[FlowMapDebug] received flowmap.analysisState: ' + + 'null=' + (analysis === null || analysis === undefined) + + ', analysis.graph?.nodes=' + (analysis && analysis.graph ? (analysis.graph.nodes || []).length : 'n/a') + + ', analysis.payload?.graph?.nodes=' + (analysis && analysis.payload && analysis.payload.graph ? (analysis.payload.graph.nodes || []).length : 'n/a') + ); + + if (analysis) { + // Part 3: guard for cy-not-ready (edge case — message could theoretically + // arrive before the cy block finishes if the event loop permits it) + if (cyReady) { + renderGraphFromAnalysis(analysis); + } else { + console.log('[FlowMapDebug] cy not ready yet — storing as pendingAnalysis'); + pendingAnalysis = analysis; + } + } else { + showAnalyzePrompt(); + } + } + }); +} diff --git a/editor/vscode/webview/graph.html b/editor/vscode/webview/graph.html index 74bd382..d8cbcaf 100644 --- a/editor/vscode/webview/graph.html +++ b/editor/vscode/webview/graph.html @@ -248,6 +248,10 @@
+ + + +