Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 244 additions & 25 deletions src/components/ai-elements/file-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,7 +93,7 @@ export const FileTree = ({
<FileTreeContext.Provider value={contextValue}>
<div
className={cn(
"rounded-lg border bg-background font-mono text-sm",
"bg-transparent text-[13px] leading-6 text-foreground",
className
)}
role="tree"
Expand Down Expand Up @@ -112,6 +122,7 @@ export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
name: string
nameClassName?: string
iconClassName?: string
showIcon?: boolean
suffix?: ReactNode
suffixClassName?: string
}
Expand All @@ -121,6 +132,7 @@ export const FileTreeFolder = ({
name,
nameClassName,
iconClassName,
showIcon = true,
suffix,
suffixClassName,
className,
Expand Down Expand Up @@ -158,29 +170,31 @@ export const FileTreeFolder = ({
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-max min-w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted"
"flex h-6 w-max min-w-full items-center gap-1 rounded-md px-1.5 text-left text-foreground transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/45",
isSelected && "bg-accent/80 text-foreground"
)}
onClick={handleSelect}
type="button"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
"size-3.5 shrink-0 text-muted-foreground/70 transition-transform",
isExpanded && "rotate-90"
)}
/>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon
className={cn("size-4 text-blue-500", iconClassName)}
/>
) : (
<FolderIcon
className={cn("size-4 text-blue-500", iconClassName)}
/>
)}
</FileTreeIcon>
{showIcon ? (
<FileTreeIcon data-testid="file-tree-folder-icon">
{isExpanded ? (
<FolderOpenIcon
className={cn("size-4 text-blue-500", iconClassName)}
/>
) : (
<FolderIcon
className={cn("size-4 text-blue-500", iconClassName)}
/>
)}
</FileTreeIcon>
) : null}
<FileTreeName className={nameClassName}>{name}</FileTreeName>
{suffix ? (
<span
Expand All @@ -195,7 +209,12 @@ export const FileTreeFolder = ({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-4 border-l pl-2">{children}</div>
<div
className="ml-3 border-l border-border/40 pl-2"
data-testid="file-tree-folder-children"
>
{children}
</div>
</CollapsibleContent>
</div>
</Collapsible>
Expand All @@ -213,16 +232,203 @@ const FileTreeFileContext = createContext<FileTreeFileContextType>({
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 (
<span
aria-hidden="true"
className={cn(
"inline-flex size-4 items-center justify-center rounded-sm text-[9px] font-semibold leading-none",
className
)}
data-file-icon={type}
>
{label}
</span>
)
}

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 (
<ContainerIcon className="size-4 text-sky-400" data-file-icon="docker" />
)
}

if (lowerName === "claude.md") {
return (
<SparklesIcon
className="size-4 text-orange-400"
data-file-icon="claude"
/>
)
}

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 (
<PackageIcon
className="size-4 text-orange-400"
data-file-icon="package"
/>
)
}

if (extension === "ts" || extension === "tsx" || extension === "d.ts") {
return (
<FileTreeBadgeIcon
className="bg-blue-950/40 text-blue-400"
label="TS"
type="typescript"
/>
)
}

if (extension === "js" || extension === "jsx") {
return (
<FileTreeBadgeIcon
className="bg-yellow-950/40 text-yellow-400"
label="JS"
type="javascript"
/>
)
}

if (extension === "json") {
return (
<BracesIcon className="size-4 text-orange-400" data-file-icon="json" />
)
}

if (extension === "md" || extension === "mdx") {
return (
<FileTreeBadgeIcon
className="bg-emerald-950/40 text-emerald-400"
label="M"
type="markdown"
/>
)
}

if (extension === "sh" || extension === "bash" || extension === "zsh") {
return (
<SquareTerminalIcon
className="size-4 text-green-500"
data-file-icon="shell"
/>
)
}

if (extension === "ps1") {
return (
<SquareTerminalIcon
className="size-4 text-blue-500"
data-file-icon="powershell"
/>
)
}

if (extension === "yml" || extension === "yaml") {
return <BracesIcon className="size-4 text-sky-400" data-file-icon="yaml" />
}

if (extension === "sql") {
return (
<DatabaseIcon className="size-4 text-fuchsia-400" data-file-icon="sql" />
)
}

if (["csv", "tsv", "xls", "xlsx"].includes(extension)) {
return (
<FileSpreadsheetIcon
className="size-4 text-cyan-400"
data-file-icon="spreadsheet"
/>
)
}

if (
lowerName.includes("config") ||
lowerName.startsWith(".") ||
lowerName === "components.json"
) {
return (
<FileCogIcon className="size-4 text-violet-400" data-file-icon="config" />
)
}

if (lowerName === "license" || extension === "txt") {
return (
<FileTextIcon
className="size-4 text-muted-foreground/80"
data-file-icon="text"
/>
)
}

if (
["rs", "go", "py", "java", "kt", "swift", "c", "cpp", "h"].includes(
extension
)
) {
return (
<FileCodeIcon className="size-4 text-blue-400" data-file-icon="code" />
)
}

return (
<FileIcon
className="size-4 text-muted-foreground/70"
data-file-icon="file"
/>
)
}

export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
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
Expand All @@ -249,8 +455,8 @@ export const FileTreeFile = ({
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex w-max min-w-full cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
"flex h-6 w-max min-w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-foreground transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/45",
isSelected && "bg-accent/80 text-foreground",
className
)}
onClick={handleClick}
Expand All @@ -262,12 +468,19 @@ export const FileTreeFile = ({
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
{prefix ?? <span className="size-3.5" />}
<FileTreeIcon>{icon ?? getFileTreeFileIcon(name)}</FileTreeIcon>
<FileTreeName className={nameClassName}>{name}</FileTreeName>
{suffix ? (
<span
className={cn(
"ml-auto shrink-0 whitespace-nowrap pl-3 text-[11px] tabular-nums text-muted-foreground/70",
suffixClassName
)}
>
{suffix}
</span>
) : null}
</>
)}
</div>
Expand All @@ -294,7 +507,13 @@ export const FileTreeName = ({
children,
...props
}: FileTreeNameProps) => (
<span className={cn("shrink-0 whitespace-nowrap", className)} {...props}>
<span
className={cn(
"shrink-0 whitespace-nowrap leading-6 text-foreground",
className
)}
{...props}
>
{children}
</span>
)
Expand Down
23 changes: 23 additions & 0 deletions src/components/layout/aux-panel-file-tree-tab-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<ScrollArea className="[^"]*px-2[^"]*py-1\.5/)
expect(source).toMatch(
/<FileTree[\s\S]*className="[^"]*bg-transparent[^"]*text-\[13px\]/
)
})

it("keeps the Codex-style workspace tree filter local to the aux panel", () => {
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(
/<FileTreeFile[\s\S]*className=\{[\s\S]*getGitFileStateClassName/
)
})
})

describe("aux-panel-file-tree-tab external-change watcher coverage", () => {
it("destructures the background-reload, stale, and prefetched-apply APIs", () => {
// Catching external changes for non-active tabs requires these APIs;
Expand Down
Loading