diff --git a/package.json b/package.json index 35ae3f765..ea373bbe9 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,14 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "lucide-react": "^0.562.0", "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "@xterm/xterm": "^5.5.0", - "@xterm/addon-fit": "^0.10.0" + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 9e75e8d30..f2308128a 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -547,7 +547,7 @@ pub(crate) async fn list_workspace_files( .get(&workspace_id) .ok_or("workspace not found")?; let root = PathBuf::from(&entry.path); - Ok(list_workspace_files_inner(&root, 20000)) + Ok(list_workspace_files_inner(&root, usize::MAX)) } #[cfg(test)] diff --git a/src/App.tsx b/src/App.tsx index d851019d0..398020ec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import "./styles/update-toasts.css"; import "./styles/composer.css"; import "./styles/diff.css"; import "./styles/diff-viewer.css"; +import "./styles/file-tree.css"; import "./styles/debug.css"; import "./styles/terminal.css"; import "./styles/plan.css"; @@ -115,6 +116,7 @@ function MainApp() { const [gitPanelMode, setGitPanelMode] = useState<"diff" | "log" | "issues">( "diff" ); + const [filePanelMode, setFilePanelMode] = useState<"git" | "files">("git"); const [accessMode, setAccessMode] = useState("current"); const [activeTab, setActiveTab] = useState< "projects" | "codex" | "git" | "log" @@ -232,7 +234,10 @@ function MainApp() { } = useModels({ activeWorkspace, onDebug: addDebugEntry }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); - const { files } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry }); + const { files, isLoading: isFilesLoading } = useWorkspaceFiles({ + activeWorkspace, + onDebug: addDebugEntry, + }); const { branches, checkoutBranch, createBranch } = useGitBranches({ activeWorkspace, onDebug: addDebugEntry @@ -753,6 +758,11 @@ function MainApp() { onCopyThread: handleCopyThread, onToggleTerminal: handleToggleTerminal, showTerminalButton: !isCompact, + filePanelMode, + onToggleFilePanel: () => { + setFilePanelMode((prev) => (prev === "git" ? "files" : "git")); + }, + fileTreeLoading: isFilesLoading, centerMode, onExitDiff: () => { setCenterMode("chat"); diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx new file mode 100644 index 000000000..75d2f332e --- /dev/null +++ b/src/features/files/components/FileTreePanel.tsx @@ -0,0 +1,395 @@ +import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import type { MouseEvent } from "react"; +import { Menu, MenuItem } from "@tauri-apps/api/menu"; +import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { + ArrowLeftRight, + ChevronsUpDown, + File, + FileArchive, + FileAudio, + FileCode, + FileImage, + FileJson, + FileSpreadsheet, + FileText, + FileVideo, + Folder, + Search, +} from "lucide-react"; + +type FileTreeNode = { + name: string; + path: string; + type: "file" | "folder"; + children: FileTreeNode[]; +}; + +type FileTreePanelProps = { + workspacePath: string; + files: string[]; + isLoading: boolean; + onToggleFilePanel: () => void; +}; + +type FileTreeBuildNode = { + name: string; + path: string; + type: "file" | "folder"; + children: Map; +}; + +function buildTree(paths: string[]): { nodes: FileTreeNode[]; folderPaths: Set } { + const root = new Map(); + const addNode = ( + map: Map, + name: string, + path: string, + type: "file" | "folder", + ) => { + const existing = map.get(name); + if (existing) { + if (type === "folder") { + existing.type = "folder"; + } + return existing; + } + const node: FileTreeBuildNode = { + name, + path, + type, + children: new Map(), + }; + map.set(name, node); + return node; + }; + + paths.forEach((path) => { + const parts = path.split("/").filter(Boolean); + let currentMap = root; + let currentPath = ""; + parts.forEach((segment, index) => { + const isFile = index === parts.length - 1; + const nextPath = currentPath ? `${currentPath}/${segment}` : segment; + const node = addNode(currentMap, segment, nextPath, isFile ? "file" : "folder"); + if (!isFile) { + currentMap = node.children; + currentPath = nextPath; + } + }); + }); + + const folderPaths = new Set(); + + const toArray = (map: Map): FileTreeNode[] => { + const nodes = Array.from(map.values()).map((node) => { + if (node.type === "folder") { + folderPaths.add(node.path); + } + return { + name: node.name, + path: node.path, + type: node.type, + children: node.type === "folder" ? toArray(node.children) : [], + }; + }); + nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + return nodes; + }; + + return { nodes: toArray(root), folderPaths }; +} + +function getFileIcon(name: string) { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + switch (ext) { + case "ts": + case "tsx": + case "js": + case "jsx": + case "mjs": + case "cjs": + case "py": + case "rs": + case "swift": + case "go": + case "java": + case "kt": + case "cs": + case "cpp": + case "c": + case "h": + case "hpp": + case "sh": + case "zsh": + case "bash": + return FileCode; + case "json": + return FileJson; + case "md": + case "mdx": + case "txt": + case "rtf": + return FileText; + case "png": + case "jpg": + case "jpeg": + case "gif": + case "svg": + case "webp": + case "heic": + return FileImage; + case "mp4": + case "mov": + case "m4v": + case "webm": + return FileVideo; + case "mp3": + case "wav": + case "flac": + case "m4a": + return FileAudio; + case "zip": + case "gz": + case "tgz": + case "tar": + case "7z": + case "rar": + return FileArchive; + case "csv": + case "tsv": + case "xls": + case "xlsx": + return FileSpreadsheet; + default: + return File; + } +} + +export function FileTreePanel({ + workspacePath, + files, + isLoading, + onToggleFilePanel, +}: FileTreePanelProps) { + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [query, setQuery] = useState(""); + const hasManualToggle = useRef(false); + const showLoading = isLoading && files.length === 0; + const deferredQuery = useDeferredValue(query); + const normalizedQuery = deferredQuery.trim().toLowerCase(); + + const filteredFiles = useMemo(() => { + if (!normalizedQuery) { + return files; + } + return files.filter((path) => path.toLowerCase().includes(normalizedQuery)); + }, [files, normalizedQuery]); + + const { nodes, folderPaths } = useMemo( + () => buildTree(normalizedQuery ? filteredFiles : files), + [files, filteredFiles, normalizedQuery], + ); + + const visibleFolderPaths = folderPaths; + const hasFolders = visibleFolderPaths.size > 0; + const allVisibleExpanded = + hasFolders && Array.from(visibleFolderPaths).every((path) => expandedFolders.has(path)); + + useEffect(() => { + setExpandedFolders((prev) => { + if (normalizedQuery) { + return new Set(folderPaths); + } + const next = new Set(); + prev.forEach((path) => { + if (folderPaths.has(path)) { + next.add(path); + } + }); + if (next.size === 0 && !hasManualToggle.current) { + nodes.forEach((node) => { + if (node.type === "folder") { + next.add(node.path); + } + }); + } + return next; + }); + }, [folderPaths, nodes, normalizedQuery]); + + const toggleAllFolders = () => { + if (!hasFolders) { + return; + } + setExpandedFolders((prev) => { + const next = new Set(prev); + if (allVisibleExpanded) { + visibleFolderPaths.forEach((path) => next.delete(path)); + } else { + visibleFolderPaths.forEach((path) => next.add(path)); + } + return next; + }); + hasManualToggle.current = true; + }; + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const resolvePath = (relativePath: string) => { + const base = workspacePath.endsWith("/") + ? workspacePath.slice(0, -1) + : workspacePath; + return `${base}/${relativePath}`; + }; + + async function showFileMenu( + event: MouseEvent, + relativePath: string, + ) { + event.preventDefault(); + event.stopPropagation(); + const menu = await Menu.new({ + items: [ + await MenuItem.new({ + text: "Reveal in Finder", + action: async () => { + await revealItemInDir(resolvePath(relativePath)); + }, + }), + ], + }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + } + + const renderNode = (node: FileTreeNode, depth: number) => { + const isFolder = node.type === "folder"; + const isExpanded = isFolder && expandedFolders.has(node.path); + const FileIcon = isFolder ? Folder : getFileIcon(node.name); + return ( +
+ + {isFolder && isExpanded && node.children.length > 0 && ( +
+ {node.children.map((child) => renderNode(child, depth + 1))} +
+ )} +
+ ); + }; + + return ( + + ); +} diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index a8462a4f6..d4b18a39b 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -4,12 +4,13 @@ import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { GitBranch } from "lucide-react"; +import { ArrowLeftRight, GitBranch } from "lucide-react"; import { formatRelativeTime } from "../../../utils/time"; type GitDiffPanelProps = { mode: "diff" | "log" | "issues"; onModeChange: (mode: "diff" | "log" | "issues") => void; + onToggleFilePanel: () => void; branchName: string; totalAdditions: number; totalDeletions: number; @@ -95,6 +96,7 @@ function getStatusClass(status: string) { export function GitDiffPanel({ mode, onModeChange, + onToggleFilePanel, branchName, totalAdditions, totalDeletions, @@ -188,10 +190,17 @@ export function GitDiffPanel({ return (