openclaw/apps/web/app/components/file-picker-modal.tsx
kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

937 lines
22 KiB
TypeScript

"use client";
import {
Fragment,
useCallback,
useEffect,
useRef,
useState,
} from "react";
// ── Types ──
type BrowseEntry = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
children?: BrowseEntry[];
};
export type SelectedFile = {
name: string;
path: string;
};
type FilePickerModalProps = {
open: boolean;
onClose: () => void;
onSelect: (files: SelectedFile[]) => void;
};
// ── Helpers ──
function getCategoryFromName(
name: string,
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "folder" | "other" {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
)
{return "image";}
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
if (ext === "pdf") {return "pdf";}
if (
[
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
"toml", "md", "sh", "bash", "sql", "swift", "kt",
].includes(ext)
)
{return "code";}
if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
)
{return "document";}
return "other";
}
function buildBreadcrumbs(
dir: string,
): { label: string; path: string }[] {
const segments: { label: string; path: string }[] = [];
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
const homeDir = homeMatch?.[1];
if (homeDir) {
segments.push({ label: "~", path: homeDir });
const rest = dir.slice(homeDir.length);
const parts = rest.split("/").filter(Boolean);
let currentPath = homeDir;
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
} else if (dir === "/") {
segments.push({ label: "/", path: "/" });
} else {
segments.push({ label: "/", path: "/" });
const parts = dir.split("/").filter(Boolean);
let currentPath = "";
for (const part of parts) {
currentPath += "/" + part;
segments.push({ label: part, path: currentPath });
}
}
return segments;
}
const pickerColors: Record<string, { bg: string; fg: string }> = {
folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
};
// ── Icons ──
function PickerIcon({
category,
size = 16,
}: {
category: string;
size?: number;
}) {
const props = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (category) {
case "folder":
return (
<svg {...props}>
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
</svg>
);
case "image":
return (
<svg {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
case "video":
return (
<svg {...props}>
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>
);
case "audio":
return (
<svg {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
case "pdf":
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M10 13h4" />
<path d="M10 17h4" />
</svg>
);
case "code":
return (
<svg {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
case "document":
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M16 13H8" />
<path d="M16 17H8" />
<path d="M10 9H8" />
</svg>
);
default:
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
}
}
// ── Main component ──
export function FilePickerModal({
open,
onClose,
onSelect,
}: FilePickerModalProps) {
const [currentDir, setCurrentDir] = useState<string | null>(null);
const [displayDir, setDisplayDir] = useState("");
const [entries, setEntries] = useState<BrowseEntry[]>([]);
const [parentDir, setParentDir] = useState<string | null>(null);
const [selected, setSelected] = useState<
Map<string, SelectedFile>
>(new Map());
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [error, setError] = useState<string | null>(null);
// Animation
const [visible, setVisible] = useState(false);
useEffect(() => {
if (open) {
requestAnimationFrame(() =>
requestAnimationFrame(() => setVisible(true)),
);
} else {
setVisible(false);
}
}, [open]);
// Reset transient state on close
useEffect(() => {
if (!open) {
setSearch("");
setCreatingFolder(false);
setNewFolderName("");
setError(null);
}
}, [open]);
// Search input ref for autofocus
const searchRef = useRef<HTMLInputElement>(null);
const newFolderRef = useRef<HTMLInputElement>(null);
// Fetch directory
const fetchDir = useCallback(async (dir: string | null) => {
setLoading(true);
setError(null);
try {
const url = dir
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
: "/api/workspace/browse";
const res = await fetch(url);
if (!res.ok) {throw new Error("Failed to list directory");}
const data = await res.json();
setEntries(data.entries || []);
setDisplayDir(data.currentDir || "");
setParentDir(data.parentDir ?? null);
} catch {
setError("Could not load this directory");
setEntries([]);
} finally {
setLoading(false);
}
}, []);
// Fetch on open and navigation
useEffect(() => {
if (open) { void fetchDir(currentDir); }
}, [open, currentDir, fetchDir]);
// Escape key
useEffect(() => {
if (!open) {return;}
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
// Handlers
const toggleSelect = useCallback(
(entry: BrowseEntry) => {
setSelected((prev) => {
const next = new Map(prev);
if (next.has(entry.path)) {
next.delete(entry.path);
} else {
next.set(entry.path, {
name: entry.name,
path: entry.path,
});
}
return next;
});
},
[],
);
const navigateInto = useCallback((path: string) => {
setCurrentDir(path);
setSearch("");
setCreatingFolder(false);
}, []);
const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim() || !displayDir) {return;}
const folderPath = `${displayDir}/${newFolderName.trim()}`;
try {
await fetch("/api/workspace/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: folderPath }),
});
setCreatingFolder(false);
setNewFolderName("");
void fetchDir(currentDir);
} catch {
setError("Failed to create folder");
}
}, [newFolderName, displayDir, currentDir, fetchDir]);
const handleConfirm = useCallback(() => {
onSelect(Array.from(selected.values()));
setSelected(new Map());
onClose();
}, [selected, onSelect, onClose]);
// Filter & sort entries (folders first, then alphabetically)
const sorted = entries
.filter(
(e) =>
!search ||
e.name
.toLowerCase()
.includes(search.toLowerCase()),
)
.toSorted((a, b) => {
if (a.type === "folder" && b.type !== "folder")
{return -1;}
if (a.type !== "folder" && b.type === "folder")
{return 1;}
return a.name.localeCompare(b.name);
});
const breadcrumbs = displayDir
? buildBreadcrumbs(displayDir)
: [];
if (!open) {return null;}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{
opacity: visible ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
>
{/* Backdrop */}
<div
className="absolute inset-0"
style={{
background: "rgba(0,0,0,0.4)",
backdropFilter: "blur(4px)",
}}
onClick={onClose}
/>
{/* Modal */}
<div
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
style={{
maxHeight: "70vh",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
transform: visible
? "scale(1)"
: "scale(0.97)",
transition:
"transform 150ms ease-out",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
style={{
borderColor: "var(--color-border)",
}}
>
<div className="flex items-center gap-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background:
pickerColors.folder
.bg,
color: pickerColors
.folder.fg,
}}
>
<PickerIcon
category="folder"
size={18}
/>
</div>
<div>
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
Select Files
</h2>
<p
className="text-[11px]"
style={{
color: "var(--color-text-muted)",
}}
>
Browse and attach
files
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Breadcrumb path */}
{displayDir && (
<div
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
style={{
borderColor:
"var(--color-border)",
scrollbarWidth: "thin",
}}
>
{breadcrumbs.map(
(seg, i) => (
<Fragment
key={
seg.path
}
>
{i >
0 && (
<span
className="text-[10px] flex-shrink-0"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
}}
>
/
</span>
)}
<button
type="button"
onClick={() =>
navigateInto(
seg.path,
)
}
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
style={{
color:
i ===
breadcrumbs.length -
1
? "var(--color-text)"
: "var(--color-text-muted)",
}}
>
{
seg.label
}
</button>
</Fragment>
),
)}
</div>
)}
{/* Search bar + New Folder */}
<div
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
style={{
borderColor: "var(--color-border)",
}}
>
<div
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{
background:
"var(--color-bg)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: "var(--color-text-muted)",
flexShrink: 0,
}}
>
<circle
cx="11"
cy="11"
r="8"
/>
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={searchRef}
type="text"
value={search}
onChange={(e) =>
setSearch(
e.target.value,
)
}
placeholder="Filter files..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
style={{
color: "var(--color-text)",
}}
/>
</div>
<button
type="button"
onClick={() => {
setCreatingFolder(true);
setTimeout(
() =>
newFolderRef.current?.focus(),
50,
);
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
Folder
</button>
</div>
{/* File list */}
<div
className="flex-1 overflow-y-auto"
style={{
background: "var(--color-bg)",
minHeight: 200,
}}
>
{loading ? (
<div className="flex items-center justify-center py-16">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor:
"var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
/>
</div>
) : error ? (
<div
className="flex items-center justify-center py-16 text-[13px]"
style={{
color: "var(--color-text-muted)",
}}
>
{error}
</div>
) : (
<>
{/* Parent directory row */}
{parentDir && (
<button
type="button"
onClick={() =>
navigateInto(
parentDir,
)
}
className="w-full flex items-center gap-3 px-4 py-2 text-left"
style={{
color: "var(--color-text-muted)",
}}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
"var(--color-surface-hover)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
</div>
<span className="text-[13px] font-medium">
..
</span>
</button>
)}
{/* New folder input */}
{creatingFolder && (
<div className="flex items-center gap-3 px-4 py-2">
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
pickerColors
.folder
.bg,
color: pickerColors
.folder
.fg,
}}
>
<PickerIcon category="folder" />
</div>
<input
ref={
newFolderRef
}
type="text"
value={
newFolderName
}
onChange={(
e,
) =>
setNewFolderName(
e
.target
.value,
)
}
onKeyDown={(
e,
) => {
if (
e.key ===
"Enter"
) {
void handleCreateFolder();
}
if (
e.key ===
"Escape"
) {
setCreatingFolder(
false,
);
setNewFolderName(
"",
);
}
}}
placeholder="Folder name..."
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
style={{
color: "var(--color-text)",
background:
"var(--color-surface)",
border: "1px solid var(--color-accent)",
}}
/>
</div>
)}
{/* Entries */}
{sorted.length ===
0 &&
!parentDir && (
<div
className="flex items-center justify-center py-16 text-[13px]"
style={{
color: "var(--color-text-muted)",
}}
>
This
folder
is
empty
</div>
)}
{sorted.map(
(entry) => {
const isFolder =
entry.type ===
"folder";
const category =
isFolder
? "folder"
: getCategoryFromName(
entry.name,
);
const colors =
pickerColors[
category
] ??
pickerColors.other;
const isSelected =
selected.has(
entry.path,
);
return (
<div
key={
entry.path
}
className="flex items-center gap-3 px-4 py-1.5 group cursor-pointer"
style={{
background:
isSelected
? "color-mix(in srgb, var(--color-accent) 8%, transparent)"
: undefined,
}}
onClick={() => {
if (
isFolder
) {
navigateInto(
entry.path,
);
} else {
toggleSelect(
entry,
);
}
}}
>
{/* Checkbox */}
<button
type="button"
onClick={(
e,
) => {
e.stopPropagation();
toggleSelect(
entry,
);
}}
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border"
style={{
borderColor:
isSelected
? "var(--color-accent)"
: "var(--color-border-strong)",
background:
isSelected
? "var(--color-accent)"
: "transparent",
}}
>
{isSelected && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
{/* Icon */}
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
colors.bg,
color: colors.fg,
}}
>
<PickerIcon
category={
category
}
/>
</div>
{/* Name */}
<span
className="flex-1 text-[13px] truncate"
style={{
color: "var(--color-text)",
fontWeight:
isFolder
? 500
: 400,
}}
title={
entry.path
}
>
{
entry.name
}
</span>
{/* Folder chevron */}
{isFolder && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</div>
);
},
)}
</>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<span
className="text-[12px]"
style={{
color: "var(--color-text-muted)",
}}
>
{selected.size > 0
? `${selected.size} ${selected.size === 1 ? "item" : "items"} selected`
: "No files selected"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
style={{
color: "var(--color-text-muted)",
background:
"var(--color-surface-hover)",
border: "1px solid var(--color-border)",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={
selected.size === 0
}
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
color: "white",
background:
selected.size > 0
? "var(--color-accent)"
: "var(--color-border-strong)",
}}
>
Attach{" "}
{selected.size > 0 &&
`${selected.size} ${selected.size === 1 ? "file" : "files"}`}
</button>
</div>
</div>
</div>
</div>
);
}