Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.vsix
bun.lockb
.vscode-test/
package-lock.json
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions mocks/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ export interface UriHandler {
handleUri(uri: Uri): ProviderResult<void>;
}

export interface ExternalUriOpener {
canOpenExternalUri?(uri: Uri): number | undefined;
openExternalUri(uri: Uri): void | Promise<void>;
}

export interface ExternalUriOpenerMetadata {
schemes: string[];
label: string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ProviderResult<T> = T | undefined | null | Thenable<T | undefined | null>;
export type ProviderResult<T> = T | undefined | null | Promise<T | undefined | null>;
export type Thenable<T> = Promise<T>;

export interface ExtensionContext {
subscriptions: { dispose(): void }[];
Expand All @@ -19,7 +30,7 @@ export interface ExtensionContext {
};
globalState: {
get<T>(key: string, defaultValue?: T): T | undefined;
update(key: string, value: unknown): Thenable<void>;
update(key: string, value: unknown): Promise<void>;
};
}

Expand Down Expand Up @@ -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(":", ""),
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
},
Expand Down
70 changes: 68 additions & 2 deletions src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
47 changes: 47 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,53 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
},
);
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 {}