Files

Un componente de árbol de archivos para mostrar estructuras de archivos jerárquicas con carpetas y archivos.

srcM
main.tsx
App.tsxM
index.css
components
uiM
button.tsx
card.tsxM
toast.tsxA
header.tsx
footer.tsx
.envI
.eslintrc.cjs
index.html
package.jsonM
tsconfig.json
vite.config.ts
1'use client';
2
3import { Files } from '@/components/ui/files';
4
5export function Default() {
6 return (
7 <Files defaultValue="src/components/ui">
8 <Files.Folder name="src" status="modified">
9 <Files.File name="main.tsx" />
10 <Files.File name="App.tsx" status="modified" />
11 <Files.File name="index.css" />
12
13 <Files.Folder name="components">
14 <Files.Folder name="ui" status="modified">
15 <Files.File name="button.tsx" />
16 <Files.File name="card.tsx" status="modified" />
17 <Files.File name="toast.tsx" status="added" />
18 </Files.Folder>
19
20 <Files.File name="header.tsx" />
21 <Files.File name="footer.tsx" />
22 </Files.Folder>
23
24 <Files.Folder name="hooks" status="untracked">
25 <Files.File name="use-auth.ts" status="untracked" />
26 <Files.File name="use-toast.ts" status="untracked" />
27 </Files.Folder>
28
29 <Files.Folder name="lib">
30 <Files.File name="cn.ts" />
31 <Files.File name="utils.ts" />
32 </Files.Folder>
33 </Files.Folder>
34
35 <Files.Folder name="public">
36 <Files.File name="favicon.svg" />
37 </Files.Folder>
38
39 <Files.File name=".env" status="ignored" />
40 <Files.File name=".eslintrc.cjs" />
41 <Files.File name="index.html" />
42 <Files.File name="package.json" status="modified" />
43 <Files.File name="tsconfig.json" />
44 <Files.File name="vite.config.ts" />
45 </Files>
46 );
47}

Instalación

