File browser as widget
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TerminalView, type TerminalViewProps } from './Terminal';
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "widgets",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./TerminalView": "./TerminalView.tsx"
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./FileBrowser": "./FileBrowser/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user