diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c4dd38b8fe9..f930cc74d29 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,6 +38,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", + "@headless-tree/core": "^1.6.3", + "@headless-tree/react": "^1.6.3", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", @@ -138,7 +140,6 @@ "posthog-js": "1.310.1", "posthog-node": "^5.18.0", "react": "19.1.0", - "react-arborist": "^3.4.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.1.0", diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index 7883c54ff34..eec69ebbab5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -1,27 +1,28 @@ +import { + asyncDataLoaderFeature, + expandAllFeature, + selectionFeature, +} from "@headless-tree/core"; +import { useTree } from "@headless-tree/react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; import { useParams } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Tree, type TreeApi } from "react-arborist"; -import { dragDropManager } from "renderer/lib/dnd"; +import { useCallback, useMemo, useState } from "react"; +import { LuFile, LuFolder } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useFileExplorerStore } from "renderer/stores/file-explorer"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { - DirectoryEntry, - FileTreeNode as FileTreeNodeType, -} from "shared/file-tree-types"; -import useResizeObserver from "use-resize-observer"; +import type { DirectoryEntry } from "shared/file-tree-types"; import { DeleteConfirmDialog } from "./components/DeleteConfirmDialog"; -import { FileSearchResultNode } from "./components/FileSearchResultNode"; -import { FileTreeContextMenu } from "./components/FileTreeContextMenu"; -import { FileTreeNode } from "./components/FileTreeNode"; +import { FileSearchResultItem } from "./components/FileSearchResultItem"; +import { FileTreeItem } from "./components/FileTreeItem"; import { FileTreeToolbar } from "./components/FileTreeToolbar"; import { NewItemInput } from "./components/NewItemInput"; -import { - OVERSCAN_COUNT, - ROW_HEIGHT, - SEARCH_RESULT_ROW_HEIGHT, - TREE_INDENT, -} from "./constants"; +import { RenameInput } from "./components/RenameInput"; +import { ROW_HEIGHT, TREE_INDENT } from "./constants"; import { useFileSearch } from "./hooks/useFileSearch"; import { useFileTreeActions } from "./hooks/useFileTreeActions"; import type { NewItemMode } from "./types"; @@ -34,201 +35,148 @@ export function FilesView() { ); const worktreePath = workspace?.worktreePath; - const treeRef = useRef>(null); - const { ref: containerRef, height: treeHeight = 400 } = useResizeObserver(); - - const { - expandedFolders, - searchTerm, - showHiddenFiles, - toggleFolder, - collapseAll, - setSelectedItems, - setSearchTerm, - toggleHiddenFiles, - } = useFileExplorerStore(); - - const currentSearchTerm = worktreePath ? searchTerm[worktreePath] || "" : ""; - const currentExpandedFolders = useMemo( - () => new Set(worktreePath ? expandedFolders[worktreePath] || [] : []), - [worktreePath, expandedFolders], - ); + const [searchTerm, setSearchTerm] = useState(""); + const [showHiddenFiles, setShowHiddenFiles] = useState(false); - const [childrenCache, setChildrenCache] = useState< - Record - >({}); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); const trpcUtils = electronTrpc.useUtils(); - const { - data: rootEntries, - isLoading, - refetch, - } = electronTrpc.filesystem.readDirectory.useQuery( - { - dirPath: worktreePath || "", - rootPath: worktreePath || "", - includeHidden: showHiddenFiles, - }, - { - enabled: !!worktreePath, - staleTime: 5000, - }, - ); - - const entriesToNodes = useCallback( - (entries: DirectoryEntry[]): FileTreeNodeType[] => { - return entries.map((entry) => { - if (!entry.isDirectory) { - return { ...entry, children: undefined }; - } - - const isExpanded = currentExpandedFolders.has(entry.id); - const cachedChildren = childrenCache[entry.path]; - - if (isExpanded && cachedChildren) { + const tree = useTree({ + rootItemId: "root", + getItemName: (item) => item.getItemData()?.name ?? "", + isItemFolder: (item) => item.getItemData()?.isDirectory ?? false, + dataLoader: { + getItem: async (itemId: string): Promise => { + if (itemId === "root") { return { - ...entry, - children: entriesToNodes(cachedChildren), + id: "root", + name: "root", + path: worktreePath ?? "", + relativePath: "", + isDirectory: true, }; } - - return { ...entry, children: null }; - }); - }, - [childrenCache, currentExpandedFolders], - ); - - const treeData = useMemo((): FileTreeNodeType[] => { - if (!rootEntries) return []; - return entriesToNodes(rootEntries); - }, [rootEntries, entriesToNodes]); - - const loadChildren = useCallback( - async (folderPath: string) => { - if ( - !worktreePath || - childrenCache[folderPath] || - loadingFolders.has(folderPath) - ) { - return; - } - - setLoadingFolders((prev) => new Set(prev).add(folderPath)); - - try { - const children = await trpcUtils.filesystem.readDirectory.fetch({ - dirPath: folderPath, - rootPath: worktreePath, - includeHidden: showHiddenFiles, - }); - - setChildrenCache((prev) => ({ - ...prev, - [folderPath]: children, - })); - } catch (error) { - console.error("[FilesView] Failed to load children:", { - folderPath, - error, - }); - } finally { - setLoadingFolders((prev) => { - const next = new Set(prev); - next.delete(folderPath); - return next; - }); - } + const parts = itemId.split(":::"); + return { + id: itemId, + name: parts[1] ?? itemId, + path: parts[0] ?? itemId, + relativePath: parts[2] ?? "", + isDirectory: parts[3] === "true", + }; + }, + getChildren: async (itemId: string): Promise => { + if (!worktreePath) return []; + const dirPath = + itemId === "root" ? worktreePath : itemId.split(":::")[0]; + if (!dirPath) return []; + + try { + const entries = await trpcUtils.filesystem.readDirectory.fetch({ + dirPath, + rootPath: worktreePath, + includeHidden: showHiddenFiles, + }); + return entries.map( + (e) => + `${e.path}:::${e.name}:::${e.relativePath}:::${e.isDirectory}`, + ); + } catch (error) { + console.error("[FilesView] Failed to load children:", error); + return []; + } + }, }, - [worktreePath, childrenCache, loadingFolders, showHiddenFiles, trpcUtils], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: reset cache on workspace/visibility change - useEffect(() => { - setChildrenCache({}); - }, [worktreePath, showHiddenFiles]); + features: [asyncDataLoaderFeature, selectionFeature, expandAllFeature], + }); const { createFile, createDirectory, rename, deleteItems, isDeleting } = useFileTreeActions({ worktreePath, - onRefresh: () => refetch(), + onRefresh: async (parentPath: string) => { + const isRoot = parentPath === worktreePath; + const itemId = isRoot + ? "root" + : tree + .getItems() + .find((item) => item.getItemData()?.path === parentPath) + ?.getId(); + if (itemId) { + await tree.getItemInstance(itemId)?.invalidateChildrenIds(); + } + }, }); const { searchResults, isFetching: isSearchFetching, - hasQuery, + hasQuery: isSearching, } = useFileSearch({ worktreePath, - searchTerm: currentSearchTerm, + searchTerm, includeHidden: showHiddenFiles, }); - const isSearching = hasQuery; const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + const openFileInEditorMutation = + electronTrpc.external.openFileInEditor.useMutation(); const [newItemMode, setNewItemMode] = useState(null); const [newItemParentPath, setNewItemParentPath] = useState(""); - const [deleteNode, setDeleteNode] = useState(null); + const [renameEntry, setRenameEntry] = useState(null); + const [deleteEntry, setDeleteEntry] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [contextMenuNode, setContextMenuNode] = - useState(null); - - const handleActivate = useCallback( - (node: { data: FileTreeNodeType }) => { - if (!workspaceId || !worktreePath || node.data.isDirectory) return; + const handleFileActivate = useCallback( + (entry: DirectoryEntry) => { + if (!workspaceId || !worktreePath || entry.isDirectory) return; addFileViewerPane(workspaceId, { - filePath: node.data.relativePath, + filePath: entry.relativePath, + viewMode: "raw", }); }, [workspaceId, worktreePath, addFileViewerPane], ); - const handleSelect = useCallback( - (nodes: { data: FileTreeNodeType }[]) => { + const handleOpenInEditor = useCallback( + (entry: DirectoryEntry) => { if (!worktreePath) return; - setSelectedItems( - worktreePath, - nodes.map((n) => n.data.id), - ); + openFileInEditorMutation.mutate({ path: entry.path, cwd: worktreePath }); }, - [worktreePath, setSelectedItems], + [worktreePath, openFileInEditorMutation], ); - const handleToggle = useCallback( - (id: string) => { - if (!worktreePath) return; - toggleFolder(worktreePath, id); - - const node = treeRef.current?.get(id); - if (node?.data.isDirectory && !node.isOpen) { - loadChildren(node.data.path); + const handleNewFile = useCallback( + async (parentPath: string) => { + if (parentPath !== worktreePath) { + const item = tree + .getItems() + .find((i) => i.getItemData()?.path === parentPath); + if (item && !item.isExpanded()) { + await item.expand(); + } } + setNewItemMode("file"); + setNewItemParentPath(parentPath); }, - [worktreePath, toggleFolder, loadChildren], + [worktreePath, tree], ); - const handleRename = useCallback( - ({ id, name }: { id: string; name: string }) => { - const node = treeRef.current?.get(id)?.data; - if (node) { - rename(node.path, name); + const handleNewFolder = useCallback( + async (parentPath: string) => { + if (parentPath !== worktreePath) { + const item = tree + .getItems() + .find((i) => i.getItemData()?.path === parentPath); + if (item && !item.isExpanded()) { + await item.expand(); + } } + setNewItemMode("folder"); + setNewItemParentPath(parentPath); }, - [rename], + [worktreePath, tree], ); - const handleNewFile = useCallback((parentPath: string) => { - setNewItemMode("file"); - setNewItemParentPath(parentPath); - }, []); - - const handleNewFolder = useCallback((parentPath: string) => { - setNewItemMode("folder"); - setNewItemParentPath(parentPath); - }, []); - const handleNewItemSubmit = useCallback( (name: string) => { if (newItemMode === "file") { @@ -247,41 +195,54 @@ export function FilesView() { setNewItemParentPath(""); }, []); - const handleDeleteRequest = useCallback((node: FileTreeNodeType) => { - setDeleteNode(node); + const handleDeleteRequest = useCallback((entry: DirectoryEntry) => { + setDeleteEntry(entry); setShowDeleteDialog(true); }, []); const handleDeleteConfirm = useCallback(() => { - if (deleteNode) { - deleteItems([deleteNode.path]); + if (deleteEntry) { + deleteItems([deleteEntry.path]); } setShowDeleteDialog(false); - setDeleteNode(null); - }, [deleteNode, deleteItems]); + setDeleteEntry(null); + }, [deleteEntry, deleteItems]); - const handleContextMenuRename = useCallback((node: FileTreeNodeType) => { - treeRef.current?.get(node.id)?.edit(); + const handleRename = useCallback((entry: DirectoryEntry) => { + setRenameEntry(entry); }, []); - const handleSearchChange = useCallback( - (term: string) => { - if (!worktreePath) return; - setSearchTerm(worktreePath, term); + const handleRenameSubmit = useCallback( + (newName: string) => { + if (renameEntry) { + rename(renameEntry.path, newName); + } + setRenameEntry(null); }, - [worktreePath, setSearchTerm], + [renameEntry, rename], ); + const handleRenameCancel = useCallback(() => { + setRenameEntry(null); + }, []); + const handleCollapseAll = useCallback(() => { - if (!worktreePath) return; - collapseAll(worktreePath); - treeRef.current?.closeAll(); - }, [worktreePath, collapseAll]); + tree.collapseAll(); + }, [tree]); const handleRefresh = useCallback(() => { - setChildrenCache({}); - refetch(); - }, [refetch]); + tree.rebuildTree(); + }, [tree]); + + const searchResultEntries = useMemo(() => { + return searchResults.map((result) => ({ + id: result.id, + name: result.name, + path: result.path, + relativePath: result.relativePath, + isDirectory: result.isDirectory, + })); + }, [searchResults]); if (!worktreePath) { return ( @@ -291,113 +252,128 @@ export function FilesView() { ); } - if (isLoading) { - return ( -
- Loading files... -
- ); - } - return (
handleNewFile(worktreePath)} onNewFolder={() => handleNewFolder(worktreePath)} onCollapseAll={handleCollapseAll} onRefresh={handleRefresh} showHiddenFiles={showHiddenFiles} - onToggleHiddenFiles={toggleHiddenFiles} + onToggleHiddenFiles={() => setShowHiddenFiles((v) => !v)} /> - - {/* biome-ignore lint/a11y/noStaticElementInteractions: context menu handler for tree container */} -
{ - const nodeEl = (e.target as HTMLElement).closest("[data-node-id]"); - if (nodeEl) { - const nodeId = nodeEl.getAttribute("data-node-id"); - setContextMenuNode( - treeRef.current?.get(nodeId || "")?.data || null, - ); - } else { - setContextMenuNode(null); - } - }} - > - {newItemMode && newItemParentPath === worktreePath && ( - - )} - - {isSearching ? ( - searchResults.length > 0 ? ( - - ref={treeRef} - data={searchResults} - width="100%" - height={treeHeight} - rowHeight={SEARCH_RESULT_ROW_HEIGHT} - indent={0} - overscanCount={OVERSCAN_COUNT} - idAccessor="id" - childrenAccessor="children" - openByDefault={false} - disableMultiSelection={false} - onActivate={handleActivate} - onSelect={handleSelect} - onRename={handleRename} - dndManager={dragDropManager} - > - {FileSearchResultNode} - + + +
+ {newItemMode && newItemParentPath === worktreePath && ( + + )} + + {isSearching ? ( + searchResultEntries.length > 0 ? ( +
+ {searchResultEntries.map((entry) => + renameEntry?.path === entry.path ? ( + + ) : ( + + ), + )} +
+ ) : ( +
+ {isSearchFetching + ? "Searching files..." + : "No matching files"} +
+ ) ) : ( -
- {isSearchFetching ? "Searching files..." : "No matching files"} +
+ {tree.getItems().map((item) => { + const data = item.getItemData(); + if (!data || item.getId() === "root") return null; + const showNewItemInput = + newItemMode && + data.isDirectory && + data.path === newItemParentPath; + const isRenaming = renameEntry?.path === data.path; + return ( +
+ {isRenaming ? ( + + ) : ( + + )} + {showNewItemInput && ( + + )} +
+ ); + })}
- ) - ) : ( - - ref={treeRef} - data={treeData} - width="100%" - height={treeHeight} - rowHeight={ROW_HEIGHT} - indent={TREE_INDENT} - overscanCount={OVERSCAN_COUNT} - idAccessor="id" - childrenAccessor="children" - openByDefault={false} - disableMultiSelection={false} - onActivate={handleActivate} - onSelect={handleSelect} - onToggle={handleToggle} - onRename={handleRename} - dndManager={dragDropManager} - > - {FileTreeNode} - - )} -
- + )} +
+
+ + handleNewFile(worktreePath)}> + + New File + + handleNewFolder(worktreePath)}> + + New Folder + + +
void; onConfirm: () => void; @@ -18,17 +18,17 @@ interface DeleteConfirmDialogProps { } export function DeleteConfirmDialog({ - node, + entry, open, onOpenChange, onConfirm, isDeleting = false, }: DeleteConfirmDialogProps) { - if (!node) return null; + if (!entry) return null; - const itemType = node.isDirectory ? "folder" : "file"; - const title = `Delete ${itemType} "${node.name}"?`; - const description = node.isDirectory + const itemType = entry.isDirectory ? "folder" : "file"; + const title = `Delete ${itemType} "${entry.name}"?`; + const description = entry.isDirectory ? "This folder and all its contents will be moved to the trash. This action can be undone from the system trash." : "This file will be moved to the trash. This action can be undone from the system trash."; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx new file mode 100644 index 00000000000..b01220973f3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx @@ -0,0 +1,185 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { cn } from "@superset/ui/utils"; +import { + LuClipboard, + LuCopy, + LuExternalLink, + LuFile, + LuFolder, + LuFolderOpen, + LuPencil, + LuTrash2, +} from "react-icons/lu"; +import type { DirectoryEntry } from "shared/file-tree-types"; +import { usePathActions } from "../../../ChangesView/hooks"; +import { SEARCH_RESULT_ROW_HEIGHT } from "../../constants"; +import { getFileIcon } from "../../utils"; + +interface FileSearchResultItemProps { + entry: DirectoryEntry; + worktreePath: string; + onActivate: (entry: DirectoryEntry) => void; + onOpenInEditor: (entry: DirectoryEntry) => void; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (entry: DirectoryEntry) => void; + onDelete: (entry: DirectoryEntry) => void; +} + +const PATH_LABEL_MAX_CHARS = 48; + +function getFolderLabel(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, "/"); + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash <= 0) { + return "root"; + } + return normalized.slice(0, lastSlash); +} + +function truncatePathStart(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + const sliceLength = Math.max(1, maxLength - 3); + return `...${value.slice(value.length - sliceLength)}`; +} + +export function FileSearchResultItem({ + entry, + worktreePath, + onActivate, + onOpenInEditor, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: FileSearchResultItemProps) { + const { icon: Icon, color } = getFileIcon( + entry.name, + entry.isDirectory, + false, + ); + const folderLabel = getFolderLabel(entry.relativePath); + const folderLabelDisplay = truncatePathStart( + folderLabel, + PATH_LABEL_MAX_CHARS, + ); + + const parentPath = entry.isDirectory + ? entry.path + : entry.path.split("/").slice(0, -1).join("/") || worktreePath; + + const { copyPath, copyRelativePath, revealInFinder, openInEditor } = + usePathActions({ + absolutePath: entry.path, + relativePath: entry.relativePath, + cwd: worktreePath, + }); + + const handleClick = () => { + if (!entry.isDirectory) { + onActivate(entry); + } + }; + + const handleDoubleClick = () => { + onOpenInEditor(entry); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!entry.isDirectory) { + onActivate(entry); + } + } + }; + + const itemContent = ( +
+ +
+ + {folderLabelDisplay} + +
+ + {entry.name} +
+
+
+ ); + + return ( + + {itemContent} + + onNewFile(parentPath)}> + + New File + + onNewFolder(parentPath)}> + + New Folder + + + + + + + Copy Path + + + + Copy Relative Path + + + + + + + Reveal in Finder + + + + Open in Editor + + + + + onRename(entry)}> + + Rename + + onDelete(entry)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/index.ts new file mode 100644 index 00000000000..866a3fa782d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/index.ts @@ -0,0 +1 @@ +export { FileSearchResultItem } from "./FileSearchResultItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/FileSearchResultNode.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/FileSearchResultNode.tsx deleted file mode 100644 index 055648faf94..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/FileSearchResultNode.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import type { NodeRendererProps } from "react-arborist"; -import type { FileTreeNode as FileTreeNodeType } from "shared/file-tree-types"; -import { getFileIcon } from "../../utils"; - -type FileSearchResultNodeProps = NodeRendererProps; - -const PATH_LABEL_MAX_CHARS = 48; - -function getFolderLabel(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, "/"); - const lastSlash = normalized.lastIndexOf("/"); - if (lastSlash <= 0) { - return "root"; - } - return normalized.slice(0, lastSlash); -} - -function truncatePathStart(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - const sliceLength = Math.max(1, maxLength - 3); - return `...${value.slice(value.length - sliceLength)}`; -} - -export function FileSearchResultNode({ - node, - style, - dragHandle, -}: FileSearchResultNodeProps) { - const { data } = node; - const { icon: Icon, color } = getFileIcon( - data.name, - data.isDirectory, - node.isOpen, - ); - const folderLabel = getFolderLabel(data.relativePath); - const folderLabelDisplay = truncatePathStart( - folderLabel, - PATH_LABEL_MAX_CHARS, - ); - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - node.select(); - if (data.isDirectory) { - node.toggle(); - } - }; - - const handleDoubleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - node.activate(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (data.isDirectory) { - node.toggle(); - } else { - node.activate(); - } - } - }; - - return ( -
- -
- - {folderLabelDisplay} - -
- - {node.isEditing ? ( - { - const dotIndex = data.name.lastIndexOf("."); - if (dotIndex > 0) { - e.target.setSelectionRange(0, dotIndex); - return; - } - e.target.select(); - }} - onBlur={() => node.reset()} - onKeyDown={(e) => { - if (e.key === "Enter") { - const newName = e.currentTarget.value.trim(); - if (newName && newName !== data.name) { - node.submit(newName); - } else { - node.reset(); - } - } - if (e.key === "Escape") { - node.reset(); - } - }} - className={cn( - "flex-1 min-w-0 px-1 py-0 text-xs bg-background border border-ring rounded outline-none", - )} - /> - ) : ( - - {data.name} - - )} -
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/index.ts deleted file mode 100644 index ef0647ad9a9..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultNode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FileSearchResultNode } from "./FileSearchResultNode"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx deleted file mode 100644 index 0776dbf2733..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/FileTreeContextMenu.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - LuClipboard, - LuCopy, - LuExternalLink, - LuFile, - LuFolder, - LuFolderOpen, - LuPencil, - LuTrash2, -} from "react-icons/lu"; -import type { FileTreeNode } from "shared/file-tree-types"; -import { usePathActions } from "../../../ChangesView/hooks"; - -interface FileTreeContextMenuProps { - children: React.ReactNode; - node: FileTreeNode | null; - worktreePath: string; - onNewFile: (parentPath: string) => void; - onNewFolder: (parentPath: string) => void; - onRename: (node: FileTreeNode) => void; - onDelete: (node: FileTreeNode) => void; -} - -export function FileTreeContextMenu({ - children, - node, - worktreePath, - onNewFile, - onNewFolder, - onRename, - onDelete, -}: FileTreeContextMenuProps) { - const targetPath = node?.path ?? worktreePath; - const parentPath = node?.isDirectory ? node.path : worktreePath; - - const { copyPath, copyRelativePath, revealInFinder, openInEditor } = - usePathActions({ - absolutePath: targetPath, - relativePath: node?.relativePath, - cwd: worktreePath, - }); - - return ( - - - {children} - - - onNewFile(parentPath)}> - - New File - - onNewFolder(parentPath)}> - - New Folder - - - {node && ( - <> - - - - - Copy Path - - - - Copy Relative Path - - - - - - - Reveal in Finder - - {!node.isDirectory && ( - - - Open in Editor - - )} - - - - onRename(node)}> - - Rename - - onDelete(node)} - className="text-destructive focus:text-destructive" - > - - Delete - - - )} - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts deleted file mode 100644 index 7e6da9b2209..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeContextMenu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FileTreeContextMenu } from "./FileTreeContextMenu"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx new file mode 100644 index 00000000000..4ae81282c66 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx @@ -0,0 +1,190 @@ +import type { ItemInstance } from "@headless-tree/core"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { cn } from "@superset/ui/utils"; +import { + LuChevronDown, + LuChevronRight, + LuClipboard, + LuCopy, + LuExternalLink, + LuFile, + LuFolder, + LuFolderOpen, + LuPencil, + LuTrash2, +} from "react-icons/lu"; +import type { DirectoryEntry } from "shared/file-tree-types"; +import { usePathActions } from "../../../ChangesView/hooks"; +import { getFileIcon } from "../../utils"; + +interface FileTreeItemProps { + item: ItemInstance; + entry: DirectoryEntry; + rowHeight: number; + indent: number; + worktreePath: string; + onActivate: (entry: DirectoryEntry) => void; + onOpenInEditor: (entry: DirectoryEntry) => void; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (entry: DirectoryEntry) => void; + onDelete: (entry: DirectoryEntry) => void; +} + +export function FileTreeItem({ + item, + entry, + rowHeight, + indent, + worktreePath, + onActivate, + onOpenInEditor, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: FileTreeItemProps) { + const isFolder = entry.isDirectory; + const isExpanded = item.isExpanded(); + const level = item.getItemMeta().level; + const { icon: Icon, color } = getFileIcon(entry.name, isFolder, isExpanded); + + const parentPath = isFolder + ? entry.path + : entry.path.split("/").slice(0, -1).join("/") || worktreePath; + + const { copyPath, copyRelativePath, revealInFinder, openInEditor } = + usePathActions({ + absolutePath: entry.path, + relativePath: entry.relativePath, + cwd: worktreePath, + }); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isFolder) { + if (isExpanded) { + item.collapse(); + } else { + item.expand(); + } + } else { + onActivate(entry); + } + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenInEditor(entry); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (isFolder) { + if (isExpanded) { + item.collapse(); + } else { + item.expand(); + } + } else { + onActivate(entry); + } + } + }; + + const itemContent = ( +
+ + {isFolder ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + + + + + {entry.name} +
+ ); + + return ( + + {itemContent} + + onNewFile(parentPath)}> + + New File + + onNewFolder(parentPath)}> + + New Folder + + + + + + + Copy Path + + + + Copy Relative Path + + + + + + + Reveal in Finder + + + + Open in Editor + + + + + onRename(entry)}> + + Rename + + onDelete(entry)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/index.ts new file mode 100644 index 00000000000..915eec0997c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/index.ts @@ -0,0 +1 @@ +export { FileTreeItem } from "./FileTreeItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx deleted file mode 100644 index 4916b71a1f1..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/FileTreeNode.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import type { NodeRendererProps } from "react-arborist"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; -import type { FileTreeNode as FileTreeNodeType } from "shared/file-tree-types"; -import { getFileIcon } from "../../utils"; - -type FileTreeNodeProps = NodeRendererProps; - -export function FileTreeNode({ node, style, dragHandle }: FileTreeNodeProps) { - const { data } = node; - const { icon: Icon, color } = getFileIcon( - data.name, - data.isDirectory, - node.isOpen, - ); - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - node.select(); - if (data.isDirectory) { - node.toggle(); - } - }; - - const handleDoubleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - node.activate(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (data.isDirectory) { - node.toggle(); - } else { - node.activate(); - } - } - }; - - return ( -
- - {data.isDirectory ? ( - node.isOpen ? ( - - ) : ( - - ) - ) : null} - - - - - {node.isEditing ? ( - { - if (!data.isDirectory) { - const dotIndex = data.name.lastIndexOf("."); - if (dotIndex > 0) { - e.target.setSelectionRange(0, dotIndex); - return; - } - } - e.target.select(); - }} - onBlur={() => node.reset()} - onKeyDown={(e) => { - if (e.key === "Enter") { - const newName = e.currentTarget.value.trim(); - if (newName && newName !== data.name) { - node.submit(newName); - } else { - node.reset(); - } - } - if (e.key === "Escape") { - node.reset(); - } - }} - className={cn( - "flex-1 min-w-0 px-1 py-0 text-xs bg-background border border-ring rounded outline-none", - )} - /> - ) : ( - - {data.name} - - )} -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts deleted file mode 100644 index f7f68a0b3cd..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeNode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FileTreeNode } from "./FileTreeNode"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx index 20cdca8eb22..fc818be2591 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/NewItemInput/NewItemInput.tsx @@ -1,6 +1,7 @@ import { cn } from "@superset/ui/utils"; -import { useEffect, useRef, useState } from "react"; -import { LuFile, LuFolder } from "react-icons/lu"; +import { useState } from "react"; +import { LuCheck, LuFile, LuFolder, LuX } from "react-icons/lu"; +import { TREE_INDENT } from "../../constants"; import type { NewItemMode } from "../../types"; interface NewItemInputProps { @@ -19,11 +20,6 @@ export function NewItemInput({ level = 0, }: NewItemInputProps) { const [value, setValue] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); const handleSubmit = () => { const trimmed = value.trim(); @@ -35,6 +31,7 @@ export function NewItemInput({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); handleSubmit(); @@ -45,28 +42,44 @@ export function NewItemInput({ } }; - const Icon = mode === "folder" ? LuFolder : LuFile; + const isFolder = mode === "folder"; + const Icon = isFolder ? LuFolder : LuFile; return (
setValue(e.target.value)} - onBlur={handleSubmit} onKeyDown={handleKeyDown} - placeholder={mode === "folder" ? "folder name" : "file name"} + placeholder={isFolder ? "folder name" : "file name"} className={cn( "flex-1 min-w-0 px-1 py-0 text-xs", "bg-background border border-ring rounded outline-none", )} /> + +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/RenameInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/RenameInput.tsx new file mode 100644 index 00000000000..484bdc90857 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/RenameInput.tsx @@ -0,0 +1,103 @@ +import { cn } from "@superset/ui/utils"; +import { useEffect, useRef, useState } from "react"; +import { LuCheck, LuX } from "react-icons/lu"; +import type { DirectoryEntry } from "shared/file-tree-types"; +import { getFileIcon } from "../../utils"; + +interface RenameInputProps { + entry: DirectoryEntry; + onSubmit: (newName: string) => void; + onCancel: () => void; + level?: number; +} + +export function RenameInput({ + entry, + onSubmit, + onCancel, + level = 0, +}: RenameInputProps) { + const [value, setValue] = useState(entry.name); + const inputRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + const lastDot = entry.name.lastIndexOf("."); + if (!entry.isDirectory && lastDot > 0) { + inputRef.current.setSelectionRange(0, lastDot); + } else { + inputRef.current.select(); + } + } + }, 50); + return () => clearTimeout(timer); + }, [entry.name, entry.isDirectory]); + + const handleSubmit = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== entry.name) { + onSubmit(trimmed); + } else { + onCancel(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + const { icon: Icon, color } = getFileIcon( + entry.name, + entry.isDirectory, + false, + ); + + return ( +
+ + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmit} + className={cn( + "flex-1 min-w-0 px-1 py-0 text-xs", + "bg-background border border-ring rounded outline-none", + )} + /> + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/index.ts new file mode 100644 index 00000000000..987187bef58 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/RenameInput/index.ts @@ -0,0 +1 @@ +export { RenameInput } from "./RenameInput"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts index 0366c413948..e5cd222bda9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileTreeActions.ts @@ -4,17 +4,17 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; interface UseFileTreeActionsProps { worktreePath: string | undefined; - onRefresh: () => void; + onRefresh: (parentPath: string) => void | Promise; } export function useFileTreeActions({ - worktreePath: _worktreePath, + worktreePath, onRefresh, }: UseFileTreeActionsProps) { const createFileMutation = electronTrpc.filesystem.createFile.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { toast.success(`Created ${data.path.split("/").pop()}`); - onRefresh(); + onRefresh(variables.dirPath); }, onError: (error) => { toast.error(`Failed to create file: ${error.message}`); @@ -23,9 +23,9 @@ export function useFileTreeActions({ const createDirectoryMutation = electronTrpc.filesystem.createDirectory.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { toast.success(`Created ${data.path.split("/").pop()}`); - onRefresh(); + onRefresh(variables.parentPath); }, onError: (error) => { toast.error(`Failed to create folder: ${error.message}`); @@ -33,9 +33,10 @@ export function useFileTreeActions({ }); const renameMutation = electronTrpc.filesystem.rename.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { toast.success(`Renamed to ${data.newPath.split("/").pop()}`); - onRefresh(); + const parentPath = variables.oldPath.split("/").slice(0, -1).join("/"); + onRefresh(parentPath || worktreePath || ""); }, onError: (error) => { toast.error(`Failed to rename: ${error.message}`); @@ -43,7 +44,7 @@ export function useFileTreeActions({ }); const deleteMutation = electronTrpc.filesystem.delete.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { const count = data.deleted.length; if (count === 1) { toast.success(`Moved to trash`); @@ -53,7 +54,9 @@ export function useFileTreeActions({ if (data.errors.length > 0) { toast.error(`Failed to delete ${data.errors.length} items`); } - onRefresh(); + const firstPath = variables.paths[0]; + const parentPath = firstPath?.split("/").slice(0, -1).join("/"); + onRefresh(parentPath || worktreePath || ""); }, onError: (error) => { toast.error(`Failed to delete: ${error.message}`); @@ -61,7 +64,7 @@ export function useFileTreeActions({ }); const moveMutation = electronTrpc.filesystem.move.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { const count = data.moved.length; if (count === 1) { toast.success(`Moved ${data.moved[0].to.split("/").pop()}`); @@ -71,7 +74,7 @@ export function useFileTreeActions({ if (data.errors.length > 0) { toast.error(`Failed to move ${data.errors.length} items`); } - onRefresh(); + onRefresh(variables.destinationDir); }, onError: (error) => { toast.error(`Failed to move: ${error.message}`); @@ -79,7 +82,7 @@ export function useFileTreeActions({ }); const copyMutation = electronTrpc.filesystem.copy.useMutation({ - onSuccess: (data) => { + onSuccess: (data, variables) => { const count = data.copied.length; if (count === 1) { toast.success(`Copied ${data.copied[0].to.split("/").pop()}`); @@ -89,7 +92,7 @@ export function useFileTreeActions({ if (data.errors.length > 0) { toast.error(`Failed to copy ${data.errors.length} items`); } - onRefresh(); + onRefresh(variables.destinationDir); }, onError: (error) => { toast.error(`Failed to copy: ${error.message}`); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 81ddf303493..cf11aefab05 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useParams } from "@tanstack/react-router"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { LuExpand, LuFile, @@ -11,6 +11,7 @@ import { } from "react-icons/lu"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useChangesStore } from "renderer/stores/changes"; import { RightSidebarTab, SidebarMode, @@ -53,6 +54,20 @@ export function RightSidebar() { { enabled: !!workspaceId }, ); const worktreePath = workspace?.worktreePath; + const { baseBranch } = useChangesStore(); + const { data: branchData } = electronTrpc.changes.getBranches.useQuery( + { worktreePath: worktreePath || "" }, + { enabled: !!worktreePath }, + ); + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; + const { data: status } = electronTrpc.changes.getStatus.useQuery( + { worktreePath: worktreePath || "", defaultBranch: effectiveBaseBranch }, + { + enabled: !!worktreePath, + refetchInterval: 2500, + refetchOnWindowFocus: true, + }, + ); const { currentMode, rightSidebarTab, @@ -61,6 +76,20 @@ export function RightSidebar() { setMode, } = useSidebarStore(); const isExpanded = currentMode === SidebarMode.Changes; + const hasChanges = status + ? (status.againstBase?.length ?? 0) > 0 || + (status.commits?.length ?? 0) > 0 || + (status.staged?.length ?? 0) > 0 || + (status.unstaged?.length ?? 0) > 0 || + (status.untracked?.length ?? 0) > 0 + : true; + const showChangesTab = !!worktreePath && hasChanges; + + useEffect(() => { + if (!showChangesTab && rightSidebarTab === RightSidebarTab.Changes) { + setRightSidebarTab(RightSidebarTab.Files); + } + }, [rightSidebarTab, setRightSidebarTab, showChangesTab]); const handleExpandToggle = () => { setMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); @@ -124,12 +153,14 @@ export function RightSidebar() { return (