返回 DeepSeek-Reasonix
PresentedFiles.tsx
根目录 / desktop / frontend / src / components / PresentedFiles.tsx
1 import { ErrorMessage } from "./ErrorMessage";
2 import { memo, useState } from "react";
3 import {
4 ChevronDown, ChevronUp, Code2, ExternalLink, FileArchive, FileAudio,
5 FileImage, FileText, FileVideo, FolderSearch, Globe, Save,
6 } from "lucide-react";
7 import type { PresentedFileView } from "../lib/chatViewSource";
8 import type { TurnFileView } from "../lib/turnFiles";
9 import type { WireCompletionSummary } from "../lib/types";
10 import { useT } from "../lib/i18n";
11 import {
12 openResource, performResourceAction, resolveFileResourcePath,
13 type FileResourceRef, type PresentedFileAction,
14 } from "../lib/presentedFileNavigation";
15 import { fileResourceCapabilities } from "../lib/fileResource";
16 import { writeClipboardText } from "../lib/clipboard";
17 import { ContextMenu, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
18 import "./PresentedFiles.css";
19
20 const basename = (path: string) => path.replaceAll("\\", "/").split("/").filter(Boolean).pop() || path;
21 const extension = (path: string) => basename(path).split(".").pop()?.toLowerCase() ?? "";
22
23 function iconFor(path: string) {
24 const ext = extension(path);
25 if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"].includes(ext)) return FileImage;
26 if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext)) return FileAudio;
27 if (["mp4", "webm", "mov", "m4v", "ogv"].includes(ext)) return FileVideo;
28 if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return FileArchive;
29 if (["js", "jsx", "ts", "tsx", "go", "rs", "py", "java", "c", "cpp", "css", "html", "htm", "json", "csv", "md"].includes(ext)) return Code2;
30 return FileText;
31 }
32
33 export const PresentedFiles = memo(function PresentedFiles({ files, tabId, hostId }: { files: readonly PresentedFileView[]; tabId?: string; hostId?: string }) {
34 const t = useT();
35 const [expanded, setExpanded] = useState(false);
36 const shown = expanded ? files : files.slice(0, 4);
37 if (!files.length) return null;
38 return <section className="presented-files" aria-label={t("present.files")}>
39 <div className="presented-files__grid">
40 {shown.map(file => <FileEntry key={JSON.stringify([hostId, tabId, file.toolCallId, file.path])} description={file.description}
41 refValue={{ source: "presented", hostId: hostId ?? "local", tabId: tabId ?? "", toolCallId: file.toolCallId, path: file.path }} />)}
42 </div>
43 {files.length > 4 && <button type="button" className="presented-files__toggle" onClick={() => setExpanded(value => !value)}>
44 {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
45 {t(expanded ? "present.collapse" : "present.showAll", { count: files.length })}
46 </button>}
47 </section>;
48 });
49
50 export const ModifiedFiles = memo(function ModifiedFiles({ files, summary, onOpenReview }: {
51 files: readonly TurnFileView[];
52 summary?: WireCompletionSummary;
53 tabId?: string;
54 hostId?: string;
55 onOpenReview?: (summary: WireCompletionSummary, initialPath?: string) => void;
56 }) {
57 const t = useT();
58 const [expanded, setExpanded] = useState(false);
59 const [error, setError] = useState("");
60 const recorded = summary?.receipt?.diff;
61 // A complete empty receipt means all edits were undone. Never reconstruct
62 // its net inventory from mutation calls or sum per-call preview diffs.
63 const rows = recorded && recorded.coverage !== "unknown" && (recorded.files.length > 0 || recorded.coverage === "complete") ? recorded.files.map(file => ({
64 path: file.path,
65 added: file.added,
66 removed: file.removed,
67 binary: file.binary,
68 uncounted: file.uncounted,
69 modeOnly: file.modeOnly,
70 })) : files.map(file => ({ path: file.path, added: 0, removed: 0, binary: false, modeOnly: false, uncounted: true }));
71 const shown = expanded ? rows : rows.slice(0, 4);
72 if (!rows.length) return null;
73 const added = recorded?.added ?? rows.reduce((total, file) => total + (file.added ?? 0), 0);
74 const removed = recorded?.removed ?? rows.reduce((total, file) => total + (file.removed ?? 0), 0);
75 const countsUnknown = !recorded || recorded.coverage === "unknown" || rows.some(file => file.uncounted);
76 const openReview = async (path: string) => {
77 setError("");
78 try {
79 if (summary && onOpenReview) { onOpenReview(summary, path); return; }
80 setError(t("completion.diffUnavailable"));
81 } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
82 };
83 return <section className="turn-files" aria-label={t("present.modifiedFiles")}>
84 <button type="button" className="turn-files__head" onClick={() => void openReview(rows[0]!.path)}
85 aria-label={t("present.openReview")}>
86 <span className="turn-files__icon"><Code2 size={18} /></span>
87 <span className="turn-files__summary">
88 <strong>{t("present.editedCount", { count: rows.length })}</strong>
89 <span>{countsUnknown ? t("present.linesUnknown") : <><span className="turn-files__added">+{added}</span> <span className="turn-files__removed">−{removed}</span></>}</span>
90 {recorded?.coverage === "partial" && <small>{t("completion.partialStats")}</small>}
91 </span>
92 </button>
93 <ul className="turn-files__list">
94 {shown.map(file => <li key={file.path}>
95 <button type="button" title={file.path} aria-label={t("present.openFileReview", { name: file.path })} onClick={() => void openReview(file.path)}>
96 <span>{file.path}</span>
97 <span>{file.binary ? t("present.binary") : file.modeOnly ? t("completion.modeOnly") : file.uncounted ? t("present.linesUnknown") : <>
98 <span className="turn-files__added">+{file.added ?? 0}</span>{" "}<span className="turn-files__removed">−{file.removed ?? 0}</span>
99 </>}</span>
100 </button>
101 </li>)}
102 </ul>
103 {rows.length > 4 && <button type="button" className="presented-files__toggle" onClick={() => setExpanded(value => !value)}>
104 {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
105 {t(expanded ? "present.collapse" : "present.showAll", { count: rows.length })}
106 </button>}
107 {error && <p className="turn-files__error" role="status"><ErrorMessage error={error} /></p>}
108 </section>;
109 });
110
111 function FileEntry({ refValue, description }: { refValue: FileResourceRef; description?: string }) {
112 const t = useT();
113 const [menu, setMenu] = useState<ContextMenuPoint | null>(null);
114 const [error, setError] = useState("");
115 const [busy, setBusy] = useState(false);
116 const Icon = iconFor(refValue.path);
117 const capabilities = fileResourceCapabilities(refValue);
118 const run = async (action: PresentedFileAction) => {
119 setMenu(null); setError(""); setBusy(true);
120 try {
121 const outcome = action === "preview" || action === "source" || action === "browser"
122 ? await openResource(refValue, { view: action })
123 : await performResourceAction(refValue, action);
124 // A cancelled command reports nothing: it lost its dock rather than
125 // failing, and the row must not claim an error the user never hit.
126 if (outcome.status === "failed") setError(outcome.error.message);
127 }
128 catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
129 finally { setBusy(false); }
130 };
131 const copyPath = async () => {
132 setMenu(null); setError(""); setBusy(true);
133 try {
134 const path = await resolveFileResourcePath(refValue);
135 if (!await writeClipboardText(path)) throw new Error(t("present.copyFailed"));
136 } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
137 finally { setBusy(false); }
138 };
139 const menuItems: ContextMenuItem[] = [];
140 const addAction = (action: PresentedFileAction, Icon: typeof FileText, label: string) => {
141 menuItems.push({ key: action, icon: <Icon size={14} />, label, onSelect: () => void run(action) });
142 };
143 if (capabilities.browser) addAction("browser", Globe, t("present.browser"));
144 if (capabilities.revealTree) addAction("reveal-tree", FolderSearch, t("present.revealTree"));
145 if (capabilities.source) addAction("source", Code2, t("present.source"));
146 if (capabilities.copyPath) menuItems.push({ key: "copy-path", icon: <FileText size={14} />, label: t("present.copyPath"), onSelect: () => void copyPath() });
147 if (capabilities.openNative) addAction("open-native", ExternalLink, t("present.openNative"));
148 if (capabilities.revealNative) addAction("reveal-native", FolderSearch, t("present.revealNative"));
149 if (capabilities.saveCopy) addAction("save-copy", Save, t("present.saveCopy"));
150 return <article className="presented-file" title={refValue.path} aria-busy={busy || undefined}>
151 <button type="button" className="presented-file__main" disabled={busy} onClick={() => void run("preview")}>
152 <span className="presented-file__icon"><Icon size={20} /></span>
153 <span className="presented-file__copy"><strong>{basename(refValue.path)}</strong>{description && <small>{description}</small>}</span>
154 </button>
155 <div className="presented-file__split">
156 <button type="button" className="presented-file__open" disabled={busy} onClick={() => void run("preview")}>{t(busy ? "chat.loading" : "present.open")}</button>
157 <button type="button" className="presented-file__more" disabled={busy} aria-label={t("present.more")} aria-haspopup="menu" aria-expanded={menu !== null} onClick={event => {
158 const rect = event.currentTarget.getBoundingClientRect();
159 setMenu({ left: rect.right - 208, top: rect.bottom + 4, keyboardTarget: event.currentTarget });
160 }}><ChevronDown size={13} /></button>
161 <ContextMenu open={menu !== null} point={menu} items={menuItems} onClose={() => setMenu(null)} minWidth={208} ariaLabel={t("present.more")} />
162 </div>
163 {error && <p className="presented-file__error" role="status"><ErrorMessage error={error} /></p>}
164 </article>;
165 }
166
166 lines Plain Text