diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index d24a5b8d..1def6d3d 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { RiArrowLeftSLine, + RiArrowDownSLine, RiClipboardLine, RiCloseLine, RiCodeLine, @@ -274,6 +275,7 @@ export const FilesView: React.FC = () => { const [searching, setSearching] = React.useState(false); const [selectedFile, setSelectedFile] = React.useState(null); + const [openFiles, setOpenFiles] = React.useState([]); const [fileContent, setFileContent] = React.useState(''); const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); @@ -285,6 +287,7 @@ export const FilesView: React.FC = () => { const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const pendingSelectFileRef = React.useRef(null); const pendingTabRef = React.useRef(null); + const pendingClosePathRef = React.useRef(null); const skipDirtyOnceRef = React.useRef(false); const copiedContentTimeoutRef = React.useRef(null); const copiedPathTimeoutRef = React.useRef(null); @@ -543,6 +546,7 @@ export const FilesView: React.FC = () => { void refreshRoot(); setSelectedFile(null); + setOpenFiles([]); setFileContent(''); setFileError(null); setDesktopImageSrc(''); @@ -631,8 +635,15 @@ export const FilesView: React.FC = () => { if (result.success) { toast.success('Renamed successfully'); await refreshRoot(); - if (selectedFile?.path === oldPath) { + setOpenFiles((prev) => prev.filter((file) => file.path !== oldPath && !file.path.startsWith(`${oldPath}/`))); + if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { setSelectedFile(null); + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + if (isMobile) { + setShowMobilePageContent(false); + } } } } else { @@ -644,9 +655,15 @@ export const FilesView: React.FC = () => { if (result.success) { toast.success('Deleted successfully'); await refreshRoot(); + setOpenFiles((prev) => prev.filter((file) => file.path !== dialogData.path && !file.path.startsWith(`${dialogData.path}/`))); if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) { setSelectedFile(null); setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + if (isMobile) { + setShowMobilePageContent(false); + } } } } else { @@ -659,7 +676,7 @@ export const FilesView: React.FC = () => { } finally { setIsDialogSubmitting(false); } - }, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, selectedFile]); + }, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, selectedFile, isMobile]); const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { const q = query.trim().toLowerCase(); @@ -882,6 +899,26 @@ export const FilesView: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [isSaving, saveDraft]); + const upsertOpenFile = React.useCallback((node: FileNode) => { + setOpenFiles((prev) => { + const index = prev.findIndex((file) => file.path === node.path); + if (index === -1) { + return [...prev, node]; + } + const next = prev.slice(); + next[index] = node; + return next; + }); + }, []); + + const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => { + const index = filesList.findIndex((file) => file.path === path); + if (index === -1 || filesList.length <= 1) { + return null; + } + return filesList[index + 1] ?? filesList[index - 1] ?? null; + }, []); + const handleSelectFile = React.useCallback(async (node: FileNode) => { if (skipDirtyOnceRef.current) { skipDirtyOnceRef.current = false; @@ -892,6 +929,7 @@ export const FilesView: React.FC = () => { } setSelectedFile(node); + upsertOpenFile(node); setFileError(null); setDesktopImageSrc(''); @@ -932,14 +970,16 @@ export const FilesView: React.FC = () => { } finally { setFileLoading(false); } - }, [isDirty, isMobile, readFile, runtime.isDesktop]); + }, [isDirty, isMobile, readFile, runtime.isDesktop, upsertOpenFile]); const discardAndContinue = React.useCallback(() => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; + const closePath = pendingClosePathRef.current; pendingSelectFileRef.current = null; pendingTabRef.current = null; + pendingClosePathRef.current = null; // Allow one guarded navigation (tab/file) without re-opening dialog. skipDirtyOnceRef.current = true; @@ -949,6 +989,24 @@ export const FilesView: React.FC = () => { // Discard draft by reverting back to last loaded content setDraftContent(displayedContent); + if (closePath) { + setOpenFiles((prev) => prev.filter((file) => file.path !== closePath)); + if (selectedFile?.path === closePath) { + if (nextFile) { + void handleSelectFile(nextFile); + } else { + setSelectedFile(null); + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + return; + } + if (nextFile) { void handleSelectFile(nextFile); return; @@ -958,14 +1016,16 @@ export const FilesView: React.FC = () => { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } - }, [displayedContent, handleSelectFile, setMainTabGuard]); + }, [displayedContent, handleSelectFile, isMobile, selectedFile?.path, setMainTabGuard]); const saveAndContinue = React.useCallback(async () => { const nextFile = pendingSelectFileRef.current; const nextTab = pendingTabRef.current; + const closePath = pendingClosePathRef.current; pendingSelectFileRef.current = null; pendingTabRef.current = null; + pendingClosePathRef.current = null; // We'll proceed after saving; suppress guard reopening. skipDirtyOnceRef.current = true; @@ -974,6 +1034,24 @@ export const FilesView: React.FC = () => { await saveDraft(); + if (closePath) { + setOpenFiles((prev) => prev.filter((file) => file.path !== closePath)); + if (selectedFile?.path === closePath) { + if (nextFile) { + await handleSelectFile(nextFile); + } else { + setSelectedFile(null); + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } + return; + } + if (nextFile) { await handleSelectFile(nextFile); return; @@ -983,7 +1061,38 @@ export const FilesView: React.FC = () => { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } - }, [handleSelectFile, saveDraft, setMainTabGuard]); + }, [handleSelectFile, isMobile, saveDraft, selectedFile?.path, setMainTabGuard]); + + const handleCloseFile = React.useCallback((path: string) => { + const isActive = selectedFile?.path === path; + const nextFile = getNextOpenFile(path, openFiles); + + if (isActive && isDirty) { + setConfirmDiscardOpen(true); + pendingSelectFileRef.current = nextFile; + pendingClosePathRef.current = path; + return; + } + + setOpenFiles((prev) => prev.filter((file) => file.path !== path)); + + if (!isActive) { + return; + } + + if (nextFile) { + void handleSelectFile(nextFile); + return; + } + + setSelectedFile(null); + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + if (isMobile) { + setShowMobilePageContent(false); + } + }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, selectedFile?.path]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); @@ -1131,17 +1240,20 @@ export const FilesView: React.FC = () => { const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); - const displaySelectedPath = React.useMemo(() => { - if (!selectedFile?.path) return ''; - const normalizedFilePath = normalizePath(selectedFile.path); + const getDisplayPath = React.useCallback((path: string): string => { + if (!path) return ''; + const normalizedFilePath = normalizePath(path); if (root && normalizedFilePath.startsWith(root)) { const relative = normalizedFilePath.slice(root.length); return relative.startsWith('/') ? relative.slice(1) : relative; } return normalizedFilePath; - }, [selectedFile?.path, root]); - + }, [root]); + const displaySelectedPath = React.useMemo(() => { + if (!selectedFile?.path) return ''; + return getDisplayPath(selectedFile.path); + }, [getDisplayPath, selectedFile?.path]); const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); @@ -1395,13 +1507,120 @@ export const FilesView: React.FC = () => { )}
-
- {selectedFile ? selectedFile.name : 'Select a file'} -
- {selectedFile && !isMobile && ( -
- {displaySelectedPath} -
+ {isMobile ? ( + selectedFile ? ( + + + + + + {openFiles.map((file) => { + const isActive = selectedFile?.path === file.path; + return ( + { + const target = event.target as HTMLElement; + if (target.closest('[data-close-open-file]')) { + event.preventDefault(); + return; + } + if (!isActive) { + void handleSelectFile(file); + } + }} + className={cn( + 'flex items-center justify-between gap-2', + isActive && 'bg-accent/70' + )} + > + + {file.name} + + + + ); + })} + + + ) : ( +
Select a file
+ ) + ) : ( + openFiles.length > 0 ? ( +
+
+ {openFiles.map((file) => { + const isActive = selectedFile?.path === file.path; + return ( +
+ + +
+ ); + })} +
+ {selectedFile && ( +
+ {displaySelectedPath} +
+ )} +
+ ) : ( +
Select a file
+ ) )}