feat: implement multi-file tabs (desktop) and dropdown (mobile) in FilesView (#257)

* feat: track and prune open files in FilesView

Add openFiles state to manage currently opened file tabs
Prune open files when a file is renamed or deleted to keep in sync
Reset openFiles on refresh and clear file content when selection changes

* style: improve file dropdown layout and truncation

Adjust button layout to allow long file names to truncate correctly
Keep dropdown arrow visible while resizing by preventing shrink
This commit is contained in:
Nelson Pires
2026-01-31 22:35:46 +02:00
committed by GitHub
parent 6d678c4eee
commit 0b12678f24
+236 -17
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { import {
RiArrowLeftSLine, RiArrowLeftSLine,
RiArrowDownSLine,
RiClipboardLine, RiClipboardLine,
RiCloseLine, RiCloseLine,
RiCodeLine, RiCodeLine,
@@ -274,6 +275,7 @@ export const FilesView: React.FC = () => {
const [searching, setSearching] = React.useState(false); const [searching, setSearching] = React.useState(false);
const [selectedFile, setSelectedFile] = React.useState<FileNode | null>(null); const [selectedFile, setSelectedFile] = React.useState<FileNode | null>(null);
const [openFiles, setOpenFiles] = React.useState<FileNode[]>([]);
const [fileContent, setFileContent] = React.useState<string>(''); const [fileContent, setFileContent] = React.useState<string>('');
const [fileLoading, setFileLoading] = React.useState(false); const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState<string | null>(null); const [fileError, setFileError] = React.useState<string | null>(null);
@@ -285,6 +287,7 @@ export const FilesView: React.FC = () => {
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef<FileNode | null>(null); const pendingSelectFileRef = React.useRef<FileNode | null>(null);
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null); const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
const pendingClosePathRef = React.useRef<string | null>(null);
const skipDirtyOnceRef = React.useRef(false); const skipDirtyOnceRef = React.useRef(false);
const copiedContentTimeoutRef = React.useRef<number | null>(null); const copiedContentTimeoutRef = React.useRef<number | null>(null);
const copiedPathTimeoutRef = React.useRef<number | null>(null); const copiedPathTimeoutRef = React.useRef<number | null>(null);
@@ -543,6 +546,7 @@ export const FilesView: React.FC = () => {
void refreshRoot(); void refreshRoot();
setSelectedFile(null); setSelectedFile(null);
setOpenFiles([]);
setFileContent(''); setFileContent('');
setFileError(null); setFileError(null);
setDesktopImageSrc(''); setDesktopImageSrc('');
@@ -631,8 +635,15 @@ export const FilesView: React.FC = () => {
if (result.success) { if (result.success) {
toast.success('Renamed successfully'); toast.success('Renamed successfully');
await refreshRoot(); 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); setSelectedFile(null);
setFileContent('');
setFileError(null);
setDesktopImageSrc('');
if (isMobile) {
setShowMobilePageContent(false);
}
} }
} }
} else { } else {
@@ -644,9 +655,15 @@ export const FilesView: React.FC = () => {
if (result.success) { if (result.success) {
toast.success('Deleted successfully'); toast.success('Deleted successfully');
await refreshRoot(); 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 + '/')) { if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
setSelectedFile(null); setSelectedFile(null);
setFileContent(''); setFileContent('');
setFileError(null);
setDesktopImageSrc('');
if (isMobile) {
setShowMobilePageContent(false);
}
} }
} }
} else { } else {
@@ -659,7 +676,7 @@ export const FilesView: React.FC = () => {
} finally { } finally {
setIsDialogSubmitting(false); 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 fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -882,6 +899,26 @@ export const FilesView: React.FC = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]); }, [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) => { const handleSelectFile = React.useCallback(async (node: FileNode) => {
if (skipDirtyOnceRef.current) { if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false; skipDirtyOnceRef.current = false;
@@ -892,6 +929,7 @@ export const FilesView: React.FC = () => {
} }
setSelectedFile(node); setSelectedFile(node);
upsertOpenFile(node);
setFileError(null); setFileError(null);
setDesktopImageSrc(''); setDesktopImageSrc('');
@@ -932,14 +970,16 @@ export const FilesView: React.FC = () => {
} finally { } finally {
setFileLoading(false); setFileLoading(false);
} }
}, [isDirty, isMobile, readFile, runtime.isDesktop]); }, [isDirty, isMobile, readFile, runtime.isDesktop, upsertOpenFile]);
const discardAndContinue = React.useCallback(() => { const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current; const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current; const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
pendingSelectFileRef.current = null; pendingSelectFileRef.current = null;
pendingTabRef.current = null; pendingTabRef.current = null;
pendingClosePathRef.current = null;
// Allow one guarded navigation (tab/file) without re-opening dialog. // Allow one guarded navigation (tab/file) without re-opening dialog.
skipDirtyOnceRef.current = true; skipDirtyOnceRef.current = true;
@@ -949,6 +989,24 @@ export const FilesView: React.FC = () => {
// Discard draft by reverting back to last loaded content // Discard draft by reverting back to last loaded content
setDraftContent(displayedContent); 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) { if (nextFile) {
void handleSelectFile(nextFile); void handleSelectFile(nextFile);
return; return;
@@ -958,14 +1016,16 @@ export const FilesView: React.FC = () => {
setMainTabGuard(null); setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab); useUIStore.getState().setActiveMainTab(nextTab);
} }
}, [displayedContent, handleSelectFile, setMainTabGuard]); }, [displayedContent, handleSelectFile, isMobile, selectedFile?.path, setMainTabGuard]);
const saveAndContinue = React.useCallback(async () => { const saveAndContinue = React.useCallback(async () => {
const nextFile = pendingSelectFileRef.current; const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current; const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
pendingSelectFileRef.current = null; pendingSelectFileRef.current = null;
pendingTabRef.current = null; pendingTabRef.current = null;
pendingClosePathRef.current = null;
// We'll proceed after saving; suppress guard reopening. // We'll proceed after saving; suppress guard reopening.
skipDirtyOnceRef.current = true; skipDirtyOnceRef.current = true;
@@ -974,6 +1034,24 @@ export const FilesView: React.FC = () => {
await saveDraft(); 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) { if (nextFile) {
await handleSelectFile(nextFile); await handleSelectFile(nextFile);
return; return;
@@ -983,7 +1061,38 @@ export const FilesView: React.FC = () => {
setMainTabGuard(null); setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab); 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 toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath); const normalized = normalizePath(dirPath);
@@ -1131,17 +1240,20 @@ export const FilesView: React.FC = () => {
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const displaySelectedPath = React.useMemo(() => { const getDisplayPath = React.useCallback((path: string): string => {
if (!selectedFile?.path) return ''; if (!path) return '';
const normalizedFilePath = normalizePath(selectedFile.path); const normalizedFilePath = normalizePath(path);
if (root && normalizedFilePath.startsWith(root)) { if (root && normalizedFilePath.startsWith(root)) {
const relative = normalizedFilePath.slice(root.length); const relative = normalizedFilePath.slice(root.length);
return relative.startsWith('/') ? relative.slice(1) : relative; return relative.startsWith('/') ? relative.slice(1) : relative;
} }
return normalizedFilePath; 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 canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
@@ -1395,13 +1507,120 @@ export const FilesView: React.FC = () => {
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate"> {isMobile ? (
{selectedFile ? selectedFile.name : 'Select a file'} selectedFile ? (
</div> <DropdownMenu>
{selectedFile && !isMobile && ( <DropdownMenuTrigger asChild>
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}> <button
{displaySelectedPath} type="button"
</div> className="inline-flex min-w-0 max-w-full items-center gap-1 text-left typography-ui-label font-medium"
aria-label="Open files"
>
<span className="min-w-0 flex-1 truncate">{selectedFile.name}</span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[16rem]">
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
<DropdownMenuItem
key={file.path}
onSelect={(event) => {
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'
)}
>
<span className="min-w-0 flex-1 truncate">
{file.name}
</span>
<button
type="button"
data-close-open-file
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCloseFile(file.path);
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
aria-label={`Close ${file.name}`}
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
)
) : (
openFiles.length > 0 ? (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
<div
key={file.path}
title={getDisplayPath(file.path)}
className={cn(
'group inline-flex items-center gap-1 rounded-md border px-2 py-1 typography-ui-label transition-colors',
isActive
? 'border-border/60 bg-accent/70 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-accent/40 hover:text-foreground'
)}
>
<button
type="button"
onClick={() => {
if (!isActive) {
void handleSelectFile(file);
}
}}
className="max-w-[12rem] truncate text-left"
>
{file.name}
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleCloseFile(file.path);
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label={`Close ${file.name}`}
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
{selectedFile && (
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
{displaySelectedPath}
</div>
)}
</div>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
)
)} )}
</div> </div>