refactor(file-tree): make file tree a feature based component

This commit is contained in:
Haileyesus
2026-02-18 13:42:49 +03:00
parent 233a16b64e
commit b8e387d6dc
18 changed files with 1045 additions and 729 deletions

View File

@@ -1,729 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Input } from './ui/input';
import {
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
ChevronRight,
FileJson, FileType, FileSpreadsheet, FileArchive,
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
} from 'lucide-react';
import { cn } from '../lib/utils';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
// ─── File Icon Registry ──────────────────────────────────────────────
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
// Uses lucide-react icons mapped semantically to file types.
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP = {
// ── JavaScript / TypeScript ──
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
// ── Python ──
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
// ── Rust ──
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
// ── Go ──
go: { icon: Hexagon, color: 'text-cyan-500' },
// ── Ruby ──
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
// ── PHP ──
php: { icon: Blocks, color: 'text-violet-500' },
// ── Java / Kotlin ──
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
// ── C / C++ ──
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
// ── C# ──
cs: { icon: Hexagon, color: 'text-purple-600' },
// ── Swift ──
swift:{ icon: Flame, color: 'text-orange-500' },
// ── Lua ──
lua: { icon: SquareFunction, color: 'text-blue-500' },
// ── R ──
r: { icon: FlaskConical, color: 'text-blue-600' },
// ── Web ──
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte:{ icon: FileCode2, color: 'text-orange-500' },
// ── Data / Config ──
json: { icon: Braces, color: 'text-yellow-600' },
jsonc:{ icon: Braces, color: 'text-yellow-500' },
json5:{ icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql:{ icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto:{ icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
// ── Documents ──
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
// ── Shell / Scripts ──
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
// ── Images ──
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
// ── Audio ──
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
// ── Video ──
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
// ── Fonts ──
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2:{ icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
// ── Archives ──
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
// ── Lock files ──
lock: { icon: Lock, color: 'text-gray-500' },
// ── Binary / Executable ──
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib:{ icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
// ── Misc config ──
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
// Special full-filename matches (highest priority)
const FILENAME_ICON_MAP = {
'Dockerfile': { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
'Gemfile': { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
'Makefile': { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
function getFileIconData(filename) {
// 1. Exact filename match
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
// 2. Check for .env prefix pattern
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
// 3. Extension-based lookup
const ext = filename.split('.').pop()?.toLowerCase();
if (ext && FILE_ICON_MAP[ext]) {
return FILE_ICON_MAP[ext];
}
// 4. Fallback
return { icon: File, color: 'text-muted-foreground' };
}
// ─── Component ───────────────────────────────────────────────────────
function FileTree({ selectedProject, onFileOpen }) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
setViewMode(savedViewMode);
}
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFiles(files);
} else {
const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered);
const expandMatches = (items) => {
items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) {
setExpandedDirs(prev => new Set(prev.add(item.path)));
expandMatches(item.children);
}
});
};
expandMatches(filtered);
}
}, [files, searchQuery]);
const filterFiles = (items, query) => {
return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query);
let filteredChildren = [];
if (item.type === 'directory' && item.children) {
filteredChildren = filterFiles(item.children, query);
}
if (matchesName || filteredChildren.length > 0) {
filtered.push({
...item,
children: filteredChildren
});
}
return filtered;
}, []);
};
const fetchFiles = async () => {
setLoading(true);
try {
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatRelativeTime = (date) => {
if (!date) return '-';
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return t('fileTree.justNow');
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
return past.toLocaleDateString();
};
const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE, color)} />;
};
// ── Click handler shared across all view modes ──
const handleItemClick = (item) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else if (onFileOpen) {
onFileOpen(item.path);
}
};
// ── Indent guide + folder/file icon rendering ──
const renderIndentGuides = (level) => {
if (level === 0) return null;
return (
<span className="flex items-center flex-shrink-0" aria-hidden="true">
{Array.from({ length: level }).map((_, i) => (
<span
key={i}
className="inline-block w-4 h-full border-l border-border/50"
/>
))}
</span>
);
};
const renderItemIcons = (item) => {
const isDir = item.type === 'directory';
const isOpen = expandedDirs.has(item.path);
if (isDir) {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return (
<span className="flex items-center flex-shrink-0 ml-[18px]">
{getFileIcon(item.name)}
</span>
);
};
// ─── Simple (Tree) View ────────────────────────────────────────────
const renderFileTree = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
'hover:bg-accent/60 transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
{isDir && isOpen && item.children && item.children.length > 0 && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Detailed View ────────────────────────────────────────────────
const renderDetailedView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-sm text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">
{item.permissionsRwx || ''}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderDetailedView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderCompactView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Loading state ─────────────────────────────────────────────────
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">
{t('fileTree.loading')}
</div>
</div>
);
}
// ─── Main render ───────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
{t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')}
title={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')}
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
)}
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (
<div>
{viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div>
)}
</ScrollArea>
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

