返回 DeepSeek-Reasonix
PresentedFiles.tsx
根目录 / desktop / frontend / src / components / PresentedFiles.tsx
1 import { useEffect, useRef, useState } from "react";
2 import {
3 ChevronDown, ChevronUp, Code2, ExternalLink, FileArchive, FileAudio,
4 FileImage, FileText, FileVideo, FolderSearch, Globe, MoreHorizontal, Save,
5 } from "lucide-react";
6 import type { PresentedFileView } from "../lib/chatViewSource";
7 import type { TurnFileView } from "../lib/turnFiles";
8 import { useT } from "../lib/i18n";
9 import {
10 openResource, performResourceAction, resolveFileResourcePath,
11 type FileResourceRef, type PresentedFileAction,
12 } from "../lib/presentedFileNavigation";
13 import { fileResourceCapabilities } from "../lib/fileResource";
14 import { writeClipboardText } from "../lib/clipboard";
15 import "./PresentedFiles.css";
16
17 const basename = (path: string) => path.replaceAll("\\", "/").split("/").filter(Boolean).pop() || path;
18 const extension = (path: string) => basename(path).split(".").pop()?.toLowerCase() ?? "";
19
20 function iconFor(path: string) {
21 const ext = extension(path);
22 if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"].includes(ext)) return FileImage;
23 if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext)) return FileAudio;
24 if (["mp4", "webm", "mov", "m4v", "ogv"].includes(ext)) return FileVideo;
25 if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return FileArchive;
26 if (["js", "jsx", "ts", "tsx", "go", "rs", "py", "java", "c", "cpp", "css", "html", "htm", "json", "csv", "md"].includes(ext)) return Code2;
27 return FileText;
28 }
29
30 export function PresentedFiles({ files, tabId, hostId }: { files: readonly PresentedFileView[]; tabId?: string; hostId?: string }) {
31 const t = useT();
32 const [expanded, setExpanded] = useState(false);
33 const shown = expanded ? files : files.slice(0, 4);
34 if (!files.length) return null;
35 return <section className="presented-files" aria-label={t("present.files")}>
36 <div className="presented-files__grid">
37 {shown.map(file => <FileEntry key={file.path} description={file.description}
38 refValue={{ source: "presented", hostId: hostId ?? "local", tabId: tabId ?? "", toolCallId: file.toolCallId, path: file.path }} />)}
39 </div>
40 {files.length > 4 && <button type="button" className="presented-files__toggle" onClick={() => setExpanded(value => !value)}>
41 {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
42 {t(expanded ? "present.collapse" : "present.showAll", { count: files.length })}
43 </button>}
44 </section>;
45 }
46
47 export function ModifiedFiles({ files, tabId, hostId }: { files: readonly TurnFileView[]; tabId?: string; hostId?: string }) {
48 const t = useT();
49 const [expanded, setExpanded] = useState(false);
50 const shown = expanded ? files : files.slice(0, 6);
51 if (!files.length) return null;
52 return <section className="turn-files" aria-label={t("present.modifiedFiles")}>
53 <strong className="turn-files__title">{t("present.modifiedFiles")}</strong>
54 <div className="turn-files__list">
55 {shown.map(file => <FileEntry key={file.path} compact
56 description={t(file.operation === "modified" ? "present.modified" : "present.written")}
57 refValue={{ source: "workspace", hostId: hostId ?? "local", tabId: tabId ?? "", toolCallId: file.toolCallId, path: file.path }} />)}
58 </div>
59 {files.length > 6 && <button type="button" className="presented-files__toggle" onClick={() => setExpanded(value => !value)}>
60 {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
61 {t(expanded ? "present.collapse" : "present.showAll", { count: files.length })}
62 </button>}
63 </section>;
64 }
65
66 function FileEntry({ refValue, description, compact = false }: { refValue: FileResourceRef; description?: string; compact?: boolean }) {
67 const t = useT();
68 const [menu, setMenu] = useState(false);
69 const [error, setError] = useState("");
70 const [busy, setBusy] = useState(false);
71 const menuRoot = useRef<HTMLDivElement>(null);
72 const menuTrigger = useRef<HTMLButtonElement>(null);
73 const Icon = iconFor(refValue.path);
74 const capabilities = fileResourceCapabilities(refValue);
75 useEffect(() => {
76 if (!menu) return;
77 menuRoot.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
78 const onPointerDown = (event: PointerEvent) => {
79 if (!menuRoot.current?.contains(event.target as Node)) setMenu(false);
80 };
81 const onKeyDown = (event: KeyboardEvent) => {
82 if (event.key === "Escape") {
83 event.preventDefault(); setMenu(false); menuTrigger.current?.focus(); return;
84 }
85 if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
86 const items = Array.from(menuRoot.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []);
87 if (!items.length) return;
88 event.preventDefault();
89 const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement));
90 const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
91 : event.key === "ArrowDown" ? (current + 1) % items.length : (current - 1 + items.length) % items.length;
92 items[next]?.focus();
93 };
94 window.addEventListener("pointerdown", onPointerDown);
95 window.addEventListener("keydown", onKeyDown);
96 return () => {
97 window.removeEventListener("pointerdown", onPointerDown);
98 window.removeEventListener("keydown", onKeyDown);
99 };
100 }, [menu]);
101 const run = async (action: PresentedFileAction) => {
102 setMenu(false); setError(""); setBusy(true);
103 try {
104 const outcome = action === "preview" || action === "source" || action === "browser"
105 ? await openResource(refValue, { view: action })
106 : await performResourceAction(refValue, action);
107 // A cancelled command reports nothing: it lost its dock rather than
108 // failing, and the row must not claim an error the user never hit.
109 if (outcome.status === "failed") setError(outcome.error.message);
110 }
111 catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
112 finally { setBusy(false); }
113 };
114 const copyPath = async () => {
115 setMenu(false); setError(""); setBusy(true);
116 try {
117 const path = await resolveFileResourcePath(refValue);
118 if (!await writeClipboardText(path)) throw new Error(t("present.copyFailed"));
119 } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
120 finally { setBusy(false); }
121 };
122 return <article className={compact ? "presented-file presented-file--compact" : "presented-file"} title={refValue.path} aria-busy={busy || undefined}>
123 <button type="button" className="presented-file__main" disabled={busy} onClick={() => void run("preview")}>
124 <span className="presented-file__icon"><Icon size={compact ? 16 : 20} /></span>
125 <span className="presented-file__copy"><strong>{basename(refValue.path)}</strong>{description && <small>{description}</small>}</span>
126 </button>
127 {!compact && <button type="button" className="presented-file__open" disabled={busy} onClick={() => void run("preview")}>{t(busy ? "chat.loading" : "present.open")}</button>}
128 <div className="presented-file__menu-wrap" ref={menuRoot}>
129 <button ref={menuTrigger} type="button" className="presented-file__more" disabled={busy} aria-label={t("present.more")} aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(value => !value)}><MoreHorizontal size={17} /></button>
130 {menu && <div className="presented-file__menu" role="menu">
131 {capabilities.browser && <MenuItem icon={Globe} label={t("present.browser")} onClick={() => void run("browser")} />}
132 {capabilities.revealTree && <MenuItem icon={FolderSearch} label={t("present.revealTree")} onClick={() => void run("reveal-tree")} />}
133 {capabilities.source && <MenuItem icon={Code2} label={t("present.source")} onClick={() => void run("source")} />}
134 {capabilities.copyPath && <MenuItem icon={FileText} label={t("present.copyPath")} onClick={() => void copyPath()} />}
135 {capabilities.openNative && <MenuItem icon={ExternalLink} label={t("present.openNative")} onClick={() => void run("open-native")} />}
136 {capabilities.revealNative && <MenuItem icon={FolderSearch} label={t("present.revealNative")} onClick={() => void run("reveal-native")} />}
137 {capabilities.saveCopy && <MenuItem icon={Save} label={t("present.saveCopy")} onClick={() => void run("save-copy")} />}
138 </div>}
139 </div>
140 {error && <p className="presented-file__error" role="status">{error}</p>}
141 </article>;
142 }
143
144 function MenuItem({ icon: Icon, label, onClick }: { icon: typeof FileText; label: string; onClick: () => void }) {
145 return <button type="button" role="menuitem" onClick={onClick}><Icon size={14} /><span>{label}</span></button>;
146 }
147
147 lines Plain Text