Download src/modules/explorer/FileExplorer.tsx from lekmikdok/termix: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/spaces/lekmikdok/termix/resolve/main/src/modules/explorer/FileExplorer.tsx
- Command line
-
hf download hf://spaces/lekmikdok/termix/src/modules/explorer/FileExplorer.tsx
-
curl -L -o FileExplorer.tsx https://huggingface.co/spaces/lekmikdok/termix/resolve/main/src/modules/explorer/FileExplorer.tsx
11.7 kB
| import { Button } from "@/components/ui/button"; | |
| import { | |
| ContextMenu, | |
| ContextMenuContent, | |
| ContextMenuItem, | |
| ContextMenuSeparator, | |
| ContextMenuTrigger, | |
| } from "@/components/ui/context-menu"; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { | |
| FileAddIcon, | |
| Folder01Icon, | |
| FolderAddIcon, | |
| Refresh01Icon, | |
| Search01Icon, | |
| } from "@hugeicons/core-free-icons"; | |
| import { HugeiconsIcon } from "@hugeicons/react"; | |
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { ExplorerSearch, type ExplorerSearchHandle } from "./ExplorerSearch"; | |
| import { FileTreeNode } from "./FileTreeNode"; | |
| import { InlineInput } from "./InlineInput"; | |
| import { copyToClipboard, revealInFinder } from "./lib/contextActions"; | |
| import { fileIconUrl, folderIconUrl } from "./lib/iconResolver"; | |
| import { COMPACT_CONTENT, COMPACT_ITEM } from "./lib/menuItemClass"; | |
| import { useFileTree } from "./lib/useFileTree"; | |
| import { useGlobalShortcuts } from "@/modules/shortcuts"; | |
| type Props = { | |
| rootPath: string | null; | |
| onOpenFile: (path: string, pin?: boolean) => void; | |
| onPathRenamed?: (from: string, to: string) => void; | |
| onPathDeleted?: (path: string) => void; | |
| onRevealInTerminal?: (path: string) => void; | |
| onAttachToAgent?: (path: string) => void; | |
| }; | |
| function basename(path: string): string { | |
| const parts = path.split(/[\\/]/).filter(Boolean); | |
| return parts.length ? parts[parts.length - 1] : path; | |
| } | |
| export function FileExplorer({ | |
| rootPath, | |
| onOpenFile, | |
| onPathRenamed, | |
| onPathDeleted, | |
| onRevealInTerminal, | |
| onAttachToAgent, | |
| }: Props) { | |
| const tree = useFileTree(rootPath, { onPathRenamed, onPathDeleted }); | |
| const [selectedPath, setSelectedPath] = useState<string | null>(null); | |
| const [isSearchOpen, setIsSearchOpen] = useState(false); | |
| const [isSearchActive, setIsSearchActive] = useState(false); | |
| const listRef = useRef<HTMLDivElement>(null); | |
| const searchRef = useRef<ExplorerSearchHandle>(null); | |
| type FlatItem = { path: string; isDir: boolean }; | |
| const flat = useMemo<FlatItem[]>(() => { | |
| if (!rootPath) return []; | |
| const out: FlatItem[] = []; | |
| const walk = (parent: string) => { | |
| const node = tree.nodes[parent]; | |
| if (!node || node.status !== "loaded") return; | |
| for (const e of node.entries) { | |
| const p = tree.joinPath(parent, e.name); | |
| const isDir = e.kind === "dir"; | |
| out.push({ path: p, isDir }); | |
| if (isDir && tree.expanded.has(p)) walk(p); | |
| } | |
| }; | |
| walk(rootPath); | |
| return out; | |
| }, [rootPath, tree.nodes, tree.expanded, tree.joinPath]); | |
| useEffect(() => { | |
| if (selectedPath && !flat.some((f) => f.path === selectedPath)) { | |
| setSelectedPath(null); | |
| } | |
| }, [flat, selectedPath]); | |
| useGlobalShortcuts({ | |
| "explorer.search": () => { | |
| if (searchRef.current?.isFocused()) { | |
| setIsSearchOpen(false); | |
| return; | |
| } | |
| setIsSearchOpen(true); | |
| searchRef.current?.focus(); | |
| }, | |
| }); | |
| if (!rootPath) { | |
| return ( | |
| <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center"> | |
| <HugeiconsIcon | |
| icon={Folder01Icon} | |
| size={24} | |
| strokeWidth={1.5} | |
| className="text-muted-foreground" | |
| /> | |
| <div className="text-xs text-muted-foreground"> | |
| No current directory | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const root = tree.nodes[rootPath]; | |
| const pendingAtRoot = | |
| tree.pendingCreate?.parentPath === rootPath ? tree.pendingCreate : null; | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { | |
| if (tree.renaming || tree.pendingCreate || isSearchOpen) return; | |
| const target = e.target as HTMLElement; | |
| if ( | |
| target.tagName === "INPUT" || | |
| target.tagName === "TEXTAREA" || | |
| target.isContentEditable | |
| ) | |
| return; | |
| if (flat.length === 0) return; | |
| const currentIdx = selectedPath | |
| ? flat.findIndex((f) => f.path === selectedPath) | |
| : -1; | |
| const move = (next: number) => { | |
| const clamped = Math.max(0, Math.min(flat.length - 1, next)); | |
| const path = flat[clamped].path; | |
| setSelectedPath(path); | |
| requestAnimationFrame(() => { | |
| const el = listRef.current?.querySelector<HTMLElement>( | |
| `[data-fs-path="${CSS.escape(path)}"]` | |
| ); | |
| el?.scrollIntoView({ block: "nearest" }); | |
| }); | |
| }; | |
| switch (e.key) { | |
| case "ArrowDown": | |
| e.preventDefault(); | |
| move(currentIdx < 0 ? 0 : currentIdx + 1); | |
| break; | |
| case "ArrowUp": | |
| e.preventDefault(); | |
| move(currentIdx < 0 ? flat.length - 1 : currentIdx - 1); | |
| break; | |
| case "ArrowRight": { | |
| if (currentIdx < 0) return; | |
| e.preventDefault(); | |
| const item = flat[currentIdx]; | |
| if (item.isDir) { | |
| if (!tree.expanded.has(item.path)) tree.toggle(item.path); | |
| else move(currentIdx + 1); | |
| } | |
| break; | |
| } | |
| case "ArrowLeft": { | |
| if (currentIdx < 0) return; | |
| e.preventDefault(); | |
| const item = flat[currentIdx]; | |
| if (item.isDir && tree.expanded.has(item.path)) { | |
| tree.toggle(item.path); | |
| } else { | |
| const parent = item.path.slice(0, item.path.lastIndexOf("/")); | |
| if (parent && parent !== rootPath) setSelectedPath(parent); | |
| } | |
| break; | |
| } | |
| case "Enter": | |
| if (currentIdx < 0) return; | |
| e.preventDefault(); | |
| { | |
| const item = flat[currentIdx]; | |
| if (item.isDir) tree.toggle(item.path); | |
| else onOpenFile(item.path); | |
| } | |
| break; | |
| } | |
| }; | |
| return ( | |
| <div | |
| className="flex h-full flex-col outline-none" | |
| tabIndex={0} | |
| onKeyDown={handleKeyDown} | |
| > | |
| <div className="flex h-8 shrink-0 items-center gap-1 border-b border-border/60 px-2"> | |
| <span | |
| className="flex-1 flex truncate text-xs font-medium text-foreground/80" | |
| title={rootPath} | |
| > | |
| <img | |
| src={folderIconUrl(basename(rootPath), false)} | |
| alt="" | |
| height={15} | |
| width={15} | |
| className="mx-1.5" | |
| /> | |
| {basename(rootPath)} | |
| </span> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="size-6 text-muted-foreground hover:text-foreground" | |
| onClick={() => setIsSearchOpen((v) => !v)} | |
| title="Search files" | |
| aria-label="Search files" | |
| > | |
| <HugeiconsIcon icon={Search01Icon} size={13} strokeWidth={2} /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="size-6 text-muted-foreground hover:text-foreground" | |
| onClick={() => tree.beginCreate(rootPath, "file")} | |
| title="New file" | |
| > | |
| <HugeiconsIcon icon={FileAddIcon} size={13} strokeWidth={2} /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="size-6 text-muted-foreground hover:text-foreground" | |
| onClick={() => tree.beginCreate(rootPath, "dir")} | |
| title="New folder" | |
| > | |
| <HugeiconsIcon icon={FolderAddIcon} size={13} strokeWidth={2} /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="size-6 text-muted-foreground hover:text-foreground" | |
| onClick={() => tree.refresh(rootPath)} | |
| title="Refresh" | |
| > | |
| <HugeiconsIcon icon={Refresh01Icon} size={12} strokeWidth={2} /> | |
| </Button> | |
| </div> | |
| <ExplorerSearch | |
| ref={searchRef} | |
| rootPath={rootPath} | |
| onOpenFile={onOpenFile} | |
| open={isSearchOpen} | |
| onRequestClose={() => setIsSearchOpen(false)} | |
| onActiveChange={setIsSearchActive} | |
| /> | |
| {!isSearchActive ? ( | |
| <ContextMenu> | |
| <ContextMenuTrigger asChild> | |
| <ScrollArea className="min-h-0 flex-1"> | |
| <div className="py-1" ref={listRef}> | |
| {pendingAtRoot && ( | |
| <div | |
| className="flex w-full items-center gap-2 px-1.5 py-0.5 text-[13px]" | |
| style={{ paddingLeft: 6 }} | |
| > | |
| <span className="size-3.5 shrink-0" /> | |
| <img | |
| src={ | |
| pendingAtRoot.kind === "dir" | |
| ? folderIconUrl("", false) | |
| : fileIconUrl("untitled") | |
| } | |
| alt="" | |
| className="size-4 shrink-0 opacity-70" | |
| /> | |
| <InlineInput | |
| initial="" | |
| placeholder={ | |
| pendingAtRoot.kind === "dir" ? "New folder" : "New file" | |
| } | |
| onCommit={tree.commitCreate} | |
| onCancel={tree.cancelCreate} | |
| /> | |
| </div> | |
| )} | |
| {root?.status === "loading" && ( | |
| <div className="px-3 py-2 text-[11px] text-muted-foreground"> | |
| Loading… | |
| </div> | |
| )} | |
| {root?.status === "error" && ( | |
| <div className="px-3 py-2 text-[11px] text-destructive"> | |
| {root.message} | |
| </div> | |
| )} | |
| {root?.status === "loaded" && | |
| root.entries.map((entry) => ( | |
| <FileTreeNode | |
| key={entry.name} | |
| entry={entry} | |
| parentPath={rootPath} | |
| rootPath={rootPath} | |
| depth={0} | |
| tree={tree} | |
| onOpenFile={onOpenFile} | |
| onRevealInTerminal={onRevealInTerminal} | |
| onAttachToAgent={onAttachToAgent} | |
| selectedPath={selectedPath} | |
| onSelectPath={setSelectedPath} | |
| /> | |
| ))} | |
| </div> | |
| </ScrollArea> | |
| </ContextMenuTrigger> | |
| <ContextMenuContent | |
| className={COMPACT_CONTENT} | |
| onCloseAutoFocus={(e) => { | |
| if (tree.renaming || tree.pendingCreate) e.preventDefault(); | |
| }} | |
| > | |
| {onRevealInTerminal && ( | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => onRevealInTerminal(rootPath)} | |
| > | |
| Open in Terminal | |
| </ContextMenuItem> | |
| )} | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => void revealInFinder(rootPath)} | |
| > | |
| Reveal in Finder | |
| </ContextMenuItem> | |
| <ContextMenuSeparator /> | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => tree.beginCreate(rootPath, "file")} | |
| > | |
| New File | |
| </ContextMenuItem> | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => tree.beginCreate(rootPath, "dir")} | |
| > | |
| New Folder | |
| </ContextMenuItem> | |
| <ContextMenuSeparator /> | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => void copyToClipboard(rootPath)} | |
| > | |
| Copy Path | |
| </ContextMenuItem> | |
| <ContextMenuItem | |
| className={COMPACT_ITEM} | |
| onSelect={() => tree.refresh(rootPath)} | |
| > | |
| Refresh | |
| </ContextMenuItem> | |
| </ContextMenuContent> | |
| </ContextMenu> | |
| ) : null} | |
| </div> | |
| ); | |
| } | |