diff --git a/.gitignore b/.gitignore index 1b5bada..2b798cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.vsix bun.lockb .vscode-test/ +package-lock.json diff --git a/README.md b/README.md index 052bdbe..073768c 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,29 @@ Opens [Plannotator](https://github.com/backnotprop/plannotator) plan reviews ins - Persists your Plannotator settings (identity, permissions, editor preferences) across sessions - Auto-closes the panel when you approve or send feedback on a plan - Works with Claude Code running in VS Code's integrated terminal +- Works with Claude Code as a VS Code extension - Configurable via VS Code settings - Manual URL opening via command palette ## How It Works -When Plannotator opens a browser to show a plan review, this extension intercepts the request and opens it in a VS Code panel instead: +When Plannotator opens a browser to show a plan review, this extension intercepts the request and opens it in a VS Code panel instead. + +### For Claude Code in Integrated Terminal: 1. The extension injects a `PLANNOTATOR_BROWSER` environment variable into integrated terminals 2. When Plannotator opens a URL, the bundled router script sends it to the extension via a local HTTP server 3. The extension opens the URL in a custom WebviewPanel with an embedded iframe -4. A local reverse proxy handles cookie persistence (VS Code webview iframes don't support cookies natively) — settings are stored in VS Code's global state and restored transparently + +### For Claude Code as VS Code Extension: + +1. The extension registers an external URI opener for HTTP/HTTPS URLs +2. When Claude Code (or any extension) tries to open a localhost URL via `vscode.env.openExternal()`, the opener intercepts it +3. If the URL is a localhost URL (matching `http://localhost:*`, `https://localhost:*`, `http://127.0.0.1:*`, or `https://127.0.0.1:*`), it opens in a VS Code panel instead of an external browser + +### Cookie Persistence: + +A local reverse proxy handles cookie persistence (VS Code webview iframes don't support cookies natively) — settings are stored in VS Code's global state and restored transparently across sessions. ## Requirements diff --git a/mocks/vscode.ts b/mocks/vscode.ts index 90f5923..0ef8705 100644 --- a/mocks/vscode.ts +++ b/mocks/vscode.ts @@ -5,8 +5,19 @@ export interface UriHandler { handleUri(uri: Uri): ProviderResult; } +export interface ExternalUriOpener { + canOpenExternalUri?(uri: Uri): number | undefined; + openExternalUri(uri: Uri): void | Promise; +} + +export interface ExternalUriOpenerMetadata { + schemes: string[]; + label: string; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ProviderResult = T | undefined | null | Thenable; +export type ProviderResult = T | undefined | null | Promise; +export type Thenable = Promise; export interface ExtensionContext { subscriptions: { dispose(): void }[]; @@ -19,7 +30,7 @@ export interface ExtensionContext { }; globalState: { get(key: string, defaultValue?: T): T | undefined; - update(key: string, value: unknown): Thenable; + update(key: string, value: unknown): Promise; }; } @@ -49,6 +60,7 @@ export class Uri { } static parse(value: string): Uri { + // Use globalThis.URL for explicit global scope reference const parsed = new globalThis.URL(value); return new Uri( parsed.protocol.replace(":", ""), @@ -58,6 +70,15 @@ export class Uri { parsed.hash.replace("#", ""), ); } + + toString(): string { + let result = `${this.scheme}://`; + if (this.authority) result += this.authority; + result += this.path; + if (this.query) result += `?${this.query}`; + if (this.fragment) result += `#${this.fragment}`; + return result; + } } export const commands = { @@ -85,6 +106,13 @@ export const window = { registerUriHandler(_handler: unknown) { return { dispose() {} }; }, + registerExternalUriOpener( + _id: string, + _opener: ExternalUriOpener, + _metadata: ExternalUriOpenerMetadata, + ) { + return { dispose() {} }; + }, async showInformationMessage(_message: string) { return undefined; }, diff --git a/src/extension.test.ts b/src/extension.test.ts index b609bdf..ec212d6 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -74,7 +74,73 @@ describe("activate", () => { it("pushes disposables to context.subscriptions", async () => { await activate(context as unknown as vscode.ExtensionContext); - // Cookie proxy + IPC server + command = at least 3 subscriptions - expect(context.subscriptions.length).toBeGreaterThanOrEqual(3); + // Cookie proxy + IPC server + command + external URI opener = at least 4 subscriptions + expect(context.subscriptions.length).toBeGreaterThanOrEqual(4); + }); + + it("registers external URI opener for localhost URLs", async () => { + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + expect(spy).toHaveBeenCalledWith( + "plannotator-webview.opener", + expect.objectContaining({ + canOpenExternalUri: expect.any(Function), + openExternalUri: expect.any(Function), + }), + expect.objectContaining({ + schemes: ["http", "https"], + label: "Open Plannotator in VS Code", + }), + ); + }); + + it("external URI opener returns priority for localhost URLs", async () => { + let capturedOpener: any; + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spy.mockImplementation((_id: string, opener: any) => { + capturedOpener = opener; + return { dispose() {} }; + }); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + // Test localhost with port + const testUri = vscode.Uri.parse("http://localhost:3000/"); + const priority = capturedOpener.canOpenExternalUri(testUri); + expect(priority).toBe(2); + + // Test 127.0.0.1 with port + const testUri2 = vscode.Uri.parse("http://127.0.0.1:8080/review"); + const priority2 = capturedOpener.canOpenExternalUri(testUri2); + expect(priority2).toBe(2); + }); + + it("external URI opener returns undefined for non-localhost URLs", async () => { + let capturedOpener: any; + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spy.mockImplementation((_id: string, opener: any) => { + capturedOpener = opener; + return { dispose() {} }; + }); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + const testUri = vscode.Uri.parse("http://example.com"); + const priority = capturedOpener.canOpenExternalUri(testUri); + expect(priority).toBeUndefined(); }); }); diff --git a/src/extension.ts b/src/extension.ts index 2f34711..33db8ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -77,6 +77,53 @@ export async function activate(context: vscode.ExtensionContext): Promise }, ); context.subscriptions.push(openCommand); + + // Register external URI opener to intercept URLs from Claude Code VSCode extension + // This uses optional chaining because the API might not be available in older VSCode versions + // Note: This API was added in VSCode 1.54 but may not be in all type definitions + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: ( + id: string, + opener: { + canOpenExternalUri(uri: vscode.Uri): number | undefined; + openExternalUri(uri: vscode.Uri): void; + }, + metadata: { schemes: string[]; label: string }, + ) => vscode.Disposable; + }; + + const externalOpener = windowWithOpener.registerExternalUriOpener?.( + "plannotator-webview.opener", + { + canOpenExternalUri(uri: vscode.Uri): number | undefined { + const urlString = uri.toString(); + // Match localhost URLs - Plannotator runs on localhost with dynamic ports + // This matches both http://localhost:PORT and http://127.0.0.1:PORT patterns + if ( + urlString.startsWith("http://localhost:") || + urlString.startsWith("https://localhost:") || + urlString.startsWith("http://127.0.0.1:") || + urlString.startsWith("https://127.0.0.1:") + ) { + // Priority 2 (higher than default 0) to intercept these URLs before the default browser opener + return 2; + } + return undefined; // Don't handle this URL - let default browser opener handle it + }, + openExternalUri(uri: vscode.Uri): void { + const urlString = uri.toString(); + log.info(`[external-opener] Opening URL: ${urlString}`); + openInPanel(urlString); + }, + }, + { + schemes: ["http", "https"], + label: "Open Plannotator in VS Code", + }, + ); + if (externalOpener) { + context.subscriptions.push(externalOpener); + } } export function deactivate(): void {}