| author | |
| committer | |
| log | e5fba263e6820350e4edc7e5d93955aabef53df1 |
| tree | 3a20e8c627fd7b0928b50e2062f0914c9e34b238 |
| parent | 18b468871e6a3bfad207109898011ea330afeadb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>3 files changed, 230 insertions(+), 37 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+24-37| ... | ... | @@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm"; |
| 42 | 42 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 43 | 43 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| 44 | 44 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 45 | import { | |
| 46 | resolveExternalWebLinkHost, | |
| 47 | showExternalLinkContextMenu, | |
| 48 | } from "./chat/externalLinkContextMenu"; | |
| 45 | 49 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; |
| 46 | 50 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 47 | 51 | import { Button } from "./ui/button"; |
| ... | ... | @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; |
| 76 | 80 | import { previewEnvironment } from "../state/preview"; |
| 77 | 81 | import { useAtomCommand } from "../state/use-atom-command"; |
| 78 | 82 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 83 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | |
| 79 | 84 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 80 | 85 | import { |
| 81 | 86 | isBrowserPreviewFile, |
| ... | ... | @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; |
| 831 | 836 | /** Hosts whose favicon request already failed this session — skip straight to the globe. */ |
| 832 | 837 | const failedFaviconHosts = new Set<string>(); |
| 833 | 838 | |
| 834 | function resolveExternalLinkHost(href: string | undefined): string | null { | |
| 835 | if (!href) return null; | |
| 836 | try { | |
| 837 | const url = new URL(href); | |
| 838 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; | |
| 839 | return url.hostname || null; | |
| 840 | } catch { | |
| 841 | return null; | |
| 842 | } | |
| 843 | } | |
| 844 | ||
| 845 | 839 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { |
| 846 | 840 | const [failedHost, setFailedHost] = useState<string | null>(null); |
| 847 | 841 | return ( |
| ... | ... | @@ -1393,7 +1387,7 @@ function ChatMarkdown({ |
| 1393 | 1387 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; |
| 1394 | 1388 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; |
| 1395 | 1389 | if (!fileLinkMeta) { |
| 1396 | const faviconHost = resolveExternalLinkHost(href); | |
| 1390 | const faviconHost = resolveExternalWebLinkHost(href); | |
| 1397 | 1391 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 1398 | 1392 | const onClick = props.onClick; |
| 1399 | 1393 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
| ... | ... | @@ -1410,37 +1404,30 @@ function ChatMarkdown({ |
| 1410 | 1404 | } |
| 1411 | 1405 | }} |
| 1412 | 1406 | onContextMenu={(event) => { |
| 1413 | if (!canOpenInPreview || !href) return; | |
| 1407 | if (!canOpenInPreview || !href || !faviconHost) return; | |
| 1414 | 1408 | event.preventDefault(); |
| 1415 | 1409 | event.stopPropagation(); |
| 1416 | 1410 | const api = readLocalApi(); |
| 1417 | 1411 | if (!api) return; |
| 1418 | void (async () => { | |
| 1419 | let operation = "show-link-context-menu"; | |
| 1420 | try { | |
| 1421 | const clicked = await api.contextMenu.show( | |
| 1422 | [ | |
| 1423 | { id: "open-in-browser", label: "Open in integrated browser" }, | |
| 1424 | { id: "open-external", label: "Open in system browser" }, | |
| 1425 | ] as const, | |
| 1426 | { x: event.clientX, y: event.clientY }, | |
| 1427 | ); | |
| 1428 | if (clicked === "open-in-browser") { | |
| 1429 | operation = "open-link-in-preview"; | |
| 1430 | const result = await openExternalLinkInPreview(href); | |
| 1431 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 1432 | reportMarkdownActionFailure({ operation, target: href }, result.cause); | |
| 1433 | } | |
| 1434 | return; | |
| 1412 | void showExternalLinkContextMenu({ | |
| 1413 | href, | |
| 1414 | position: { x: event.clientX, y: event.clientY }, | |
| 1415 | showContextMenu: (items, position) => api.contextMenu.show(items, position), | |
| 1416 | openInPreview: async (target) => { | |
| 1417 | const result = await openExternalLinkInPreview(target); | |
| 1418 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 1419 | reportMarkdownActionFailure( | |
| 1420 | { operation: "open-link-in-preview", target }, | |
| 1421 | result.cause, | |
| 1422 | ); | |
| 1435 | 1423 | } |
| 1436 | if (clicked === "open-external") { | |
| 1437 | operation = "open-link-external"; | |
| 1438 | await api.shell.openExternal(href); | |
| 1439 | } | |
| 1440 | } catch (cause) { | |
| 1424 | }, | |
| 1425 | openExternal: (target) => api.shell.openExternal(target), | |
| 1426 | copyLink: (target) => writeTextToClipboard(target, "link"), | |
| 1427 | reportFailure: (operation, cause) => { | |
| 1441 | 1428 | reportMarkdownActionFailure({ operation, target: href }, cause); |
| 1442 | } | |
| 1443 | })(); | |
| 1429 | }, | |
| 1430 | }); | |
| 1444 | 1431 | }} |
| 1445 | 1432 | > |
| 1446 | 1433 | {faviconHost ? ( |
apps/web/src/components/chat/externalLinkContextMenu.test.ts created+128| ... | ... | @@ -0,0 +1,128 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu"; | |
| 4 | ||
| 5 | function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { | |
| 6 | const showContextMenu = vi.fn().mockResolvedValue(selection); | |
| 7 | const openInPreview = vi.fn().mockResolvedValue(undefined); | |
| 8 | const openExternal = vi.fn().mockResolvedValue(undefined); | |
| 9 | const copyLink = vi.fn().mockResolvedValue(undefined); | |
| 10 | const reportFailure = vi.fn(); | |
| 11 | ||
| 12 | return { | |
| 13 | showContextMenu, | |
| 14 | openInPreview, | |
| 15 | openExternal, | |
| 16 | copyLink, | |
| 17 | reportFailure, | |
| 18 | }; | |
| 19 | } | |
| 20 | ||
| 21 | describe("external chat link context menu", () => { | |
| 22 | it("offers both open actions and Copy Link", async () => { | |
| 23 | const harness = createHarness(null); | |
| 24 | ||
| 25 | await showExternalLinkContextMenu({ | |
| 26 | href: "https://example.com/docs?topic=menus#copy", | |
| 27 | position: { x: 12, y: 24 }, | |
| 28 | ...harness, | |
| 29 | }); | |
| 30 | ||
| 31 | expect(harness.showContextMenu).toHaveBeenCalledWith( | |
| 32 | [ | |
| 33 | { id: "open-in-preview", label: "Open in integrated browser" }, | |
| 34 | { id: "open-external", label: "Open in system browser" }, | |
| 35 | { id: "copy-link", label: "Copy Link" }, | |
| 36 | ], | |
| 37 | { x: 12, y: 24 }, | |
| 38 | ); | |
| 39 | expect(harness.openInPreview).not.toHaveBeenCalled(); | |
| 40 | expect(harness.openExternal).not.toHaveBeenCalled(); | |
| 41 | expect(harness.copyLink).not.toHaveBeenCalled(); | |
| 42 | }); | |
| 43 | ||
| 44 | it("copies the exact destination without opening it", async () => { | |
| 45 | const harness = createHarness("copy-link"); | |
| 46 | const href = "https://example.com/docs?topic=menus#copy"; | |
| 47 | ||
| 48 | await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); | |
| 49 | ||
| 50 | expect(harness.copyLink).toHaveBeenCalledWith(href); | |
| 51 | expect(harness.openInPreview).not.toHaveBeenCalled(); | |
| 52 | expect(harness.openExternal).not.toHaveBeenCalled(); | |
| 53 | }); | |
| 54 | ||
| 55 | it.each([ | |
| 56 | ["open-in-preview" as const, "openInPreview" as const], | |
| 57 | ["open-external" as const, "openExternal" as const], | |
| 58 | ])("preserves the %s action", async (selection, expectedCallback) => { | |
| 59 | const harness = createHarness(selection); | |
| 60 | const href = "https://example.com/docs"; | |
| 61 | ||
| 62 | await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); | |
| 63 | ||
| 64 | expect(harness[expectedCallback]).toHaveBeenCalledWith(href); | |
| 65 | expect(harness.copyLink).not.toHaveBeenCalled(); | |
| 66 | }); | |
| 67 | ||
| 68 | it("reports the selected action when it fails", async () => { | |
| 69 | const harness = createHarness("copy-link"); | |
| 70 | const cause = new Error("clipboard denied"); | |
| 71 | harness.copyLink.mockRejectedValue(cause); | |
| 72 | ||
| 73 | await showExternalLinkContextMenu({ | |
| 74 | href: "https://example.com/docs", | |
| 75 | position: { x: 1, y: 2 }, | |
| 76 | ...harness, | |
| 77 | }); | |
| 78 | ||
| 79 | expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause); | |
| 80 | }); | |
| 81 | ||
| 82 | it("reports the menu operation when the native menu cannot be shown", async () => { | |
| 83 | const harness = createHarness(null); | |
| 84 | const cause = new Error("menu unavailable"); | |
| 85 | harness.showContextMenu.mockRejectedValue(cause); | |
| 86 | ||
| 87 | await showExternalLinkContextMenu({ | |
| 88 | href: "https://example.com/docs", | |
| 89 | position: { x: 1, y: 2 }, | |
| 90 | ...harness, | |
| 91 | }); | |
| 92 | ||
| 93 | expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); | |
| 94 | expect(harness.openInPreview).not.toHaveBeenCalled(); | |
| 95 | expect(harness.openExternal).not.toHaveBeenCalled(); | |
| 96 | expect(harness.copyLink).not.toHaveBeenCalled(); | |
| 97 | }); | |
| 98 | ||
| 99 | it.each([ | |
| 100 | ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"], | |
| 101 | ["open-external" as const, "openExternal" as const, "open-link-external"], | |
| 102 | ])("reports a failed %s action", async (selection, callback, operation) => { | |
| 103 | const harness = createHarness(selection); | |
| 104 | const cause = new Error("open failed"); | |
| 105 | harness[callback].mockRejectedValue(cause); | |
| 106 | ||
| 107 | await showExternalLinkContextMenu({ | |
| 108 | href: "https://example.com/docs", | |
| 109 | position: { x: 1, y: 2 }, | |
| 110 | ...harness, | |
| 111 | }); | |
| 112 | ||
| 113 | expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause); | |
| 114 | }); | |
| 115 | ||
| 116 | it.each([ | |
| 117 | ["https://example.com", "example.com"], | |
| 118 | ["http://localhost:3000/path", "localhost"], | |
| 119 | ["#details", null], | |
| 120 | ["mailto:hello@example.com", null], | |
| 121 | ["file:///tmp/example.txt", null], | |
| 122 | ["javascript:void(0)", null], | |
| 123 | ["not a URL", null], | |
| 124 | [undefined, null], | |
| 125 | ])("resolves the external web-link host for %s as %s", (href, expected) => { | |
| 126 | expect(resolveExternalWebLinkHost(href)).toBe(expected); | |
| 127 | }); | |
| 128 | }); |
apps/web/src/components/chat/externalLinkContextMenu.ts created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link"; | |
| 4 | ||
| 5 | export type ExternalLinkContextMenuFailureOperation = | |
| 6 | | "show-link-context-menu" | |
| 7 | | "open-link-in-preview" | |
| 8 | | "open-link-external" | |
| 9 | | "copy-link"; | |
| 10 | ||
| 11 | const FAILURE_OPERATION_BY_ACTION = { | |
| 12 | "open-in-preview": "open-link-in-preview", | |
| 13 | "open-external": "open-link-external", | |
| 14 | "copy-link": "copy-link", | |
| 15 | } as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>; | |
| 16 | ||
| 17 | const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ | |
| 18 | { id: "open-in-preview", label: "Open in integrated browser" }, | |
| 19 | { id: "open-external", label: "Open in system browser" }, | |
| 20 | { id: "copy-link", label: "Copy Link" }, | |
| 21 | ] as const satisfies readonly ContextMenuItem<ExternalLinkContextMenuAction>[]; | |
| 22 | ||
| 23 | interface ShowExternalLinkContextMenuOptions { | |
| 24 | readonly href: string; | |
| 25 | readonly position: { readonly x: number; readonly y: number }; | |
| 26 | readonly showContextMenu: ( | |
| 27 | items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[], | |
| 28 | position: { readonly x: number; readonly y: number }, | |
| 29 | ) => Promise<ExternalLinkContextMenuAction | null>; | |
| 30 | readonly openInPreview: (href: string) => Promise<void>; | |
| 31 | readonly openExternal: (href: string) => Promise<void>; | |
| 32 | readonly copyLink: (href: string) => Promise<unknown>; | |
| 33 | readonly reportFailure: ( | |
| 34 | operation: ExternalLinkContextMenuFailureOperation, | |
| 35 | cause: unknown, | |
| 36 | ) => void; | |
| 37 | } | |
| 38 | ||
| 39 | export function resolveExternalWebLinkHost(href: string | undefined): string | null { | |
| 40 | if (!href) return null; | |
| 41 | try { | |
| 42 | const url = new URL(href); | |
| 43 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; | |
| 44 | return url.hostname || null; | |
| 45 | } catch { | |
| 46 | return null; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export async function showExternalLinkContextMenu({ | |
| 51 | href, | |
| 52 | position, | |
| 53 | showContextMenu, | |
| 54 | openInPreview, | |
| 55 | openExternal, | |
| 56 | copyLink, | |
| 57 | reportFailure, | |
| 58 | }: ShowExternalLinkContextMenuOptions): Promise<void> { | |
| 59 | let action: ExternalLinkContextMenuAction | null; | |
| 60 | try { | |
| 61 | action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); | |
| 62 | } catch (cause) { | |
| 63 | reportFailure("show-link-context-menu", cause); | |
| 64 | return; | |
| 65 | } | |
| 66 | ||
| 67 | try { | |
| 68 | if (action === "open-in-preview") { | |
| 69 | await openInPreview(href); | |
| 70 | } else if (action === "open-external") { | |
| 71 | await openExternal(href); | |
| 72 | } else if (action === "copy-link") { | |
| 73 | await copyLink(href); | |
| 74 | } | |
| 75 | } catch (cause) { | |
| 76 | if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause); | |
| 77 | } | |
| 78 | } |