Skip to content

Commit 74df7f4

Browse files
committed
UX improvements
1 parent 3b7d7b1 commit 74df7f4

File tree

5 files changed

+168
-100
lines changed

5 files changed

+168
-100
lines changed

package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@
129129
"when": "resourceExtname == .json || resourceExtname == .sol || resourceExtname == .vy",
130130
"group": "2_workspace"
131131
},
132+
{
133+
"command": "recon.previewArgusCallGraphHere",
134+
"when": "resourceExtname == .sol",
135+
"group": "2_workspace"
136+
},
132137
{
133138
"command": "recon.cleanupCoverageReport",
134139
"when": "resourceExtname == .html && (resourcePath =~ /echidna/ || resourcePath =~ /medusa/ || resourcePath =~ /halmos/)",
@@ -268,6 +273,12 @@
268273
"category": "Recon",
269274
"icon": "$(graph)"
270275
},
276+
{
277+
"command": "recon.previewArgusCallGraphHere",
278+
"title": "Preview Argus Call Graph",
279+
"category": "Recon",
280+
"icon": "$(graph)"
281+
},
271282
{
272283
"command": "recon.buildWithInfo",
273284
"title": "Forge Build (with build-info)",
@@ -462,4 +473,4 @@
462473
"solc-typed-ast": "^18.2.5",
463474
"yaml": "^2.8.0"
464475
}
465-
}
476+
}

src/argus/argusEditorProvider.ts

Lines changed: 71 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import * as vscode from 'vscode';
22
import { generateCallGraph } from './generateCallGraph';
33

44
interface ArgusSettings {
5-
includeAll: boolean; // formerly --all
6-
includeDeps: boolean; // formerly --libs
5+
includeAll: boolean; // formerly --all
6+
includeDeps: boolean; // formerly --libs
77
}
88

99
/**
@@ -12,14 +12,14 @@ interface ArgusSettings {
1212
* Later we will integrate the real processing pipeline from processor.ts (processCompilerOutput) adapted for single-file focus.
1313
*/
1414
export class ArgusCallGraphEditorProvider implements vscode.CustomTextEditorProvider {
15-
public static readonly viewType = 'recon.argusCallGraph';
15+
public static readonly viewType = 'recon.argusCallGraph';
1616

17-
constructor(private readonly context: vscode.ExtensionContext) {}
17+
constructor(private readonly context: vscode.ExtensionContext) { }
1818

19-
async resolveCustomTextEditor(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel): Promise<void> {
20-
webviewPanel.webview.options = {
21-
enableScripts: true,
22-
};
19+
async resolveCustomTextEditor(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel): Promise<void> {
20+
webviewPanel.webview.options = {
21+
enableScripts: true,
22+
};
2323

2424
const settings: ArgusSettings = { includeAll: false, includeDeps: false };
2525
let genToken = 0;
@@ -33,39 +33,42 @@ export class ArgusCallGraphEditorProvider implements vscode.CustomTextEditorProv
3333
includeAll: settings.includeAll,
3434
includeDeps: settings.includeDeps
3535
});
36-
if (token !== genToken) return; // stale generation
36+
if (token !== genToken) { return; } // stale generation
3737
lastPrimaryContract = result.primaryContractName || lastPrimaryContract;
38-
webviewPanel.webview.html = this.getHtml(webviewPanel.webview, document, settings, result.html);
38+
webviewPanel.webview.html = this.getHtml(webviewPanel.webview, document, settings, result.html);
3939
};
4040
const scheduleUpdate = debounce(updateWebview, 300);
4141

42-
// Listen for document changes to refresh preview (future: incremental regen)
43-
const changeSub = vscode.workspace.onDidChangeTextDocument(e => {
44-
if (e.document.uri.toString() === document.uri.toString()) {
42+
// Listen for document changes to refresh preview (future: incremental regen)
43+
const changeSub = vscode.workspace.onDidChangeTextDocument(e => {
44+
if (e.document.uri.toString() === document.uri.toString()) {
4545
scheduleUpdate();
46-
}
47-
});
48-
webviewPanel.onDidDispose(() => changeSub.dispose());
46+
}
47+
});
48+
webviewPanel.onDidDispose(() => changeSub.dispose());
4949

