diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index e8acf2875..3918e346b 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -9,10 +9,20 @@ import { } from "@/components/ui/collapsible" import { cn } from "@/lib/utils" import { + BracesIcon, ChevronRightIcon, + ContainerIcon, + DatabaseIcon, + FileCodeIcon, + FileCogIcon, FileIcon, + FileSpreadsheetIcon, + FileTextIcon, FolderIcon, FolderOpenIcon, + PackageIcon, + SparklesIcon, + SquareTerminalIcon, } from "lucide-react" import { createContext, @@ -83,7 +93,7 @@ export const FileTree = ({
& { name: string nameClassName?: string iconClassName?: string + showIcon?: boolean suffix?: ReactNode suffixClassName?: string } @@ -121,6 +132,7 @@ export const FileTreeFolder = ({ name, nameClassName, iconClassName, + showIcon = true, suffix, suffixClassName, className, @@ -158,29 +170,31 @@ export const FileTreeFolder = ({
@@ -213,16 +232,203 @@ const FileTreeFileContext = createContext({ path: "", }) +function getFileExtension(name: string): string { + const lowerName = name.toLowerCase() + if (lowerName.endsWith(".d.ts")) return "d.ts" + const dotIndex = lowerName.lastIndexOf(".") + return dotIndex >= 0 ? lowerName.slice(dotIndex + 1) : "" +} + +type FileTreeBadgeIconProps = { + label: string + type: string + className: string +} + +function FileTreeBadgeIcon({ label, type, className }: FileTreeBadgeIconProps) { + return ( + + ) +} + +export function getFileTreeFileIcon(name: string): ReactNode { + const lowerName = name.toLowerCase() + const extension = getFileExtension(name) + + if ( + lowerName === "dockerfile" || + lowerName.startsWith("dockerfile.") || + lowerName === "docker-compose.yml" || + lowerName === "docker-compose.yaml" + ) { + return ( + + ) + } + + if (lowerName === "claude.md") { + return ( + + ) + } + + if ( + lowerName === "package.json" || + lowerName === "package-lock.json" || + lowerName === "pnpm-lock.yaml" || + lowerName === "pnpm-lock.yml" || + lowerName === "yarn.lock" || + lowerName === "bun.lock" || + lowerName === "bun.lockb" + ) { + return ( + + ) + } + + if (extension === "ts" || extension === "tsx" || extension === "d.ts") { + return ( + + ) + } + + if (extension === "js" || extension === "jsx") { + return ( + + ) + } + + if (extension === "json") { + return ( + + ) + } + + if (extension === "md" || extension === "mdx") { + return ( + + ) + } + + if (extension === "sh" || extension === "bash" || extension === "zsh") { + return ( + + ) + } + + if (extension === "ps1") { + return ( + + ) + } + + if (extension === "yml" || extension === "yaml") { + return + } + + if (extension === "sql") { + return ( + + ) + } + + if (["csv", "tsv", "xls", "xlsx"].includes(extension)) { + return ( + + ) + } + + if ( + lowerName.includes("config") || + lowerName.startsWith(".") || + lowerName === "components.json" + ) { + return ( + + ) + } + + if (lowerName === "license" || extension === "txt") { + return ( + + ) + } + + if ( + ["rs", "go", "py", "java", "kt", "swift", "c", "cpp", "h"].includes( + extension + ) + ) { + return ( + + ) + } + + return ( + + ) +} + export type FileTreeFileProps = HTMLAttributes & { path: string name: string icon?: ReactNode + nameClassName?: string + prefix?: ReactNode + suffix?: ReactNode + suffixClassName?: string } export const FileTreeFile = ({ path, name, icon, + nameClassName, + prefix, + suffix, + suffixClassName, className, children, ...props @@ -249,8 +455,8 @@ export const FileTreeFile = ({
{children ?? ( <> - {/* Spacer for alignment */} - - - {icon ?? } - - {name} + {prefix ?? } + {icon ?? getFileTreeFileIcon(name)} + {name} + {suffix ? ( + + {suffix} + + ) : null} )}
@@ -294,7 +507,13 @@ export const FileTreeName = ({ children, ...props }: FileTreeNameProps) => ( - + {children} ) diff --git a/src/components/layout/aux-panel-file-tree-tab-source.test.ts b/src/components/layout/aux-panel-file-tree-tab-source.test.ts index 61326055d..d28544e35 100644 --- a/src/components/layout/aux-panel-file-tree-tab-source.test.ts +++ b/src/components/layout/aux-panel-file-tree-tab-source.test.ts @@ -29,6 +29,29 @@ describe("aux-panel-file-tree-tab external conflict reload wiring", () => { }) }) +describe("aux-panel-file-tree-tab file tree presentation", () => { + it("uses a padded transparent tree surface in the aux panel", () => { + expect(source).toMatch(/ { + expect(source).toMatch(/placeholder=\{t\("filterPlaceholder"\)\}/) + expect(source).toMatch(/\bfilterFileTreeNodesForQuery\b/) + expect(source).not.toMatch(/file-workspace-panel/) + expect(source).not.toMatch(/monaco-editor/) + }) + + it("uses compact git status markers instead of coloring whole file rows", () => { + expect(source).toMatch(/prefix=\{getGitFileStateIndicator/) + expect(source).not.toMatch( + / { it("destructures the background-reload, stale, and prefetched-apply APIs", () => { // Catching external changes for non-active tabs requires these APIs; diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 32e923794..f8cb4f6d9 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -10,7 +10,7 @@ import { } from "react" import { revealItemInDir } from "@/lib/platform" import ignore from "ignore" -import { Check, ChevronRight } from "lucide-react" +import { Check, ChevronRight, Search } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useActiveFolder } from "@/contexts/active-folder-context" @@ -130,7 +130,7 @@ async function copyPathToClipboard( } } -const FILE_TREE_ROOT_PATH = "__workspace_root__" +export const FILE_TREE_ROOT_PATH = "__workspace_root__" const GITIGNORE_MUTED_CLASS = "text-muted-foreground/55" interface FileActionTarget { @@ -166,6 +166,55 @@ function normalizeComparePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, "") } +export function filterFileTreeNodesForQuery( + nodes: FileTreeNode[], + query: string +): FileTreeNode[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return nodes + + const matches = (node: FileTreeNode) => + node.name.toLowerCase().includes(normalizedQuery) || + node.path.toLowerCase().includes(normalizedQuery) + + const filterNode = (node: FileTreeNode): FileTreeNode | null => { + if (node.kind === "file") return matches(node) ? node : null + + if (matches(node)) return node + + const children = node.children + .map(filterNode) + .filter((child): child is FileTreeNode => child !== null) + + if (children.length === 0) return null + return { ...node, children } + } + + return nodes + .map(filterNode) + .filter((node): node is FileTreeNode => node !== null) +} + +export function collectFilteredFileTreeExpandedPaths( + nodes: FileTreeNode[], + query: string +): Set { + const expanded = new Set([FILE_TREE_ROOT_PATH]) + if (!query.trim()) return expanded + + const filteredNodes = filterFileTreeNodesForQuery(nodes, query) + const collect = (items: FileTreeNode[]) => { + for (const node of items) { + if (node.kind !== "dir") continue + expanded.add(node.path) + collect(node.children) + } + } + + collect(filteredNodes) + return expanded +} + function prefixFileTreeNodePaths( nodes: FileTreeNode[], prefix: string @@ -228,16 +277,37 @@ function classifyGitFileState(status: string): GitFileState | null { return null } -function getGitFileStateClassName(status?: string): string { - if (!status) return "" +function getGitFileStateIndicator(status?: string): ReactNode { + if (!status) return null const state = classifyGitFileState(status) - if (state === "untracked") return "text-red-500 dark:text-red-400" - if (state === "modified") return "text-emerald-600 dark:text-emerald-400" - if (state === "staged") return "text-emerald-500 dark:text-emerald-400" - if (state === "conflicted") return "text-amber-500 dark:text-amber-400" - if (state === "deleted") return "text-orange-500 dark:text-orange-400" - if (state === "renamed") return "text-violet-500 dark:text-violet-400" - return "" + if (!state) return null + + const labelByState: Record = { + conflicted: "!", + deleted: "D", + modified: "M", + renamed: "R", + staged: "M", + untracked: "U", + } + const classByState: Record = { + conflicted: "text-amber-400", + deleted: "text-orange-400", + modified: "text-emerald-400", + renamed: "text-violet-400", + staged: "text-emerald-400", + untracked: "text-red-400", + } + + return ( + + {labelByState[state]} + + ) } function getParentPath(path: string): string | null { @@ -572,11 +642,10 @@ function RenderNode({ @@ -731,12 +800,12 @@ function RenderNode({ M + ) : null } iconClassName={isGitignoreIgnored ? GITIGNORE_MUTED_CLASS : undefined} > @@ -971,6 +1040,7 @@ export function FileTreeTab() { const [compareLocalOpen, setCompareLocalOpen] = useState(false) const [compareRemoteOpen, setCompareRemoteOpen] = useState(false) const [comparing, setComparing] = useState(false) + const [fileTreeFilter, setFileTreeFilter] = useState("") const [expandedPaths, setExpandedPaths] = useState>( () => new Set([FILE_TREE_ROOT_PATH]) ) @@ -1424,6 +1494,16 @@ export function FileTreeTab() { return dirs }, [gitStatusByPath, dirChildrenByPath]) + const visibleNodes = useMemo( + () => filterFileTreeNodesForQuery(nodes, fileTreeFilter), + [nodes, fileTreeFilter] + ) + + const visibleExpandedPaths = useMemo(() => { + if (!fileTreeFilter.trim()) return expandedPaths + return collectFilteredFileTreeExpandedPaths(nodes, fileTreeFilter) + }, [expandedPaths, fileTreeFilter, nodes]) + const handleTreeSelect = useCallback( (path: string) => { if (!filePathSet.has(path)) return @@ -2523,194 +2603,86 @@ export function FileTreeTab() { )} - - - {folder?.path && ( - - - - {nodes.map((node) => ( - { - void openFilePreview(path) - }} - onOpenFileDiff={(path) => { - void openWorkingTreeDiff(path) - }} - onOpenDirDiff={(path) => { - void openWorkingTreeDiff(path, { - mode: "overview", - }) - }} - onOpenCommitWindow={handleOpenCommitWindow} - onRequestCompareWithBranch={ - handleRequestCompareWithBranch - } - onRequestRollback={handleRequestRollback} - onOpenDirInTerminal={handleOpenDirInTerminal} - onRequestCreate={handleRequestCreate} - onRequestAddToVcs={handleAddToVcs} - onRequestRename={handleRequestRename} - onRequestDelete={handleRequestDelete} - onRequestUpload={handleRequestUpload} - onRequestDownloadFile={(target) => - void handleRequestDownloadFile(target) - } - onRequestDownloadDir={(target) => - void handleRequestDownloadDir(target) - } - onRefresh={fetchTree} - /> - ))} - - - - - {t("new")} - - handleRequestCreate("", "file")} - > - {t("newFile")} - - handleRequestCreate("", "dir")} - > - {t("newDirectory")} - - - - - - {t("git")} - - - handleOpenCommitWindow()} - disabled={!gitEnabled} - > - {t("actions.commitCode")} - - void handleAddToVcs(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.addToVcs")} - - - void openWorkingTreeDiff(".", { - mode: "overview", - }) - } - disabled={!gitEnabled} - > - {tCommon("viewDiff")} - - - handleRequestCompareWithBranch(rootTarget) - } - disabled={!gitEnabled} - > - {t("compareWithBranch")} - - handleRequestRollback(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.rollback")} - - - - { - void fetchTree() - }} - > - {t("reloadFromDisk")} - - - - {t("openIn")} - - - { - void revealItemInDir(folder.path) - }} - > - {systemExplorerLabel} - - { - void handleOpenDirInTerminal( - folder.path, - rootNodeName - ) - }} - > - {t("openInTerminal")} - - - - - void copyPathToClipboard(folder.path, { - success: t("toasts.pathCopied"), - failure: t("toasts.copyPathFailed"), - }) - } - > - {t("copyPath")} - - {webMode && ( - <> - handleRequestUpload("")} - > - {t("upload")} - - - void handleRequestDownloadDir(rootTarget) - } - > - {t("downloadAsZip")} - - - )} - - - )} - - +
+
+
+ + setFileTreeFilter(event.target.value)} + placeholder={t("filterPlaceholder")} + className="h-7 rounded-md border-border/70 bg-background/60 pl-7 pr-2 text-[13px] shadow-none placeholder:text-muted-foreground/65 focus-visible:ring-1 focus-visible:ring-ring/35" + /> +
+
+ + + {folder?.path && ( + <> + {visibleNodes.map((node) => ( + { + void openFilePreview(path) + }} + onOpenFileDiff={(path) => { + void openWorkingTreeDiff(path) + }} + onOpenDirDiff={(path) => { + void openWorkingTreeDiff(path, { + mode: "overview", + }) + }} + onOpenCommitWindow={handleOpenCommitWindow} + onRequestCompareWithBranch={ + handleRequestCompareWithBranch + } + onRequestRollback={handleRequestRollback} + onOpenDirInTerminal={handleOpenDirInTerminal} + onRequestCreate={handleRequestCreate} + onRequestAddToVcs={handleAddToVcs} + onRequestRename={handleRequestRename} + onRequestDelete={handleRequestDelete} + onRequestUpload={handleRequestUpload} + onRequestDownloadFile={(target) => + void handleRequestDownloadFile(target) + } + onRequestDownloadDir={(target) => + void handleRequestDownloadDir(target) + } + onRefresh={fetchTree} + /> + ))} + {fileTreeFilter.trim() && visibleNodes.length === 0 ? ( +
+ {t("filterNoResults")} +
+ ) : null} + + )} +
+
+
@@ -2724,6 +2696,48 @@ export function FileTreeTab() { + + + {t("git")} + + + handleOpenCommitWindow()} + disabled={!gitEnabled} + > + {t("actions.commitCode")} + + void handleAddToVcs(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.addToVcs")} + + + void openWorkingTreeDiff(".", { + mode: "overview", + }) + } + disabled={!gitEnabled} + > + {tCommon("viewDiff")} + + handleRequestCompareWithBranch(rootTarget)} + disabled={!gitEnabled} + > + {t("compareWithBranch")} + + handleRequestRollback(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.rollback")} + + + {webMode && ( handleRequestUpload("")}> {t("upload")} @@ -2736,6 +2750,42 @@ export function FileTreeTab() { > {t("reloadFromDisk")} + + {t("openIn")} + + { + void revealItemInDir(folder.path) + }} + > + {systemExplorerLabel} + + { + void handleOpenDirInTerminal(folder.path, rootNodeName) + }} + > + {t("openInTerminal")} + + + + + void copyPathToClipboard(folder.path, { + success: t("toasts.pathCopied"), + failure: t("toasts.copyPathFailed"), + }) + } + > + {t("copyPath")} + + {webMode && ( + void handleRequestDownloadDir(rootTarget)} + > + {t("downloadAsZip")} + + )}
{webMode && folder?.path && ( diff --git a/src/components/layout/aux-panel-git-changes-tab.tsx b/src/components/layout/aux-panel-git-changes-tab.tsx index a5d6d92fe..f9493886c 100644 --- a/src/components/layout/aux-panel-git-changes-tab.tsx +++ b/src/components/layout/aux-panel-git-changes-tab.tsx @@ -11,15 +11,6 @@ import { import { ChevronsDownUp, ChevronsUpDown, GitBranch } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { - CommitFileAdditions, - CommitFileChanges, - CommitFileDeletions, - CommitFileIcon, - CommitFileInfo, - CommitFilePath, - CommitFileStatus, -} from "@/components/ai-elements/commit" import { FileTree, FileTreeFile, @@ -362,14 +353,41 @@ function isUntrackedStatus(status: string): boolean { return status.trim().toUpperCase() === UNTRACKED_STATUS } -function mapStatus( - status: string -): "added" | "modified" | "deleted" | "renamed" { +function getChangeStatusClassName(status: string): string { + const state = classifyGitFileState(status) + if (state === "untracked") return "text-red-400" + if (state === "conflicted") return "text-amber-400" + if (state === "deleted") return "text-orange-400" + if (state === "renamed") return "text-violet-400" + return "text-emerald-400" +} + +function getChangeStatusPrefix(status: string) { const normalized = status.trim().toUpperCase() - if (normalized.includes("A")) return "added" - if (normalized.includes("R") || normalized.includes("C")) return "renamed" - if (normalized.includes("D")) return "deleted" - return "modified" + const label = normalized === UNTRACKED_STATUS ? "U" : normalized || "M" + + return ( + + {label} + + ) +} + +function getChangeSummary(change: WorkingTreeChange) { + return ( + + {change.additions > 0 ? ( + +{change.additions} + ) : null} + {change.deletions > 0 ? ( + -{change.deletions} + ) : null} + + ) } function canOpenFile(status: string): boolean { @@ -838,6 +856,8 @@ export function GitChangesTab() { { void openWorkingTreeDiff(file.path) }} path={node.path} + prefix={getChangeStatusPrefix(file.status)} + suffix={getChangeSummary(file)} title={file.path} - > - <> - - - - {file.status} - - - {node.name} - - - - - - - + /> { void openWorkingTreeDiff(file.path) }} path={node.path} + prefix={getChangeStatusPrefix(file.status)} title={file.path} - > - <> - - - - {node.name} - - - + /> @@ -1258,6 +1262,8 @@ export function GitChangesTab() { @@ -1367,6 +1373,8 @@ export function GitChangesTab() {