386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import {
|
|
IconChevronRight,
|
|
IconChevronDown,
|
|
IconFolder,
|
|
IconFolderOpen,
|
|
IconRefresh,
|
|
} from "@tabler/icons-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { FileIcon } from "@/components/ui/file-icon";
|
|
import type { FileTreeNode } from "@/lib/types/backend";
|
|
import type { FileInfo } from "@/lib/state/store";
|
|
import type { FileBrowserRow } from "./file-browser-hooks";
|
|
import { InlineFileInput } from "./inline-file-input";
|
|
import { renderSessionOrLoadState } from "./file-browser-load-state";
|
|
import {
|
|
FileContextMenu,
|
|
useFileRename,
|
|
TreeNodeName,
|
|
getGitStatusTextClass,
|
|
} from "./file-context-menu";
|
|
|
|
export {
|
|
compareTreeNodes,
|
|
mergeTreeNodes,
|
|
insertNodeInTree,
|
|
removeNodeFromTree,
|
|
renameNodeInTree,
|
|
} from "./file-tree-utils";
|
|
|
|
type GitFileStatus = FileInfo["status"] | undefined;
|
|
|
|
type TreeNodeRowProps = {
|
|
row: FileBrowserRow;
|
|
activeFolderPath: string;
|
|
activeFilePath?: string | null;
|
|
visibleLoadingPaths: Set<string>;
|
|
fileStatuses: Map<string, GitFileStatus>;
|
|
tree: FileTreeNode | null;
|
|
onToggleExpand: (node: FileTreeNode) => void;
|
|
onOpenFile: (path: string) => void;
|
|
onDeleteFile?: (path: string) => Promise<boolean>;
|
|
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
|
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
|
|
isSelectedFn?: (path: string) => boolean;
|
|
onSelect?: (path: string, e: React.MouseEvent) => boolean;
|
|
isDragging?: boolean;
|
|
dragOverPath?: string | null;
|
|
onDragStart?: (path: string, e: React.DragEvent) => void;
|
|
onDragEnd?: () => void;
|
|
onDragOver?: (path: string, e: React.DragEvent) => void;
|
|
onDragLeave?: (e: React.DragEvent) => void;
|
|
onDrop?: (targetPath: string, e: React.DragEvent) => void;
|
|
selectedCount?: number;
|
|
selectedPaths?: Set<string>;
|
|
};
|
|
|
|
function treeNodePaddingLeft(depth: number, isDir: boolean): string {
|
|
return `${depth * 12 + 8 + (isDir ? 0 : 20)}px`;
|
|
}
|
|
|
|
function handleTreeNodeClick(
|
|
node: FileTreeNode,
|
|
onToggleExpand: (node: FileTreeNode) => void,
|
|
onOpenFile: (path: string) => void,
|
|
) {
|
|
if (node.is_dir) {
|
|
onToggleExpand(node);
|
|
return;
|
|
}
|
|
onOpenFile(node.path);
|
|
}
|
|
|
|
/** Expand/collapse chevron for directory nodes. */
|
|
function TreeNodeExpandChevron({
|
|
isLoading,
|
|
isExpanded,
|
|
}: {
|
|
isLoading: boolean;
|
|
isExpanded: boolean;
|
|
}) {
|
|
if (isLoading)
|
|
return <IconRefresh className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />;
|
|
if (isExpanded) return <IconChevronDown className="h-3 w-3 text-muted-foreground/60" />;
|
|
return <IconChevronRight className="h-3 w-3 text-muted-foreground/60" />;
|
|
}
|
|
|
|
/** Directory or file icon for a tree node. */
|
|
function TreeNodeFileIcon({
|
|
node,
|
|
isExpanded,
|
|
isActive,
|
|
}: {
|
|
node: FileTreeNode;
|
|
isExpanded: boolean;
|
|
isActive: boolean;
|
|
}) {
|
|
if (node.is_dir) {
|
|
return isExpanded ? (
|
|
<IconFolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<IconFolder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
|
);
|
|
}
|
|
return (
|
|
<FileIcon
|
|
fileName={node.name}
|
|
filePath={node.path}
|
|
className="flex-shrink-0"
|
|
style={{ width: "14px", height: "14px", opacity: isActive ? 1 : 0.7 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function getTreeNodeRowClass(
|
|
isActive: boolean,
|
|
isActiveFolder: boolean,
|
|
isSelected: boolean,
|
|
isDragging: boolean | undefined,
|
|
isDropTarget: boolean,
|
|
) {
|
|
return cn(
|
|
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
|
|
isSelected
|
|
? "bg-accent text-accent-foreground hover:bg-accent/80 [&_span]:text-accent-foreground"
|
|
: "hover:bg-muted",
|
|
isActive && !isSelected && "bg-muted",
|
|
isActiveFolder && !isSelected && "bg-muted/50",
|
|
isDragging && isSelected && "opacity-50",
|
|
isDropTarget && "bg-accent/40 ring-1 ring-accent",
|
|
);
|
|
}
|
|
|
|
export function TreeNodeItem(props: TreeNodeRowProps) {
|
|
const { row, activeFolderPath, activeFilePath, visibleLoadingPaths } = props;
|
|
const { fileStatuses, tree, onToggleExpand, onOpenFile, onDeleteFile, onRenameFile, setTree } =
|
|
props;
|
|
const node = row.node;
|
|
|
|
const isExpanded = row.isExpanded;
|
|
const isActive = !node.is_dir && activeFilePath === node.path;
|
|
const isActiveFolder = node.is_dir && activeFolderPath === node.path;
|
|
const gitStatus = node.is_dir ? undefined : fileStatuses.get(node.path);
|
|
const rename = useFileRename(node, tree, setTree, onRenameFile);
|
|
const isSelected = props.isSelectedFn?.(node.path) ?? false;
|
|
const isDropTarget = node.is_dir && props.dragOverPath === node.path;
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
if (e.button === 2) return;
|
|
const consumed = props.onSelect?.(node.path, e);
|
|
if (!consumed) {
|
|
handleTreeNodeClick(node, onToggleExpand, onOpenFile);
|
|
}
|
|
};
|
|
|
|
// Inline the row JSX so ContextMenuTrigger asChild can attach directly to the DOM div
|
|
const rowContent = (
|
|
<div
|
|
data-testid="file-tree-node"
|
|
data-path={node.path}
|
|
data-is-dir={node.is_dir ? "true" : "false"}
|
|
data-selected={isSelected ? "true" : "false"}
|
|
aria-selected={isSelected}
|
|
role="treeitem"
|
|
className={getTreeNodeRowClass(
|
|
isActive,
|
|
isActiveFolder,
|
|
isSelected,
|
|
props.isDragging,
|
|
isDropTarget,
|
|
)}
|
|
style={{ paddingLeft: treeNodePaddingLeft(row.depth, node.is_dir) }}
|
|
onClick={handleClick}
|
|
draggable={!!props.onDragStart}
|
|
onDragStart={(e) => props.onDragStart?.(node.path, e)}
|
|
onDragEnd={() => props.onDragEnd?.()}
|
|
onDragOver={(e) => {
|
|
if (node.is_dir) props.onDragOver?.(node.path, e);
|
|
}}
|
|
onDragLeave={(e) => props.onDragLeave?.(e)}
|
|
onDrop={(e) => {
|
|
if (node.is_dir) props.onDrop?.(node.path, e);
|
|
}}
|
|
>
|
|
{node.is_dir && (
|
|
<span className="flex-shrink-0">
|
|
<TreeNodeExpandChevron
|
|
isLoading={visibleLoadingPaths.has(node.path)}
|
|
isExpanded={isExpanded}
|
|
/>
|
|
</span>
|
|
)}
|
|
<TreeNodeFileIcon node={node} isExpanded={isExpanded} isActive={isActive} />
|
|
<TreeNodeName node={node} isActive={isActive} gitStatus={gitStatus} rename={rename} />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<FileContextMenu
|
|
node={node}
|
|
tree={tree}
|
|
setTree={setTree}
|
|
onDeleteFile={onDeleteFile}
|
|
onRenameFile={onRenameFile}
|
|
onStartRename={rename.handleStartRename}
|
|
selectedCount={props.selectedCount}
|
|
selectedPaths={props.selectedPaths}
|
|
>
|
|
{rowContent}
|
|
</FileContextMenu>
|
|
);
|
|
}
|
|
|
|
type SearchResultsListProps = {
|
|
searchResults: string[] | null;
|
|
fileStatuses: Map<string, GitFileStatus>;
|
|
onOpenFile: (path: string) => void;
|
|
};
|
|
|
|
export function SearchResultsList({
|
|
searchResults,
|
|
fileStatuses,
|
|
onOpenFile,
|
|
}: SearchResultsListProps) {
|
|
if (!searchResults) return null;
|
|
|
|
if (searchResults.length === 0) {
|
|
return <div className="p-4 text-sm text-muted-foreground text-center">No files found</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="pb-2">
|
|
{searchResults.map((path) => {
|
|
const name = path.split("/").pop() || path;
|
|
const folder = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
|
|
const gitStatus = fileStatuses.get(path);
|
|
return (
|
|
<div
|
|
key={path}
|
|
className={cn(
|
|
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
|
|
"hover:bg-muted",
|
|
)}
|
|
onClick={() => onOpenFile(path)}
|
|
>
|
|
<FileIcon
|
|
fileName={name}
|
|
filePath={path}
|
|
className="flex-shrink-0"
|
|
style={{ width: "14px", height: "14px" }}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"truncate group-hover:text-foreground",
|
|
getGitStatusTextClass(gitStatus) || "text-muted-foreground",
|
|
)}
|
|
>
|
|
{folder && <span>{folder}/</span>}
|
|
<span>{name}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { FileBrowserToolbar } from "./file-browser-toolbar";
|
|
|
|
type FileBrowserContentAreaProps = {
|
|
isSearchActive: boolean;
|
|
searchResults: string[] | null;
|
|
isSessionFailed: boolean;
|
|
sessionError?: string | null;
|
|
loadState: string;
|
|
isLoadingTree: boolean;
|
|
tree: FileTreeNode | null;
|
|
loadError: string | null;
|
|
creatingInPath: string | null;
|
|
fileStatuses: Map<string, GitFileStatus>;
|
|
visibleRows: FileBrowserRow[];
|
|
activeFolderPath: string;
|
|
activeFilePath?: string | null;
|
|
visibleLoadingPaths: Set<string>;
|
|
onOpenFile: (path: string) => void;
|
|
onToggleExpand: (node: FileTreeNode) => void;
|
|
onDeleteFile?: (path: string) => Promise<boolean>;
|
|
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
|
onCreateFileSubmit: (parentPath: string, name: string) => void;
|
|
onCancelCreate: () => void;
|
|
onRetry: () => void;
|
|
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
|
|
isSelectedFn?: (path: string) => boolean;
|
|
onSelect?: (path: string, e: React.MouseEvent) => boolean;
|
|
isDragging?: boolean;
|
|
dragOverPath?: string | null;
|
|
onDragStart?: (path: string, e: React.DragEvent) => void;
|
|
onDragEnd?: () => void;
|
|
onDragOver?: (path: string, e: React.DragEvent) => void;
|
|
onDragLeave?: (e: React.DragEvent) => void;
|
|
onDrop?: (targetPath: string, e: React.DragEvent) => void;
|
|
selectedCount?: number;
|
|
selectedPaths?: Set<string>;
|
|
};
|
|
|
|
function rowToItemProps(props: FileBrowserContentAreaProps, row: FileBrowserRow): TreeNodeRowProps {
|
|
return {
|
|
row,
|
|
activeFolderPath: props.activeFolderPath,
|
|
activeFilePath: props.activeFilePath,
|
|
visibleLoadingPaths: props.visibleLoadingPaths,
|
|
fileStatuses: props.fileStatuses,
|
|
tree: props.tree,
|
|
onToggleExpand: props.onToggleExpand,
|
|
onOpenFile: props.onOpenFile,
|
|
onDeleteFile: props.onDeleteFile,
|
|
onRenameFile: props.onRenameFile,
|
|
setTree: props.setTree,
|
|
isSelectedFn: props.isSelectedFn,
|
|
onSelect: props.onSelect,
|
|
isDragging: props.isDragging,
|
|
dragOverPath: props.dragOverPath,
|
|
onDragStart: props.onDragStart,
|
|
onDragEnd: props.onDragEnd,
|
|
onDragOver: props.onDragOver,
|
|
onDragLeave: props.onDragLeave,
|
|
onDrop: props.onDrop,
|
|
selectedCount: props.selectedCount,
|
|
selectedPaths: props.selectedPaths,
|
|
};
|
|
}
|
|
|
|
function FileTreeView(props: FileBrowserContentAreaProps) {
|
|
const { tree, visibleRows, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
|
|
if (!tree) return null;
|
|
return (
|
|
<div className="pb-2">
|
|
{creatingInPath === "" && (
|
|
<InlineFileInput
|
|
depth={0}
|
|
onSubmit={(name) => onCreateFileSubmit("", name)}
|
|
onCancel={onCancelCreate}
|
|
/>
|
|
)}
|
|
{visibleRows.map((row) => (
|
|
<React.Fragment key={row.path}>
|
|
<TreeNodeItem {...rowToItemProps(props, row)} />
|
|
{creatingInPath === row.path && row.isDir && row.isExpanded && (
|
|
<InlineFileInput
|
|
depth={row.depth + 1}
|
|
onSubmit={(name) => onCreateFileSubmit(row.path, name)}
|
|
onCancel={onCancelCreate}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FileBrowserContentArea(props: FileBrowserContentAreaProps) {
|
|
if (props.isSearchActive && props.searchResults !== null) {
|
|
return (
|
|
<SearchResultsList
|
|
searchResults={props.searchResults}
|
|
fileStatuses={props.fileStatuses}
|
|
onOpenFile={props.onOpenFile}
|
|
/>
|
|
);
|
|
}
|
|
const loadStateResult = renderSessionOrLoadState({
|
|
isSessionFailed: props.isSessionFailed,
|
|
sessionError: props.sessionError,
|
|
loadState: props.loadState,
|
|
isLoadingTree: props.isLoadingTree,
|
|
tree: props.tree,
|
|
loadError: props.loadError,
|
|
onRetry: props.onRetry,
|
|
});
|
|
if (loadStateResult) return loadStateResult;
|
|
if (props.tree) return <FileTreeView {...props} />;
|
|
return <div className="p-4 text-sm text-muted-foreground">No files found</div>;
|
|
}
|