50-
// Handle messages from the webview
51-
webviewPanel.webview.onDidReceiveMessage(msg => {
52-
switch (msg.type) {
53-
case 'updateSetting':
54-
if (msg.key in settings) {
50+
// Handle messages from the webview
51+
webviewPanel.webview.onDidReceiveMessage(msg => {
52+
switch (msg.type) {
53+
case 'updateSetting':
54+
if (msg.key in settings) {
5555
(settings as any)[msg.key] = !!msg.value;
5656
scheduleUpdate();
57-
}
58-
break;
57+
}
58+
break;
5959
case 'runBuild':
6060
// Show interim building message
6161
webviewPanel.webview.postMessage?.({}); // no-op safeguard
62-
webviewPanel.webview.html = `<div style="font-family:var(--vscode-font-family);padding:16px;">`+
63-
`<strong>Building project (forge build --build-info)...</strong><br/><br/>`+
64-
`Open the <em>Recon</em> output channel to watch progress. The call graph will refresh automatically when done.`+
62+
webviewPanel.webview.html = `<div style="font-family:var(--vscode-font-family);padding:16px;">` +
63+
`<strong>Building project (forge build --build-info)...</strong><br/><br/>` +
64+
`Open the <em>Recon</em> output channel to watch progress. The call graph will refresh automatically when done.` +
6565
`</div>`;
66-
vscode.commands.executeCommand('recon.buildWithInfo').then(() => {
67-
scheduleUpdate();
68-
});
66+
// Await the build command; our command now returns a promise that resolves when build finishes
67+
Promise.resolve(vscode.commands.executeCommand('recon.buildWithInfo'))
68+
.finally(() => {
69+
// Refresh regardless of success/failure/cancel so the page doesn't get stuck
70+
scheduleUpdate();
71+
});
6972
break;
7073
case 'copyToClipboard':
7174
if (typeof msg.text === 'string' && msg.text.length > 0) {
@@ -95,25 +98,25 @@ export class ArgusCallGraphEditorProvider implements vscode.CustomTextEditorProv
9598
: pathMod.dirname(document.uri.fsPath);
9699
const baseDirUri = vscode.Uri.file(baseDirFs);
97100
const inferredName = lastPrimaryContract ? `${lastPrimaryContract}-callgraph.png` : 'callgraph.png';
98-
const fileBase = (suggested || inferredName).replace(/[^a-z0-9_.-]/gi,'_');
101+
const fileBase = (suggested || inferredName).replace(/[^a-z0-9_.-]/gi, '_');
99102
let targetName = fileBase;
100103
let attempt = 0;
101104
while (attempt < 50) {
102105
const candidate = pathMod.join(baseDirFs, targetName);
103-
console.log('[Argus] exportImage attempt', attempt+1, 'candidate', candidate);
106+
console.log('[Argus] exportImage attempt', attempt + 1, 'candidate', candidate);
104107
if (!fs.existsSync(candidate)) {
105108
const uri = vscode.Uri.file(candidate);
106109
await vscode.workspace.fs.writeFile(uri, buffer);
107110
const rel = workspaceRoot ? pathMod.relative(workspaceRoot, uri.fsPath) : uri.fsPath;
108-
vscode.window.showInformationMessage(`Argus call graph image saved at workspace root: ${rel}` , 'Open').then(sel => {
111+
vscode.window.showInformationMessage(`Argus call graph image saved at workspace root: ${rel}`, 'Open').then(sel => {
109112
if (sel === 'Open') { vscode.commands.executeCommand('vscode.open', uri); }
110113
});
111114
webviewPanel.webview.postMessage({ type: 'exportImageResult', ok: true, file: uri.fsPath });
112115
console.log('[Argus] exportImage success', uri.fsPath);
113116
return;
114117
}
115118
attempt++;
116-
const stem = fileBase.replace(/\.png$/i,'');
119+
const stem = fileBase.replace(/\.png$/i, '');
117120
targetName = `${stem}-${attempt}.png`;
118121
}
119122
vscode.window.showWarningMessage('Unable to save image: too many existing versions.');
@@ -127,44 +130,44 @@ export class ArgusCallGraphEditorProvider implements vscode.CustomTextEditorProv
127130
})();
128131
break;
129132
}
130-
}
131-
});
133+
}
134+
});
132135

