Files
pi-agent-integrated/pi-web/components/FileExplorer.tsx

907 lines
33 KiB
TypeScript

"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<typeof useI18n>["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<FileNode[]> {
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<GitStatusResponse> {
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<GitStatusResponse>;
}
const GIT_STATUS_KEYS: Record<GitFileStatusKind, string> = {
modified: "files.modified",
added: "files.added",
deleted: "files.deleted",
renamed: "files.renamed",
untracked: "files.untracked",
conflict: "files.conflict",
};
const GIT_STATUS_COLORS: Record<GitFileStatusKind, string> = {
modified: "#d6a84b",
added: "#4ade80",
deleted: "#f87171",
renamed: "#60a5fa",
untracked: "#4ade80",
conflict: "#f87171",
};
function GitStatusBadge({ status, t }: { status: GitFileStatus; t: Translate }) {
return (
<span
title={t(GIT_STATUS_KEYS[status.status])}
aria-label={t(GIT_STATUS_KEYS[status.status])}
style={{
width: 14,
height: 14,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: GIT_STATUS_COLORS[status.status],
fontFamily: "var(--font-mono)",
fontSize: 11,
fontWeight: 600,
}}
>
{status.code}
</span>
);
}
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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</svg>
);
}
function DismissButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
style={{ width: 24, height: 24, padding: 0, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, border: "none", borderRadius: 4, background: "none", color: "var(--text-dim)", cursor: "pointer" }}
onMouseEnter={(event) => { event.currentTarget.style.color = "var(--text-muted)"; event.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(event) => { event.currentTarget.style.color = "var(--text-dim)"; event.currentTarget.style.background = "none"; }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="m6 6 12 12" />
<path d="m18 6-12 12" />
</svg>
</button>
);
}
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<string>;
onToggleExpanded: (fullPath: string, open: boolean) => void;
refreshToken: string;
highlightedPaths: Set<string>;
gitStatusByPath: Map<string, GitFileStatus>;
changedDirectoryPaths: Set<string>;
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<FileNode[]>(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 (
<div>
<div
onClick={handleClick}
onMouseEnter={() => 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 && (
<svg
width="10" height="10" viewBox="0 0 10 10" fill="none"
stroke="var(--text-dim)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transform: open ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}
>
<polyline points="3 2 7 5 3 8" />
</svg>
)}
{!node.isDir && <span style={{ width: 10, flexShrink: 0 }} />}
<span style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>
{node.isDir ? <FolderIcon size={14} open={open} /> : getFileIcon(node.name, 14)}
</span>
<span
style={{
fontSize: 12,
color: "var(--text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}
title={node.fullPath}
>
{node.name}
</span>
{highlighted && (
<span
title={t("files.newlyUploaded")}
aria-label={t("files.newlyUploaded")}
style={{ width: 14, height: 14, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#3b82f6" }} />
</span>
)}
{!hovered && !node.isDir && gitStatus && (
<GitStatusBadge status={gitStatus} t={t} />
)}
{!hovered && containsGitChanges && (
<span
title={t("files.containsChangedFiles")}
aria-label={t("files.containsChangedFiles")}
style={{
width: 14,
height: 14,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#d6a84b" }} />
</span>
)}
{loading && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth="2" strokeLinecap="round">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4" />
</svg>
)}
{onAtMention && hovered && (
<button
onClick={(e) => {
e.stopPropagation();
onAtMention(getRelativeFilePath(node.fullPath, cwd), node.isDir);
}}
title={t("files.insertPath")}
style={{
position: "absolute",
right: !node.isDir ? 28 : 4,
top: "50%",
transform: "translateY(-50%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
padding: "0 8px",
height: 20,
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 4,
color: "var(--accent)",
cursor: "pointer",
fontSize: 11,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
<MentionIcon />
{t("files.mention")}
</button>
)}
{hovered && !node.isDir && (
<a
href={`/api/files/${encodeFilePathForApi(node.fullPath)}?type=download`}
download
onClick={(e) => 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",
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
)}
</div>
{node.isDir && open && (
<div>
{children.map((child) => (
<TreeNode
key={child.fullPath}
node={child}
depth={depth + 1}
cwd={cwd}
onOpenFile={onOpenFile}
onAtMention={onAtMention}
expandedPaths={expandedPaths}
onToggleExpanded={onToggleExpanded}
refreshToken={refreshToken}
highlightedPaths={highlightedPaths}
gitStatusByPath={gitStatusByPath}
changedDirectoryPaths={changedDirectoryPaths}
t={t}
/>
))}
{children.length === 0 && loaded && (
<div style={{ paddingLeft: 8 + (depth + 1) * 14, fontSize: 11, color: "var(--text-dim)", height: 22, display: "flex", alignItems: "center" }}>
empty
</div>
)}
</div>
)}
</div>
);
}
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 (
<div
onClick={() => 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",
}}
>
<GitStatusBadge status={status} t={t} />
<span style={{ flexShrink: 0, display: "flex", alignItems: "center", opacity: 0.85 }}>
{getFileIcon(name, 13)}
</span>
<span
style={{
fontSize: 12,
color: "var(--text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}
>
{rel}
</span>
</div>
);
}
export const FileExplorer = forwardRef<FileExplorerHandle, Props>(function FileExplorer({
cwd,
onOpenFile,
refreshKey,
onAtMention,
onAtMentions,
onUploadBusyChange,
changesCollapsed,
onChangesCountChange,
}, ref) {
const { t } = useI18n();
const [roots, setRoots] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
const [highlightedPaths, setHighlightedPaths] = useState<Set<string>>(new Set());
const [gitFiles, setGitFiles] = useState<GitFileStatus[]>([]);
const [gitLineStats, setGitLineStats] = useState({ additions: 0, deletions: 0 });
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSummary, setUploadSummary] = useState<UploadSummary | null>(null);
const [pendingConflict, setPendingConflict] = useState<PendingConflict | null>(null);
const prevCwdRef = useRef<string | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(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<string>();
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<HTMLInputElement>) => {
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 (
<div style={{ minHeight: "100%" }}>
<input ref={uploadInputRef} type="file" multiple hidden onChange={handleUploadInput} />
{showUploadFeedback && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)" }}>
{uploadBusy && (
<div role="status" aria-live="polite" aria-label={uploadPhase === "checking" ? t("files.checking") : t("files.uploading", { progress: uploadProgress })}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, minHeight: 14, color: "var(--text-muted)" }}>
{uploadPhase === "checking" ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M5 20h14" />
</svg>
)}
{uploadPhase === "uploading" && <span style={{ fontSize: 10 }}>{uploadProgress}%</span>}
</div>
{uploadPhase === "uploading" && (
<div style={{ height: 3, marginTop: 4, overflow: "hidden", borderRadius: 2, background: "var(--border)" }}>
<div style={{ width: `${uploadProgress}%`, height: "100%", background: "var(--text-muted)", transition: "width 120ms ease" }} />
</div>
)}
</div>
)}
{pendingConflict && (
<div role="alert" style={{ padding: 7, border: "1px solid color-mix(in srgb, #f59e0b 55%, var(--border))", borderRadius: 4, background: "color-mix(in srgb, #f59e0b 9%, var(--bg-panel))" }}>
<div style={{ fontSize: 11, color: "var(--text)", lineHeight: 1.35, overflowWrap: "anywhere" }}>
{t("files.conflictSummary", { count: pendingConflict.conflicts.length, countSuffix: pendingConflict.conflicts.length === 1 ? "" : "s", files: pendingConflict.conflicts.join(", ") })}
</div>
{pendingConflict.nonReplaceable.length > 0 && (
<div style={{ marginTop: 3, fontSize: 10, color: "#f59e0b", lineHeight: 1.35, overflowWrap: "anywhere" }}>
{t("files.cannotReplace", { files: pendingConflict.nonReplaceable.join(", ") })}
</div>
)}
<div style={{ display: "flex", gap: 5, marginTop: 7 }}>
<button type="button" onClick={() => void performUpload(pendingConflict.files, "overwrite")} style={{ height: 22, padding: "0 7px", border: "1px solid #ef4444", borderRadius: 4, background: "transparent", color: "#ef4444", cursor: "pointer", fontSize: 10 }}>
{t("files.replace")}
</button>
<button type="button" onClick={() => void performUpload(pendingConflict.files, "skip")} style={{ height: 22, padding: "0 7px", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg-panel)", color: "var(--text)", cursor: "pointer", fontSize: 10 }}>
{t("files.skipExisting")}
</button>
<button type="button" onClick={() => setPendingConflict(null)} style={{ height: 22, padding: "0 7px", border: "none", borderRadius: 4, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 10 }}>
{t("files.cancel")}
</button>
</div>
</div>
)}
{uploadError && (
<div role="alert" style={{ display: "flex", alignItems: "flex-start", gap: 6, fontSize: 11, lineHeight: 1.35, color: "#f87171" }}>
<span style={{ minWidth: 0, flex: 1, overflowWrap: "anywhere" }}>{uploadError}</span>
<DismissButton onClick={() => setUploadError(null)} title={t("files.dismissError")} />
</div>
)}
{uploadSummary && (
<div aria-live="polite">
<div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 22, fontSize: 11 }}>
<div style={{ minWidth: 0, flex: 1, display: "flex", alignItems: "center", gap: 8 }}>
{uploadSummary.uploaded.length > 0 && (
<span title={`${uploadSummary.uploaded.length} uploaded`} aria-label={`${uploadSummary.uploaded.length} uploaded`} style={{ display: "flex", alignItems: "center", gap: 3, color: "#22c55e" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12 4 4L19 6" />
</svg>
<span>{uploadSummary.uploaded.length}</span>
</span>
)}
{uploadSummary.skipped.length > 0 && (
<span title={`${uploadSummary.skipped.length} skipped`} aria-label={`${uploadSummary.skipped.length} skipped`} style={{ display: "flex", alignItems: "center", gap: 3, color: "var(--text-dim)" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M8 12h8" />
</svg>
<span>{uploadSummary.skipped.length}</span>
</span>
)}
{uploadSummary.errors.length > 0 && (
<span title={`${uploadSummary.errors.length} failed`} aria-label={`${uploadSummary.errors.length} failed`} style={{ display: "flex", alignItems: "center", gap: 3, color: "#f87171" }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3 2.5 20h19L12 3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>{uploadSummary.errors.length}</span>
</span>
)}
</div>
{uploadSummary.uploaded.length > 0 && onAtMentions && (
<button
type="button"
onClick={addUploadedFilesToChat}
title={uploadSummary.uploaded.length === 1 ? t("files.addUploadedFile") : t("files.addAllUploadedFiles")}
aria-label={uploadSummary.uploaded.length === 1 ? t("files.addUploadedFile") : t("files.addAllUploadedFiles")}
style={{ height: 22, padding: "0 7px", display: "flex", alignItems: "center", justifyContent: "center", gap: 4, flexShrink: 0, border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg-panel)", color: "var(--accent)", cursor: "pointer", fontSize: 11, fontWeight: 600, whiteSpace: "nowrap" }}
>
<MentionIcon />
{t("files.mention")}
</button>
)}
<DismissButton onClick={() => setUploadSummary(null)} title={t("files.dismissUploadResults")} />
</div>
{uploadSummary.errors.map((item) => (
<div key={item.name} title={item.error} style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 3, minWidth: 0, fontSize: 10, color: "#f87171" }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5" />
<path d="M12 17h.01" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.name}</span>
</div>
))}
</div>
)}
</div>
)}
{!changesCollapsed && gitFiles.length > 0 && (
<div style={{ padding: "0 4px 2px" }}>
<div
aria-label={t("files.changeStats", {
count: gitFiles.length,
additions: gitLineStats.additions,
deletions: gitLineStats.deletions,
})}
style={{ display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 10px", fontSize: 12 }}
>
<span style={{ color: "var(--text-dim)" }}>
{t("files.changedCount", { count: gitFiles.length })}
</span>
<span style={{ color: GIT_STATUS_COLORS.added, fontFamily: "var(--font-mono)" }}>+{gitLineStats.additions}</span>
<span style={{ color: GIT_STATUS_COLORS.deleted, fontFamily: "var(--font-mono)" }}>-{gitLineStats.deletions}</span>
</div>
{gitFiles.map((status) => (
<ChangeRow key={status.filePath} status={status} cwd={cwd} onOpenFile={onOpenFile} t={t} />
))}
</div>
)}
{(changesCollapsed || gitFiles.length === 0) && (
<div style={{ padding: "2px 4px" }}>
{loading ? (
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>Loading files...</div>
) : error ? (
<div style={{ padding: "8px 12px", fontSize: 11, color: "#f87171" }}>{error}</div>
) : (
roots.map((node) => (
<TreeNode
key={node.fullPath}
node={node}
depth={0}
cwd={cwd}
onOpenFile={onOpenFile}
onAtMention={onAtMention}
expandedPaths={expandedPaths}
onToggleExpanded={handleToggleExpanded}
refreshToken={refreshToken}
highlightedPaths={highlightedPaths}
gitStatusByPath={gitStatusByPath}
changedDirectoryPaths={changedDirectoryPaths}
t={t}
/>
))
)}
{!loading && !error && roots.length === 0 && (
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>
{t("files.noFiles")}
</div>
)}
</div>
)}
</div>
);
});