| 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,6 +42,10 @@ import remarkGfm from "remark-gfm"; |
| 42 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; | 42 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 43 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; | 43 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| 44 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; | 44 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 45 | import { | ||
| 46 | resolveExternalWebLinkHost, | ||
| 47 | showExternalLinkContextMenu, | ||
| 48 | } from "./chat/externalLinkContextMenu"; | ||
| 45 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; | 49 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; |
| 46 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 50 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 47 | import { Button } from "./ui/button"; | 51 | import { Button } from "./ui/button"; |
| ... | @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; | ... | @@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session"; |
| 76 | import { previewEnvironment } from "../state/preview"; | 80 | import { previewEnvironment } from "../state/preview"; |
| 77 | import { useAtomCommand } from "../state/use-atom-command"; | 81 | import { useAtomCommand } from "../state/use-atom-command"; |
| 78 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 82 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 83 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | ||
| 79 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | 84 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 80 | import { | 85 | import { |
| 81 | isBrowserPreviewFile, | 86 | isBrowserPreviewFile, |
| ... | @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; | ... | @@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; |
| 831 | /** Hosts whose favicon request already failed this session — skip straight to the globe. */ | 836 | /** Hosts whose favicon request already failed this session — skip straight to the globe. */ |
| 832 | const failedFaviconHosts = new Set<string>(); | 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 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { | 839 | const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { |
| 846 | const [failedHost, setFailedHost] = useState<string | null>(null); | 840 | const [failedHost, setFailedHost] = useState<string | null>(null); |
| 847 | return ( | 841 | return ( |
| ... | @@ -1393,7 +1387,7 @@ function ChatMarkdown({ | ... | @@ -1393,7 +1387,7 @@ function ChatMarkdown({ |
| 1393 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; | 1387 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; |
| 1394 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; | 1388 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; |
| 1395 | if (!fileLinkMeta) { | 1389 | if (!fileLinkMeta) { |
| 1396 | const faviconHost = resolveExternalLinkHost(href); | 1390 | const faviconHost = resolveExternalWebLinkHost(href); |
| 1397 | const isSameDocumentLink = href?.startsWith("#") ?? false; | 1391 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 1398 | const onClick = props.onClick; | 1392 | const onClick = props.onClick; |
| 1399 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); | 1393 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
| ... | @@ -1410,37 +1404,30 @@ function ChatMarkdown({ | ... | @@ -1410,37 +1404,30 @@ function ChatMarkdown({ |
| 1410 | } | 1404 | } |
| 1411 | }} | 1405 | }} |
| 1412 | onContextMenu={(event) => { | 1406 | onContextMenu={(event) => { |
| 1413 | if (!canOpenInPreview || !href) return; | 1407 | if (!canOpenInPreview || !href || !faviconHost) return; |
| 1414 | event.preventDefault(); | 1408 | event.preventDefault(); |
| 1415 | event.stopPropagation(); | 1409 | event.stopPropagation(); |
| 1416 | const api = readLocalApi(); | 1410 | const api = readLocalApi(); |
| 1417 | if (!api) return; | 1411 | if (!api) return; |
| 1418 | void (async () => { | 1412 | void showExternalLinkContextMenu({ |
| 1419 | let operation = "show-link-context-menu"; | 1413 | href, |
| 1420 | try { | 1414 | position: { x: event.clientX, y: event.clientY }, |
| 1421 | const clicked = await api.contextMenu.show( | 1415 | showContextMenu: (items, position) => api.contextMenu.show(items, position), |
| 1422 | [ | 1416 | openInPreview: async (target) => { |
| 1423 | { id: "open-in-browser", label: "Open in integrated browser" }, | 1417 | const result = await openExternalLinkInPreview(target); |
| 1424 | { id: "open-external", label: "Open in system browser" }, | 1418 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 1425 | ] as const, | 1419 | reportMarkdownActionFailure( |
| 1426 | { x: event.clientX, y: event.clientY }, | 1420 | { operation: "open-link-in-preview", target }, |
| 1427 | ); | 1421 | result.cause, |
| 1428 | if (clicked === "open-in-browser") { | 1422 | ); |
| 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; | ||
| 1435 | } | 1423 | } |
| 1436 | if (clicked === "open-external") { | 1424 | }, |
| 1437 | operation = "open-link-external"; | 1425 | openExternal: (target) => api.shell.openExternal(target), |
| 1438 | await api.shell.openExternal(href); | 1426 | copyLink: (target) => writeTextToClipboard(target, "link"), |
| 1439 | } | 1427 | reportFailure: (operation, cause) => { |
| 1440 | } catch (cause) { | ||
| 1441 | reportMarkdownActionFailure({ operation, target: href }, cause); | 1428 | reportMarkdownActionFailure({ operation, target: href }, cause); |
| 1442 | } | 1429 | }, |
| 1443 | })(); | 1430 | }); |
| 1444 | }} | 1431 | }} |
| 1445 | > | 1432 | > |
| 1446 | {faviconHost ? ( | 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 | } | ||