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,43 @@
import { ChevronRight, Home } from 'lucide-react';
type BreadcrumbProps = {
path: string;
onNavigate: (path: string) => void;
};
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => {
const segments = path.split('/').filter(Boolean);
return (
<nav className="flex items-center gap-1 text-sm flex-wrap">
<button
onClick={() => onNavigate('/')}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" />
<span>home</span>
</button>
{segments.map((segment, i) => {
const segmentPath = '/' + segments.slice(0, i + 1).join('/');
const isLast = i === segments.length - 1;
return (
<span key={segmentPath} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
{isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span>
) : (
<button
onClick={() => onNavigate(segmentPath)}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment}
</button>
)}
</span>
);
})}
</nav>
);
};
@@ -0,0 +1,197 @@
import { useEffect, useRef, useState } from 'react';
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
type ToolbarProps = {
onCreateDir: (name: string) => void;
onUpload: (files: FileList) => void;
selectionCount: number;
hasClipboard: boolean;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDeleteSelected: () => void;
onClearSelection: () => void;
};
export const Toolbar = ({
onCreateDir,
onUpload,
selectionCount,
hasClipboard,
onCut,
onCopy,
onPaste,
onDeleteSelected,
onClearSelection,
}: ToolbarProps) => {
const [showInput, setShowInput] = useState(false);
const [folderName, setFolderName] = useState('');
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const input = folderInputRef.current;
if (!input) return;
input.setAttribute('webkitdirectory', '');
}, []);
const handleCreate = () => {
const name = folderName.trim();
if (!name) return;
onCreateDir(name);
setFolderName('');
setShowInput(false);
};
if (selectionCount > 0) {
return (
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-duck-dark/70 mr-1">
{selectionCount}
<span className="hidden md:inline"> selected</span>
</span>
<button
onClick={onCut}
title="Cut"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Scissors className="h-4 w-4" />
</button>
<button
onClick={onCopy}
title="Copy"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Copy className="h-4 w-4" />
</button>
{hasClipboard && (
<button
onClick={onPaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
<button
onClick={onDeleteSelected}
title="Delete"
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onClearSelection}
title="Clear selection"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1">
{showInput ? (
<form
onSubmit={(ev) => {
ev.preventDefault();
handleCreate();
}}
className="flex items-center gap-2"
>
<input
autoFocus
value={folderName}
onChange={(ev) => setFolderName(ev.target.value)}
placeholder="Folder name"
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
setShowInput(false);
setFolderName('');
}
}}
/>
<button
type="submit"
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
title="Create"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setFolderName('');
}}
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<button
onClick={() => setShowInput(true)}
title="New folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderPlus className="h-4 w-4" />
</button>
)}
<button
onClick={() => fileInputRef.current?.click()}
title="Upload files"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<Upload className="h-4 w-4" />
</button>
<button
onClick={() => folderInputRef.current?.click()}
title="Upload folder"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<FolderUp className="h-4 w-4" />
</button>
{hasClipboard && (
<button
onClick={onPaste}
title="Paste"
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) {
onUpload(ev.target.files);
ev.target.value = '';
}
}}
/>
<input
ref={folderInputRef}
type="file"
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) {
onUpload(ev.target.files);
ev.target.value = '';
}
}}
/>
</div>
);
};
@@ -0,0 +1,4 @@
export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { useFiles, type DirEntry } from './useFiles';
export { useTasks, type TaskSummary } from './useTasks';
@@ -0,0 +1,87 @@
import { useClient, getHeaders } from 'hooks/useClient';
import { config } from 'config';
export type DirEntry = {
name: string;
path?: string;
type: 'file' | 'directory';
size: number;
modifiedAt: number;
};
type ListDirResponse = {
path: string;
entries: DirEntry[];
reset?: boolean;
};
export const useFiles = (root: string = 'home') => {
const client = useClient();
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
const withRoot = (url: string) =>
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
return {
listDir: (path: string) =>
client.get<ListDirResponse>(withRoot(`/file-browser/ls?path=${encodeURIComponent(path)}`)),
createDir: (path: string) => client.post(withRoot('/file-browser/mkdir'), { path }),
remove: (path: string) => client.delete(withRoot('/file-browser/rm'), { path }),
rename: (path: string, newName: string) => client.post(withRoot('/file-browser/rename'), { path, newName }),
readFile: (path: string) =>
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
search: (query: string) =>
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
copy: (sources: string[], destination: string) =>
client.post(withRoot('/file-browser/copy'), {
items: sources.map((source) => ({
source,
destination: `${destination}/${source.split('/').pop()}`,
})),
}),
move: (sources: string[], destination: string) =>
client.post(withRoot('/file-browser/move'), {
items: sources.map((source) => ({
source,
destination: `${destination}/${source.split('/').pop()}`,
})),
}),
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
const formData = new FormData();
for (const file of Array.from(files)) {
const name = (file as any).webkitRelativePath || file.name;
formData.append('file', file, name);
}
const authHeaders = getHeaders();
const url = withRoot(`${config.API_URL}/file-browser/upload?path=${encodeURIComponent(path)}`);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
if (authHeaders['Authorization']) {
xhr.setRequestHeader('Authorization', authHeaders['Authorization']);
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 400) reject(new Error(xhr.responseText));
else resolve();
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(formData);
});
},
};
};
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
export type TaskSummary = {
dirName: string;
name: string;
description: string;
scope: 'user' | 'global';
triggers: TriggerConfig[];
filePath: string;
};
export const useTasks = () => {
const client = useClient();
const { data: tasks = [] } = useQuery<TaskSummary[]>({
queryKey: ['tasks'],
queryFn: () => client.get('/tasks'),
staleTime: 60_000,
});
const getMatchingTasks = useCallback(
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
if (entryType === 'directory') {
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
}
const ext = fileName.split('.').pop()?.toLowerCase();
if (!ext) return [];
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
},
[tasks],
);
return { tasks, getMatchingTasks };
};
+1
View File
@@ -0,0 +1 @@
export { TerminalView, type TerminalViewProps } from './Terminal';
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "widgets",
"private": true,
"exports": {
"./TerminalView": "./TerminalView.tsx"
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts"
}
}