feat: Advanced file editor and file tree improvements (#444)

# Features
- File drag and drop upload: Support uploading files and folders via drag and drop
- Binary file handling: Detect binary files and display a friendly message instead of trying to edit them
- Folder download: Download folders as ZIP files (using JSZip library)
- Context menu integration: Full right-click context menu for file operations (rename, delete, copy path, download, new file/folder)
This commit is contained in:
朱见
2026-03-03 20:19:46 +08:00
committed by GitHub
parent 503c384685
commit 97689588aa
30 changed files with 2270 additions and 94 deletions

View File

@@ -0,0 +1,312 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileText,
FolderPlus,
Pencil,
Trash2,
Copy,
Download,
RefreshCw
} from 'lucide-react';
import { cn } from '../lib/utils';
/**
* FileContextMenu Component
* Right-click context menu for file/directory operations
*/
const FileContextMenu = ({
children,
item,
onRename,
onDelete,
onNewFile,
onNewFolder,
onRefresh,
onCopyPath,
onDownload,
isLoading = false,
className = ''
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const triggerRef = useRef(null);
const isDirectory = item?.type === 'directory';
const isFile = item?.type === 'file';
const isBackground = !item; // Clicked on empty space
// Handle right-click
const handleContextMenu = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// Adjust position if menu would go off screen
const menuWidth = 200;
const menuHeight = 300;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = window.innerHeight - menuHeight - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
setIsOpen(true);
}, []);
// Close menu
const closeMenu = useCallback(() => {
setIsOpen(false);
}, []);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeMenu();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, closeMenu]);
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
if (!menuItems || menuItems.length === 0) return;
const currentIndex = Array.from(menuItems).findIndex(
(item) => item === document.activeElement
);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
menuItems[nextIndex]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
menuItems[prevIndex]?.focus();
break;
case 'Enter':
case ' ':
if (document.activeElement?.hasAttribute('role', 'menuitem')) {
e.preventDefault();
document.activeElement.click();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Handle action click
const handleAction = (action, ...args) => {
closeMenu();
action?.(...args);
};
// Menu item component
const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
<button
role="menuitem"
tabIndex={disabled ? -1 : 0}
disabled={disabled || isLoading}
onClick={() => handleAction(onClick)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
disabled
? 'opacity-50 cursor-not-allowed'
: danger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none'
)}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
)}
</button>
);
// Menu divider
const MenuDivider = () => (
<div className="h-px bg-border my-1 mx-2" />
);
// Build menu items based on context
const renderMenuItems = () => {
if (isFile) {
return (
<>
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
if (isDirectory) {
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.(item.path)}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.(item.path)}
/>
<MenuDivider />
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
// Background context (empty space)
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.('')}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.('')}
/>
<MenuDivider />
<MenuItem
icon={RefreshCw}
label={t('fileTree.context.refresh', 'Refresh')}
onClick={onRefresh}
/>
</>
);
};
return (
<>
{/* Trigger element */}
<div
ref={triggerRef}
onContextMenu={handleContextMenu}
className={cn('contents', className)}
>
{children}
</div>
{/* Context menu portal */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999
}}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t('fileTree.context.loading', 'Loading...')}
</span>
</div>
) : (
renderMenuItems()
)}
</div>
)}
</>
);
};
export default FileContextMenu;

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
@@ -21,6 +22,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const fileProjectName = file.projectName ?? projectPath;
const filePath = file.path;
const fileName = file.name;
@@ -31,6 +33,14 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const loadFileContent = async () => {
try {
setLoading(true);
setIsBinary(false);
// Check if file is binary by extension
if (isBinaryFile(file.name)) {
setIsBinary(true);
setLoading(false);
return;
}
// Diff payload may already include full old/new snapshots, so avoid disk read.
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
@@ -60,7 +70,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
};
loadFileContent();
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
const handleSave = useCallback(async () => {
setSaving(true);
@@ -120,6 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saving,
saveSuccess,
saveError,
isBinary,
handleSave,
handleDownload,
};

View File

@@ -65,12 +65,15 @@ export const useEditorSidebar = ({
return;
}
const container = resizeHandleRef.current?.parentElement;
if (!container) {
// Get the main container (parent of EditorSidebar's parent) that contains both left content and editor
const editorContainer = resizeHandleRef.current?.parentElement;
const mainContainer = editorContainer?.parentElement;
if (!mainContainer) {
return;
}
const containerRect = container.getBoundingClientRect();
const containerRect = mainContainer.getBoundingClientRect();
// Calculate new editor width: distance from mouse to right edge of main container
const newWidth = containerRect.right - event.clientX;
const minWidth = 300;

View File

@@ -0,0 +1,22 @@
// Binary file extensions (images are handled by ImageViewer, not here)
const BINARY_EXTENSIONS = [
// Archives
'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz',
// Executables
'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi',
// Media
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg',
// Documents
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
// Fonts
'ttf', 'otf', 'woff', 'woff2', 'eot',
// Database
'db', 'sqlite', 'sqlite3',
// Other binary
'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo'
];
export const isBinaryFile = (filename: string): boolean => {
const ext = filename.split('.').pop()?.toLowerCase();
return BINARY_EXTENSIONS.includes(ext ?? '');
};

View File

@@ -14,6 +14,7 @@ import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
type CodeEditorProps = {
file: CodeEditorFile;
@@ -54,6 +55,7 @@ export default function CodeEditor({
saving,
saveSuccess,
saveError,
isBinary,
handleSave,
handleDownload,
} = useCodeEditorDocument({
@@ -158,6 +160,21 @@ export default function CodeEditor({
);
}
// Binary file display
if (isBinary) {
return (
<CodeEditorBinaryFile
file={file}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
onClose={onClose}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
title={t('binaryFile.title', 'Binary File')}
message={t('binaryFile.message', 'The file "{{fileName}}" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })}
/>
);
}
const outerContainerClassName = isSidebar
? 'w-full h-full flex flex-col'
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import type { MouseEvent, MutableRefObject } from 'react';
import type { CodeEditorFile } from '../types/types';
import CodeEditor from './CodeEditor';
@@ -17,6 +17,11 @@ type EditorSidebarProps = {
fillSpace?: boolean;
};
// Minimum width for the left content (file tree, chat, etc.)
const MIN_LEFT_CONTENT_WIDTH = 200;
// Minimum width for the editor sidebar
const MIN_EDITOR_WIDTH = 280;
export default function EditorSidebar({
editingFile,
isMobile,
@@ -31,6 +36,49 @@ export default function EditorSidebar({
fillSpace,
}: EditorSidebarProps) {
const [poppedOut, setPoppedOut] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [effectiveWidth, setEffectiveWidth] = useState(editorWidth);
// Adjust editor width when container size changes to ensure buttons are always visible
useEffect(() => {
if (!editingFile || isMobile || poppedOut) return;
const updateWidth = () => {
if (!containerRef.current) return;
const parentElement = containerRef.current.parentElement;
if (!parentElement) return;
const containerWidth = parentElement.clientWidth;
// Calculate maximum allowed editor width
const maxEditorWidth = containerWidth - MIN_LEFT_CONTENT_WIDTH;
if (maxEditorWidth < MIN_EDITOR_WIDTH) {
// Not enough space - pop out the editor so user can still see everything
setPoppedOut(true);
} else if (editorWidth > maxEditorWidth) {
// Editor is too wide - constrain it to ensure left content has space
setEffectiveWidth(maxEditorWidth);
} else {
setEffectiveWidth(editorWidth);
}
};
updateWidth();
window.addEventListener('resize', updateWidth);
// Also use ResizeObserver for more accurate detection
const resizeObserver = new ResizeObserver(updateWidth);
const parentEl = containerRef.current?.parentElement;
if (parentEl) {
resizeObserver.observe(parentEl);
}
return () => {
window.removeEventListener('resize', updateWidth);
resizeObserver.disconnect();
};
}, [editingFile, isMobile, poppedOut, editorWidth]);
if (!editingFile) {
return null;
@@ -54,7 +102,7 @@ export default function EditorSidebar({
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return (
<>
<div ref={containerRef} className={`flex h-full flex-shrink-0 min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
{!editorExpanded && (
<div
ref={resizeHandleRef}
@@ -67,8 +115,8 @@ export default function EditorSidebar({
)}
<div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
className={`border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1 min-w-0' : `flex-shrink-0 min-w-[${MIN_EDITOR_WIDTH}px]`}`}
style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}
>
<CodeEditor
file={editingFile}
@@ -80,6 +128,6 @@ export default function EditorSidebar({
onPopOut={() => setPoppedOut(true)}
/>
</div>
</>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import type { CodeEditorFile } from '../../types/types';
type CodeEditorBinaryFileProps = {
file: CodeEditorFile;
isSidebar: boolean;
isFullscreen: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
title: string;
message: string;
};
export default function CodeEditorBinaryFile({
file,
isSidebar,
isFullscreen,
onClose,
onToggleFullscreen,
title,
message,
}: CodeEditorBinaryFileProps) {
const binaryContent = (
<div className="w-full h-full flex flex-col items-center justify-center bg-background text-muted-foreground p-8">
<div className="flex flex-col items-center gap-4 max-w-md text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<svg className="w-8 h-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
<button
onClick={onClose}
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Close
</button>
</div>
</div>
);
if (isSidebar) {
return (
<div className="w-full h-full flex flex-col bg-background">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{binaryContent}
</div>
);
}
const containerClassName = isFullscreen
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
const innerClassName = isFullscreen
? 'bg-background flex flex-col w-full h-full'
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-2xl md:h-auto md:max-h-[60vh]';
return (
<div className={containerClassName}>
<div className={innerClassName}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={onToggleFullscreen}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{binaryContent}
</div>
</div>
);
}

View File

@@ -49,13 +49,14 @@ export default function CodeEditorHeader({
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0 gap-2">
{/* File info - can shrink */}
<div className="flex items-center gap-2 min-w-0 flex-1 shrink">
<div className="min-w-0 shrink">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap shrink-0">
{labels.showingChanges}
</span>
)}
@@ -64,12 +65,13 @@ export default function CodeEditorHeader({
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{/* Buttons - don't shrink, always visible */}
<div className="flex items-center gap-0.5 shrink-0">
{isMarkdownFile && (
<button
type="button"
onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
className={`p-1.5 rounded-md flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
@@ -83,7 +85,7 @@ export default function CodeEditorHeader({
<button
type="button"
onClick={onOpenSettings}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title={labels.settings}
>
<SettingsIcon className="w-4 h-4" />
@@ -92,7 +94,7 @@ export default function CodeEditorHeader({
<button
type="button"
onClick={onDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title={labels.download}
>
<Download className="w-4 h-4" />
@@ -102,7 +104,7 @@ export default function CodeEditorHeader({
type="button"
onClick={onSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors ${
saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
@@ -122,7 +124,7 @@ export default function CodeEditorHeader({
<button
type="button"
onClick={onToggleFullscreen}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
@@ -132,7 +134,7 @@ export default function CodeEditorHeader({
<button
type="button"
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-center"
title={labels.close}
>
<X className="w-4 h-4" />

View File

@@ -4,6 +4,7 @@ type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
collapseAll: () => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
@@ -35,10 +36,15 @@ export function useExpandedDirectories(): UseExpandedDirectoriesResult {
});
}, []);
const collapseAll = useCallback(() => {
setExpandedDirs(new Set());
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
collapseAll,
};
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
@@ -6,11 +6,18 @@ import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
refreshFiles: () => void;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const refreshFiles = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
useEffect(() => {
const projectName = selectedProject?.name;
@@ -21,7 +28,12 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
return;
}
const abortController = new AbortController();
// Abort previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
@@ -30,7 +42,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
if (!response.ok) {
const errorText = await response.text();
@@ -65,12 +77,13 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
return () => {
isActive = false;
abortController.abort();
abortControllerRef.current?.abort();
};
}, [selectedProject?.name]);
}, [selectedProject?.name, refreshKey]);
return {
files,
loading,
refreshFiles,
};
}

View File

@@ -0,0 +1,382 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import JSZip from 'jszip';
import { api } from '../../../utils/api';
import type { FileTreeNode } from '../types/types';
import type { Project } from '../../../types/app';
// Invalid filename characters
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
export type ToastMessage = {
message: string;
type: 'success' | 'error';
};
export type DeleteConfirmation = {
isOpen: boolean;
item: FileTreeNode | null;
};
export type UseFileTreeOperationsOptions = {
selectedProject: Project | null;
onRefresh: () => void;
showToast: (message: string, type: 'success' | 'error') => void;
};
export type UseFileTreeOperationsResult = {
// Rename operations
renamingItem: FileTreeNode | null;
renameValue: string;
handleStartRename: (item: FileTreeNode) => void;
handleCancelRename: () => void;
handleConfirmRename: () => Promise<void>;
setRenameValue: (value: string) => void;
// Delete operations
deleteConfirmation: DeleteConfirmation;
handleStartDelete: (item: FileTreeNode) => void;
handleCancelDelete: () => void;
handleConfirmDelete: () => Promise<void>;
// Create operations
isCreating: boolean;
newItemParent: string;
newItemType: 'file' | 'directory';
newItemName: string;
handleStartCreate: (parentPath: string, type: 'file' | 'directory') => void;
handleCancelCreate: () => void;
handleConfirmCreate: () => Promise<void>;
setNewItemName: (name: string) => void;
// Other operations
handleCopyPath: (item: FileTreeNode) => void;
handleDownload: (item: FileTreeNode) => Promise<void>;
// Loading state
operationLoading: boolean;
// Validation
validateFilename: (name: string) => string | null;
};
export function useFileTreeOperations({
selectedProject,
onRefresh,
showToast,
}: UseFileTreeOperationsOptions): UseFileTreeOperationsResult {
const { t } = useTranslation();
// State
const [renamingItem, setRenamingItem] = useState<FileTreeNode | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation>({
isOpen: false,
item: null,
});
const [isCreating, setIsCreating] = useState(false);
const [newItemParent, setNewItemParent] = useState('');
const [newItemType, setNewItemType] = useState<'file' | 'directory'>('file');
const [newItemName, setNewItemName] = useState('');
const [operationLoading, setOperationLoading] = useState(false);
// Validation
const validateFilename = useCallback((name: string): string | null => {
if (!name || !name.trim()) {
return t('fileTree.validation.emptyName', 'Filename cannot be empty');
}
if (INVALID_FILENAME_CHARS.test(name)) {
return t('fileTree.validation.invalidChars', 'Filename contains invalid characters');
}
if (RESERVED_NAMES.test(name)) {
return t('fileTree.validation.reserved', 'Filename is a reserved name');
}
if (/^\.+$/.test(name)) {
return t('fileTree.validation.dotsOnly', 'Filename cannot be only dots');
}
return null;
}, [t]);
// Rename operations
const handleStartRename = useCallback((item: FileTreeNode) => {
setRenamingItem(item);
setRenameValue(item.name);
setIsCreating(false);
}, []);
const handleCancelRename = useCallback(() => {
setRenamingItem(null);
setRenameValue('');
}, []);
const handleConfirmRename = useCallback(async () => {
if (!renamingItem || !selectedProject) return;
const error = validateFilename(renameValue);
if (error) {
showToast(error, 'error');
return;
}
if (renameValue === renamingItem.name) {
handleCancelRename();
return;
}
setOperationLoading(true);
try {
const response = await api.renameFile(selectedProject.name, {
oldPath: renamingItem.path,
newName: renameValue,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to rename');
}
showToast(t('fileTree.toast.renamed', 'Renamed successfully'), 'success');
onRefresh();
handleCancelRename();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [renamingItem, renameValue, selectedProject, validateFilename, showToast, t, onRefresh, handleCancelRename]);
// Delete operations
const handleStartDelete = useCallback((item: FileTreeNode) => {
setDeleteConfirmation({ isOpen: true, item });
}, []);
const handleCancelDelete = useCallback(() => {
setDeleteConfirmation({ isOpen: false, item: null });
}, []);
const handleConfirmDelete = useCallback(async () => {
const { item } = deleteConfirmation;
if (!item || !selectedProject) return;
setOperationLoading(true);
try {
const response = await api.deleteFile(selectedProject.name, {
path: item.path,
type: item.type,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete');
}
showToast(
item.type === 'directory'
? t('fileTree.toast.folderDeleted', 'Folder deleted')
: t('fileTree.toast.fileDeleted', 'File deleted'),
'success'
);
onRefresh();
handleCancelDelete();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [deleteConfirmation, selectedProject, showToast, t, onRefresh, handleCancelDelete]);
// Create operations
const handleStartCreate = useCallback((parentPath: string, type: 'file' | 'directory') => {
setNewItemParent(parentPath || '');
setNewItemType(type);
setNewItemName(type === 'file' ? 'untitled.txt' : 'new-folder');
setIsCreating(true);
setRenamingItem(null);
}, []);
const handleCancelCreate = useCallback(() => {
setIsCreating(false);
setNewItemParent('');
setNewItemName('');
}, []);
const handleConfirmCreate = useCallback(async () => {
if (!selectedProject) return;
const error = validateFilename(newItemName);
if (error) {
showToast(error, 'error');
return;
}
setOperationLoading(true);
try {
const response = await api.createFile(selectedProject.name, {
path: newItemParent,
type: newItemType,
name: newItemName,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create');
}
showToast(
newItemType === 'file'
? t('fileTree.toast.fileCreated', 'File created successfully')
: t('fileTree.toast.folderCreated', 'Folder created successfully'),
'success'
);
onRefresh();
handleCancelCreate();
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [selectedProject, newItemParent, newItemType, newItemName, validateFilename, showToast, t, onRefresh, handleCancelCreate]);
// Copy path to clipboard
const handleCopyPath = useCallback((item: FileTreeNode) => {
navigator.clipboard.writeText(item.path).catch(() => {
// Clipboard API may fail in some contexts (e.g., non-HTTPS)
showToast(t('fileTree.toast.copyFailed', 'Failed to copy path'), 'error');
return;
});
showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');
}, [showToast, t]);
// Download file or folder
const handleDownload = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
setOperationLoading(true);
try {
if (item.type === 'directory') {
// Download folder as ZIP
await downloadFolderAsZip(item);
} else {
// Download single file
await downloadSingleFile(item);
}
} catch (err) {
showToast((err as Error).message, 'error');
} finally {
setOperationLoading(false);
}
}, [selectedProject, showToast]);
// Download a single file
const downloadSingleFile = useCallback(async (item: FileTreeNode) => {
if (!selectedProject) return;
const response = await api.readFile(selectedProject.name, item.path);
if (!response.ok) {
throw new Error('Failed to download file');
}
const data = await response.json();
const content = data.content;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = item.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [selectedProject]);
// Download folder as ZIP
const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {
if (!selectedProject) return;
const zip = new JSZip();
// Recursively get all files in the folder
const collectFiles = async (node: FileTreeNode, currentPath: string) => {
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
if (node.type === 'file') {
// Fetch file content
const response = await api.readFile(selectedProject.name, node.path);
if (response.ok) {
const data = await response.json();
zip.file(fullPath, data.content);
}
} else if (node.type === 'directory' && node.children) {
// Recursively process children
for (const child of node.children) {
await collectFiles(child, fullPath);
}
}
};
// If the folder has children, process them
if (folder.children && folder.children.length > 0) {
for (const child of folder.children) {
await collectFiles(child, '');
}
}
// Generate ZIP file
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${folder.name}.zip`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');
}, [selectedProject, showToast, t]);
return {
// Rename operations
renamingItem,
renameValue,
handleStartRename,
handleCancelRename,
handleConfirmRename,
setRenameValue,
// Delete operations
deleteConfirmation,
handleStartDelete,
handleCancelDelete,
handleConfirmDelete,
// Create operations
isCreating,
newItemParent,
newItemType,
newItemName,
handleStartCreate,
handleCancelCreate,
handleConfirmCreate,
setNewItemName,
// Other operations
handleCopyPath,
handleDownload,
// Loading state
operationLoading,
// Validation
validateFilename,
};
}

View File

@@ -0,0 +1,205 @@
import { useCallback, useState, useRef } from 'react';
import type { Project } from '../../../types/app';
import { api } from '../../../utils/api';
type UseFileTreeUploadOptions = {
selectedProject: Project | null;
onRefresh: () => void;
showToast: (message: string, type: 'success' | 'error') => void;
};
// Helper function to read all files from a directory entry recursively
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
const files: File[] = [];
const reader = directoryEntry.createReader();
let entries: FileSystemEntry[] = [];
// Read all entries from the directory (may need multiple reads)
let batch: FileSystemEntry[];
do {
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
entries = entries.concat(batch);
} while (batch.length > 0);
// Files to ignore (system files)
const ignoredFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];
for (const entry of entries) {
const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject);
});
// Skip ignored files
if (ignoredFiles.includes(file.name)) {
continue;
}
// Create a new file with the relative path as the name
const fileWithPath = new File([file], entryPath, {
type: file.type,
lastModified: file.lastModified,
});
files.push(fileWithPath);
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const subFiles = await readAllDirectoryEntries(dirEntry, entryPath);
files.push(...subFiles);
}
}
return files;
};
export const useFileTreeUpload = ({
selectedProject,
onRefresh,
showToast,
}: UseFileTreeUploadOptions) => {
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropTarget(null);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
setOperationLoading(true);
try {
const files: File[] = [];
// Use DataTransferItemList for folder support
const items = e.dataTransfer.items;
if (items) {
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject);
});
files.push(file);
} else if (entry.isDirectory) {
// Pass the directory name as basePath so files include the folder path
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
files.push(...dirFiles);
}
}
}
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry
const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file);
}
}
if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null);
return;
}
const formData = new FormData();
formData.append('targetPath', targetPath);
// Store relative paths separately since FormData strips path info from File.name
const relativePaths: string[] = [];
files.forEach((file) => {
// Create a new file with just the filename (without path) for FormData
// but store the relative path separately
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});
// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
`/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,
formData
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
showToast(
`Uploaded ${files.length} file(s)`,
'success'
);
onRefresh();
} catch (err) {
console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
} finally {
setOperationLoading(false);
setDropTarget(null);
}
}, [dropTarget, selectedProject, onRefresh, showToast]);
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault();
e.stopPropagation();
setDropTarget(itemPath);
}, []);
return {
isDragOver,
dropTarget,
operationLoading,
treeRef,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
handleItemDragOver,
handleItemDrop,
setDropTarget,
};
};

View File

@@ -1,12 +1,15 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
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 { useFileTreeOperations } from '../hooks/useFileTreeOperations';
import { useFileTreeSearch } from '../hooks/useFileTreeSearch';
import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
import { useFileTreeUpload } from '../hooks/useFileTreeUpload';
import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import FileTreeBody from './FileTreeBody';
@@ -14,24 +17,72 @@ import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState';
import { Project } from '../../../types/app';
import { Input } from '../../ui/input';
import { ScrollArea } from '../../ui/scroll-area';
type FileTreeProps = {
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 [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const newItemInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const { files, loading } = useFileTreeData(selectedProject);
// Show toast notification
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type });
}, []);
// Auto-hide toast
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
const { files, loading, refreshFiles } = useFileTreeData(selectedProject);
const { viewMode, changeViewMode } = useFileTreeViewMode();
const { expandedDirs, toggleDirectory, expandDirectories } = useExpandedDirectories();
const { expandedDirs, toggleDirectory, expandDirectories, collapseAll } = useExpandedDirectories();
const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
files,
expandDirectories,
});
// File operations
const operations = useFileTreeOperations({
selectedProject,
onRefresh: refreshFiles,
showToast,
});
// File upload (drag and drop)
const upload = useFileTreeUpload({
selectedProject,
onRefresh: refreshFiles,
showToast,
});
// Focus input when creating new item
useEffect(() => {
if (operations.isCreating && newItemInputRef.current) {
newItemInputRef.current.focus();
newItemInputRef.current.select();
}
}, [operations.isCreating]);
// Focus input when renaming
useEffect(() => {
if (operations.renamingItem && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [operations.renamingItem]);
const renderFileIcon = useCallback((filename: string) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE_CLASS, color)} />;
@@ -70,27 +121,99 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}
return (
<div className="h-full flex flex-col bg-background">
<div
ref={upload.treeRef}
className="h-full flex flex-col bg-background relative"
onDragEnter={upload.handleDragEnter}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
>
{/* Drag overlay */}
{upload.isDragOver && (
<div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 flex items-center justify-center">
<div className="bg-background/95 px-6 py-4 rounded-lg shadow-lg flex items-center gap-3">
<Upload className="w-6 h-6 text-blue-500" />
<span className="text-sm font-medium">{t('fileTree.dropToUpload', 'Drop files to upload')}</span>
</div>
</div>
)}
<FileTreeHeader
viewMode={viewMode}
onViewModeChange={changeViewMode}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles}
onCollapseAll={collapseAll}
loading={loading}
operationLoading={operations.operationLoading}
/>
{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}
/>
<ScrollArea className="flex-1 px-2 py-1">
{/* New item input */}
{operations.isCreating && (
<div
className="flex items-center gap-1.5 py-[3px] pr-2 mb-1"
style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }}
>
{operations.newItemType === 'directory' ? (
<Folder className={cn(ICON_SIZE_CLASS, 'text-blue-500')} />
) : (
<span className="ml-[18px]">{renderFileIcon(operations.newItemName)}</span>
)}
<Input
ref={newItemInputRef}
type="text"
value={operations.newItemName}
onChange={(e) => operations.setNewItemName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') operations.handleConfirmCreate();
if (e.key === 'Escape') operations.handleCancelCreate();
}}
onBlur={() => {
setTimeout(() => {
if (operations.isCreating) operations.handleConfirmCreate();
}, 100);
}}
className="h-6 text-sm flex-1"
disabled={operations.operationLoading}
/>
</div>
)}
<FileTreeBody
files={files}
filteredFiles={filteredFiles}
searchQuery={searchQuery}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={handleItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTimeLabel}
onRename={operations.handleStartRename}
onDelete={operations.handleStartDelete}
onNewFile={(path) => operations.handleStartCreate(path, 'file')}
onNewFolder={(path) => operations.handleStartCreate(path, 'directory')}
onCopyPath={operations.handleCopyPath}
onDownload={operations.handleDownload}
onRefresh={refreshFiles}
// Pass rename state and handlers for inline editing
renamingItem={operations.renamingItem}
renameValue={operations.renameValue}
setRenameValue={operations.setRenameValue}
handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operations.operationLoading}
/>
</ScrollArea>
{selectedImage && (
<ImageViewer
@@ -98,6 +221,70 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onClose={() => setSelectedImage(null)}
/>
)}
{/* Delete Confirmation Dialog */}
{operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg shadow-lg p-4 max-w-sm mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-medium text-foreground">
{t('fileTree.delete.title', 'Delete {{type}}', {
type: operations.deleteConfirmation.item.type === 'directory' ? 'Folder' : 'File'
})}
</h3>
<p className="text-sm text-muted-foreground">
{operations.deleteConfirmation.item.name}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
{operations.deleteConfirmation.item.type === 'directory'
? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.')
: t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={operations.handleCancelDelete}
disabled={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md hover:bg-accent transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={operations.handleConfirmDelete}
disabled={operations.operationLoading}
className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{operations.operationLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{t('fileTree.delete.confirm', 'Delete')}
</button>
</div>
</div>
</div>
)}
{/* Toast Notification */}
{toast && (
<div
className={cn(
'fixed bottom-4 right-4 z-[9999] px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2',
toast.type === 'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'
)}
>
{toast.type === 'success' ? (
<Check className="w-4 h-4" />
) : (
<X className="w-4 h-4" />
)}
<span className="text-sm">{toast.message}</span>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
import type { ReactNode } from 'react';
import type { ReactNode, RefObject } 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';
@@ -16,6 +15,21 @@ type FileTreeBodyProps = {
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
onRename?: (item: FileTreeNode) => void;
onDelete?: (item: FileTreeNode) => void;
onNewFile?: (path: string) => void;
onNewFolder?: (path: string) => void;
onCopyPath?: (item: FileTreeNode) => void;
onDownload?: (item: FileTreeNode) => void;
onRefresh?: () => void;
// Rename state for inline editing
renamingItem?: FileTreeNode | null;
renameValue?: string;
setRenameValue?: (value: string) => void;
handleConfirmRename?: () => void;
handleCancelRename?: () => void;
renameInputRef?: RefObject<HTMLInputElement>;
operationLoading?: boolean;
};
export default function FileTreeBody({
@@ -28,11 +42,25 @@ export default function FileTreeBody({
renderFileIcon,
formatFileSize,
formatRelativeTime,
onRename,
onDelete,
onNewFile,
onNewFolder,
onCopyPath,
onDownload,
onRefresh,
renamingItem,
renameValue,
setRenameValue,
handleConfirmRename,
handleCancelRename,
renameInputRef,
operationLoading,
}: FileTreeBodyProps) {
const { t } = useTranslation();
return (
<ScrollArea className="flex-1 px-2 py-1">
<>
{files.length === 0 ? (
<FileTreeEmptyState
icon={Folder}
@@ -54,9 +82,22 @@ export default function FileTreeBody({
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
onRename={onRename}
onDelete={onDelete}
onNewFile={onNewFile}
onNewFolder={onNewFolder}
onCopyPath={onCopyPath}
onDownload={onDownload}
onRefresh={onRefresh}
renamingItem={renamingItem}
renameValue={renameValue}
setRenameValue={setRenameValue}
handleConfirmRename={handleConfirmRename}
handleCancelRename={handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operationLoading}
/>
)}
</ScrollArea>
</>
);
}

View File

@@ -1,7 +1,8 @@
import { Eye, List, Search, TableProperties, X } from 'lucide-react';
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { cn } from '../../../lib/utils';
import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = {
@@ -9,6 +10,14 @@ type FileTreeHeaderProps = {
onViewModeChange: (mode: FileTreeViewMode) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
// Toolbar actions
onNewFile?: () => void;
onNewFolder?: () => void;
onRefresh?: () => void;
onCollapseAll?: () => void;
// Loading state
loading?: boolean;
operationLoading?: boolean;
};
export default function FileTreeHeader({
@@ -16,20 +25,83 @@ export default function FileTreeHeader({
onViewModeChange,
searchQuery,
onSearchQueryChange,
onNewFile,
onNewFolder,
onRefresh,
onCollapseAll,
loading,
operationLoading,
}: FileTreeHeaderProps) {
const { t } = useTranslation();
return (
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
{/* Title and Toolbar */}
<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">
<div className="flex items-center gap-0.5">
{/* Action buttons */}
{onNewFile && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onNewFile}
title={t('fileTree.newFile', 'New File (Cmd+N)')}
aria-label={t('fileTree.newFile', 'New File (Cmd+N)')}
disabled={operationLoading}
>
<FileText className="w-3.5 h-3.5" />
</Button>
)}
{onNewFolder && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onNewFolder}
title={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}
disabled={operationLoading}
>
<FolderPlus className="w-3.5 h-3.5" />
</Button>
)}
{onRefresh && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onRefresh}
title={t('fileTree.refresh', 'Refresh')}
aria-label={t('fileTree.refresh', 'Refresh')}
disabled={operationLoading}
>
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
</Button>
)}
{onCollapseAll && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onCollapseAll}
title={t('fileTree.collapseAll', 'Collapse All')}
aria-label={t('fileTree.collapseAll', 'Collapse All')}
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
)}
{/* Divider */}
<div className="w-px h-4 bg-border mx-0.5" />
{/* View mode buttons */}
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('simple')}
title={t('fileTree.simpleView')}
aria-label={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
@@ -39,6 +111,7 @@ export default function FileTreeHeader({
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('compact')}
title={t('fileTree.compactView')}
aria-label={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
@@ -48,12 +121,14 @@ export default function FileTreeHeader({
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('detailed')}
title={t('fileTree.detailedView')}
aria-label={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
@@ -70,6 +145,7 @@ export default function FileTreeHeader({
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')}
aria-label={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
@@ -78,4 +154,3 @@ export default function FileTreeHeader({
</div>
);
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import type { ReactNode, RefObject } from 'react';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
import FileTreeNode from './FileTreeNode';
@@ -10,6 +10,21 @@ type FileTreeListProps = {
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
onRename?: (item: FileTreeNodeType) => void;
onDelete?: (item: FileTreeNodeType) => void;
onNewFile?: (path: string) => void;
onNewFolder?: (path: string) => void;
onCopyPath?: (item: FileTreeNodeType) => void;
onDownload?: (item: FileTreeNodeType) => void;
onRefresh?: () => void;
// Rename state for inline editing
renamingItem?: FileTreeNodeType | null;
renameValue?: string;
setRenameValue?: (value: string) => void;
handleConfirmRename?: () => void;
handleCancelRename?: () => void;
renameInputRef?: RefObject<HTMLInputElement>;
operationLoading?: boolean;
};
export default function FileTreeList({
@@ -20,6 +35,20 @@ export default function FileTreeList({
renderFileIcon,
formatFileSize,
formatRelativeTime,
onRename,
onDelete,
onNewFile,
onNewFolder,
onCopyPath,
onDownload,
onRefresh,
renamingItem,
renameValue,
setRenameValue,
handleConfirmRename,
handleCancelRename,
renameInputRef,
operationLoading,
}: FileTreeListProps) {
return (
<div>
@@ -34,9 +63,22 @@ export default function FileTreeList({
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
onRename={onRename}
onDelete={onDelete}
onNewFile={onNewFile}
onNewFolder={onNewFolder}
onCopyPath={onCopyPath}
onDownload={onDownload}
onRefresh={onRefresh}
renamingItem={renamingItem}
renameValue={renameValue}
setRenameValue={setRenameValue}
handleConfirmRename={handleConfirmRename}
handleCancelRename={handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operationLoading}
/>
))}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import type { ReactNode, RefObject } from 'react';
import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '../../../lib/utils';
import FileContextMenu from '../../FileContextMenu';
import { Input } from '../../ui/input';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
type FileTreeNodeProps = {
@@ -12,6 +14,21 @@ type FileTreeNodeProps = {
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
onRename?: (item: FileTreeNodeType) => void;
onDelete?: (item: FileTreeNodeType) => void;
onNewFile?: (path: string) => void;
onNewFolder?: (path: string) => void;
onCopyPath?: (item: FileTreeNodeType) => void;
onDownload?: (item: FileTreeNodeType) => void;
onRefresh?: () => void;
// Rename state for inline editing
renamingItem?: FileTreeNodeType | null;
renameValue?: string;
setRenameValue?: (value: string) => void;
handleConfirmRename?: () => void;
handleCancelRename?: () => void;
renameInputRef?: RefObject<HTMLInputElement>;
operationLoading?: boolean;
};
type TreeItemIconProps = {
@@ -51,10 +68,25 @@ export default function FileTreeNode({
renderFileIcon,
formatFileSize,
formatRelativeTime,
onRename,
onDelete,
onNewFile,
onNewFolder,
onCopyPath,
onDownload,
onRefresh,
renamingItem,
renameValue,
setRenameValue,
handleConfirmRename,
handleCancelRename,
renameInputRef,
operationLoading,
}: FileTreeNodeProps) {
const isDirectory = item.type === 'directory';
const isOpen = isDirectory && expandedDirs.has(item.path);
const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
const isRenaming = renamingItem?.path === item.path;
const nameClassName = cn(
'text-[13px] leading-tight truncate',
@@ -72,47 +104,100 @@ export default function FileTreeNode({
(isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
);
return (
<div className="select-none">
// Render rename input if this item is being renamed
if (isRenaming && setRenameValue && handleConfirmRename && handleCancelRename) {
return (
<div
className={rowClassName}
className={cn(rowClassName, 'bg-accent/30')}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => onItemClick(item)}
onClick={(e) => e.stopPropagation()}
>
{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} />
<Input
ref={renameInputRef}
type="text"
value={renameValue || ''}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') handleCancelRename();
}}
onBlur={() => {
setTimeout(() => {
handleConfirmRename();
}, 100);
}}
className="h-6 text-sm flex-1"
disabled={operationLoading}
/>
</div>
);
}
const rowContent = (
<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>
<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>
);
// Check if context menu callbacks are provided
const hasContextMenu = onRename || onDelete || onNewFile || onNewFolder || onCopyPath || onDownload || onRefresh;
return (
<div className="select-none">
{hasContextMenu ? (
<FileContextMenu
item={item}
onRename={onRename}
onDelete={onDelete}
onNewFile={onNewFile}
onNewFolder={onNewFolder}
onCopyPath={onCopyPath}
onDownload={onDownload}
onRefresh={onRefresh}
>
{rowContent}
</FileContextMenu>
) : (
rowContent
)}
{isDirectory && isOpen && hasChildren && (
<div className="relative">
@@ -132,6 +217,20 @@ export default function FileTreeNode({
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
onRename={onRename}
onDelete={onDelete}
onNewFile={onNewFile}
onNewFolder={onNewFolder}
onCopyPath={onCopyPath}
onDownload={onDownload}
onRefresh={onRefresh}
renamingItem={renamingItem}
renameValue={renameValue}
setRenameValue={setRenameValue}
handleConfirmRename={handleConfirmRename}
handleCancelRename={handleCancelRename}
renameInputRef={renameInputRef}
operationLoading={operationLoading}
/>
))}
</div>

View File

@@ -107,7 +107,7 @@ function MainContent({
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
<div className={`flex flex-col min-h-0 min-w-0 overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>
<div className={`flex flex-col min-h-0 min-w-[200px] overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails>
<ChatInterface

View File

@@ -28,5 +28,9 @@
"lines": "Lines:",
"characters": "Characters:",
"shortcuts": "Press Ctrl+S to save • Esc to close"
},
"binaryFile": {
"title": "Binary File",
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "just now",
"minAgo": "{{count}} min ago",
"hoursAgo": "{{count}} hours ago",
"daysAgo": "{{count}} days ago"
"daysAgo": "{{count}} days ago",
"newFile": "New File (Cmd+N)",
"newFolder": "New Folder (Cmd+Shift+N)",
"refresh": "Refresh",
"collapseAll": "Collapse All"
},
"projectWizard": {
"title": "Create New Project",

View File

@@ -26,5 +26,9 @@
"lines": "行数:",
"characters": "文字数:",
"shortcuts": "Ctrl+Sで保存 • Escで閉じる"
},
"binaryFile": {
"title": "バイナリファイル",
"message": "ファイル \"{{fileName}}\" はバイナリファイルのため、テキストエディタで表示できません。"
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "たった今",
"minAgo": "{{count}}分前",
"hoursAgo": "{{count}}時間前",
"daysAgo": "{{count}}日前"
"daysAgo": "{{count}}日前",
"newFile": "新規ファイル (Cmd+N)",
"newFolder": "新規フォルダ (Cmd+Shift+N)",
"refresh": "更新",
"collapseAll": "すべて折りたたむ"
},
"projectWizard": {
"title": "新規プロジェクトを作成",

View File

@@ -26,5 +26,9 @@
"lines": "줄:",
"characters": "문자:",
"shortcuts": "Ctrl+S로 저장 • Esc로 닫기"
},
"binaryFile": {
"title": "바이너리 파일",
"message": "파일 \"{{fileName}}\"은(는) 바이너리 파일이므로 텍스트 편집기에서 표시할 수 없습니다."
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "방금 전",
"minAgo": "{{count}}분 전",
"hoursAgo": "{{count}}시간 전",
"daysAgo": "{{count}}일 전"
"daysAgo": "{{count}}일 전",
"newFile": "새 파일 (Cmd+N)",
"newFolder": "새 폴더 (Cmd+Shift+N)",
"refresh": "새로고침",
"collapseAll": "모두 접기"
},
"projectWizard": {
"title": "새 프로젝트 생성",

View File

@@ -26,5 +26,9 @@
"lines": "行数:",
"characters": "字符数:",
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
},
"binaryFile": {
"title": "二进制文件",
"message": "文件 \"{{fileName}}\" 无法在文本编辑器中显示,因为它是二进制文件。"
}
}

View File

@@ -114,7 +114,11 @@
"justNow": "刚刚",
"minAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
"daysAgo": "{{count}} 天前",
"newFile": "新建文件 (Cmd+N)",
"newFolder": "新建文件夹 (Cmd+Shift+N)",
"refresh": "刷新",
"collapseAll": "全部折叠"
},
"projectWizard": {
"title": "创建新项目",

View File

@@ -108,6 +108,33 @@ export const api = {
}),
getFiles: (projectName, options = {}) =>
authenticatedFetch(`/api/projects/${projectName}/files`, options),
// File operations
createFile: (projectName, { path, type, name }) =>
authenticatedFetch(`/api/projects/${projectName}/files/create`, {
method: 'POST',
body: JSON.stringify({ path, type, name }),
}),
renameFile: (projectName, { oldPath, newName }) =>
authenticatedFetch(`/api/projects/${projectName}/files/rename`, {
method: 'PUT',
body: JSON.stringify({ oldPath, newName }),
}),
deleteFile: (projectName, { path, type }) =>
authenticatedFetch(`/api/projects/${projectName}/files`, {
method: 'DELETE',
body: JSON.stringify({ path, type }),
}),
uploadFiles: (projectName, formData) =>
authenticatedFetch(`/api/projects/${projectName}/files/upload`, {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
}),
transcribe: (formData) =>
authenticatedFetch('/api/transcribe', {
method: 'POST',
@@ -187,4 +214,22 @@ export const api = {
// Generic GET method for any endpoint
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
// Generic POST method for any endpoint
post: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {
method: 'POST',
...(body instanceof FormData ? { body } : { body: JSON.stringify(body) }),
}),
// Generic PUT method for any endpoint
put: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
// Generic DELETE method for any endpoint
delete: (endpoint, options = {}) => authenticatedFetch(`/api${endpoint}`, {
method: 'DELETE',
...options,
}),
};