Copia y pega el siguiente código en tu proyecto.
'use client';
import {
ArrowRight01Icon,
File01Icon,
Folder01Icon,
Folder02Icon,
} from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { AnimatePresence, motion } from 'motion/react';
import React from 'react';
import { cn } from '../lib/cn';
// --- Types ---
export type GitStatus = 'modified' | 'deleted' | 'added' | 'untracked' | 'renamed' | 'ignored';
// --- Constants (module level) ---
const GIT_STATUS_STYLES: Record<GitStatus, { color: string; letter: string }> = {
modified: { color: 'text-yellow-500', letter: 'M' },
deleted: { color: 'text-red-500 line-through opacity-70', letter: 'D' },
added: { color: 'text-green-500', letter: 'A' },
untracked: { color: 'text-green-500', letter: 'U' },
renamed: { color: 'text-blue-500', letter: 'R' },
ignored: { color: 'text-muted-foreground opacity-50', letter: 'I' },
};
const FILE_HOVER = { x: 4, backgroundColor: 'rgba(0, 0, 0, 0.05)' } as const;
const FILE_TAP = { scale: 0.98 } as const;
const FILE_TRANSITION = { duration: 0.15 } as const;
const FILE_STYLE = { willChange: 'transform' } as const;
const FOLDER_CHEVRON_OPEN = { rotate: 90 } as const;
const FOLDER_CHEVRON_CLOSED = { rotate: 0 } as const;
const FOLDER_CHEVRON_TRANSITION = { duration: 0.2, ease: 'easeInOut' } as const;
const FOLDER_ICON_OPEN = { scale: 1.1 } as const;
const FOLDER_ICON_CLOSED = { scale: 1 } as const;
const FOLDER_ICON_TRANSITION = { duration: 0.2 } as const;
const FOLDER_CONTENT_VARIANTS = {
open: { height: 'auto', opacity: 1 },
closed: { height: 0, opacity: 0 },
} as const;
const FOLDER_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeInOut' } as const;
const FOLDER_CONTENT_STYLE = { willChange: 'height, opacity' } as const;
// --- Context ---
interface FilesContextValue {
openFolders: Set<string>;
toggleFolder: (path: string) => void;
}
const FilesContext = React.createContext<FilesContextValue | null>(null);
const useFiles = () => {
const context = React.use(FilesContext);
if (!context) {
throw new Error('File components must be used within Files');
}
return context;
};
const FolderPathContext = React.createContext<string>('');
// --- Components ---
export type FileProps = {
name: string;
className?: string;
onClick?: () => void;
status?: GitStatus;
};
const File: React.FC<FileProps> = ({ name, className, onClick, status }) => {
const statusConfig = status ? GIT_STATUS_STYLES[status] : undefined;
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
};
return (
<motion.div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
whileHover={FILE_HOVER}
whileTap={FILE_TAP}
transition={FILE_TRANSITION}
style={FILE_STYLE}
className={cn(
'flex cursor-pointer items-center space-x-2 rounded px-2 py-1 text-sm transition-colors',
className,
)}
>
<span className={cn('text-muted-foreground', statusConfig?.color)}>
<HugeiconsIcon icon={File01Icon} className="size-4" size={16} />
</span>
<span className={cn('flex-1 truncate', statusConfig?.color)}>{name}</span>
{statusConfig ? (
<span className={cn('ml-auto w-4 text-center text-xs font-bold', statusConfig.color)}>
{statusConfig.letter}
</span>
) : null}
</motion.div>
);
};
export type FolderProps = {
name: string;
children?: React.ReactNode;
className?: string;
path?: string;
status?: GitStatus;
};
const Folder: React.FC<FolderProps> = ({
name,
children,
className,
path: externalPath,
status,
}) => {
const { openFolders, toggleFolder } = useFiles();
const parentPath = React.use(FolderPathContext);
const currentPath = externalPath || (parentPath ? `${parentPath}/${name}` : name);
const isOpen = openFolders.has(currentPath);
const hasChildren = React.Children.count(children) > 0;
const statusConfig = status ? GIT_STATUS_STYLES[status] : undefined;
const handleToggle = () => {
if (hasChildren) {
toggleFolder(currentPath);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (hasChildren && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
toggleFolder(currentPath);
}
};
return (
<div className={className}>
<motion.div
role="button"
tabIndex={0}
aria-expanded={hasChildren ? isOpen : undefined}
onClick={handleToggle}
onKeyDown={handleKeyDown}
whileHover={hasChildren ? { x: 2 } : {}}
whileTap={hasChildren ? { scale: 0.98 } : {}}
className={cn(
'hover:bg-muted flex items-center rounded px-2 py-1 text-sm font-medium transition-colors',
hasChildren && 'cursor-pointer',
)}
>
{hasChildren ? (
<motion.div
animate={isOpen ? FOLDER_CHEVRON_OPEN : FOLDER_CHEVRON_CLOSED}
transition={FOLDER_CHEVRON_TRANSITION}
>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="text-muted-foreground mr-1 size-4 shrink-0"
size={16}
/>
</motion.div>
) : (
<span className="w-5" />
)}
<motion.div
animate={isOpen ? FOLDER_ICON_OPEN : FOLDER_ICON_CLOSED}
transition={FOLDER_ICON_TRANSITION}
className={cn('text-muted-foreground', statusConfig?.color)}
>
{isOpen ? (
<HugeiconsIcon icon={Folder02Icon} className="size-4" size={16} />
) : (
<HugeiconsIcon icon={Folder01Icon} className="size-4" size={16} />
)}
</motion.div>
<span className={cn('ml-2 flex-1 truncate', statusConfig?.color)}>{name}</span>
{statusConfig ? (
<span className={cn('ml-auto w-4 text-center text-xs font-bold', statusConfig.color)}>
{statusConfig.letter}
</span>
) : null}
</motion.div>
<AnimatePresence initial={false}>
{hasChildren && isOpen && (
<motion.div
variants={FOLDER_CONTENT_VARIANTS}
initial="closed"
animate="open"
exit="closed"
transition={FOLDER_CONTENT_TRANSITION}
style={FOLDER_CONTENT_STYLE}
className="overflow-hidden"
>
<div className="border-border mt-1 ml-2 border-l pl-4">
<FolderPathContext value={currentPath}>{children}</FolderPathContext>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
type FilesProps = {
defaultValue?: string;
children: React.ReactNode;
className?: string;
};
const FilesRoot: React.FC<FilesProps> = ({ children, defaultValue, className }) => {
const [openFolders, setOpenFolders] = React.useState<Set<string>>(() => {
if (!defaultValue) return new Set();
const paths = defaultValue.split('/');
const fullPaths = new Set<string>();
let currentPath = '';
paths.forEach((segment) => {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
fullPaths.add(currentPath);
});
return fullPaths;
});
const toggleFolder = React.useCallback((path: string) => {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const contextValue = React.useMemo(
() => ({ openFolders, toggleFolder }),
[openFolders, toggleFolder],
);
return (
<FilesContext value={contextValue}>
<div className={cn('w-full min-w-[250px]', className)}>{children}</div>
</FilesContext>
);
};
const Files = Object.assign(FilesRoot, {
Folder,
File,
});
export { Files };
Asegúrate de actualizar las rutas de importación según la estructura de tu proyecto.

Anatomía

import { Files } from '@/components/ui/files';
<Files defaultValue="src/components">
<Files.Folder name="src">
<Files.Folder name="components">
<Files.File name="button.tsx" />
<Files.File name="card.tsx" />
</Files.Folder>
<Files.File name="index.ts" />
</Files.Folder>
<Files.File name="package.json" />
</Files>

Características

  • Carpetas expandibles - Haz clic para expandir/colapsar estructuras anidadas
  • Indicadores de estado Git - Muestra estados de modificación de archivos
  • Animaciones suaves - Transiciones animadas de expandir/colapsar
  • Auto-expansión - Usa defaultValue para expandir rutas específicas al cargar

Referencia de API

Files

PropTipoPor defectoDescripción
defaultValuestring-Ruta a expandir por defecto (ej: "src/components")
classNamestring-Clases CSS adicionales

Files.Folder

PropTipoPor defectoDescripción
namestring-Nombre de la carpeta
pathstring-Ruta explícita opcional
statusGitStatus-Indicador de estado Git
classNamestring-Clases CSS adicionales
childrenReactNode-Archivos y carpetas anidados

Files.File

PropTipoPor defectoDescripción
namestring-Nombre del archivo
onClick() => void-Manejador de clic
statusGitStatus-Indicador de estado Git
classNamestring-Clases CSS adicionales

Tipos de Estado Git

Valores de estado disponibles: 'modified' | 'deleted' | 'added' | 'untracked' | 'renamed' | 'ignored'
Cada estado tiene un color único e indicador de letra:
  • M (Modificado) - Amarillo
  • D (Eliminado) - Rojo con tachado
  • A (Agregado) - Verde
  • U (Sin rastrear) - Verde
  • R (Renombrado) - Azul
  • I (Ignorado) - Gris