diff --git a/apps/desktop/src-electron/main/window.test.ts b/apps/desktop/src-electron/main/window.test.ts index 1259eb7b..68dfab31 100644 --- a/apps/desktop/src-electron/main/window.test.ts +++ b/apps/desktop/src-electron/main/window.test.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveRendererDevUrl, resolveWindowIconPath } from "./window"; +import { + resolveRendererDevUrl, + resolveWindowIconPath, + shouldOpenInSystemBrowser, +} from "./window"; describe("resolveRendererDevUrl", () => { it("returns the dev URL for unpackaged builds", () => { @@ -65,3 +69,21 @@ describe("resolveWindowIconPath", () => { ).toBeUndefined(); }); }); + +describe("shouldOpenInSystemBrowser", () => { + it("allows website and email links", () => { + expect(shouldOpenInSystemBrowser("https://example.com/docs")).toBe( + true, + ); + expect(shouldOpenInSystemBrowser("http://localhost:3000")).toBe(true); + expect(shouldOpenInSystemBrowser("mailto:team@example.com")).toBe(true); + }); + + it("blocks app, file, and malformed URLs", () => { + expect(shouldOpenInSystemBrowser("neverwrite://clip")).toBe(false); + expect(shouldOpenInSystemBrowser("file:///Users/test/note.md")).toBe( + false, + ); + expect(shouldOpenInSystemBrowser("not a url")).toBe(false); + }); +}); diff --git a/apps/desktop/src-electron/main/window.ts b/apps/desktop/src-electron/main/window.ts index b0f4be9c..c7efd04b 100644 --- a/apps/desktop/src-electron/main/window.ts +++ b/apps/desktop/src-electron/main/window.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { app, BrowserWindow, nativeTheme } from "electron"; +import { app, BrowserWindow, nativeTheme, shell } from "electron"; import { ELECTRON_IPC } from "../shared/ipc"; import { writeAppLog } from "./appLogger"; import { removeWindowVaultRoute } from "./shellState"; @@ -44,6 +44,7 @@ function readTrafficLightPosition( const windowsByLabel = new Map(); const labelsByWebContentsId = new Map(); +const SYSTEM_BROWSER_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); function preloadPath() { return fileURLToPath( @@ -174,6 +175,30 @@ function getOptionalNumber( : undefined; } +export function shouldOpenInSystemBrowser(url: string) { + try { + return SYSTEM_BROWSER_PROTOCOLS.has(new URL(url).protocol); + } catch { + return false; + } +} + +function bindExternalWindowOpenHandler(window: BrowserWindow) { + window.webContents.setWindowOpenHandler(({ url }) => { + if (shouldOpenInSystemBrowser(url)) { + void shell.openExternal(url).catch((error: unknown) => { + writeAppLog("main", "error", "Failed to open external link", { + url, + error: + error instanceof Error ? error.message : String(error), + }); + }); + } + + return { action: "deny" }; + }); +} + function bindWindowLifecycle(label: string, window: BrowserWindow) { const webContentsId = window.webContents.id; windowsByLabel.set(label, window); @@ -384,6 +409,7 @@ export function createAppWindow( }, }); + bindExternalWindowOpenHandler(window); bindWindowLifecycle(label, window); const rendererEntry = resolveRendererEntry(search); diff --git a/apps/desktop/src/features/ai/components/MarkdownContent.test.tsx b/apps/desktop/src/features/ai/components/MarkdownContent.test.tsx index 23b629e0..fe08ac57 100644 --- a/apps/desktop/src/features/ai/components/MarkdownContent.test.tsx +++ b/apps/desktop/src/features/ai/components/MarkdownContent.test.tsx @@ -71,6 +71,23 @@ describe("MarkdownContent", () => { ).not.toBeInTheDocument(); }); + it("renders raw http and https URLs as external links", () => { + renderComponent( + , + ); + + expect( + screen.getByRole("link", { name: "https://example.com/docs" }), + ).toHaveAttribute("href", "https://example.com/docs"); + expect( + screen.getByRole("link", { name: "http://localhost:3000" }), + ).toHaveAttribute("href", "http://localhost:3000"); + expect(document.body).toHaveTextContent("http://localhost:3000."); + }); + it("opens relative markdown text file links in a new tab from the context menu", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockImplementation(async (command, args) => { diff --git a/apps/desktop/src/features/ai/components/MarkdownContent.tsx b/apps/desktop/src/features/ai/components/MarkdownContent.tsx index 80fefc45..62a31989 100644 --- a/apps/desktop/src/features/ai/components/MarkdownContent.tsx +++ b/apps/desktop/src/features/ai/components/MarkdownContent.tsx @@ -328,6 +328,34 @@ type InlineContextMenuHandler = ( reference: string, ) => void; +function renderExternalLink(key: number, href: string, label = href) { + return ( + + {label} + + ); +} + +function splitTrailingUrlPunctuation(url: string) { + const match = /^(.+?)([.,!?;:]*)$/.exec(url); + return { + href: match?.[1] ?? url, + trailing: match?.[2] ?? "", + }; +} + function renderInlineMarkdown( text: string, pillMetrics: ChatPillMetrics, @@ -337,9 +365,9 @@ function renderInlineMarkdown( onFileContextMenu?: InlineContextMenuHandler, ): Array { const parts: Array = []; - // Process: wikilinks, inline code, bold, italic, links, and absolute vault file paths. + // Process: wikilinks, inline code, bold, italic, links, raw URLs, and absolute vault file paths. const inlineRegex = - /(\[\[[^\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]+\))|((?"']+)|((?, ); } else { - parts.push( - - {linkMatch[1]} - , - ); + parts.push(renderExternalLink(key, url, linkMatch[1])); } } } else if (match[6]) { + const { href, trailing } = splitTrailingUrlPunctuation(full); + parts.push(renderExternalLink(key, href)); + if (trailing) { + parts.push(trailing); + } + } else if (match[7]) { // Absolute vault file path. const filePath = safeDecodeUriComponent(full); const excalidrawRef = parseExcalidrawReference(filePath);