Blame · Line-by-line history
iframe-view.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 63fe719 | 1 | /** |
| 2 | * Generic WebviewViewProvider that embeds a Gluecron page in an iframe. | |
| 3 | * | |
| 4 | * We re-resolve the URL on every `resolveWebviewView` call so that | |
| 5 | * workspace changes (different repo, different host) are picked up | |
| 6 | * without a window reload. | |
| 7 | * | |
| 8 | * The chat view also passes `?embed=1` so the server can render a | |
| 9 | * sidebar-friendly layout (no nav chrome). | |
| 10 | */ | |
| 11 | ||
| 12 | import * as vscode from "vscode"; | |
| 13 | import { getGluecronRepoInfo, hostUrl } from "../repo"; | |
| 14 | ||
| 15 | export type UrlBuilder = (repo: { owner: string; repo: string }) => string; | |
| 16 | ||
| 17 | export class IframeView implements vscode.WebviewViewProvider { | |
| 18 | constructor( | |
| 19 | private readonly emptyState: string, | |
| 20 | private readonly buildUrl: UrlBuilder | |
| 21 | ) {} | |
| 22 | ||
| 23 | resolveWebviewView( | |
| 24 | webviewView: vscode.WebviewView, | |
| 25 | _ctx: vscode.WebviewViewResolveContext, | |
| 26 | _token: vscode.CancellationToken | |
| 27 | ): void | Thenable<void> { | |
| 28 | webviewView.webview.options = { | |
| 29 | enableScripts: true, | |
| 30 | enableForms: true, | |
| 31 | enableCommandUris: false, | |
| 32 | }; | |
| 33 | this.render(webviewView); | |
| 34 | ||
| 35 | // Re-render on host changes (settings) — covers self-host switches. | |
| 36 | const sub = vscode.workspace.onDidChangeConfiguration((e) => { | |
| 37 | if (e.affectsConfiguration("gluecron.host")) { | |
| 38 | this.render(webviewView); | |
| 39 | } | |
| 40 | }); | |
| 41 | webviewView.onDidDispose(() => sub.dispose()); | |
| 42 | } | |
| 43 | ||
| 44 | private async render(view: vscode.WebviewView): Promise<void> { | |
| 45 | const info = await getGluecronRepoInfo(); | |
| 46 | if (!info) { | |
| 47 | view.webview.html = htmlShell(emptyHtml(this.emptyState)); | |
| 48 | return; | |
| 49 | } | |
| 50 | const url = this.buildUrl(info); | |
| 51 | view.webview.html = htmlShell(iframeHtml(url)); | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | export function chatViewProvider(): IframeView { | |
| 56 | return new IframeView( | |
| 57 | "Open a folder backed by a Gluecron repository to start chatting.", | |
| 58 | ({ owner, repo }) => hostUrl(`/${owner}/${repo}/chat?embed=1`) | |
| 59 | ); | |
| 60 | } | |
| 61 | ||
| 62 | export function pullsViewProvider(): IframeView { | |
| 63 | return new IframeView( | |
| 64 | "Open a Gluecron-hosted folder to see its pull requests.", | |
| 65 | ({ owner, repo }) => hostUrl(`/${owner}/${repo}/pulls?embed=1`) | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | export function issuesViewProvider(): IframeView { | |
| 70 | return new IframeView( | |
| 71 | "Open a Gluecron-hosted folder to see its issues.", | |
| 72 | ({ owner, repo }) => hostUrl(`/${owner}/${repo}/issues?embed=1`) | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | export function standupsViewProvider(): IframeView { | |
| 77 | return new IframeView( | |
| 78 | "Open a Gluecron-hosted folder to see AI standups.", | |
| 79 | ({ owner, repo }) => hostUrl(`/${owner}/${repo}/standups?embed=1`) | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | function htmlShell(body: string): string { | |
| 84 | // Note: we deliberately allow `frame-src` to whatever HTTP host we | |
| 85 | // embed, but we keep `default-src` tight so an injected script can't | |
| 86 | // execute anything inside the webview shell itself. | |
| 87 | return `<!DOCTYPE html> | |
| 88 | <html> | |
| 89 | <head> | |
| 90 | <meta charset="utf-8" /> | |
| 91 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; frame-src http: https:;" /> | |
| 92 | <style> | |
| 93 | html, body { margin: 0; padding: 0; height: 100%; background: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); } | |
| 94 | iframe { width: 100%; height: 100%; border: 0; } | |
| 95 | .empty { padding: 16px; font-size: 13px; line-height: 1.5; opacity: 0.8; } | |
| 96 | </style> | |
| 97 | </head> | |
| 98 | <body> | |
| 99 | ${body} | |
| 100 | </body> | |
| 101 | </html>`; | |
| 102 | } | |
| 103 | ||
| 104 | function iframeHtml(url: string): string { | |
| 105 | // Escape the URL for attribute context. | |
| 106 | const safe = url.replace(/&/g, "&").replace(/"/g, """); | |
| 107 | return `<iframe src="${safe}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe>`; | |
| 108 | } | |
| 109 | ||
| 110 | function emptyHtml(message: string): string { | |
| 111 | const safe = message.replace(/&/g, "&").replace(/</g, "<"); | |
| 112 | return `<div class="empty">${safe}</div>`; | |
| 113 | } |