diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..fac3bf7d245 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + resolveExternalWebLinkHost, + showExternalLinkContextMenu, +} from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); -function resolveExternalLinkHost(href: string | undefined): string | null { - if (!href) return null; - try { - const url = new URL(href); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.hostname || null; - } catch { - return null; - } -} - const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1393,7 +1387,7 @@ function ChatMarkdown({ const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { - const faviconHost = resolveExternalLinkHost(href); + const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -1410,37 +1404,30 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href) return; + if (!canOpenInPreview || !href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void (async () => { - let operation = "show-link-context-menu"; - try { - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - operation = "open-link-in-preview"; - const result = await openExternalLinkInPreview(href); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure({ operation, target: href }, result.cause); - } - return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInPreview: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - if (clicked === "open-external") { - operation = "open-link-external"; - await api.shell.openExternal(href); - } - } catch (cause) { + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); - } - })(); + }, + }); }} > {faviconHost ? ( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts new file mode 100644 index 00000000000..64935d53e46 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu"; + +function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { + const showContextMenu = vi.fn().mockResolvedValue(selection); + const openInPreview = vi.fn().mockResolvedValue(undefined); + const openExternal = vi.fn().mockResolvedValue(undefined); + const copyLink = vi.fn().mockResolvedValue(undefined); + const reportFailure = vi.fn(); + + return { + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, + }; +} + +describe("external chat link context menu", () => { + it("offers both open actions and Copy Link", async () => { + const harness = createHarness(null); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs?topic=menus#copy", + position: { x: 12, y: 24 }, + ...harness, + }); + + expect(harness.showContextMenu).toHaveBeenCalledWith( + [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, + ], + { x: 12, y: 24 }, + ); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("copies the exact destination without opening it", async () => { + const harness = createHarness("copy-link"); + const href = "https://example.com/docs?topic=menus#copy"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness.copyLink).toHaveBeenCalledWith(href); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const], + ["open-external" as const, "openExternal" as const], + ])("preserves the %s action", async (selection, expectedCallback) => { + const harness = createHarness(selection); + const href = "https://example.com/docs"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness[expectedCallback]).toHaveBeenCalledWith(href); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("reports the selected action when it fails", async () => { + const harness = createHarness("copy-link"); + const cause = new Error("clipboard denied"); + harness.copyLink.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause); + }); + + it("reports the menu operation when the native menu cannot be shown", async () => { + const harness = createHarness(null); + const cause = new Error("menu unavailable"); + harness.showContextMenu.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); + expect(harness.openInPreview).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"], + ["open-external" as const, "openExternal" as const, "open-link-external"], + ])("reports a failed %s action", async (selection, callback, operation) => { + const harness = createHarness(selection); + const cause = new Error("open failed"); + harness[callback].mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause); + }); + + it.each([ + ["https://example.com", "example.com"], + ["http://localhost:3000/path", "localhost"], + ["#details", null], + ["mailto:hello@example.com", null], + ["file:///tmp/example.txt", null], + ["javascript:void(0)", null], + ["not a URL", null], + [undefined, null], + ])("resolves the external web-link host for %s as %s", (href, expected) => { + expect(resolveExternalWebLinkHost(href)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts new file mode 100644 index 00000000000..398ca40da51 --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -0,0 +1,78 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link"; + +export type ExternalLinkContextMenuFailureOperation = + | "show-link-context-menu" + | "open-link-in-preview" + | "open-link-external" + | "copy-link"; + +const FAILURE_OPERATION_BY_ACTION = { + "open-in-preview": "open-link-in-preview", + "open-external": "open-link-external", + "copy-link": "copy-link", +} as const satisfies Record; + +const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ + { id: "open-in-preview", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, +] as const satisfies readonly ContextMenuItem[]; + +interface ShowExternalLinkContextMenuOptions { + readonly href: string; + readonly position: { readonly x: number; readonly y: number }; + readonly showContextMenu: ( + items: readonly ContextMenuItem[], + position: { readonly x: number; readonly y: number }, + ) => Promise; + readonly openInPreview: (href: string) => Promise; + readonly openExternal: (href: string) => Promise; + readonly copyLink: (href: string) => Promise; + readonly reportFailure: ( + operation: ExternalLinkContextMenuFailureOperation, + cause: unknown, + ) => void; +} + +export function resolveExternalWebLinkHost(href: string | undefined): string | null { + if (!href) return null; + try { + const url = new URL(href); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.hostname || null; + } catch { + return null; + } +} + +export async function showExternalLinkContextMenu({ + href, + position, + showContextMenu, + openInPreview, + openExternal, + copyLink, + reportFailure, +}: ShowExternalLinkContextMenuOptions): Promise { + let action: ExternalLinkContextMenuAction | null; + try { + action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); + } catch (cause) { + reportFailure("show-link-context-menu", cause); + return; + } + + try { + if (action === "open-in-preview") { + await openInPreview(href); + } else if (action === "open-external") { + await openExternal(href); + } else if (action === "copy-link") { + await copyLink(href); + } + } catch (cause) { + if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause); + } +}