File browser as widget

This commit is contained in:
2026-02-17 19:23:01 +00:00
parent 0746844d6f
commit 21213c281d
27 changed files with 23 additions and 34 deletions
@@ -0,0 +1,123 @@
import { useRef, useCallback } from 'react';
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
import { FileItem } from './FileItem';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type FileGridProps = {
entries: DirEntry[];
viewMode: 'grid' | 'list';
currentPath: string;
selected: Set<string>;
clipboard: ClipboardState;
onOpen: (entry: DirEntry) => void;
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onSelect: (names: Set<string>) => void;
onCut: () => void;
onCopy: () => void;
renamingName: string | null;
onRenamingChange: (name: string | null) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
};
export const FileGrid = ({
entries,
viewMode,
currentPath,
selected,
clipboard,
onOpen,
onDelete,
onRename,
onChat,
onSelect,
onCut,
onCopy,
renamingName,
onRenamingChange,
getMatchingTasks,
onRunTask,
}: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1);
// Sort: directories first, then files, alphabetically within each group
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
const handleSelect = useCallback(
(entry: DirEntry, ev: React.MouseEvent) => {
const idx = sorted.findIndex((e) => e.name === entry.name);
if (ev.shiftKey && lastClickedIdx.current >= 0) {
const start = Math.min(lastClickedIdx.current, idx);
const end = Math.max(lastClickedIdx.current, idx);
const next = new Set(selected);
for (let i = start; i <= end; i++) {
next.add(sorted[i]!.name);
}
onSelect(next);
} else if (ev.ctrlKey || ev.metaKey) {
const next = new Set(selected);
if (next.has(entry.name)) {
next.delete(entry.name);
} else {
next.add(entry.name);
}
onSelect(next);
lastClickedIdx.current = idx;
} else {
// Plain select (from context menu or checkbox click without modifiers)
onSelect(new Set([entry.name]));
lastClickedIdx.current = idx;
}
},
[sorted, selected, onSelect],
);
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
const anySelected = selected.size > 0;
if (entries.length === 0) {
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
}
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const items = sorted.map((entry) => (
<FileItem
key={entry.name}
entry={entry}
viewMode={viewMode}
selected={selected.has(entry.name)}
anySelected={anySelected}
selectedCount={selected.size}
isCut={cutPaths.has(entryPath(entry.name))}
onOpen={onOpen}
onDelete={onDelete}
onRename={onRename}
onChat={onChat}
onSelect={handleSelect}
onCut={onCut}
onCopy={onCopy}
forceRename={renamingName === entry.name}
onRenamingChange={onRenamingChange}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask}
/>
));
if (viewMode === 'list') {
return <div className="file-grid flex flex-col">{items}</div>;
}
return (
<div className="file-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{items}
</div>
);
};