diff --git a/src/components/FileTree.jsx b/src/components/FileTree.jsx
deleted file mode 100644
index cf3d1e1..0000000
--- a/src/components/FileTree.jsx
+++ /dev/null
@@ -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 ;
- };
-
- // ── 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 (
-
- {Array.from({ length: level }).map((_, i) => (
-
- ))}
-
- );
- };
-
- const renderItemIcons = (item) => {
- const isDir = item.type === 'directory';
- const isOpen = expandedDirs.has(item.path);
-
- if (isDir) {
- return (
-
-
- {isOpen ? (
-
- ) : (
-
- )}
-
- );
- }
-
- return (
-
- {getFileIcon(item.name)}
-
- );
- };
-
- // ─── 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 (
-
-
handleItemClick(item)}
- >
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {isDir && isOpen && item.children && item.children.length > 0 && (
-
-
- {renderFileTree(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Detailed View ────────────────────────────────────────────────
- const renderDetailedView = (items, level = 0) => {
- return items.map((item) => {
- const isDir = item.type === 'directory';
- const isOpen = isDir && expandedDirs.has(item.path);
-
- return (
-
-
handleItemClick(item)}
- >
-
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {item.type === 'file' ? formatFileSize(item.size) : ''}
-
-
- {formatRelativeTime(item.modified)}
-
-
- {item.permissionsRwx || ''}
-
-
-
- {isDir && isOpen && item.children && (
-
-
- {renderDetailedView(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Compact View ──────────────────────────────────────────────────
- const renderCompactView = (items, level = 0) => {
- return items.map((item) => {
- const isDir = item.type === 'directory';
- const isOpen = isDir && expandedDirs.has(item.path);
-
- return (
-
-
handleItemClick(item)}
- >
-
- {renderItemIcons(item)}
-
- {item.name}
-
-
-
- {item.type === 'file' && (
- <>
- {formatFileSize(item.size)}
- {item.permissionsRwx}
- >
- )}
-
-
-
- {isDir && isOpen && item.children && (
-
-
- {renderCompactView(item.children, level + 1)}
-
- )}
-
- );
- });
- };
-
- // ─── Loading state ─────────────────────────────────────────────────
- if (loading) {
- return (
-
-
- {t('fileTree.loading')}
-
-
- );
- }
-
- // ─── Main render ───────────────────────────────────────────────────
- return (
-
- {/* Header */}
-
-
-
- {t('fileTree.files')}
-
-
-
-
-
-
-
-
- {/* Search Bar */}
-
-
- setSearchQuery(e.target.value)}
- className="pl-8 pr-8 h-8 text-sm"
- />
- {searchQuery && (
-
- )}
-
-
-
- {/* Column Headers for Detailed View */}
- {viewMode === 'detailed' && filteredFiles.length > 0 && (
-
-
-
{t('fileTree.name')}
-
{t('fileTree.size')}
-
{t('fileTree.modified')}
-
{t('fileTree.permissions')}
-
-
- )}
-
-
- {files.length === 0 ? (
-
-
-
-
-
{t('fileTree.noFilesFound')}
-
- {t('fileTree.checkProjectPath')}
-
-
- ) : filteredFiles.length === 0 && searchQuery ? (
-
-
-
-
-
{t('fileTree.noMatchesFound')}
-
- {t('fileTree.tryDifferentSearch')}
-
-
- ) : (
-
- {viewMode === 'simple' && renderFileTree(filteredFiles)}
- {viewMode === 'compact' && renderCompactView(filteredFiles)}
- {viewMode === 'detailed' && renderDetailedView(filteredFiles)}
-
- )}
-
-
- {/* Image Viewer Modal */}
- {selectedImage && (
-
setSelectedImage(null)}
- />
- )}
-
- );
-}
-
-export default FileTree;
diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx
new file mode 100644
index 0000000..aa61e1e
--- /dev/null
+++ b/src/components/FileTree.tsx
@@ -0,0 +1 @@
+export { default } from './file-tree/FileTree';
diff --git a/src/components/file-tree/FileTree.tsx b/src/components/file-tree/FileTree.tsx
new file mode 100644
index 0000000..9d4baf6
--- /dev/null
+++ b/src/components/file-tree/FileTree.tsx
@@ -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(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 ;
+ }, []);
+
+ // 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 ;
+ }
+
+ return (
+
+
+
+ {viewMode === 'detailed' && filteredFiles.length > 0 && }
+
+
+
+ {selectedImage && (
+ setSelectedImage(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/file-tree/constants/constants.ts b/src/components/file-tree/constants/constants.ts
new file mode 100644
index 0000000..a56bf6b
--- /dev/null
+++ b/src/components/file-tree/constants/constants.ts
@@ -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',
+]);
diff --git a/src/components/file-tree/constants/fileIcons.ts b/src/components/file-tree/constants/fileIcons.ts
new file mode 100644
index 0000000..eca92da
--- /dev/null
+++ b/src/components/file-tree/constants/fileIcons.ts
@@ -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' };
+}
diff --git a/src/components/file-tree/hooks/useExpandedDirectories.ts b/src/components/file-tree/hooks/useExpandedDirectories.ts
new file mode 100644
index 0000000..df1c388
--- /dev/null
+++ b/src/components/file-tree/hooks/useExpandedDirectories.ts
@@ -0,0 +1,44 @@
+import { useCallback, useState } from 'react';
+
+type UseExpandedDirectoriesResult = {
+ expandedDirs: Set;
+ toggleDirectory: (path: string) => void;
+ expandDirectories: (paths: string[]) => void;
+};
+
+export function useExpandedDirectories(): UseExpandedDirectoriesResult {
+ const [expandedDirs, setExpandedDirs] = useState>(() => 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,
+ };
+}
+
diff --git a/src/components/file-tree/hooks/useFileTreeData.ts b/src/components/file-tree/hooks/useFileTreeData.ts
new file mode 100644
index 0000000..b458167
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeData.ts
@@ -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([]);
+ 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,
+ };
+}
diff --git a/src/components/file-tree/hooks/useFileTreeSearch.ts b/src/components/file-tree/hooks/useFileTreeSearch.ts
new file mode 100644
index 0000000..388a717
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeSearch.ts
@@ -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(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,
+ };
+}
diff --git a/src/components/file-tree/hooks/useFileTreeViewMode.ts b/src/components/file-tree/hooks/useFileTreeViewMode.ts
new file mode 100644
index 0000000..a8048a1
--- /dev/null
+++ b/src/components/file-tree/hooks/useFileTreeViewMode.ts
@@ -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(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,
+ };
+}
+
diff --git a/src/components/file-tree/types/types.ts b/src/components/file-tree/types/types.ts
new file mode 100644
index 0000000..fb2ac84
--- /dev/null
+++ b/src/components/file-tree/types/types.ts
@@ -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;
diff --git a/src/components/file-tree/utils/fileTreeUtils.ts b/src/components/file-tree/utils/fileTreeUtils.ts
new file mode 100644
index 0000000..cb268e5
--- /dev/null
+++ b/src/components/file-tree/utils/fileTreeUtils.ts
@@ -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((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));
+}
+
diff --git a/src/components/file-tree/view/FileTreeBody.tsx b/src/components/file-tree/view/FileTreeBody.tsx
new file mode 100644
index 0000000..dd59585
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeBody.tsx
@@ -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;
+ 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 (
+
+ {files.length === 0 ? (
+
+ ) : filteredFiles.length === 0 && searchQuery ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeDetailedColumns.tsx b/src/components/file-tree/view/FileTreeDetailedColumns.tsx
new file mode 100644
index 0000000..38b1a00
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeDetailedColumns.tsx
@@ -0,0 +1,17 @@
+import { useTranslation } from 'react-i18next';
+
+export default function FileTreeDetailedColumns() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
{t('fileTree.name')}
+
{t('fileTree.size')}
+
{t('fileTree.modified')}
+
{t('fileTree.permissions')}
+
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeEmptyState.tsx b/src/components/file-tree/view/FileTreeEmptyState.tsx
new file mode 100644
index 0000000..088fb7c
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeEmptyState.tsx
@@ -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 (
+
+
+
+
+
{title}
+
{description}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeHeader.tsx b/src/components/file-tree/view/FileTreeHeader.tsx
new file mode 100644
index 0000000..04d3124
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeHeader.tsx
@@ -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 (
+
+
+
{t('fileTree.files')}
+
+
+
+
+
+
+
+
+
+ onSearchQueryChange(event.target.value)}
+ className="pl-8 pr-8 h-8 text-sm"
+ />
+ {searchQuery && (
+
+ )}
+
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeList.tsx b/src/components/file-tree/view/FileTreeList.tsx
new file mode 100644
index 0000000..3470ab8
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeList.tsx
@@ -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;
+ 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 (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeLoadingState.tsx b/src/components/file-tree/view/FileTreeLoadingState.tsx
new file mode 100644
index 0000000..5bc1235
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeLoadingState.tsx
@@ -0,0 +1,12 @@
+import { useTranslation } from 'react-i18next';
+
+export default function FileTreeLoadingState() {
+ const { t } = useTranslation();
+
+ return (
+
+
{t('fileTree.loading')}
+
+ );
+}
+
diff --git a/src/components/file-tree/view/FileTreeNode.tsx b/src/components/file-tree/view/FileTreeNode.tsx
new file mode 100644
index 0000000..05e43ee
--- /dev/null
+++ b/src/components/file-tree/view/FileTreeNode.tsx
@@ -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;
+ 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 (
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ return {renderFileIcon(item.name)};
+}
+
+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 (
+
+
onItemClick(item)}
+ >
+ {viewMode === 'detailed' ? (
+ <>
+
+
+ {item.name}
+
+
+ {item.type === 'file' ? formatFileSize(item.size) : ''}
+
+
{formatRelativeTime(item.modified)}
+
{item.permissionsRwx || ''}
+ >
+ ) : viewMode === 'compact' ? (
+ <>
+
+
+ {item.name}
+
+
+ {item.type === 'file' && (
+ <>
+ {formatFileSize(item.size)}
+ {item.permissionsRwx}
+ >
+ )}
+
+ >
+ ) : (
+ <>
+
+
{item.name}
+ >
+ )}
+
+
+ {isDirectory && isOpen && hasChildren && (
+
+
+ {item.children?.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}