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