返回 DeepSeek-Reasonix
ChatFileLink.tsx
根目录 / desktop / frontend / src / components / ChatFileLink.tsx
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
98 lines Plain Text