diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index c296f8c8..c3f2cb77 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -66,6 +66,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; @@ -255,7 +256,7 @@ export const FilesView: React.FC = () => { const showGitignored = useFilesViewShowGitignored(); const currentDirectory = useEffectiveDirectory() ?? ''; - const root = normalizePath(currentDirectory); + const root = normalizePath(currentDirectory.trim()); const searchFiles = useFileSearchStore((state) => state.searchFiles); const [searchQuery, setSearchQuery] = React.useState(''); @@ -274,13 +275,38 @@ export const FilesView: React.FC = () => { const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); - const [selectedFile, setSelectedFile] = React.useState(null); - const [openFiles, setOpenFiles] = React.useState([]); + const EMPTY_PATHS: string[] = React.useMemo(() => [], []); + const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); + const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); + const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); + const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath); + const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); + const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); + + const toFileNode = React.useCallback((path: string): FileNode => { + const normalized = normalizePath(path); + const parts = normalized.split('/'); + const name = parts[parts.length - 1] || normalized; + const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; + return { + name, + path: normalized, + type: 'file', + extension, + }; + }, []); + + const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); + const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]); + const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); + const [fileContent, setFileContent] = React.useState(''); const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + const [loadedFilePath, setLoadedFilePath] = React.useState(null); + const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); @@ -330,7 +356,8 @@ export const FilesView: React.FC = () => { const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setMainTabGuard = useUIStore((state) => state.setMainTabGuard); - const { inputBarOffset, isKeyboardOpen } = useUIStore(); + const inputBarOffset = useUIStore((state) => state.inputBarOffset); + const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen); // Global mouseup to end drag selection @@ -524,42 +551,52 @@ export const FilesView: React.FC = () => { }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); const refreshRoot = React.useCallback(async () => { - const normalizedRoot = normalizePath(currentDirectory.trim()); - if (!normalizedRoot) { + if (!root) { return; } loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); - setExpandedDirs(new Set()); - setChildrenByDir({}); - - await loadDirectory(normalizedRoot); - }, [currentDirectory, loadDirectory]); + setExpandedDirs((prev) => (prev.size === 0 ? prev : new Set())); + setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); + await loadDirectory(root); + }, [loadDirectory, root]); + const lastFilesViewDirRef = React.useRef(''); + const lastFilesViewTreeKeyRef = React.useRef(''); React.useEffect(() => { - if (!currentDirectory) { + if (!root) { return; } - void refreshRoot(); - setSelectedFile(null); - setOpenFiles([]); - setFileContent(''); - setFileError(null); - setDesktopImageSrc(''); - setShowMobilePageContent(false); - }, [currentDirectory, refreshRoot]); + const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`; + const dirChanged = lastFilesViewDirRef.current !== root; + const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey; - React.useEffect(() => { - if (!currentDirectory) { + if (!dirChanged && !treeKeyChanged) { return; } - void refreshRoot(); - }, [currentDirectory, refreshRoot, showGitignored]); + if (dirChanged) { + lastFilesViewDirRef.current = root; + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + setShowMobilePageContent(false); + } + + if (treeKeyChanged) { + lastFilesViewTreeKeyRef.current = treeKey; + loadedDirsRef.current = new Set(); + inFlightDirsRef.current = new Set(); + setExpandedDirs((prev) => (prev.size === 0 ? prev : new Set())); + setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); + void loadDirectory(root); + } + }, [loadDirectory, root, showGitignored, showHidden]); const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; @@ -631,41 +668,51 @@ export const FilesView: React.FC = () => { const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); if (files.rename) { - const result = await files.rename(oldPath, newPath); - if (result.success) { - toast.success('Renamed successfully'); - await refreshRoot(); - 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); - } - } - } + const result = await files.rename(oldPath, newPath); + if (result.success) { + toast.success('Renamed successfully'); + await refreshRoot(); + if (root) { + removeOpenPathsByPrefix(root, oldPath); + } + if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } } else { toast.error("Rename not supported"); } } else if (activeDialog === 'delete') { if (files.delete) { - const result = await files.delete(dialogData.path); - 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); - } - } - } + const result = await files.delete(dialogData.path); + if (result.success) { + toast.success('Deleted successfully'); + await refreshRoot(); + if (root) { + removeOpenPathsByPrefix(root, dialogData.path); + } + if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) { + if (root) { + setSelectedPath(root, null); + } + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(false); + } + } + } } else { toast.error("Delete not supported"); } @@ -676,7 +723,7 @@ export const FilesView: React.FC = () => { } finally { setIsDialogSubmitting(false); } - }, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, selectedFile, isMobile]); + }, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]); const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { const q = query.trim().toLowerCase(); @@ -728,14 +775,6 @@ export const FilesView: React.FC = () => { return score; }, []); - React.useEffect(() => { - if (!currentDirectory) { - return; - } - - void refreshRoot(); - }, [currentDirectory, refreshRoot, showGitignored]); - React.useEffect(() => { if (!currentDirectory) { setSearchResults([]); @@ -899,51 +938,22 @@ 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; - } else if (isDirty) { - setConfirmDiscardOpen(true); - pendingSelectFileRef.current = node; - return; - } - - setSelectedFile(node); - upsertOpenFile(node); + const loadSelectedFile = React.useCallback(async (node: FileNode) => { setFileError(null); setDesktopImageSrc(''); + setLoadedFilePath(node.path); const selectedIsImage = isImageFile(node.path); + const isSvg = node.path.toLowerCase().endsWith('.svg'); if (isMobile) { setShowMobilePageContent(true); } - const isSvg = node.path.toLowerCase().endsWith('.svg'); - // Desktop: binary images are loaded via readFileBinary (data URL). if (runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); + setDraftContent(''); setFileLoading(true); return; } @@ -951,6 +961,7 @@ export const FilesView: React.FC = () => { // Web: binary images should not be read as utf8. if (!runtime.isDesktop && selectedIsImage && !isSvg) { setFileContent(''); + setDraftContent(''); setFileLoading(false); return; } @@ -970,7 +981,52 @@ export const FilesView: React.FC = () => { } finally { setFileLoading(false); } - }, [isDirty, isMobile, readFile, runtime.isDesktop, upsertOpenFile]); + }, [isMobile, readFile, runtime.isDesktop]); + + 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; + } else if (isDirty) { + setConfirmDiscardOpen(true); + pendingSelectFileRef.current = node; + return; + } + + if (root) { + setSelectedPath(root, node.path); + addOpenPath(root, node.path); + } + + setFileError(null); + setDesktopImageSrc(''); + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(null); + if (isMobile) { + setShowMobilePageContent(true); + } + }, [addOpenPath, isDirty, isMobile, root, setSelectedPath]); + + React.useEffect(() => { + if (!selectedFile) { + return; + } + + if (loadedFilePath === selectedFile.path) { + return; + } + + // Selection changes are guarded; this effect is also what restores persisted tabs on mount. + void loadSelectedFile(selectedFile); + }, [loadSelectedFile, loadedFilePath, selectedFile]); const discardAndContinue = React.useCallback(() => { const nextFile = pendingSelectFileRef.current; @@ -990,15 +1046,20 @@ export const FilesView: React.FC = () => { setDraftContent(displayedContent); if (closePath) { - setOpenFiles((prev) => prev.filter((file) => file.path !== closePath)); + if (root) { + removeOpenPath(root, closePath); + } if (selectedFile?.path === closePath) { if (nextFile) { void handleSelectFile(nextFile); } else { - setSelectedFile(null); + if (root) { + setSelectedPath(root, null); + } setFileContent(''); setFileError(null); setDesktopImageSrc(''); + setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } @@ -1016,7 +1077,7 @@ export const FilesView: React.FC = () => { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } - }, [displayedContent, handleSelectFile, isMobile, selectedFile?.path, setMainTabGuard]); + }, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]); const saveAndContinue = React.useCallback(async () => { const nextFile = pendingSelectFileRef.current; @@ -1035,15 +1096,20 @@ export const FilesView: React.FC = () => { await saveDraft(); if (closePath) { - setOpenFiles((prev) => prev.filter((file) => file.path !== closePath)); + if (root) { + removeOpenPath(root, closePath); + } if (selectedFile?.path === closePath) { if (nextFile) { await handleSelectFile(nextFile); } else { - setSelectedFile(null); + if (root) { + setSelectedPath(root, null); + } setFileContent(''); setFileError(null); setDesktopImageSrc(''); + setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } @@ -1061,7 +1127,7 @@ export const FilesView: React.FC = () => { setMainTabGuard(null); useUIStore.getState().setActiveMainTab(nextTab); } - }, [handleSelectFile, isMobile, saveDraft, selectedFile?.path, setMainTabGuard]); + }, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]); const handleCloseFile = React.useCallback((path: string) => { const isActive = selectedFile?.path === path; @@ -1074,7 +1140,9 @@ export const FilesView: React.FC = () => { return; } - setOpenFiles((prev) => prev.filter((file) => file.path !== path)); + if (root) { + removeOpenPath(root, path); + } if (!isActive) { return; @@ -1085,14 +1153,17 @@ export const FilesView: React.FC = () => { return; } - setSelectedFile(null); + if (root) { + setSelectedPath(root, null); + } setFileContent(''); setFileError(null); setDesktopImageSrc(''); + setLoadedFilePath(null); if (isMobile) { setShowMobilePageContent(false); } - }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, selectedFile?.path]); + }, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); @@ -1544,7 +1615,7 @@ export const FilesView: React.FC = () => { }} className={cn( 'flex items-center justify-between gap-2', - isActive && 'bg-accent/70' + isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]' )} > @@ -1562,7 +1633,7 @@ export const FilesView: React.FC = () => { event.stopPropagation(); handleCloseFile(file.path); }} - className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground" + className="inline-flex h-6 w-6 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]" aria-label={`Close ${file.name}`} > @@ -1586,10 +1657,10 @@ export const FilesView: React.FC = () => { 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', + 'group inline-flex items-center gap-1 rounded-md border px-2 py-1 typography-ui-label transition-colors whitespace-nowrap', isActive - ? 'border-border/60 bg-accent/70 text-foreground' - : 'border-transparent text-muted-foreground hover:bg-accent/40 hover:text-foreground' + ? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]' + : 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]' )} > ); diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts new file mode 100644 index 00000000..67b362eb --- /dev/null +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -0,0 +1,211 @@ +import { create } from 'zustand'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; + +import { getSafeStorage } from './utils/safeStorage'; + +type RootTabsState = { + openPaths: string[]; + selectedPath: string | null; + touchedAt: number; +}; + +type FilesViewTabsState = { + byRoot: Record; +}; + +type FilesViewTabsActions = { + addOpenPath: (root: string, path: string) => void; + removeOpenPath: (root: string, path: string) => void; + removeOpenPathsByPrefix: (root: string, prefixPath: string) => void; + setSelectedPath: (root: string, path: string | null) => void; + ensureSelectedPath: (root: string) => void; +}; + +export type FilesViewTabsStore = FilesViewTabsState & FilesViewTabsActions; + +const normalizePath = (value: string): string => value.replace(/\\/g, '/'); + +const clampRoots = (byRoot: Record, maxRoots: number): Record => { + const entries = Object.entries(byRoot); + if (entries.length <= maxRoots) { + return byRoot; + } + + entries.sort((a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0)); + const next: Record = {}; + for (const [root, state] of entries.slice(0, maxRoots)) { + next[root] = state; + } + return next; +}; + +const touchRoot = (prev: RootTabsState | undefined): RootTabsState => { + if (prev) { + return { ...prev, touchedAt: Date.now() }; + } + return { openPaths: [], selectedPath: null, touchedAt: Date.now() }; +}; + +export const useFilesViewTabsStore = create()( + devtools( + persist( + (set, get) => ({ + byRoot: {}, + + addOpenPath: (root, path) => { + const normalizedRoot = normalizePath((root || '').trim()); + const normalizedPath = normalizePath((path || '').trim()); + if (!normalizedRoot || !normalizedPath) { + return; + } + + set((state) => { + const prev = state.byRoot[normalizedRoot]; + const current = touchRoot(prev); + const exists = current.openPaths.includes(normalizedPath); + const nextOpenPaths = exists ? current.openPaths : [...current.openPaths, normalizedPath]; + const nextSelectedPath = current.selectedPath ?? normalizedPath; + + if (prev && exists && prev.selectedPath === nextSelectedPath) { + return state; + } + const byRoot = { + ...state.byRoot, + [normalizedRoot]: { + ...current, + openPaths: nextOpenPaths, + selectedPath: nextSelectedPath, + }, + }; + return { byRoot: clampRoots(byRoot, 20) }; + }); + }, + + removeOpenPath: (root, path) => { + const normalizedRoot = normalizePath((root || '').trim()); + const normalizedPath = normalizePath((path || '').trim()); + if (!normalizedRoot || !normalizedPath) { + return; + } + + set((state) => { + const current = state.byRoot[normalizedRoot]; + if (!current) { + return state; + } + + if (!current.openPaths.includes(normalizedPath) && current.selectedPath !== normalizedPath) { + return state; + } + + const openPaths = current.openPaths.filter((p) => p !== normalizedPath); + const selectedPath = current.selectedPath === normalizedPath ? (openPaths[0] ?? null) : current.selectedPath; + + const byRoot = { + ...state.byRoot, + [normalizedRoot]: { + ...current, + openPaths, + selectedPath, + touchedAt: Date.now(), + }, + }; + return { byRoot: clampRoots(byRoot, 20) }; + }); + }, + + removeOpenPathsByPrefix: (root, prefixPath) => { + const normalizedRoot = normalizePath((root || '').trim()); + const normalizedPrefix = normalizePath((prefixPath || '').trim()); + if (!normalizedRoot || !normalizedPrefix) { + return; + } + + set((state) => { + const current = state.byRoot[normalizedRoot]; + if (!current) { + return state; + } + + const prefixWithSlash = normalizedPrefix.endsWith('/') ? normalizedPrefix : `${normalizedPrefix}/`; + const openPaths = current.openPaths.filter((p) => p !== normalizedPrefix && !p.startsWith(prefixWithSlash)); + if (openPaths.length === current.openPaths.length) { + return state; + } + + const selectedPath = current.selectedPath && (current.selectedPath === normalizedPrefix || current.selectedPath.startsWith(prefixWithSlash)) + ? (openPaths[0] ?? null) + : current.selectedPath; + + const byRoot = { + ...state.byRoot, + [normalizedRoot]: { + ...current, + openPaths, + selectedPath, + touchedAt: Date.now(), + }, + }; + + return { byRoot: clampRoots(byRoot, 20) }; + }); + }, + + setSelectedPath: (root, path) => { + const normalizedRoot = normalizePath((root || '').trim()); + const normalizedPath = path ? normalizePath(path.trim()) : null; + if (!normalizedRoot) { + return; + } + + set((state) => { + const prev = state.byRoot[normalizedRoot]; + const current = touchRoot(prev); + const openPaths = normalizedPath && !current.openPaths.includes(normalizedPath) + ? [...current.openPaths, normalizedPath] + : current.openPaths; + + if (prev && prev.selectedPath === normalizedPath && openPaths === prev.openPaths) { + return state; + } + + const byRoot = { + ...state.byRoot, + [normalizedRoot]: { + ...current, + openPaths, + selectedPath: normalizedPath, + }, + }; + return { byRoot: clampRoots(byRoot, 20) }; + }); + }, + + ensureSelectedPath: (root) => { + const normalizedRoot = normalizePath((root || '').trim()); + if (!normalizedRoot) { + return; + } + + const current = get().byRoot[normalizedRoot]; + if (!current || current.selectedPath) { + return; + } + + const first = current.openPaths[0] ?? null; + if (!first) { + return; + } + + get().setSelectedPath(normalizedRoot, first); + }, + }), + { + name: 'files-view-tabs-store', + storage: createJSONStorage(() => getSafeStorage()), + partialize: (state) => ({ byRoot: state.byRoot }), + } + ), + { name: 'files-view-tabs-store' } + ) +); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index f803afe7..ed957e94 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -232,6 +232,9 @@ export const useUIStore = create()( }, setMainTabGuard: (guard) => { + if (get().mainTabGuard === guard) { + return; + } set({ mainTabGuard: guard }); },