Code editor and widget
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
|
||||
|
||||
type FileTreeProps = {
|
||||
root: string;
|
||||
basePath: string;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
};
|
||||
|
||||
type TreeNodeProps = {
|
||||
entry: DirEntry;
|
||||
parentPath: string;
|
||||
root: string;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
||||
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
};
|
||||
|
||||
const sortEntries = (entries: DirEntry[]) => {
|
||||
const dirs = entries.filter((e) => e.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
|
||||
const files = entries.filter((e) => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...dirs, ...files];
|
||||
};
|
||||
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { listDir } = useFiles(root);
|
||||
const isDir = entry.type === 'directory';
|
||||
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!isDir) {
|
||||
onOpenFile(fullPath, entry.name);
|
||||
return;
|
||||
}
|
||||
if (!expanded && children === null) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDir(fullPath);
|
||||
setChildren(sortEntries(res.entries));
|
||||
} catch {
|
||||
setChildren([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
setExpanded((prev) => !prev);
|
||||
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
|
||||
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-3.5 shrink-0" />
|
||||
<FileIcon name={entry.name} />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
|
||||
</button>
|
||||
{isDir && expanded && children && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.name}
|
||||
entry={child}
|
||||
parentPath={fullPath}
|
||||
root={root}
|
||||
onOpenFile={onOpenFile}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
||||
const { listDir } = useFiles(root);
|
||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadRoot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDir(basePath);
|
||||
setEntries(sortEntries(res.entries));
|
||||
} catch {
|
||||
setEntries([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [listDir, basePath]);
|
||||
|
||||
if (entries === null && !loading) {
|
||||
loadRoot();
|
||||
}
|
||||
|
||||
if (loading && entries === null) {
|
||||
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return <div className="p-2 text-sm text-[#888]">No files</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1 overflow-y-auto h-full code-editor-scrollable">
|
||||
{entries.map((entry) => (
|
||||
<TreeNode
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
parentPath={basePath}
|
||||
root={root}
|
||||
onOpenFile={onOpenFile}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user