133136
updateWebview();
134-
}
137+
}
135138
private getLoadingHtml(document: vscode.TextDocument, _settings: ArgusSettings): string {
136139
const fileName = vscode.workspace.asRelativePath(document.uri);
137140
return `<div style="font-family:var(--vscode-font-family);padding:16px;">Generating Argus Call Graph for <code>${escapeHtml(vscode.workspace.asRelativePath(document.uri))}</code>...</div>`;
138141
}
139142

140143
private getHtml(webview: vscode.Webview, document: vscode.TextDocument, settings: ArgusSettings, body: string): string {
141-
const nonce = getNonce();
142-
const fileName = vscode.workspace.asRelativePath(document.uri);
143-
const html2canvasUri = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules','html2canvas','dist','html2canvas.min.js'));
144+
const nonce = getNonce();
145+
const fileName = vscode.workspace.asRelativePath(document.uri);
146+
const html2canvasUri = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules', 'html2canvas', 'dist', 'html2canvas.min.js'));
144147
// Extract inner <body> content if a full HTML document was returned to avoid nested <html> issues
145-
let fragment = body;
146-
const bodyMatch = body.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
147-
if (bodyMatch) fragment = bodyMatch[1];
148-
// Collect any style tags from original HTML (head or body) to preserve design
149-
const styleTags: string[] = [];
150-
const styleRegex = /<style[^>]*>[\s\S]*?<\/style>/gi;
151-
let m: RegExpExecArray | null;
152-
while((m = styleRegex.exec(body))){ styleTags.push(m[0]); }
153-
const collectedStyles = styleTags.join('\n');
148+
let fragment = body;
149+
const bodyMatch = body.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
150+
if (bodyMatch) { fragment = bodyMatch[1]; }
151+
// Collect any style tags from original HTML (head or body) to preserve design
152+
const styleTags: string[] = [];
153+
const styleRegex = /<style[^>]*>[\s\S]*?<\/style>/gi;
154+
let m: RegExpExecArray | null;
155+
while ((m = styleRegex.exec(body))) { styleTags.push(m[0]); }
156+
const collectedStyles = styleTags.join('\n');
154157
// Ensure any <script> tags inside the fragment receive the nonce so CSP allows execution
155-
const bodyWithNonce = fragment
156-
.replace(/<script(?![^>]*nonce=)/g, `<script nonce="${nonce}"`)
157-
.replace(/<style(?![^>]*nonce=)/g, `<style nonce="${nonce}"`);
158+
const bodyWithNonce = fragment
159+
.replace(/<script(?![^>]*nonce=)/g, `<script nonce="${nonce}"`)
160+
.replace(/<style(?![^>]*nonce=)/g, `<style nonce="${nonce}"`);
158161

159162
// Prism resource URIs (mirror working implementation in logToFoundryView)
160-
const prismCore = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules','prismjs','prism.js'));
161-
const prismSolidity = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules','prismjs','components','prism-solidity.min.js'));
162-
const prismTheme = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules','prismjs','themes','prism-tomorrow.css'));
163-
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" />
163+
const prismCore = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules', 'prismjs', 'prism.js'));
164+
const prismSolidity = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules', 'prismjs', 'components', 'prism-solidity.min.js'));
165+
const prismTheme = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'node_modules', 'prismjs', 'themes', 'prism-tomorrow.css'));
166+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" />
164167
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline' ${this.getCspSource()}; script-src 'nonce-${nonce}' ${this.getCspSource()};" />
165168
<title>Argus Call Graph Preview</title>
166169
<link rel="stylesheet" href="${prismTheme}" />
167-
${collectedStyles.replace(/<style/gi, `<style nonce="${nonce}"`).replace(/<script/gi,'<!-- stripped-script')}
170+
${collectedStyles.replace(/<style/gi, `<style nonce="${nonce}"`).replace(/<script/gi, '<!-- stripped-script')}
168171
<style nonce="${nonce}">
169172
/* Header layout & logo (inline) */
170173
header.argus-header { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:12px; }
@@ -356,30 +359,30 @@ window.addEventListener('message', function(event){
356359
else { btn.innerHTML='❌ Save Failed'; setTimeout(function(){ btn.innerHTML='📷 Export as Image'; btn.disabled=false; }, 2200); }
357360
});
358361
</script></body></html>`;
359-
}
362+
}
360363

361-
private getCspSource(): string {
362-
return this.context.extensionUri.scheme === 'vscode-file' ? 'vscode-file:' : 'vscode-resource:';
363-
}
364+
private getCspSource(): string {
365+
return this.context.extensionUri.scheme === 'vscode-file' ? 'vscode-file:' : 'vscode-resource:';
366+
}
364367
}
365368

366369
function escapeHtml(str: string): string {
367-
return str.replace(/[&<>'"]/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[s] as string));
370+
return str.replace(/[&<>'"]/g, s => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[s] as string));
368371
}
369372

370373
function getNonce() {
371-
let text = '';
372-
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
373-
for (let i = 0; i < 32; i++) {
374-
text += possible.charAt(Math.floor(Math.random() * possible.length));
375-
}
376-
return text;
374+
let text = '';
375+
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
376+
for (let i = 0; i < 32; i++) {
377+
text += possible.charAt(Math.floor(Math.random() * possible.length));
378+
}
379+
return text;
377380
}
378381

379382
function debounce<T extends (...args: any[]) => unknown>(fn: T, wait: number) {
380383
let handle: NodeJS.Timeout | undefined;
381384
return (...args: Parameters<T>) => {
382-
if (handle) clearTimeout(handle);
385+
if (handle) { clearTimeout(handle); }
383386
handle = setTimeout(() => fn(...args), wait);
384387
};
385388
}

src/argus/utils.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,9 @@ export function highLevelCallWithOptions(fnCall: FunctionCall, noStatic = false)
5252
if (
5353
!(fnCall.vExpression instanceof MemberAccess) ||
5454
!(fnCall.vExpression.vExpression instanceof MemberAccess)
55-
)
56-
return false;
55+
) { return false; }
5756
const ref = fnCall.vExpression?.vExpression.vReferencedDeclaration;
58-
if (!(ref instanceof FunctionDefinition)) return false;
57+
if (!(ref instanceof FunctionDefinition)) { return false; }
5958
if (noStatic) {
6059
if (
6160
ref.stateMutability === FunctionStateMutability.Pure ||
@@ -67,10 +66,10 @@ export function highLevelCallWithOptions(fnCall: FunctionCall, noStatic = false)
6766
}
6867

6968
if (fnCall.vExpression.vExpression.vExpression.typeString.startsWith('type(library ')) {
70-
if (!ref.vReturnParameters || ref.vReturnParameters?.vParameters.length === 0) return false;
69+
if (!ref.vReturnParameters || ref.vReturnParameters?.vParameters.length === 0) { return false; }
7170
for (const inFnCall of ref.getChildrenByType(FunctionCall)) {
72-
if (highLevelCall(inFnCall, noStatic)) return true;
73-
if (highLevelCallWithOptions(inFnCall, noStatic)) return true;
71+
if (highLevelCall(inFnCall, noStatic)) { return true; }
72+
if (highLevelCallWithOptions(inFnCall, noStatic)) { return true; }
7473
}
7574
}
7675
return (
@@ -80,9 +79,9 @@ export function highLevelCallWithOptions(fnCall: FunctionCall, noStatic = false)
8079
}
8180

8281
export function highLevelCall(fnCall: FunctionCall, noStatic = false): boolean {
83-
if (!(fnCall.vExpression instanceof MemberAccess)) return false;
82+
if (!(fnCall.vExpression instanceof MemberAccess)) { return false; }
8483
const ref = fnCall.vExpression.vReferencedDeclaration;
85-
if (!(ref instanceof FunctionDefinition)) return false;
84+
if (!(ref instanceof FunctionDefinition)) { return false; }
8685
if (noStatic) {
8786
if (
8887
ref.stateMutability === FunctionStateMutability.Pure ||
@@ -94,10 +93,10 @@ export function highLevelCall(fnCall: FunctionCall, noStatic = false): boolean {
9493
}
9594

9695
if (fnCall.vExpression.vExpression.typeString.startsWith('type(library ')) {
97-
if (!ref.vReturnParameters || ref.vReturnParameters?.vParameters.length === 0) return false;
96+
if (!ref.vReturnParameters || ref.vReturnParameters?.vParameters.length === 0) { return false; }
9897
for (const inFnCall of ref.getChildrenByType(FunctionCall)) {
99-
if (highLevelCall(inFnCall, noStatic)) return true;
100-
if (highLevelCallWithOptions(inFnCall, noStatic)) return true;
98+
if (highLevelCall(inFnCall, noStatic)) { return true; }
99+
if (highLevelCallWithOptions(inFnCall, noStatic)) { return true; }
101100
}
102101
}
103102
return (
@@ -183,13 +182,13 @@ export function lowLevelTransfer(fnCall: FunctionCall): boolean {
183182

184183
export function isStateVarAssignment(node: Assignment): boolean {
185184
const decl = getStateVarAssignment(node);
186-
if (!decl) return false;
185+
if (!decl) { return false; }
187186
return decl && (decl.stateVariable || decl.storageLocation === DataLocation.Storage);
188187
}
189188

190189
export function getStateVarAssignment(node: Assignment): VariableDeclaration | null {
191190
const decl = getDeepRef(node.vLeftHandSide);
192-
if (!(decl instanceof VariableDeclaration)) return null;
191+
if (!(decl instanceof VariableDeclaration)) { return null; }
193192
return decl;
194193
}
195194

@@ -864,6 +863,26 @@ ${indent} </div>`;
864863
justify-content: space-around;
865864
text-align: center;
866865
}
866+
/* Legend panel for color and count indicators */
867+
.legend-panel {
868+
border: 1px solid var(--argus-border);
869+
border-radius: 6px;
870+
padding: 12px 14px;
871+
margin: 12px 0 20px;
872+
}
873+
.legend-title {
874+
margin: 0 0 8px 0;
875+
font-size: 12px;
876+
letter-spacing: .02em;
877+
color: var(--argus-text);
878+
opacity: .8;
879+
text-transform: uppercase;
880+
}
881+
.legend-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
882+
.legend-swatch { width: 16px; height: 12px; border-radius: 3px; border: 1px solid var(--argus-border); display: inline-block; }
883+
.legend-red { background-color: #f8d7da; border-color: #f5c6cb; }
884+
.legend-yellow { background-color: #fff3cd; border-color: #ffeaa7; }
885+
.legend-text { font-size: 12px; color: var(--argus-text); }
867886
868887
.stat-item {
869888
flex: 1;
@@ -1316,6 +1335,14 @@ ${indent} </div>`;
13161335
<div class="functions-container">
13171336
${allFunctionsHtml}
13181337
</div>
1338+
1339+
<div class="legend-panel" role="note" aria-label="Color legend for nodes and counters">
1340+
<h4 class="legend-title">Legend</h4>
1341+
<div class="legend-row"><span class="legend-swatch legend-red"></span><span class="legend-text">Red header = mutable external/public call</span></div>
1342+
<div class="legend-row"><span class="legend-swatch legend-yellow"></span><span class="legend-text">Yellow header = immutable (view/pure) external call</span></div>
1343+
<div class="legend-row"><span class="external-indicator red-indicator">2</span><span class="legend-text">Red circle = number of mutable external calls under this node (recursive)</span></div>
1344+
<div class="legend-row"><span class="external-indicator yellow-indicator">3</span><span class="legend-text">Yellow circle = number of immutable (view/pure) external calls under this node (recursive)</span></div>
1345+
</div>
13191346
13201347
${slotsViewerHtml}
13211348
${contractElementsHtml}
@@ -2103,13 +2130,12 @@ export function saveNavigationIndex(contracts: ContractInfo[], outDir: string =
21032130
}
21042131

21052132
export const signatureEquals = (a: $.FunctionDefinition, b: $.FunctionDefinition) => {
2106-
if (!a.name || !b.name) return false;
2107-
if (a.name !== b.name) return false;
2133+
if (!a.name || !b.name) { return false; }
2134+
if (a.name !== b.name) { return false; }
21082135
if (
21092136
a.vParameters.vParameters.map((x) => x.type).join(',') !==
21102137
b.vParameters.vParameters.map((x) => x.type).join(',')
2111-
)
2112-
return false;
2113-
if (a.visibility !== b.visibility) return false;
2138+
) { return false; }
2139+
if (a.visibility !== b.visibility) { return false; }
21142140
return a.stateMutability === b.stateMutability;
21152141
};

0 commit comments

Comments
 (0)