mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
907 lines
33 KiB
TypeScript
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>
|
|
);
|
|
});
|