mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
File,
|
|
FileCode,
|
|
FileImage,
|
|
FileText,
|
|
Folder,
|
|
FolderOpen,
|
|
Search,
|
|
X,
|
|
} from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/registry/default/ui/button"
|
|
import { Input } from "@/registry/default/ui/input"
|
|
import { ScrollArea } from "@/registry/default/ui/scroll-area"
|
|
|
|
const codeExplorerVariants = cva(
|
|
"relative flex flex-col overflow-hidden rounded-lg border bg-background",
|
|
{
|
|
variants: {
|
|
size: {
|
|
sm: "text-xs",
|
|
default: "text-sm",
|
|
lg: "text-base",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
size: "default",
|
|
},
|
|
}
|
|
)
|
|
|
|
export interface FileTreeNode {
|
|
id: string
|
|
name: string
|
|
type: "file" | "folder"
|
|
path: string
|
|
size?: number
|
|
modified?: Date
|
|
children?: FileTreeNode[]
|
|
content?: string
|
|
language?: string
|
|
isExpanded?: boolean
|
|
}
|
|
|
|
export interface CodeExplorerProps
|
|
extends React.HTMLAttributes<HTMLDivElement>,
|
|
VariantProps<typeof codeExplorerVariants> {
|
|
files: FileTreeNode[]
|
|
onFileSelect?: (file: FileTreeNode) => void
|
|
onFolderToggle?: (folder: FileTreeNode) => void
|
|
selectedFile?: string
|
|
showSearch?: boolean
|
|
showFileIcons?: boolean
|
|
showFileSize?: boolean
|
|
showModifiedDate?: boolean
|
|
height?: string
|
|
defaultExpanded?: string[]
|
|
searchPlaceholder?: string
|
|
emptyMessage?: string
|
|
}
|
|
|
|
// File type icons mapping
|
|
const getFileIcon = (fileName: string, isFolder: boolean = false) => {
|
|
if (isFolder) return Folder
|
|
|
|
const ext = fileName.split(".").pop()?.toLowerCase()
|
|
|
|
switch (ext) {
|
|
case "js":
|
|
case "jsx":
|
|
case "ts":
|
|
case "tsx":
|
|
case "vue":
|
|
case "svelte":
|
|
return FileCode
|
|
case "json":
|
|
case "xml":
|
|
case "yaml":
|
|
case "yml":
|
|
case "toml":
|
|
case "ini":
|
|
return FileText
|
|
case "png":
|
|
case "jpg":
|
|
case "jpeg":
|
|
case "gif":
|
|
case "svg":
|
|
case "webp":
|
|
case "ico":
|
|
return FileImage
|
|
default:
|
|
return File
|
|
}
|
|
}
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes?: number): string => {
|
|
if (!bytes) return ""
|
|
|
|
const units = ["B", "KB", "MB", "GB"]
|
|
let size = bytes
|
|
let unitIndex = 0
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024
|
|
unitIndex++
|
|
}
|
|
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
|
}
|
|
|
|
// Format modified date
|
|
const formatModifiedDate = (date?: Date): string => {
|
|
if (!date) return ""
|
|
|
|
const now = new Date()
|
|
const diff = now.getTime() - date.getTime()
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
|
|
|
if (days === 0) return "Today"
|
|
if (days === 1) return "Yesterday"
|
|
if (days < 7) return `${days} days ago`
|
|
if (days < 30) return `${Math.floor(days / 7)} weeks ago`
|
|
if (days < 365) return `${Math.floor(days / 30)} months ago`
|
|
return `${Math.floor(days / 365)} years ago`
|
|
}
|
|
|
|
const CodeExplorer = React.forwardRef<HTMLDivElement, CodeExplorerProps>(
|
|
(
|
|
{
|
|
className,
|
|
size,
|
|
files,
|
|
onFileSelect,
|
|
onFolderToggle,
|
|
selectedFile,
|
|
showSearch = true,
|
|
showFileIcons = true,
|
|
showFileSize = false,
|
|
showModifiedDate = false,
|
|
height = "400px",
|
|
defaultExpanded = [],
|
|
searchPlaceholder = "Search files...",
|
|
emptyMessage = "No files found",
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [searchTerm, setSearchTerm] = React.useState("")
|
|
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(
|
|
new Set(defaultExpanded)
|
|
)
|
|
|
|
// Build file tree with expansion state
|
|
const buildFileTree = React.useCallback(
|
|
(nodes: FileTreeNode[]): FileTreeNode[] => {
|
|
return nodes.map((node) => ({
|
|
...node,
|
|
isExpanded: expandedFolders.has(node.id),
|
|
children: node.children ? buildFileTree(node.children) : undefined,
|
|
}))
|
|
},
|
|
[expandedFolders]
|
|
)
|
|
|
|
// Filter files based on search term
|
|
const filterFiles = React.useCallback(
|
|
(nodes: FileTreeNode[], term: string): FileTreeNode[] => {
|
|
if (!term) return nodes
|
|
|
|
const filtered: FileTreeNode[] = []
|
|
|
|
for (const node of nodes) {
|
|
if (node.name.toLowerCase().includes(term.toLowerCase())) {
|
|
filtered.push(node)
|
|
} else if (node.type === "folder" && node.children) {
|
|
const filteredChildren = filterFiles(node.children, term)
|
|
if (filteredChildren.length > 0) {
|
|
filtered.push({
|
|
...node,
|
|
children: filteredChildren,
|
|
isExpanded: true, // Auto-expand folders with matches
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
},
|
|
[]
|
|
)
|
|
|
|
const processedFiles = React.useMemo(() => {
|
|
const withExpansion = buildFileTree(files)
|
|
return searchTerm ? filterFiles(withExpansion, searchTerm) : withExpansion
|
|
}, [files, searchTerm, buildFileTree, filterFiles])
|
|
|
|
const toggleFolder = React.useCallback(
|
|
(folder: FileTreeNode) => {
|
|
setExpandedFolders((prev) => {
|
|
const newSet = new Set(prev)
|
|
if (newSet.has(folder.id)) {
|
|
newSet.delete(folder.id)
|
|
} else {
|
|
newSet.add(folder.id)
|
|
}
|
|
return newSet
|
|
})
|
|
|
|
onFolderToggle?.(folder)
|
|
},
|
|
[onFolderToggle]
|
|
)
|
|
|
|
const renderFileNode = React.useCallback(
|
|
(node: FileTreeNode, depth: number = 0) => {
|
|
const Icon = showFileIcons
|
|
? getFileIcon(node.name, node.type === "folder")
|
|
: null
|
|
const FolderIcon =
|
|
node.type === "folder" && node.isExpanded ? FolderOpen : Folder
|
|
const isSelected =
|
|
selectedFile === node.id || selectedFile === node.path
|
|
|
|
return (
|
|
<div key={node.id}>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 px-2 py-1 hover:bg-muted/50 cursor-pointer transition-colors",
|
|
isSelected && "bg-muted",
|
|
"rounded-sm"
|
|
)}
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
onClick={() => {
|
|
if (node.type === "folder") {
|
|
toggleFolder(node)
|
|
} else {
|
|
onFileSelect?.(node)
|
|
}
|
|
}}
|
|
>
|
|
{/* Folder chevron */}
|
|
{node.type === "folder" && (
|
|
<div className="w-4 h-4 flex items-center justify-center">
|
|
{node.isExpanded ? (
|
|
<ChevronDown className="h-3 w-3" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* File/Folder icon */}
|
|
{showFileIcons && (
|
|
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
{node.type === "folder" ? (
|
|
<FolderIcon className="h-4 w-4 text-blue-500" />
|
|
) : Icon ? (
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* File/Folder name */}
|
|
<span className="flex-1 truncate">{node.name}</span>
|
|
|
|
{/* File size */}
|
|
{showFileSize && node.type === "file" && node.size && (
|
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
|
{formatFileSize(node.size)}
|
|
</span>
|
|
)}
|
|
|
|
{/* Modified date */}
|
|
{showModifiedDate && node.modified && (
|
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
|
{formatModifiedDate(node.modified)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Render children if folder is expanded */}
|
|
{node.type === "folder" && node.isExpanded && node.children && (
|
|
<div>
|
|
{node.children.map((child) => renderFileNode(child, depth + 1))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
[
|
|
showFileIcons,
|
|
showFileSize,
|
|
showModifiedDate,
|
|
selectedFile,
|
|
toggleFolder,
|
|
onFileSelect,
|
|
]
|
|
)
|
|
|
|
const clearSearch = () => {
|
|
setSearchTerm("")
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(codeExplorerVariants({ size }), className)}
|
|
style={{ height }}
|
|
{...props}
|
|
>
|
|
{/* Header with search */}
|
|
{showSearch && (
|
|
<div className="flex items-center gap-2 border-b px-4 py-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="pl-8 pr-8 h-8"
|
|
/>
|
|
{searchTerm && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearSearch}
|
|
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File tree */}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-2">
|
|
{processedFiles.length === 0 ? (
|
|
<div className="flex items-center justify-center h-24 text-muted-foreground">
|
|
{searchTerm ? `No files match "${searchTerm}"` : emptyMessage}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{processedFiles.map((node) => renderFileNode(node, 0))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Footer with file count */}
|
|
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
|
|
{(() => {
|
|
const countFiles = (
|
|
nodes: FileTreeNode[]
|
|
): { files: number; folders: number } => {
|
|
let files = 0
|
|
let folders = 0
|
|
|
|
for (const node of nodes) {
|
|
if (node.type === "file") {
|
|
files++
|
|
} else {
|
|
folders++
|
|
if (node.children) {
|
|
const childCounts = countFiles(node.children)
|
|
files += childCounts.files
|
|
folders += childCounts.folders
|
|
}
|
|
}
|
|
}
|
|
|
|
return { files, folders }
|
|
}
|
|
|
|
const { files: fileCount, folders: folderCount } =
|
|
countFiles(processedFiles)
|
|
const parts = []
|
|
|
|
if (fileCount > 0)
|
|
parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"}`)
|
|
if (folderCount > 0)
|
|
parts.push(`${folderCount} folder${folderCount === 1 ? "" : "s"}`)
|
|
|
|
return parts.join(", ") || "Empty"
|
|
})()}
|
|
{searchTerm && <span> (filtered)</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
CodeExplorer.displayName = "CodeExplorer"
|
|
|
|
export { CodeExplorer, codeExplorerVariants }
|