"use client"; import { forwardRef, useState, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { getFileIcon, FolderIcon } from "./FileIcons"; import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath, joinFilePath, normalizeFilePathSlashes, } from "@/lib/file-paths"; import type { GitFileStatus, GitFileStatusKind, GitStatusResponse } from "@/lib/git-types"; import { useI18n } from "@/hooks/useI18n"; type Translate = ReturnType["t"]; interface FileEntry { name: string; isDir: boolean; size: number; modified: string; } interface FileNode { name: string; fullPath: string; isDir: boolean; size: number; children?: FileNode[]; loaded?: boolean; } interface Props { cwd: string; onOpenFile: (filePath: string, fileName: string, options?: OpenFileOptions) => void; refreshKey?: number; onAtMention?: (relativePath: string, isDir: boolean) => void; onAtMentions?: (relativePaths: string[]) => void; onUploadBusyChange?: (busy: boolean) => void; changesCollapsed: boolean; onChangesCountChange?: (count: number) => void; } export interface FileExplorerHandle { openUploadPicker: () => void; } type UploadPhase = "idle" | "checking" | "uploading"; type UploadConflictStrategy = "error" | "overwrite" | "skip"; interface UploadError { name: string; error: string; } interface UploadResponse { uploaded?: string[]; skipped?: string[]; errors?: UploadError[]; conflicts?: string[]; nonReplaceable?: string[]; error?: string; } interface UploadSummary { uploaded: string[]; skipped: string[]; errors: UploadError[]; } interface PendingConflict { files: File[]; conflicts: string[]; nonReplaceable: string[]; } async function fetchEntries(dirPath: string): Promise { const encoded = encodeFilePathForApi(dirPath); const res = await fetch(`/api/files/${encoded}?type=list`); if (!res.ok) { let message = `Failed to load files (HTTP ${res.status})`; try { const data = await res.json() as { error?: string }; if (data.error) message = data.error; } catch { // ignore non-JSON error bodies } throw new Error(message); } const data = await res.json() as { entries?: FileEntry[] }; return (data.entries ?? []).map((e) => ({ name: e.name, fullPath: joinFilePath(dirPath, e.name), isDir: e.isDir, size: e.size, children: e.isDir ? [] : undefined, loaded: !e.isDir, })); } async function fetchGitStatus(cwd: string): Promise { const params = new URLSearchParams({ cwd }); const res = await fetch(`/api/git/status?${params.toString()}`); if (!res.ok) throw new Error(`Failed to load Git status (HTTP ${res.status})`); return res.json() as Promise; } const GIT_STATUS_KEYS: Record = { modified: "files.modified", added: "files.added", deleted: "files.deleted", renamed: "files.renamed", untracked: "files.untracked", conflict: "files.conflict", }; const GIT_STATUS_COLORS: Record = { modified: "#d6a84b", added: "#4ade80", deleted: "#f87171", renamed: "#60a5fa", untracked: "#4ade80", conflict: "#f87171", }; function GitStatusBadge({ status, t }: { status: GitFileStatus; t: Translate }) { return ( {status.code} ); } function uploadFiles( targetDirectory: string, files: File[], strategy: UploadConflictStrategy, onProgress: (progress: number) => void, ): Promise<{ status: number; data: UploadResponse }> { return new Promise((resolve, reject) => { const formData = new FormData(); files.forEach((file) => formData.append("files", file, file.name)); const xhr = new XMLHttpRequest(); xhr.open( "POST", `/api/files/${encodeFilePathForApi(targetDirectory)}?type=upload&conflict=${strategy}`, ); xhr.upload.onprogress = (event) => { if (event.lengthComputable && event.total > 0) { onProgress(Math.round((event.loaded / event.total) * 100)); } }; xhr.onerror = () => reject(new Error("Network error while uploading files")); xhr.onabort = () => reject(new Error("Upload cancelled")); xhr.onload = () => { let data: UploadResponse = {}; try { data = JSON.parse(xhr.responseText) as UploadResponse; } catch { if (xhr.responseText) data.error = xhr.responseText; } resolve({ status: xhr.status, data }); }; xhr.send(formData); }); } function MentionIcon({ size = 11 }: { size?: number }) { return ( ); } function DismissButton({ onClick, title }: { onClick: () => void; title: string }) { return ( ); } function TreeNode({ node, depth, cwd, onOpenFile, onAtMention, expandedPaths, onToggleExpanded, refreshToken, highlightedPaths, gitStatusByPath, changedDirectoryPaths, t, }: { node: FileNode; depth: number; cwd: string; onOpenFile: (filePath: string, fileName: string, options?: OpenFileOptions) => void; onAtMention?: (relativePath: string, isDir: boolean) => void; expandedPaths: Set; onToggleExpanded: (fullPath: string, open: boolean) => void; refreshToken: string; highlightedPaths: Set; gitStatusByPath: Map; changedDirectoryPaths: Set; t: Translate; }) { const open = expandedPaths.has(node.fullPath); const highlighted = highlightedPaths.has(node.fullPath); const normalizedPath = normalizeFilePathSlashes(node.fullPath); const gitStatus = gitStatusByPath.get(normalizedPath); const containsGitChanges = node.isDir && ( gitStatus !== undefined || changedDirectoryPaths.has(normalizedPath) ); const [children, setChildren] = useState(node.children ?? []); const [loaded, setLoaded] = useState(node.loaded ?? false); const [loading, setLoading] = useState(false); const [hovered, setHovered] = useState(false); const loadChildren = useCallback(async (force = false) => { if (loaded && !force) return; setLoading(true); try { const entries = await fetchEntries(node.fullPath); setChildren(entries); setLoaded(true); } catch { // ignore } finally { setLoading(false); } }, [loaded, node.fullPath]); // Re-fetch children when the tree refreshes and the directory is open. useEffect(() => { if (open && loaded) { loadChildren(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshToken]); const handleClick = useCallback(() => { if (node.isDir) { const next = !open; onToggleExpanded(node.fullPath, next); if (next && !loaded) loadChildren(); } else { onOpenFile(node.fullPath, node.name); } }, [node.isDir, node.fullPath, node.name, loaded, open, loadChildren, onOpenFile, onToggleExpanded]); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ position: "relative", display: "flex", alignItems: "center", gap: 4, paddingLeft: 8 + depth * 14, paddingRight: 8, height: 24, cursor: "pointer", background: hovered ? "var(--bg-hover)" : "transparent", borderRadius: 4, userSelect: "none", }} > {node.isDir && ( )} {!node.isDir && } {node.isDir ? : getFileIcon(node.name, 14)} {node.name} {highlighted && ( )} {!hovered && !node.isDir && gitStatus && ( )} {!hovered && containsGitChanges && ( )} {loading && ( )} {onAtMention && hovered && ( )} {hovered && !node.isDir && ( e.stopPropagation()} title={t("files.download")} style={{ position: "absolute", right: 4, top: "50%", transform: "translateY(-50%)", display: "flex", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 5px", height: 20, background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 4, color: "var(--text-muted)", cursor: "pointer", fontSize: 11, fontWeight: 600, whiteSpace: "nowrap", textDecoration: "none", }} > )}
{node.isDir && open && (
{children.map((child) => ( ))} {children.length === 0 && loaded && (
empty
)}
)}
); } type OpenFileOptions = { sourceSessionId?: string | null; modeHint?: "diff" }; type OpenFileHandler = (filePath: string, fileName: string, options?: OpenFileOptions) => void; function ChangeRow({ status, cwd, onOpenFile, t, }: { status: GitFileStatus; cwd: string; onOpenFile: OpenFileHandler; t: Translate; }) { const [hovered, setHovered] = useState(false); const name = getFileName(status.filePath); const rel = getRelativeFilePath(status.filePath, cwd); return (
onOpenFile(status.filePath, name, { modeHint: "diff" })} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} title={status.filePath} style={{ display: "flex", alignItems: "center", gap: 6, paddingLeft: 10, paddingRight: 8, height: 24, cursor: "pointer", background: hovered ? "var(--bg-hover)" : "transparent", borderRadius: 4, userSelect: "none", }} > {getFileIcon(name, 13)} {rel}
); } export const FileExplorer = forwardRef(function FileExplorer({ cwd, onOpenFile, refreshKey, onAtMention, onAtMentions, onUploadBusyChange, changesCollapsed, onChangesCountChange, }, ref) { const { t } = useI18n(); const [roots, setRoots] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [treeRefreshKey, setTreeRefreshKey] = useState(0); const [highlightedPaths, setHighlightedPaths] = useState>(new Set()); const [gitFiles, setGitFiles] = useState([]); const [gitLineStats, setGitLineStats] = useState({ additions: 0, deletions: 0 }); const [uploadPhase, setUploadPhase] = useState("idle"); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [uploadSummary, setUploadSummary] = useState(null); const [pendingConflict, setPendingConflict] = useState(null); const prevCwdRef = useRef(null); const uploadInputRef = useRef(null); const refreshToken = `${refreshKey ?? 0}:${treeRefreshKey}`; const uploadBusy = uploadPhase !== "idle"; const gitStatusByPath = useMemo(() => new Map( gitFiles.map((status) => [normalizeFilePathSlashes(status.filePath), status]), ), [gitFiles]); const changedDirectoryPaths = useMemo(() => { const directories = new Set(); const normalizedCwd = normalizeFilePathSlashes(cwd).replace(/\/$/, ""); for (const status of gitFiles) { let directory = getFileDirectory(normalizeFilePathSlashes(status.filePath)); while (directory === normalizedCwd || directory.startsWith(`${normalizedCwd}/`)) { directories.add(directory); if (directory === normalizedCwd) break; const parent = getFileDirectory(directory); if (parent === directory) break; directory = parent; } } return directories; }, [cwd, gitFiles]); const handleToggleExpanded = useCallback((fullPath: string, open: boolean) => { setExpandedPaths((prev) => { const next = new Set(prev); if (open) next.add(fullPath); else next.delete(fullPath); return next; }); }, []); const applyUploadResult = useCallback((data: UploadResponse) => { const uploaded = data.uploaded ?? []; const skipped = data.skipped ?? []; const errors = data.errors ?? []; setUploadSummary({ uploaded, skipped, errors }); if (uploaded.length > 0) { setHighlightedPaths(new Set(uploaded.map((name) => joinFilePath(cwd, name)))); setTreeRefreshKey((key) => key + 1); } }, [cwd]); const performUpload = useCallback(async ( files: File[], strategy: UploadConflictStrategy, ) => { setPendingConflict(null); setUploadError(null); setUploadProgress(0); setUploadPhase("uploading"); try { const { status, data } = await uploadFiles(cwd, files, strategy, setUploadProgress); if (status === 409 && data.conflicts?.length) { setPendingConflict({ files, conflicts: data.conflicts, nonReplaceable: data.nonReplaceable ?? [], }); return; } if (status < 200 || status >= 300) { throw new Error(data.error ?? `Upload failed (HTTP ${status})`); } setUploadProgress(100); applyUploadResult(data); } catch (uploadFailure) { setUploadError(uploadFailure instanceof Error ? uploadFailure.message : String(uploadFailure)); } finally { setUploadPhase("idle"); } }, [applyUploadResult, cwd]); const prepareUpload = useCallback(async (files: File[]) => { if (files.length === 0 || uploadBusy) return; setUploadSummary(null); setHighlightedPaths(new Set()); setPendingConflict(null); setUploadError(null); setUploadProgress(0); setUploadPhase("checking"); try { const res = await fetch( `/api/files/${encodeFilePathForApi(cwd)}?type=upload-check`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames: files.map((file) => file.name) }), }, ); const data = await res.json().catch(() => ({})) as UploadResponse; if (!res.ok) throw new Error(data.error ?? `Upload check failed (HTTP ${res.status})`); if (data.conflicts?.length) { setPendingConflict({ files, conflicts: data.conflicts, nonReplaceable: data.nonReplaceable ?? [], }); return; } await performUpload(files, "error"); } catch (uploadFailure) { setUploadError(uploadFailure instanceof Error ? uploadFailure.message : String(uploadFailure)); } finally { setUploadPhase("idle"); } }, [cwd, performUpload, uploadBusy]); const handleUploadInput = useCallback((event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; void prepareUpload(files); }, [prepareUpload]); useImperativeHandle(ref, () => ({ openUploadPicker() { if (!uploadBusy) uploadInputRef.current?.click(); }, }), [uploadBusy]); useEffect(() => { onUploadBusyChange?.(uploadBusy); }, [onUploadBusyChange, uploadBusy]); useEffect(() => () => onUploadBusyChange?.(false), [onUploadBusyChange]); useEffect(() => { const cwdChanged = prevCwdRef.current !== cwd; prevCwdRef.current = cwd; // Reset expanded state only when cwd changes, not on refreshKey bumps if (cwdChanged) { setExpandedPaths(new Set()); setHighlightedPaths(new Set()); setUploadSummary(null); setPendingConflict(null); setUploadError(null); } setLoading(cwdChanged); setError(null); let cancelled = false; fetchEntries(cwd) .then((entries) => { if (!cancelled) setRoots(entries); }) .catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [cwd, refreshKey, treeRefreshKey]); useEffect(() => { let cancelled = false; fetchGitStatus(cwd) .then((status) => { if (!cancelled) { setGitFiles(status.isGitRepository ? status.files : []); setGitLineStats(status.isGitRepository ? { additions: status.additions, deletions: status.deletions } : { additions: 0, deletions: 0 }); } }) .catch(() => { if (!cancelled) { setGitFiles([]); setGitLineStats({ additions: 0, deletions: 0 }); } }); return () => { cancelled = true; }; }, [cwd, refreshKey, treeRefreshKey]); useEffect(() => { onChangesCountChange?.(gitFiles.length); }, [gitFiles, onChangesCountChange]); const showUploadFeedback = uploadBusy || pendingConflict !== null || uploadError !== null || uploadSummary !== null; const addUploadedFilesToChat = useCallback(() => { if (!uploadSummary || uploadSummary.uploaded.length === 0) return; onAtMentions?.( uploadSummary.uploaded.map((name) => getRelativeFilePath(joinFilePath(cwd, name), cwd)), ); }, [cwd, onAtMentions, uploadSummary]); return (
{showUploadFeedback && (
{uploadBusy && (
{uploadPhase === "checking" ? ( ) : ( )} {uploadPhase === "uploading" && {uploadProgress}%}
{uploadPhase === "uploading" && (
)}
)} {pendingConflict && (
{t("files.conflictSummary", { count: pendingConflict.conflicts.length, countSuffix: pendingConflict.conflicts.length === 1 ? "" : "s", files: pendingConflict.conflicts.join(", ") })}
{pendingConflict.nonReplaceable.length > 0 && (
{t("files.cannotReplace", { files: pendingConflict.nonReplaceable.join(", ") })}
)}
)} {uploadError && (
{uploadError} setUploadError(null)} title={t("files.dismissError")} />
)} {uploadSummary && (
{uploadSummary.uploaded.length > 0 && ( {uploadSummary.uploaded.length} )} {uploadSummary.skipped.length > 0 && ( {uploadSummary.skipped.length} )} {uploadSummary.errors.length > 0 && ( {uploadSummary.errors.length} )}
{uploadSummary.uploaded.length > 0 && onAtMentions && ( )} setUploadSummary(null)} title={t("files.dismissUploadResults")} />
{uploadSummary.errors.map((item) => (
{item.name}
))}
)}
)} {!changesCollapsed && gitFiles.length > 0 && (
{t("files.changedCount", { count: gitFiles.length })} +{gitLineStats.additions} -{gitLineStats.deletions}
{gitFiles.map((status) => ( ))}
)} {(changesCollapsed || gitFiles.length === 0) && (
{loading ? (
Loading files...
) : error ? (
{error}
) : ( roots.map((node) => ( )) )} {!loading && !error && roots.length === 0 && (
{t("files.noFiles")}
)}
)}
); });