diff --git a/src/adapters/inbound/ui/SidecarPanelAdapter.ts b/src/adapters/inbound/ui/SidecarPanelAdapter.ts index 995acf8..8b94e7f 100644 --- a/src/adapters/inbound/ui/SidecarPanelAdapter.ts +++ b/src/adapters/inbound/ui/SidecarPanelAdapter.ts @@ -239,8 +239,8 @@ export class SidecarPanelAdapter { await this.handleOpenHNStory(message.url); break; case 'openHNStoryInPanel': - // Use new content view system instead of separate panel - this.panelStateManager?.openContentView(message.url, message.title); + // Check if URL can be embedded, otherwise open externally + await this.handleOpenHNStoryInPanel(message.url, message.title); break; case 'openContentView': this.panelStateManager?.openContentView(message.url, message.title); @@ -756,6 +756,72 @@ export class SidecarPanelAdapter { await vscode.env.openExternal(vscode.Uri.parse(hnUrl)); } + /** + * Check if a URL can be embedded in an iframe + * Returns true if the URL allows iframe embedding, false otherwise + */ + private async checkIframeEmbeddable(url: string): Promise { + try { + // Use native fetch with HEAD request to check headers + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + redirect: 'follow', + }); + + clearTimeout(timeoutId); + + // Check X-Frame-Options header + const xFrameOptions = response.headers.get('x-frame-options'); + if (xFrameOptions) { + const value = xFrameOptions.toLowerCase(); + if (value === 'deny' || value === 'sameorigin') { + return false; + } + } + + // Check Content-Security-Policy frame-ancestors directive + const csp = response.headers.get('content-security-policy'); + if (csp) { + const frameAncestorsMatch = csp.match(/frame-ancestors\s+([^;]+)/i); + if (frameAncestorsMatch) { + const ancestors = frameAncestorsMatch[1].toLowerCase().trim(); + // 'none' or 'self' means no external embedding + if (ancestors === "'none'" || ancestors === "'self'") { + return false; + } + } + } + + return true; + } catch { + // On error (timeout, network issues), assume embeddable and let iframe handle it + return true; + } + } + + /** + * Handle opening HN story in panel or external browser + * Checks if the URL can be embedded in iframe, opens externally if not + */ + private async handleOpenHNStoryInPanel(url: string, title: string): Promise { + if (!url) return; + + const canEmbed = await this.checkIframeEmbeddable(url); + if (canEmbed) { + this.panelStateManager?.openContentView(url, title); + } else { + // Open in external browser and show notification + await vscode.env.openExternal(vscode.Uri.parse(url)); + vscode.window.showInformationMessage( + `"${title}" opened in browser (site blocks iframe embedding)` + ); + } + } + private escapeHtml(text: string): string { return text .replace(/&/g, '&') diff --git a/src/adapters/inbound/ui/webview/core/App.ts b/src/adapters/inbound/ui/webview/core/App.ts index a3c9f1a..9b5a89a 100644 --- a/src/adapters/inbound/ui/webview/core/App.ts +++ b/src/adapters/inbound/ui/webview/core/App.ts @@ -828,11 +828,84 @@ function renderContentViewState(contentView: ContentView): void { } if (iframe) { + // Track if content loaded successfully + let loadSuccessful = false; + let loadTimeout: ReturnType | null = null; + + const showError = () => { + const loading = document.getElementById('content-loading'); + const error = document.getElementById('content-error'); + if (loading) loading.classList.add('hidden'); + if (error) error.classList.remove('hidden'); + iframe.style.display = 'none'; + }; + + const showContent = () => { + loadSuccessful = true; + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = null; + } + const loading = document.getElementById('content-loading'); + if (loading) loading.classList.add('hidden'); + iframe.style.display = 'block'; + }; + + // Set a timeout to detect if iframe fails to load (X-Frame-Options, CSP blocks) + // Many sites block iframe embedding but don't trigger error events + loadTimeout = setTimeout(() => { + if (!loadSuccessful) { + // After timeout, check if iframe has any accessible content + try { + // Try to access iframe content - will throw for blocked/cross-origin frames + const doc = iframe.contentDocument; + // If we can access and it's empty or about:blank, likely blocked + if (doc && (doc.body?.innerHTML === '' || doc.URL === 'about:blank')) { + showError(); + return; + } + // If accessible and has content, show it + if (doc && doc.body?.innerHTML) { + showContent(); + return; + } + // Cross-origin but might be loaded - give benefit of doubt + showContent(); + } catch { + // Cross-origin access denied - this is normal for loaded external sites + // The iframe might still be showing content, so show it + showContent(); + } + } + }, 5000); + iframe.addEventListener( 'load', () => { - const loading = document.getElementById('content-loading'); - if (loading) loading.classList.add('hidden'); + // iframe loaded - but might be empty if blocked by X-Frame-Options + // Check if we can detect empty content + setTimeout(() => { + try { + const doc = iframe.contentDocument; + // If we can access the document and it's essentially empty, likely blocked + if (doc) { + const bodyContent = doc.body?.innerHTML?.trim() || ''; + const hasContent = bodyContent.length > 0 && doc.URL !== 'about:blank'; + if (hasContent) { + showContent(); + } else { + // Empty content likely means blocked + showError(); + } + } else { + // Can't access document (cross-origin) - assume loaded successfully + showContent(); + } + } catch { + // Cross-origin access denied - iframe is loaded with external content + showContent(); + } + }, 100); }, { signal: getSignal() } ); @@ -840,10 +913,11 @@ function renderContentViewState(contentView: ContentView): void { iframe.addEventListener( 'error', () => { - const loading = document.getElementById('content-loading'); - const error = document.getElementById('content-error'); - if (loading) loading.classList.add('hidden'); - if (error) error.classList.remove('hidden'); + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = null; + } + showError(); }, { signal: getSignal() } );