diff --git a/.gitignore b/.gitignore index bf21e77..5aa5585 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ node_modules/ # The demo's AGENTS.md is rewritten by every run. examples/demo/loopfinder/AGENTS.md __pycache__/ +# The VS Code extension's copy of the CLI and the licenses (vscode/scripts/prepare.js), and its packages. +vscode/core/ +vscode/LICENSE* +vscode/*.vsix diff --git a/public/app.js b/public/app.js index faeb88d..2cbe3a9 100644 --- a/public/app.js +++ b/public/app.js @@ -11,6 +11,10 @@ let flowPaths = new Map(); // "from\u0000to" -> drawn path d (the track dots mov let dotLayer = null; let dotsFor; // which loop the moving dots belong to (picking a node does not redraw them) +// Set by the VS Code extension (vscode/media/host.js): it hands over the graph, sends a new one after +// every build, and opens a file in the editor. In a browser it is absent and flow.json is fetched. +const host = window.loopfinderHost || null; + // Dots moving along edges. Away from the picked loop they are faint and sparse, so the screen stays calm. const DOT_SPEED = 40; // px per second const DOT_TRAVEL = [1.5, 5]; // seconds to cross one edge (min, max), so long arcs are not always busy @@ -291,16 +295,24 @@ function renderDetail() { k.textContent = (n.kind === 'step' ? t('stepOf', { actor: n.actor }) : t(`kind_${n.kind}`)) + ((n.originIn || []).includes(current) ? t('originSuffix') : '') + (n.deadEnd ? t('deadEndSuffix') : '') + (n.intendedEnd ? t('intendedEndSuffix', { why: n.intendedEnd }) : ''); box.appendChild(k); const dl = document.createElement('dl'); - const add = (term, values) => { + // With a host that can open files, paths become buttons; the host decides whether the path is a file. + const value = (parent, v, openable) => { + if (!openable || !host?.open) { parent.textContent = v; return; } + const b = document.createElement('button'); + b.type = 'button'; b.className = 'open-file'; b.textContent = v; b.title = t('openFile'); + b.addEventListener('click', () => host.open(v)); + parent.appendChild(b); + }; + const add = (term, values, openable = false) => { if (!values || values.length === 0) return; const dt = document.createElement('dt'); dt.textContent = term; dl.appendChild(dt); const dd = document.createElement('dd'); - if (values.length === 1) dd.textContent = values[0]; - else { const ul = document.createElement('ul'); for (const v of values) { const li = document.createElement('li'); li.textContent = v; ul.appendChild(li); } dd.appendChild(ul); } + if (values.length === 1) value(dd, values[0], openable); + else { const ul = document.createElement('ul'); for (const v of values) { const li = document.createElement('li'); value(li, v, openable); ul.appendChild(li); } dd.appendChild(ul); } dl.appendChild(dd); }; - add(t('d_location'), n.path ? [n.path] : null); - add(t('d_files'), n.files); + add(t('d_location'), n.path ? [n.path] : null, n.kind === 'script' || n.kind === 'file'); + add(t('d_files'), n.files, true); add(t('d_frame'), n.groupBy?.[current] ? [labelOf(n.groupBy[current])] : null); // Reads folded into the scan node: only counts per top-level folder are kept. add(t('d_folded'), n.foldedBy?.[current] ? Object.entries(n.foldedBy[current]).sort((a, b) => b[1] - a[1]).map(([f, c]) => `${f} ${t('d_count', { n: c })}`) : null); @@ -336,14 +348,10 @@ function fillIcons() { async function main() { fillIcons(); - graph = await (await fetch('flow.json', { cache: 'no-store' })).json(); + graph = host ? await host.load() : await (await fetch('flow.json', { cache: 'no-store' })).json(); pickLang(graph.lang); const select = document.getElementById('flow'); - for (const f of graph.flows) { - const o = document.createElement('option'); - o.value = f.name; o.textContent = f.name; - select.appendChild(o); - } + fillFlows(select); // #flow= opens that flow const fm = /flow=([^&]+)/.exec(location.hash); const want = fm && decodeURIComponent(fm[1]); @@ -354,6 +362,30 @@ async function main() { const m = /loop=(\d+)/.exec(location.hash); if (m && graph.loops.some(l => l.id === Number(m[1]) && l.flow === current)) selectedLoop = Number(m[1]); document.getElementById('lang-toggle').addEventListener('click', () => { setLang(LANG === 'ja' ? 'en' : 'ja'); renderAll(); }); + host?.onGraph(replaceGraph); + renderAll(); +} + +function fillFlows(select) { + select.textContent = ''; + for (const f of graph.flows) { + const o = document.createElement('option'); + o.value = f.name; o.textContent = f.name; + select.appendChild(o); + } +} + +// A rebuilt graph keeps the flow on screen. Loop ids are renumbered by every build, so the picked loop +// is kept only if a loop with the same name is still there; the picked node is kept if it still exists. +function replaceGraph(next) { + const loopName = graph.loops.find(l => l.id === selectedLoop)?.name; + graph = next; + const select = document.getElementById('flow'); + fillFlows(select); + if (!graph.flows.some(f => f.name === current)) current = graph.flows[0]?.name; + select.value = current; + selectedLoop = loopName ? graph.loops.find(l => l.flow === current && l.name === loopName)?.id ?? null : null; + if (!graph.nodes.some(n => n.id === selectedNode)) selectedNode = null; renderAll(); } @@ -470,7 +502,8 @@ const zoom = (() => { })(); // Theme: auto (follow the system) -> light -> dark -> auto. The choice is kept per browser. -// Auto removes the attribute, so the CSS media query decides. +// Auto removes the attribute, so the CSS media query decides. Inside VS Code, auto follows the editor's +// theme instead, which the media query does not see. const themeToggle = (() => { const KEY = 'loopfinder:theme'; const ORDER = ['auto', 'light', 'dark']; @@ -478,9 +511,11 @@ const themeToggle = (() => { let theme = 'auto'; try { theme = ORDER.includes(localStorage.getItem(KEY)) ? localStorage.getItem(KEY) : 'auto'; } catch { /* storage unavailable */ } const apply = () => { - if (theme === 'auto') document.documentElement.removeAttribute('data-theme'); - else document.documentElement.setAttribute('data-theme', theme); + const shown = theme === 'auto' ? host?.theme?.() : theme; + if (shown) document.documentElement.setAttribute('data-theme', shown); + else document.documentElement.removeAttribute('data-theme'); }; + host?.onTheme?.(apply); const ICON = { auto: 'sun-moon', light: 'sun', dark: 'moon' }; const refresh = () => { const name = t(`theme_${theme}`); diff --git a/public/i18n.js b/public/i18n.js index 181d4d7..e6df516 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -39,6 +39,7 @@ const STRINGS = { staleEnds: 'Declared as an end, but read now or no longer written: {list}', d_location: 'Location', d_files: 'Files read or written', + openFile: 'Open in the editor', d_frame: 'Frame', d_folded: 'Folded reads', d_urls: 'Fetched from (examples)', @@ -101,6 +102,7 @@ const STRINGS = { staleEnds: '末端と宣言したのに、読まれている・もう書かれていないもの: {list}', d_location: '場所', d_files: '読み書きしたファイル', + openFile: 'エディタで開く', d_frame: '枠', d_folded: '畳んだ読み', d_urls: '取得先の例', diff --git a/public/style.css b/public/style.css index 7e0c6b6..d0efc76 100644 --- a/public/style.css +++ b/public/style.css @@ -180,6 +180,12 @@ body { .detail dt { color: var(--fg-faint); font-size: 12px; margin-top: 8px; } .detail dd { margin: 2px 0 0; color: var(--fg-muted); word-break: break-all; } .detail ul { margin: 2px 0 0; padding-left: 16px; } +.detail .open-file { + font: inherit; color: inherit; text-align: left; word-break: break-all; + background: none; border: 0; padding: 0; cursor: pointer; + text-decoration: underline; text-decoration-color: var(--fg-faint); text-underline-offset: 2px; +} +.detail .open-file:hover { color: var(--fg); } /* ---- graph ---- */ .edge { stroke: var(--line-strong); stroke-width: 1.2; fill: none; } diff --git a/vscode/.vscodeignore b/vscode/.vscodeignore new file mode 100644 index 0000000..10d1f3b --- /dev/null +++ b/vscode/.vscodeignore @@ -0,0 +1,3 @@ +scripts/** +**/*.vsix +**/__pycache__/** diff --git a/vscode/README.md b/vscode/README.md new file mode 100644 index 0000000..20de74f --- /dev/null +++ b/vscode/README.md @@ -0,0 +1,33 @@ +# loopfinder for VS Code + +**Find the feedback loops in your workspace — including the ones you never meant to build.** + +The [loopfinder](https://github.com/MotimotiNotch/loopfinder) CLI and viewer, inside the editor. No Node install +needed: it runs on the editor's own runtime (Python is needed only to trace `.py` scripts). + +## Use + +From the Command Palette: + +1. **loopfinder: Set up this workspace** — makes `loopfinder/` (config.json, flows.json, AGENTS.md). +2. Ask your AI agent (Claude Code, Codex, Cursor, Copilot, ...): + *"Read loopfinder/AGENTS.md and survey this workspace."* + (**loopfinder: Copy the request for your AI agent** puts it on the clipboard.) It writes `loopfinder/flows.json`. +3. **loopfinder: Build the graph** — runs the traced scripts with writes blocked and draws the graph. +4. **loopfinder: Show the loops** — opens the viewer. It redraws by itself after every build, from the + palette or from `loopfinder build` in a terminal. Click a path in the side pane to open the file. + +## Safety + +Building **really runs your scripts**. Writes are recorded and dropped; network and subprocesses are blocked +unless you turn on `loopfinder.allowNetwork` / `loopfinder.allowSubprocess`. It is a recorder, not a sandbox: +do not trace code you would not run. + +In a workspace you have not [trusted](https://code.visualstudio.com/docs/editor/workspace-trust), set up and +build are off; you can still view a graph that is already built. + +`.loopfinder/` (the build output) lists your file paths. Keep it out of version control. + +## Support + +[Ko-fi](https://ko-fi.com/motimotinotch) · MIT License · bundles dagre (MIT) and Lucide icons (ISC) diff --git a/vscode/extension.js b/vscode/extension.js new file mode 100644 index 0000000..09d26ff --- /dev/null +++ b/vscode/extension.js @@ -0,0 +1,196 @@ +// loopfinder inside VS Code: the same CLI and the same viewer, with the viewer in a webview. +// Init and build run the CLI as a child process (build really runs the workspace's scripts, so both are +// off in an untrusted workspace). Show only reads the built graph, and works anywhere. +'use strict'; +const vscode = require('vscode'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { fork } = require('child_process'); + +// The packaged extension carries a copy of the CLI in core/ (scripts/prepare.js). +// Run from the repository (Extension Development Host), it uses the repository itself. +const CORE = fs.existsSync(path.join(__dirname, 'core', 'bin')) ? path.join(__dirname, 'core') : path.join(__dirname, '..'); +const CLI = path.join(CORE, 'bin', 'loopfinder.js'); +const PUBLIC = path.join(CORE, 'public'); +const { loadConfig } = require(path.join(CORE, 'src', 'config')); + +const PROMPT = 'Read loopfinder/AGENTS.md and survey this workspace.'; +const configOf = folder => path.join(folder, 'loopfinder', 'config.json'); + +let output; +let viewer = null; // { panel, folder, config, watcher } + +// The workspace folder to work on. Folders without loopfinder/ are left out unless setting one up. +async function pickFolder(needConfig) { + const folders = (vscode.workspace.workspaceFolders || []).filter(f => f.uri.scheme === 'file'); + const candidates = needConfig ? folders.filter(f => fs.existsSync(configOf(f.uri.fsPath))) : folders; + if (!candidates.length) { + if (!folders.length) vscode.window.showWarningMessage('loopfinder: open a folder first.'); + else { + const init = 'Set up'; + const choice = await vscode.window.showWarningMessage('loopfinder: no loopfinder/config.json in this workspace.', ...(vscode.workspace.isTrusted ? [init] : [])); + if (choice === init) vscode.commands.executeCommand('loopfinder.init'); + } + return null; + } + if (candidates.length === 1) return candidates[0].uri.fsPath; + const pick = await vscode.window.showQuickPick(candidates.map(f => ({ label: f.name, description: f.uri.fsPath })), { placeHolder: 'Which folder?' }); + return pick ? pick.description : null; +} + +// Runs the CLI with the editor's own Node (ELECTRON_RUN_AS_NODE), so no Node install is needed. +// The traced scripts inherit it the same way. +function runCli(folder, args, token) { + return new Promise(resolve => { + output.appendLine(`> loopfinder ${args.join(' ')} (${folder})`); + const child = fork(CLI, args, { cwd: folder, silent: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }); + child.stdout.on('data', d => output.append(d.toString())); + child.stderr.on('data', d => output.append(d.toString())); + const cancel = token?.onCancellationRequested(() => child.kill()); + child.on('error', e => { output.appendLine(e.message); }); + child.on('close', code => { cancel?.dispose(); output.appendLine(`(exit ${code})`); resolve(code); }); + }); +} + +// `enablement` only greys out the menus; a command run from code still arrives here. +function trusted() { + if (vscode.workspace.isTrusted) return true; + vscode.window.showWarningMessage('loopfinder: trust this workspace first. Building runs its scripts.'); + return false; +} + +async function cmdInit() { + if (!trusted()) return; + const folder = await pickFolder(false); + if (!folder) return; + const code = await runCli(folder, ['init']); + if (code !== 0) { showFailure('init'); return; } + const copy = 'Copy the request'; + const choice = await vscode.window.showInformationMessage( + `loopfinder: set up. Next, ask your AI agent: "${PROMPT}" It writes loopfinder/flows.json; then build.`, copy); + if (choice === copy) cmdCopyPrompt(); +} + +function cmdCopyPrompt() { + vscode.env.clipboard.writeText(PROMPT); + vscode.window.setStatusBarMessage('loopfinder: copied the request for your AI agent', 4000); +} + +async function cmdBuild() { + if (!trusted()) return; + const folder = await pickFolder(true); + if (!folder) return; + const settings = vscode.workspace.getConfiguration('loopfinder', vscode.Uri.file(folder)); + const args = ['build', '--config', configOf(folder), '--budget', String(settings.get('layoutBudget', 15))]; + if (settings.get('allowNetwork')) args.push('--allow-network'); + if (settings.get('allowSubprocess')) args.push('--allow-subprocess'); + const code = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'loopfinder: tracing the scripts and building the graph', cancellable: true }, + (_, token) => runCli(folder, args, token)); + if (code !== 0) { showFailure('build'); return; } + // An open viewer picks the new graph up through its watcher. + if (!viewer || viewer.folder !== folder) show(folder); +} + +async function showFailure(what) { + const open = 'Show output'; + if (await vscode.window.showErrorMessage(`loopfinder: ${what} failed.`, open) === open) output.show(); +} + +async function cmdShow() { + const folder = await pickFolder(true); + if (folder) show(folder); +} + +function readGraph(config) { + try { return JSON.parse(fs.readFileSync(config.output, 'utf8')); } catch { return null; } +} + +async function show(folder) { + const config = loadConfig(configOf(folder)); + if (!fs.existsSync(config.output)) { + const build = 'Build'; + const choice = await vscode.window.showInformationMessage('loopfinder: no graph yet. Build it first.', ...(vscode.workspace.isTrusted ? [build] : [])); + if (choice === build) cmdBuild(); + return; + } + if (viewer && viewer.folder === folder) { viewer.panel.reveal(); return; } + viewer?.panel.dispose(); + + const panel = vscode.window.createWebviewPanel('loopfinder', `loopfinder: ${path.basename(folder)}`, vscode.ViewColumn.Active, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(PUBLIC), vscode.Uri.file(path.join(__dirname, 'media'))], + }); + panel.webview.html = viewerHtml(panel.webview); + const send = () => { + const graph = readGraph(config); + // A half-written file fails to parse; the watcher fires again when the write finishes. + if (graph) panel.webview.postMessage({ type: 'graph', graph }); + }; + panel.webview.onDidReceiveMessage(m => { + if (m?.type === 'ready') send(); + else if (m?.type === 'open' && typeof m.path === 'string') openPath(config, m.path); + }); + + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(path.dirname(config.output)), path.basename(config.output))); + let timer; + const later = () => { clearTimeout(timer); timer = setTimeout(send, 300); }; + watcher.onDidChange(later); + watcher.onDidCreate(later); + + viewer = { panel, folder, config, watcher }; + panel.onDidDispose(() => { + watcher.dispose(); + clearTimeout(timer); + if (viewer?.panel === panel) viewer = null; + }); +} + +// Opens a node's path in the editor. Paths in the graph are relative to the root, or start with ~/. +// Folded names ({date}, the scan node) are not one file and are said so. +function openPath(config, p) { + const file = p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(config.root, p); + let stat = null; + try { stat = fs.statSync(file); } catch { /* not there */ } + if (stat?.isFile()) vscode.commands.executeCommand('vscode.open', vscode.Uri.file(file), { preview: true, viewColumn: vscode.ViewColumn.Beside }); + else if (stat?.isDirectory()) vscode.commands.executeCommand('revealInExplorer', vscode.Uri.file(file)); + else vscode.window.showInformationMessage(`loopfinder: not a single file: ${p}`); +} + +// The viewer's own index.html, with its files served through the webview and a CSP that allows only them. +function viewerHtml(webview) { + const nonce = crypto.randomBytes(16).toString('base64'); + const uri = (...parts) => webview.asWebviewUri(vscode.Uri.file(path.join(...parts))).toString(); + const csp = [ + "default-src 'none'", + `style-src ${webview.cspSource}`, + `img-src ${webview.cspSource} data:`, + `script-src 'nonce-${nonce}'`, + ].join('; '); + return fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8') + .replace(/(href|src)="(?!https?:|#)([^"]+)"/g, (_, attr, file) => `${attr}="${uri(PUBLIC, file)}"`) + .replace(/\n