Vscode Extension Howto
How to Ship a VS Code Extension That Wraps an Existing Web Tool
_A technical walkthrough of the Ginexys extension architecture_
If you've built browser-based developer tools and want to bring them to VS Code, this is the pattern I used. It avoids the most common traps and doesn't require rewriting your tool.
The core problem
VS Code webviews are sandboxed iframes with strict Content Security Policy. They don't run on localhost. They don't have window.parent. Paths like src="assets/js/app.js" don't resolve — everything needs a vscode-webview:// URI.
If your tool uses any of these, it will silently break:
window.parent !== windowchecks for embedding detection- Absolute paths (
/assets/,/tools/) - Relative paths from a different base directory
- CDN scripts without explicit CSP entries
Step 1: Replace your embedding detection bridge
If your tool detects embedding via window.parent !== window, that check is always false in a VS Code webview. Replace the bridge entirely:
// vsc-bridge.js — injected INSTEAD of your existing bridge.js
(function () {
const vscode = acquireVsCodeApi();
window.CwsBridge = { isConnected: true, isEmbedded: true,
send(type, payload) { vscode.postMessage({ type, payload, __ginexys: true }); },
onData(cb) { / wire message listener / },
requestStore(value) { // return Promise that resolves via reply correlation } }; })();
The key: acquireVsCodeApi() is injected by VS Code into every webview. Use it instead of window.parent.
Step 2: Build an HTML rewriter
Your tool's HTML has paths that won't work in a webview. Write a function that rewrites them at open time:
// Absolute paths: src="/assets/js/app.js" → vscode-webview://...
result = result.replace(/(src|href)="(\/[^"]+)"/g, (match, attr, absPath) => {
const localPath = path.join(portfolioRoot, absPath);
return ${attr}="${webview.asWebviewUri(vscode.Uri.file(localPath))}";
});
// Relative paths: src="src/js/tifany.js" → vscode-webview://... result = result.replace( /(src|href)="(?!https?:\/\/|vscode-|data:|blob:|#|\/)([^"]+)"/g, (match, attr, relPath) => { const localPath = path.join(toolRoot, relPath); return ${attr}="${webview.asWebviewUri(vscode.Uri.file(localPath))}"; } );
// Replace bridge.js src with vsc-bridge.js result = result.replace( /src="[^"]*\/assets\/os\/bridge\.js"/g, src="${vscBridgeUri}" );
Also inject a CSP meta tag that includes your CDN sources:
const csp = <meta http-equiv="Content-Security-Policy" content="
default-src 'none';
script-src 'nonce-${nonce}' ${webview.cspSource} https://cdn.jsdelivr.net https://cdnjs.cloudflare.com;
style-src 'unsafe-inline' ${webview.cspSource} https://cdn.jsdelivr.net;
font-src ${webview.cspSource} data: https://cdnjs.cloudflare.com;
img-src ${webview.cspSource} data: blob: https:;
worker-src blob:;
">;
Step 3: Create the Custom Editor Provider
export class TafneEditorProvider implements vscode.CustomTextEditorProvider {
async resolveCustomTextEditor(
document: vscode.TextDocument,
panel: vscode.WebviewPanel
): Promise<void> {
panel.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.file(this.portfolioRoot)]
};
// Read tool HTML, inject initial file content, rewrite paths let html = fs.readFileSync(path.join(this.portfolioRoot, 'tools/tafne/index.html'), 'utf8');
const nonce = crypto.randomUUID().replace(/-/g, ''); html = html.replace('</body>', <script nonce="${nonce}"> window.__GINEXYS_INITIAL_FILE__ = ${JSON.stringify({ content: document.getText(), ext: path.extname(document.fileName) })}; </script></body> );
panel.webview.html = rewriteHtmlForWebview({ html, webview: panel.webview, ... }); } }
Register it in package.json:
"contributes": {
"customEditors": [{
"viewType": "yourext.toolname",
"displayName": "Your Tool Name",
"selector": [
{ "filenamePattern": "*.csv" },
{ "filenamePattern": "*.json" }
],
"priority": "option"
}]
}
"priority": "option" means VS Code asks the user first — it won't hijack the default editor.
Step 4: Add mode commands with context menu wiring
Don't make users hunt through menus. Wire commands to the Explorer right-click context:
"contributes": {
"commands": [
{ "command": "yourext.openNodeEditor", "title": "Open Node Editor", "category": "YourExt" }
],
"menus": {
"explorer/context": [{
"command": "yourext.open",
"when": "resourceExtname == .csv || resourceExtname == .json",
"group": "navigation@10"
}]
}
}
For mode commands, use a static class property to pass intent through VS Code's editor framework:
// Command handler
YourEditorProvider.pendingMode = 'node-editor';
vscode.commands.executeCommand('vscode.openWith', uri, 'yourext.toolname');
// In resolveCustomTextEditor const mode = YourEditorProvider.pendingMode; YourEditorProvider.pendingMode = null; // inject into webview HTML: window.__INITIAL_MODE__ = mode
Step 5: Live sync without the performance trap
If you want VS Code text editor → tool live sync, debounce it and update in-place:
// Extension host — debounced, structured
let debounceTimer: NodeJS.Timeout | undefined;
vscode.workspace.onDidChangeTextDocument(e => {
if (e.document.uri.toString() !== document.uri.toString()) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
panel.webview.postMessage({
type: 'tool:document-changed',
payload: { sheets: extractSheets(e.document.getText(), ext) }
});
}, 300);
});
// Tool JS — patch existing state, don't create new tabs
window.addEventListener('message', e => {
if (e.data?.type !== 'tool:document-changed') return;
e.data.payload.sheets.forEach((data, i) => {
const parsed = parseForFormat(data.content, data.format);
if (i < window.sheets.length) {
// Update in-place
window.sheets[i].rawHtml = parsed;
if (window.sheets[i].id === window.activeSheetId) {
rerenderActiveSheet(parsed);
}
} else {
// Add new sheet entry directly
window.sheets.push({ id: newId(), rawHtml: parsed, name: data.name });
}
});
});
The key rule: never call your "add sheet" function on live sync. It always creates a new entry. Call your render functions directly instead.
The package name trap
VS Code extension IDs are publisher.name. If your pnpm workspace package is "name": "my-tool-core" but satellites declare "extensionDependencies": ["mypublisher.core"], VS Code loads both but can't match them. Rename your package to "name": "core" so the ID resolves to mypublisher.core.
Checklist
- [ ] Replace
window.parentbridge withacquireVsCodeApi()version - [ ] HTML rewriter handles both
/absoluteandrelativepaths - [ ] CSP includes all CDN sources your tool loads from
- [ ]
localResourceRootsincludes the full tool source directory - [ ] Custom editor priority is
"option"not"default" - [ ] Commands registered in
activate()— not just declared inpackage.json - [ ] Live sync debounced (300ms) and updates existing state in-place
- [ ] Package name matches
publisher.nameconvention for allextensionDependencies