feat(files-view): add per-root open file tabs store
- Add per-root tab state to track open files and current selection - Derive open file nodes for the active root and trim root paths
This commit is contained in:
@@ -66,6 +66,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
@@ -255,7 +256,7 @@ export const FilesView: React.FC = () => {
|
|||||||
const showGitignored = useFilesViewShowGitignored();
|
const showGitignored = useFilesViewShowGitignored();
|
||||||
|
|
||||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||||
const root = normalizePath(currentDirectory);
|
const root = normalizePath(currentDirectory.trim());
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
@@ -274,13 +275,38 @@ export const FilesView: React.FC = () => {
|
|||||||
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = React.useState<FileNode | null>(null);
|
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
|
||||||
const [openFiles, setOpenFiles] = React.useState<FileNode[]>([]);
|
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<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);
|
||||||
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
||||||
|
|
||||||
|
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [draftContent, setDraftContent] = React.useState('');
|
const [draftContent, setDraftContent] = React.useState('');
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
|
||||||
@@ -330,7 +356,8 @@ export const FilesView: React.FC = () => {
|
|||||||
const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession);
|
const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
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
|
// Global mouseup to end drag selection
|
||||||
@@ -524,42 +551,52 @@ export const FilesView: React.FC = () => {
|
|||||||
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
|
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
|
||||||
|
|
||||||
const refreshRoot = React.useCallback(async () => {
|
const refreshRoot = React.useCallback(async () => {
|
||||||
const normalizedRoot = normalizePath(currentDirectory.trim());
|
if (!root) {
|
||||||
if (!normalizedRoot) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedDirsRef.current = new Set();
|
loadedDirsRef.current = new Set();
|
||||||
inFlightDirsRef.current = new Set();
|
inFlightDirsRef.current = new Set();
|
||||||
setExpandedDirs(new Set());
|
setExpandedDirs((prev) => (prev.size === 0 ? prev : new Set()));
|
||||||
setChildrenByDir({});
|
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||||
|
|
||||||
await loadDirectory(normalizedRoot);
|
|
||||||
}, [currentDirectory, loadDirectory]);
|
|
||||||
|
|
||||||
|
await loadDirectory(root);
|
||||||
|
}, [loadDirectory, root]);
|
||||||
|
|
||||||
|
const lastFilesViewDirRef = React.useRef<string>('');
|
||||||
|
const lastFilesViewTreeKeyRef = React.useRef<string>('');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshRoot();
|
const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`;
|
||||||
setSelectedFile(null);
|
const dirChanged = lastFilesViewDirRef.current !== root;
|
||||||
setOpenFiles([]);
|
const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey;
|
||||||
setFileContent('');
|
|
||||||
setFileError(null);
|
|
||||||
setDesktopImageSrc('');
|
|
||||||
setShowMobilePageContent(false);
|
|
||||||
}, [currentDirectory, refreshRoot]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
if (!dirChanged && !treeKeyChanged) {
|
||||||
if (!currentDirectory) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshRoot();
|
if (dirChanged) {
|
||||||
}, [currentDirectory, refreshRoot, showGitignored]);
|
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';
|
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()}`);
|
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
||||||
|
|
||||||
if (files.rename) {
|
if (files.rename) {
|
||||||
const result = await files.rename(oldPath, newPath);
|
const result = await files.rename(oldPath, newPath);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('Renamed successfully');
|
toast.success('Renamed successfully');
|
||||||
await refreshRoot();
|
await refreshRoot();
|
||||||
setOpenFiles((prev) => prev.filter((file) => file.path !== oldPath && !file.path.startsWith(`${oldPath}/`)));
|
if (root) {
|
||||||
if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) {
|
removeOpenPathsByPrefix(root, oldPath);
|
||||||
setSelectedFile(null);
|
}
|
||||||
setFileContent('');
|
if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) {
|
||||||
setFileError(null);
|
if (root) {
|
||||||
setDesktopImageSrc('');
|
setSelectedPath(root, null);
|
||||||
if (isMobile) {
|
}
|
||||||
setShowMobilePageContent(false);
|
setFileContent('');
|
||||||
}
|
setFileError(null);
|
||||||
}
|
setDesktopImageSrc('');
|
||||||
}
|
setLoadedFilePath(null);
|
||||||
|
if (isMobile) {
|
||||||
|
setShowMobilePageContent(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("Rename not supported");
|
toast.error("Rename not supported");
|
||||||
}
|
}
|
||||||
} else if (activeDialog === 'delete') {
|
} else if (activeDialog === 'delete') {
|
||||||
if (files.delete) {
|
if (files.delete) {
|
||||||
const result = await files.delete(dialogData.path);
|
const result = await files.delete(dialogData.path);
|
||||||
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 (root) {
|
||||||
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
|
removeOpenPathsByPrefix(root, dialogData.path);
|
||||||
setSelectedFile(null);
|
}
|
||||||
setFileContent('');
|
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
|
||||||
setFileError(null);
|
if (root) {
|
||||||
setDesktopImageSrc('');
|
setSelectedPath(root, null);
|
||||||
if (isMobile) {
|
}
|
||||||
setShowMobilePageContent(false);
|
setFileContent('');
|
||||||
}
|
setFileError(null);
|
||||||
}
|
setDesktopImageSrc('');
|
||||||
}
|
setLoadedFilePath(null);
|
||||||
|
if (isMobile) {
|
||||||
|
setShowMobilePageContent(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("Delete not supported");
|
toast.error("Delete not supported");
|
||||||
}
|
}
|
||||||
@@ -676,7 +723,7 @@ export const FilesView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDialogSubmitting(false);
|
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 fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -728,14 +775,6 @@ export const FilesView: React.FC = () => {
|
|||||||
return score;
|
return score;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentDirectory) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void refreshRoot();
|
|
||||||
}, [currentDirectory, refreshRoot, showGitignored]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -899,51 +938,22 @@ 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) => {
|
const loadSelectedFile = React.useCallback(async (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);
|
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
|
setLoadedFilePath(node.path);
|
||||||
|
|
||||||
const selectedIsImage = isImageFile(node.path);
|
const selectedIsImage = isImageFile(node.path);
|
||||||
|
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(true);
|
setShowMobilePageContent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
|
||||||
|
|
||||||
// Desktop: binary images are loaded via readFileBinary (data URL).
|
// Desktop: binary images are loaded via readFileBinary (data URL).
|
||||||
if (runtime.isDesktop && selectedIsImage && !isSvg) {
|
if (runtime.isDesktop && selectedIsImage && !isSvg) {
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
|
setDraftContent('');
|
||||||
setFileLoading(true);
|
setFileLoading(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -951,6 +961,7 @@ export const FilesView: React.FC = () => {
|
|||||||
// Web: binary images should not be read as utf8.
|
// Web: binary images should not be read as utf8.
|
||||||
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
|
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
|
setDraftContent('');
|
||||||
setFileLoading(false);
|
setFileLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -970,7 +981,52 @@ export const FilesView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setFileLoading(false);
|
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 discardAndContinue = React.useCallback(() => {
|
||||||
const nextFile = pendingSelectFileRef.current;
|
const nextFile = pendingSelectFileRef.current;
|
||||||
@@ -990,15 +1046,20 @@ export const FilesView: React.FC = () => {
|
|||||||
setDraftContent(displayedContent);
|
setDraftContent(displayedContent);
|
||||||
|
|
||||||
if (closePath) {
|
if (closePath) {
|
||||||
setOpenFiles((prev) => prev.filter((file) => file.path !== closePath));
|
if (root) {
|
||||||
|
removeOpenPath(root, closePath);
|
||||||
|
}
|
||||||
if (selectedFile?.path === closePath) {
|
if (selectedFile?.path === closePath) {
|
||||||
if (nextFile) {
|
if (nextFile) {
|
||||||
void handleSelectFile(nextFile);
|
void handleSelectFile(nextFile);
|
||||||
} else {
|
} else {
|
||||||
setSelectedFile(null);
|
if (root) {
|
||||||
|
setSelectedPath(root, null);
|
||||||
|
}
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
|
setLoadedFilePath(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(false);
|
setShowMobilePageContent(false);
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1077,7 @@ export const FilesView: React.FC = () => {
|
|||||||
setMainTabGuard(null);
|
setMainTabGuard(null);
|
||||||
useUIStore.getState().setActiveMainTab(nextTab);
|
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 saveAndContinue = React.useCallback(async () => {
|
||||||
const nextFile = pendingSelectFileRef.current;
|
const nextFile = pendingSelectFileRef.current;
|
||||||
@@ -1035,15 +1096,20 @@ export const FilesView: React.FC = () => {
|
|||||||
await saveDraft();
|
await saveDraft();
|
||||||
|
|
||||||
if (closePath) {
|
if (closePath) {
|
||||||
setOpenFiles((prev) => prev.filter((file) => file.path !== closePath));
|
if (root) {
|
||||||
|
removeOpenPath(root, closePath);
|
||||||
|
}
|
||||||
if (selectedFile?.path === closePath) {
|
if (selectedFile?.path === closePath) {
|
||||||
if (nextFile) {
|
if (nextFile) {
|
||||||
await handleSelectFile(nextFile);
|
await handleSelectFile(nextFile);
|
||||||
} else {
|
} else {
|
||||||
setSelectedFile(null);
|
if (root) {
|
||||||
|
setSelectedPath(root, null);
|
||||||
|
}
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
|
setLoadedFilePath(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(false);
|
setShowMobilePageContent(false);
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1127,7 @@ export const FilesView: React.FC = () => {
|
|||||||
setMainTabGuard(null);
|
setMainTabGuard(null);
|
||||||
useUIStore.getState().setActiveMainTab(nextTab);
|
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 handleCloseFile = React.useCallback((path: string) => {
|
||||||
const isActive = selectedFile?.path === path;
|
const isActive = selectedFile?.path === path;
|
||||||
@@ -1074,7 +1140,9 @@ export const FilesView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenFiles((prev) => prev.filter((file) => file.path !== path));
|
if (root) {
|
||||||
|
removeOpenPath(root, path);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
@@ -1085,14 +1153,17 @@ export const FilesView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFile(null);
|
if (root) {
|
||||||
|
setSelectedPath(root, null);
|
||||||
|
}
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
|
setLoadedFilePath(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(false);
|
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 toggleDirectory = React.useCallback(async (dirPath: string) => {
|
||||||
const normalized = normalizePath(dirPath);
|
const normalized = normalizePath(dirPath);
|
||||||
@@ -1544,7 +1615,7 @@ export const FilesView: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-2',
|
'flex items-center justify-between gap-2',
|
||||||
isActive && 'bg-accent/70'
|
isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
@@ -1562,7 +1633,7 @@ export const FilesView: React.FC = () => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleCloseFile(file.path);
|
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}`}
|
aria-label={`Close ${file.name}`}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-3.5 w-3.5" />
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||||||
@@ -1586,10 +1657,10 @@ export const FilesView: React.FC = () => {
|
|||||||
key={file.path}
|
key={file.path}
|
||||||
title={getDisplayPath(file.path)}
|
title={getDisplayPath(file.path)}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? 'border-border/60 bg-accent/70 text-foreground'
|
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
||||||
: 'border-transparent text-muted-foreground hover:bg-accent/40 hover:text-foreground'
|
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -1609,10 +1680,13 @@ export const FilesView: React.FC = () => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleCloseFile(file.path);
|
handleCloseFile(file.path);
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
className={cn(
|
||||||
|
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
||||||
|
!isActive && 'opacity-0 group-hover:opacity-100'
|
||||||
|
)}
|
||||||
aria-label={`Close ${file.name}`}
|
aria-label={`Close ${file.name}`}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-3.5 w-3.5" />
|
<RiCloseLine size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string, RootTabsState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, RootTabsState>, maxRoots: number): Record<string, RootTabsState> => {
|
||||||
|
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<string, RootTabsState> = {};
|
||||||
|
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<FilesViewTabsStore>()(
|
||||||
|
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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -232,6 +232,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setMainTabGuard: (guard) => {
|
setMainTabGuard: (guard) => {
|
||||||
|
if (get().mainTabGuard === guard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ mainTabGuard: guard });
|
set({ mainTabGuard: guard });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user