From 795fb715e34ad22e61472581f7ce8a41bd34fdb4 Mon Sep 17 00:00:00 2001 From: suyao Date: Fri, 24 Oct 2025 04:40:10 +0800 Subject: [PATCH] Simplify file operations and improve note navigation - Remove fallback copy+delete logic from file/directory move operations, relying solely on `fs.rename` for better performance - Implement note history tracking with smart navigation when deleting files, automatically switching to previously opened notes - Cancel pending saves before delete/move operations to prevent file recreation and update path references --- src/main/services/FileStorage.ts | 51 +------ src/renderer/src/pages/notes/HeaderNavbar.tsx | 12 +- src/renderer/src/pages/notes/NotesPage.tsx | 128 ++++++++++++++++-- 3 files changed, 124 insertions(+), 67 deletions(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index b25b07b096..115acc402c 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -320,18 +320,8 @@ class FileStorage { await fs.promises.mkdir(destDir, { recursive: true }) } - try { - // Try rename first - this is the fastest way for same-filesystem moves - await fs.promises.rename(filePath, newPath) - logger.debug(`File moved successfully: ${filePath} to ${newPath}`) - } catch (renameError: any) { - // If rename fails (e.g., cross-filesystem move), use copy+delete approach - // This ensures the original file is removed after copying, completing the move operation - logger.debug(`Rename failed, using copy+delete approach: ${renameError.message}`) - await fs.promises.copyFile(filePath, newPath) - await fs.promises.unlink(filePath) - logger.debug(`File moved successfully using copy+delete: ${filePath} to ${newPath}`) - } + await fs.promises.rename(filePath, newPath) + logger.debug(`File moved successfully: ${filePath} to ${newPath}`) } catch (error) { logger.error('Move file failed:', error as Error) throw error @@ -350,47 +340,14 @@ class FileStorage { await fs.promises.mkdir(parentDir, { recursive: true }) } - try { - // Try rename first - this is the fastest way for same-filesystem moves - await fs.promises.rename(dirPath, newDirPath) - logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`) - } catch (renameError: any) { - // If rename fails (e.g., cross-filesystem move), use copy+delete approach - // This ensures the original directory is removed after copying, completing the move operation - logger.debug(`Rename failed, using copy+delete approach: ${renameError.message}`) - await this.copyDirectory(dirPath, newDirPath) - await fs.promises.rm(dirPath, { recursive: true, force: true }) - logger.debug(`Directory moved successfully using copy+delete: ${dirPath} to ${newDirPath}`) - } + await fs.promises.rename(dirPath, newDirPath) + logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`) } catch (error) { logger.error('Move directory failed:', error as Error) throw error } } - /** - * Recursively copy a directory and all its contents - * @private - * @param source Source directory path - * @param destination Destination directory path - */ - private async copyDirectory(source: string, destination: string): Promise { - await fs.promises.mkdir(destination, { recursive: true }) - - const entries = await fs.promises.readdir(source, { withFileTypes: true }) - - for (const entry of entries) { - const sourcePath = path.join(source, entry.name) - const destPath = path.join(destination, entry.name) - - if (entry.isDirectory()) { - await this.copyDirectory(sourcePath, destPath) - } else { - await fs.promises.copyFile(sourcePath, destPath) - } - } - } - public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise => { try { if (!fs.existsSync(filePath)) { diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index 01d72895e5..a838e7f8c7 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -176,18 +176,8 @@ const HeaderNavbar = ({ } }) - // Add root directory as the first breadcrumb item if not already at root - if (pathParts.length > 0) { - items.unshift({ - key: 'root', - title: t('notes.root_directory') || 'Notes', - treePath: '/', - isFolder: true - }) - } - setBreadcrumbItems(items) - }, [activeNode, notesTree, t]) + }, [activeNode, notesTree]) return ( { const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null) + const noteHistoryRef = useRef([]) // Track recently opened notes for smart navigation const activeFilePathRef = useRef(activeFilePath) const currentContentRef = useRef(currentContent) @@ -114,6 +115,31 @@ const NotesPage: FC = () => { [dispatch, store] ) + // Find the previous valid note from history, excluding the current path + const findPreviousNote = useCallback( + (excludePath: string): string | undefined => { + const normalizedExclude = normalizePathValue(excludePath) + // Iterate through history in reverse order (most recent first) + for (let i = noteHistoryRef.current.length - 1; i >= 0; i--) { + const historicalPath = noteHistoryRef.current[i] + const normalizedHistorical = normalizePathValue(historicalPath) + + // Skip if it's the excluded path or if it's inside a deleted folder + if (normalizedHistorical === normalizedExclude || normalizedHistorical.startsWith(`${normalizedExclude}/`)) { + continue + } + + // Check if the note still exists in the tree + const node = findNodeByPath(notesTree, normalizedHistorical) + if (node && node.type === 'file') { + return historicalPath + } + } + return undefined + }, + [notesTree] + ) + const mergeTreeState = useCallback( (nodes: NotesTreeNode[]): NotesTreeNode[] => { return nodes.map((node) => { @@ -214,6 +240,26 @@ const NotesPage: FC = () => { useEffect(() => { activeFilePathRef.current = activeFilePath + + // Track note history for smart navigation + if (activeFilePath) { + const normalized = normalizePathValue(activeFilePath) + const history = noteHistoryRef.current + const existingIndex = history.findIndex((p) => normalizePathValue(p) === normalized) + + // Remove if already exists (to move to end) + if (existingIndex !== -1) { + history.splice(existingIndex, 1) + } + + // Add to end (most recent) + history.push(activeFilePath) + + // Keep only last 20 notes + if (history.length > 20) { + history.shift() + } + } }, [activeFilePath]) useEffect(() => { @@ -537,6 +583,21 @@ const NotesPage: FC = () => { const nodeToDelete = findNode(notesTree, nodeId) if (!nodeToDelete) return + // Cancel any pending debounced saves before deleting files + // to prevent the deleted file from being recreated + const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath) + const normalizedLastPath = lastFilePathRef.current ? normalizePathValue(lastFilePathRef.current) : undefined + + if (nodeToDelete.type === 'file' && normalizedLastPath === normalizedDeletePath) { + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = undefined + lastContentRef.current = '' + } else if (nodeToDelete.type === 'folder' && normalizedLastPath?.startsWith(`${normalizedDeletePath}/`)) { + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = undefined + lastContentRef.current = '' + } + await delNode(nodeToDelete) updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')) @@ -545,7 +606,6 @@ const NotesPage: FC = () => { ) const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined - const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath) const isActiveNode = normalizedActivePath === normalizedDeletePath const isActiveDescendant = nodeToDelete.type === 'folder' && @@ -553,8 +613,17 @@ const NotesPage: FC = () => { normalizedActivePath.startsWith(`${normalizedDeletePath}/`) if (isActiveNode || isActiveDescendant) { - dispatch(setActiveFilePath(undefined)) - editorRef.current?.clear() + // Try to find the previous note from history + const previousNote = findPreviousNote(nodeToDelete.externalPath) + if (previousNote) { + // Navigate to previous note + dispatch(setActiveFilePath(previousNote)) + invalidateFileContent(previousNote) + } else { + // No previous note available, clear editor + dispatch(setActiveFilePath(undefined)) + editorRef.current?.clear() + } } await refreshTree() @@ -562,7 +631,16 @@ const NotesPage: FC = () => { logger.error('Failed to delete node:', error as Error) } }, - [notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths] + [ + notesTree, + activeFilePath, + dispatch, + refreshTree, + updateStarredPaths, + updateExpandedPaths, + findPreviousNote, + invalidateFileContent + ] ) // 重命名节点 @@ -698,10 +776,24 @@ const NotesPage: FC = () => { return } + // Cancel any pending debounced saves before moving files + // to prevent the old path from being recreated if (sourceNode.type === 'file') { + debouncedSaveRef.current?.cancel() await window.api.file.move(sourceNode.externalPath, destinationPath) + // Update lastFilePathRef to prevent emergency save using old path + if (lastFilePathRef.current === sourceNode.externalPath) { + lastFilePathRef.current = destinationPath + } } else { + // For folder moves, cancel saves for all affected files + debouncedSaveRef.current?.cancel() await window.api.file.moveDir(sourceNode.externalPath, destinationPath) + // Update lastFilePathRef if it's inside the moved folder + if (lastFilePathRef.current && lastFilePathRef.current.startsWith(`${sourceNode.externalPath}/`)) { + const suffix = lastFilePathRef.current.slice(sourceNode.externalPath.length) + lastFilePathRef.current = `${destinationPath}${suffix}` + } } updateStarredPaths((prev) => @@ -713,22 +805,40 @@ const NotesPage: FC = () => { return next }) + // First refresh the tree to ensure the new structure is loaded + await refreshTree() + + // Then update active file path if needed const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined if (normalizedActivePath) { + let newActivePath: string | undefined if (normalizedActivePath === sourceNode.externalPath) { - dispatch(setActiveFilePath(destinationPath)) + newActivePath = destinationPath } else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) { const suffix = normalizedActivePath.slice(sourceNode.externalPath.length) - dispatch(setActiveFilePath(`${destinationPath}${suffix}`)) + newActivePath = `${destinationPath}${suffix}` + } + + if (newActivePath) { + // Update active file path and invalidate cache to trigger reload + dispatch(setActiveFilePath(newActivePath)) + invalidateFileContent(newActivePath) } } - - await refreshTree() } catch (error) { logger.error('Failed to move nodes:', error as Error) } }, - [activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] + [ + activeFilePath, + dispatch, + notesPath, + notesTree, + refreshTree, + updateStarredPaths, + updateExpandedPaths, + invalidateFileContent + ] ) // 处理节点排序