View File

@@ -0,0 +1 @@
export { default } from './file-tree/FileTree';

View File

@@ -0,0 +1,110 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../lib/utils';
import ImageViewer from '../ImageViewer';
import { ICON_SIZE_CLASS, getFileIconData } from './constants/fileIcons';
import { useExpandedDirectories } from './hooks/useExpandedDirectories';
import { useFileTreeData } from './hooks/useFileTreeData';
import { useFileTreeSearch } from './hooks/useFileTreeSearch';
import { useFileTreeViewMode } from './hooks/useFileTreeViewMode';
import type { FileTreeImageSelection, FileTreeNode } from './types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from './utils/fileTreeUtils';
import FileTreeBody from './view/FileTreeBody';
import FileTreeDetailedColumns from './view/FileTreeDetailedColumns';
import FileTreeHeader from './view/FileTreeHeader';
import FileTreeLoadingState from './view/FileTreeLoadingState';
import { Project } from '../../types/app';
type ImageViewerProps = {
file: FileTreeImageSelection;
onClose: () => void;
};
const ImageViewerComponent = ImageViewer as unknown as (props: ImageViewerProps) => JSX.Element;
type FileTreeProps = {
selectedProject: Project | null;
onFileOpen?: (filePath: string) => void;
}
export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {
const { t } = useTranslation();
const [selectedImage, setSelectedImage] = useState<FileTreeImageSelection | null>(null);
const { files, loading } = useFileTreeData(selectedProject);
const { viewMode, changeViewMode } = useFileTreeViewMode();
const { expandedDirs, toggleDirectory, expandDirectories } = useExpandedDirectories();
const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
files,
expandDirectories,
});
const renderFileIcon = useCallback((filename: string) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE_CLASS, color)} />;
}, []);
// Centralized click behavior keeps file actions identical across all presentation modes.
const handleItemClick = useCallback(
(item: FileTreeNode) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
return;
}
if (isImageFile(item.name) && selectedProject) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name,
});
return;
}
onFileOpen?.(item.path);
},
[onFileOpen, selectedProject, toggleDirectory],
);
const formatRelativeTimeLabel = useCallback(
(date?: string) => formatRelativeTime(date, t),
[t],
);
if (loading) {
return <FileTreeLoadingState />;
}
return (
<div className="h-full flex flex-col bg-background">
<FileTreeHeader
viewMode={viewMode}
onViewModeChange={changeViewMode}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<FileTreeBody
files={files}
filteredFiles={filteredFiles}
searchQuery={searchQuery}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={handleItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTimeLabel}
/>
{selectedImage && (
<ImageViewerComponent
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type { FileTreeViewMode } from '../types/types';
export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const IMAGE_FILE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'ico',
'bmp',
]);

View File

@@ -0,0 +1,224 @@
import {
Archive,
Binary,
Blocks,
BookOpen,
Box,
Braces,
Code2,
Cog,
Coffee,
Cpu,
Database,
File,
FileCheck,
FileCode,
FileCode2,
FileSpreadsheet,
FileText,
FileType,
Flame,
FlaskConical,
Gem,
Globe,
Hash,
Hexagon,
Image,
Lock,
Music2,
NotebookPen,
Palette,
Scroll,
Settings,
Shield,
SquareFunction,
Terminal,
Video,
Workflow,
} from 'lucide-react';
import type { FileIconData, FileIconMap } from '../types/types';
export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP: FileIconMap = {
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb: { icon: NotebookPen, color: 'text-orange-500' },
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
go: { icon: Hexagon, color: 'text-cyan-500' },
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
php: { icon: Blocks, color: 'text-violet-500' },
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
cs: { icon: Hexagon, color: 'text-purple-600' },
swift: { icon: Flame, color: 'text-orange-500' },
lua: { icon: SquareFunction, color: 'text-blue-500' },
r: { icon: FlaskConical, color: 'text-blue-600' },
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte: { icon: FileCode2, color: 'text-orange-500' },
json: { icon: Braces, color: 'text-yellow-600' },
jsonc: { icon: Braces, color: 'text-yellow-500' },
json5: { icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql: { icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto: { icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2: { icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
lock: { icon: Lock, color: 'text-gray-500' },
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib: { icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
const FILENAME_ICON_MAP: FileIconMap = {
Dockerfile: { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json': { icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
Gemfile: { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
Makefile: { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js': { icon: Cog, color: 'text-blue-500' },
'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },
'postcss.config.js': { icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
LICENSE: { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
export function getFileIconData(filename: string): FileIconData {
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
const extension = filename.split('.').pop()?.toLowerCase();
if (extension && FILE_ICON_MAP[extension]) {
return FILE_ICON_MAP[extension];
}
return { icon: File, color: 'text-muted-foreground' };
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
const toggleDirectory = useCallback((path: string) => {
setExpandedDirs((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandDirectories = useCallback((paths: string[]) => {
if (paths.length === 0) {
return;
}
setExpandedDirs((previous) => {
const next = new Set(previous);
paths.forEach((path) => next.add(path));
return next;
});
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
};
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const projectName = selectedProject?.name;
if (!projectName) {
setFiles([]);
return;
}
const abortController = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
const fetchFiles = async () => {
if (isActive) {
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
if (!response.ok) {
const errorText = await response.text();
console.error('File fetch failed:', response.status, errorText);
if (isActive) {
setFiles([]);
}
return;
}
const data = (await response.json()) as FileTreeNode[];
if (isActive) {
setFiles(data);
}
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return;
}
console.error('Error fetching files:', error);
if (isActive) {
setFiles([]);
}
} finally {
if (isActive) {
setLoading(false);
}
}
};
void fetchFiles();
return () => {
isActive = false;
abortController.abort();
};
}, [selectedProject?.name]);
return {
files,
loading,
};
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';
import type { FileTreeNode } from '../types/types';
type UseFileTreeSearchArgs = {
files: FileTreeNode[];
expandDirectories: (paths: string[]) => void;
};
type UseFileTreeSearchResult = {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredFiles: FileTreeNode[];
};
export function useFileTreeSearch({
files,
expandDirectories,
}: UseFileTreeSearchArgs): UseFileTreeSearchResult {
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState<FileTreeNode[]>(files);
useEffect(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
setFilteredFiles(files);
return;
}
const filtered = filterFileTree(files, query);
setFilteredFiles(filtered);
// Keep search results visible by opening every matching ancestor directory once per query update.
expandDirectories(collectExpandedDirectoryPaths(filtered));
}, [files, searchQuery, expandDirectories]);
return {
searchQuery,
setSearchQuery,
filteredFiles,
};
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
import {
FILE_TREE_DEFAULT_VIEW_MODE,
FILE_TREE_VIEW_MODES,
FILE_TREE_VIEW_MODE_STORAGE_KEY,
} from '../constants/constants';
import type { FileTreeViewMode } from '../types/types';
type UseFileTreeViewModeResult = {
viewMode: FileTreeViewMode;
changeViewMode: (mode: FileTreeViewMode) => void;
};
export function useFileTreeViewMode(): UseFileTreeViewModeResult {
const [viewMode, setViewMode] = useState<FileTreeViewMode>(FILE_TREE_DEFAULT_VIEW_MODE);
useEffect(() => {
try {
const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);
if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {
setViewMode(savedViewMode as FileTreeViewMode);
}
} catch {
// Keep default view mode when storage is unavailable.
}
}, []);
const changeViewMode = useCallback((mode: FileTreeViewMode) => {
setViewMode(mode);
try {
localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Keep runtime state even when persistence fails.
}
}, []);
return {
viewMode,
changeViewMode,
};
}

View File

@@ -0,0 +1,30 @@
import type { LucideIcon } from 'lucide-react';
export type FileTreeViewMode = 'simple' | 'compact' | 'detailed';
export type FileTreeItemType = 'file' | 'directory';
export interface FileTreeNode {
name: string;
type: FileTreeItemType;
path: string;
size?: number;
modified?: string;
permissionsRwx?: string;
children?: FileTreeNode[];
[key: string]: unknown;
}
export interface FileTreeImageSelection {
name: string;
path: string;
projectPath?: string;
projectName: string;
}
export interface FileIconData {
icon: LucideIcon;
color: string;
}
export type FileIconMap = Record<string, FileIconData>;

View File

@@ -0,0 +1,83 @@
import type { TFunction } from 'i18next';
import { IMAGE_FILE_EXTENSIONS } from '../constants/constants';
import type { FileTreeNode } from '../types/types';
export function filterFileTree(items: FileTreeNode[], query: string): FileTreeNode[] {
return items.reduce<FileTreeNode[]>((filteredItems, item) => {
const matchesName = item.name.toLowerCase().includes(query);
const filteredChildren =
item.type === 'directory' && item.children ? filterFileTree(item.children, query) : [];
if (matchesName || filteredChildren.length > 0) {
filteredItems.push({
...item,
children: filteredChildren,
});
}
return filteredItems;
}, []);
}
// During search we auto-expand every directory present in the filtered subtree.
export function collectExpandedDirectoryPaths(items: FileTreeNode[]): string[] {
const paths: string[] = [];
const visit = (nodes: FileTreeNode[]) => {
nodes.forEach((node) => {
if (node.type === 'directory' && node.children && node.children.length > 0) {
paths.push(node.path);
visit(node.children);
}
});
};
visit(items);
return paths;
}
export function formatFileSize(bytes?: number): string {
if (!bytes || bytes === 0) {
return '0 B';
}
const base = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const index = Math.floor(Math.log(bytes) / Math.log(base));
return `${(bytes / Math.pow(base, index)).toFixed(1).replace(/\.0$/, '')} ${sizes[index]}`;
}
export function formatRelativeTime(date: string | undefined, t: TFunction): string {
if (!date) {
return '-';
}
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
if (diffInSeconds < 60) {
return t('fileTree.justNow');
}
if (diffInSeconds < 3600) {
return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
}
if (diffInSeconds < 86400) {
return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
}
if (diffInSeconds < 2592000) {
return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
}
return past.toLocaleDateString();
}
export function isImageFile(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase();
return Boolean(extension && IMAGE_FILE_EXTENSIONS.has(extension));
}

View File

@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Folder, Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '../../ui/scroll-area';
import type { FileTreeNode, FileTreeViewMode } from '../types/types';
import FileTreeEmptyState from './FileTreeEmptyState';
import FileTreeList from './FileTreeList';
type FileTreeBodyProps = {
files: FileTreeNode[];
filteredFiles: FileTreeNode[];
searchQuery: string;
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNode) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
export default function FileTreeBody({
files,
filteredFiles,
searchQuery,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeBodyProps) {
const { t } = useTranslation();
return (
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<FileTreeEmptyState
icon={Folder}
title={t('fileTree.noFilesFound')}
description={t('fileTree.checkProjectPath')}
/>
) : filteredFiles.length === 0 && searchQuery ? (
<FileTreeEmptyState
icon={Search}
title={t('fileTree.noMatchesFound')}
description={t('fileTree.tryDifferentSearch')}
/>
) : (
<FileTreeList
items={filteredFiles}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
)}
</ScrollArea>
);
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
export default function FileTreeDetailedColumns() {
const { t } = useTranslation();
return (
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react';
type FileTreeEmptyStateProps = {
icon: LucideIcon;
title: string;
description: string;
};
export default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Icon className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{title}</h4>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { Eye, List, Search, TableProperties, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = {
viewMode: FileTreeViewMode;
onViewModeChange: (mode: FileTreeViewMode) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
export default function FileTreeHeader({
viewMode,
onViewModeChange,
searchQuery,
onSearchQueryChange,
}: FileTreeHeaderProps) {
const { t } = useTranslation();
return (
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('detailed')}
title={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => onSearchQueryChange('')}
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
import FileTreeNode from './FileTreeNode';
type FileTreeListProps = {
items: FileTreeNodeType[];
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNodeType) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
export default function FileTreeList({
items,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeListProps) {
return (
<div>
{items.map((item) => (
<FileTreeNode
key={item.path}
item={item}
level={0}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { useTranslation } from 'react-i18next';
export default function FileTreeLoadingState() {
const { t } = useTranslation();
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">{t('fileTree.loading')}</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from 'react';
import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '../../../lib/utils';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
type FileTreeNodeProps = {
item: FileTreeNodeType;
level: number;
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNodeType) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
type TreeItemIconProps = {
item: FileTreeNodeType;
isOpen: boolean;
renderFileIcon: (filename: string) => ReactNode;
};
function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {
if (item.type === 'directory') {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90',
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return <span className="flex items-center flex-shrink-0 ml-[18px]">{renderFileIcon(item.name)}</span>;
}
export default function FileTreeNode({
item,
level,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeNodeProps) {
const isDirectory = item.type === 'directory';
const isOpen = isDirectory && expandedDirs.has(item.path);
const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
const nameClassName = cn(
'text-[13px] leading-tight truncate',
isDirectory ? 'font-medium text-foreground' : 'text-foreground/90',
);
// View mode only changes the row layout; selection, expansion, and recursion stay shared.
const rowClassName = cn(
viewMode === 'detailed'
? 'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100'
: viewMode === 'compact'
? 'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100'
: 'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm hover:bg-accent/60 transition-colors duration-100',
isDirectory && isOpen && 'border-l-2 border-primary/30',
(isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
);
return (
<div className="select-none">
<div
className={rowClassName}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => onItemClick(item)}
>
{viewMode === 'detailed' ? (
<>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
</>
) : viewMode === 'compact' ? (
<>
<div className="flex items-center gap-1.5 min-w-0">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</>
) : (
<>
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</>
)}
</div>
{isDirectory && isOpen && hasChildren && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{item.children?.map((child) => (
<FileTreeNode
key={child.path}
item={child}
level={level + 1}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
))}
</div>
)}
</div>
);
}