| 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 |