| 1 | // A file the answer named, rendered as a first-class chat file reference. |
| 2 | // |
| 3 | // Left click goes to the right-hand preview and leaves the conversation where |
| 4 | // it is; the context menu keeps the actions that need a deliberate choice — |
| 5 | // source, reveal in the file tree, copy path, save a copy, or hand the file to |
| 6 | // the system. Every action re-verifies through the host, so a reference that |
| 7 | // has since been deleted reports itself instead of opening something else. |
| 8 | |
| 9 | import { useCallback, useMemo, useState, type ReactNode } from "react"; |
| 10 | import { Code2, Copy, ExternalLink, FileText, FolderSearch, FolderOpen, Save } from "lucide-react"; |
| 11 | import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu"; |
| 12 | import { useT } from "../lib/i18n"; |
| 13 | import { useToast } from "../lib/toast"; |
| 14 | import { writeClipboardText } from "../lib/clipboard"; |
| 15 | import { fileResourceCapabilities } from "../lib/fileResource"; |
| 16 | import { openResource, performResourceAction, resolveFileResourcePath } from "../lib/presentedFileNavigation"; |
| 17 | import type { ChatFileLink } from "./ChatFileLinkContext"; |
| 18 | |
| 19 | function errorText(reason: unknown): string { |
| 20 | return reason instanceof Error ? reason.message : String(reason); |
| 21 | } |
| 22 | |
| 23 | /** The shared menu and click behavior behind every rendered file reference. */ |
| 24 | export function useChatFileLinkActions(link: ChatFileLink) { |
| 25 | const t = useT(); |
| 26 | const { showToast } = useToast(); |
| 27 | const [point, setPoint] = useState<ContextMenuPoint | null>(null); |
| 28 | const close = useCallback(() => setPoint(null), []); |
| 29 | const capabilities = useMemo(() => fileResourceCapabilities(link.ref, link.actions), [link.actions, link.ref]); |
| 30 | |
| 31 | const run = useCallback(async (action: "preview" | "source" | "reveal-tree" | "open-native" | "reveal-native" | "save-copy") => { |
| 32 | try { |
| 33 | const outcome = action === "preview" || action === "source" |
| 34 | ? await openResource(link.ref, { view: action }) |
| 35 | : await performResourceAction(link.ref, action); |
| 36 | if (outcome.status === "failed") throw outcome.error; |
| 37 | } catch (reason) { |
| 38 | showToast(t("chat.fileActionFailed", { error: errorText(reason) }), "error"); |
| 39 | } |
| 40 | }, [link.ref, showToast, t]); |
| 41 | |
| 42 | const copyPath = useCallback(async () => { |
| 43 | try { |
| 44 | if (!await writeClipboardText(await resolveFileResourcePath(link.ref))) throw new Error(t("present.copyFailed")); |
| 45 | } catch (reason) { |
| 46 | showToast(t("chat.fileActionFailed", { error: errorText(reason) }), "error"); |
| 47 | } |
| 48 | }, [link.ref, showToast, t]); |
| 49 | |
| 50 | const menuItems = useMemo<ContextMenuItem[]>(() => [ |
| 51 | { key: "preview", icon: <FileText size={13} />, label: t("present.open"), onSelect: () => { close(); void run("preview"); } }, |
| 52 | ...(capabilities.source ? [{ key: "source", icon: <Code2 size={13} />, label: t("present.source"), onSelect: () => { close(); void run("source"); } }] : []), |
| 53 | ...(capabilities.revealTree ? [{ key: "reveal-tree", icon: <FolderSearch size={13} />, label: t("present.revealTree"), onSelect: () => { close(); void run("reveal-tree"); } }] : []), |
| 54 | { type: "separator" as const, key: "path-separator" }, |
| 55 | ...(capabilities.copyPath ? [{ key: "copy-path", icon: <Copy size={13} />, label: t("present.copyPath"), onSelect: () => { close(); void copyPath(); } }] : []), |
| 56 | ...(capabilities.saveCopy ? [{ key: "save-copy", icon: <Save size={13} />, label: t("present.saveCopy"), onSelect: () => { close(); void run("save-copy"); } }] : []), |
| 57 | ...(capabilities.openNative ? [{ key: "open-native", icon: <ExternalLink size={13} />, label: t("present.openNative"), onSelect: () => { close(); void run("open-native"); } }] : []), |
| 58 | ...(capabilities.revealNative ? [{ key: "reveal-native", icon: <FolderOpen size={13} />, label: t("present.revealNative"), onSelect: () => { close(); void run("reveal-native"); } }] : []), |
| 59 | ], [capabilities, close, copyPath, run, t]); |
| 60 | |
| 61 | const onContextMenu = useCallback((event: { preventDefault(): void; stopPropagation(): void; clientX: number; clientY: number }) => { |
| 62 | event.preventDefault(); |
| 63 | event.stopPropagation(); |
| 64 | setPoint(contextMenuPointFromEvent(event as never)); |
| 65 | }, []); |
| 66 | |
| 67 | const menu = <ContextMenu open={point !== null} point={point} items={menuItems} onClose={close} minWidth={220} ariaLabel={t("present.more")} />; |
| 68 | return { open: run, onContextMenu, menu, capabilities }; |
| 69 | } |
| 70 | |
| 71 | export function ChatFileReferenceAnchor({ link, href, children }: { link: ChatFileLink; href: string; children: ReactNode }) { |
| 72 | const { open, onContextMenu, menu } = useChatFileLinkActions(link); |
| 73 | return <> |
| 74 | <a |
| 75 | className="md-rich-link md-rich-link--local" |
| 76 | href={href} |
| 77 | title={link.path} |
| 78 | onClick={(event) => { event.preventDefault(); void open("preview"); }} |
| 79 | onAuxClick={(event) => event.button === 1 && event.preventDefault()} |
| 80 | onMouseDown={(event) => { if (event.button === 1) event.preventDefault(); }} |
| 81 | onContextMenu={onContextMenu} |
| 82 | > |
| 83 | <ExternalLink aria-hidden="true" size={13} strokeWidth={2} /> |
| 84 | <span className="md-rich-link__label">{children}</span> |
| 85 | </a> |
| 86 | {menu} |
| 87 | </>; |
| 88 | } |
| 89 | |
| 90 | export function ChatFileReferenceCode({ link, children }: { link: ChatFileLink; children: ReactNode }) { |
| 91 | const { open, onContextMenu, menu } = useChatFileLinkActions(link); |
| 92 | return <> |
| 93 | <button type="button" className="md-code md-code--presented-file" title={link.path} |
| 94 | onClick={() => void open("preview")} onContextMenu={onContextMenu}>{children}</button> |
| 95 | {menu} |
| 96 | </>; |
| 97 | } |
| 98 |