mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-17 19:11:33 +00:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
18
src/components/file-tree/constants/constants.ts
Normal file
18
src/components/file-tree/constants/constants.ts
Normal 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',
|
||||
]);
|
||||
224
src/components/file-tree/constants/fileIcons.ts
Normal file
224
src/components/file-tree/constants/fileIcons.ts
Normal 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' };
|
||||
}
|
||||
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal file
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal file
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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([]);
|
||||
setLoading(false);
|
||||
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,
|
||||
};
|
||||
}
|
||||
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal file
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal file
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
30
src/components/file-tree/types/types.ts
Normal file
30
src/components/file-tree/types/types.ts
Normal 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>;
|
||||
83
src/components/file-tree/utils/fileTreeUtils.ts
Normal file
83
src/components/file-tree/utils/fileTreeUtils.ts
Normal 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));
|
||||
}
|
||||
|
||||
103
src/components/file-tree/view/FileTree.tsx
Normal file
103
src/components/file-tree/view/FileTree.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
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 './FileTreeBody';
|
||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './FileTreeHeader';
|
||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||
import { Project } from '../../../types/app';
|
||||
|
||||
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 && (
|
||||
<ImageViewer
|
||||
file={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/file-tree/view/FileTreeBody.tsx
Normal file
62
src/components/file-tree/view/FileTreeBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
17
src/components/file-tree/view/FileTreeDetailedColumns.tsx
Normal file
17
src/components/file-tree/view/FileTreeDetailedColumns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/file-tree/view/FileTreeEmptyState.tsx
Normal file
20
src/components/file-tree/view/FileTreeEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
81
src/components/file-tree/view/FileTreeHeader.tsx
Normal file
81
src/components/file-tree/view/FileTreeHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
42
src/components/file-tree/view/FileTreeList.tsx
Normal file
42
src/components/file-tree/view/FileTreeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
12
src/components/file-tree/view/FileTreeLoadingState.tsx
Normal file
12
src/components/file-tree/view/FileTreeLoadingState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/file-tree/view/FileTreeNode.tsx
Normal file
141
src/components/file-tree/view/FileTreeNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/file-tree/view/ImageViewer.tsx
Normal file
97
src/components/file-tree/view/ImageViewer.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { FileTreeImageSelection } from '../types/types';
|
||||
|
||||
type ImageViewerProps = {
|
||||
file: FileTreeImageSelection;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ImageViewer({ file, onClose }: ImageViewerProps) {
|
||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setImageUrl(null);
|
||||
|
||||
const response = await authenticatedFetch(imagePath, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
} catch (loadError: unknown) {
|
||||
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading image:', loadError);
|
||||
setError('Unable to load image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [imagePath]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{file.name}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
||||
{loading && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||
/>
|
||||
)}
|
||||
{!loading && !imageUrl && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>{error || 'Unable to load image'}</p>
|
||||
<p className="text-sm mt-2 break-all">{file.path}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user