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
This commit is contained in:
suyao 2025-10-24 04:40:10 +08:00
parent d8bbd3fdb9
commit 795fb715e3
No known key found for this signature in database
3 changed files with 124 additions and 67 deletions

View File

@ -320,18 +320,8 @@ class FileStorage {
await fs.promises.mkdir(destDir, { recursive: true }) await fs.promises.mkdir(destDir, { recursive: true })
} }
try { await fs.promises.rename(filePath, newPath)
// Try rename first - this is the fastest way for same-filesystem moves logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
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}`)
}
} catch (error) { } catch (error) {
logger.error('Move file failed:', error as Error) logger.error('Move file failed:', error as Error)
throw error throw error
@ -350,47 +340,14 @@ class FileStorage {
await fs.promises.mkdir(parentDir, { recursive: true }) await fs.promises.mkdir(parentDir, { recursive: true })
} }
try { await fs.promises.rename(dirPath, newDirPath)
// Try rename first - this is the fastest way for same-filesystem moves logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
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}`)
}
} catch (error) { } catch (error) {
logger.error('Move directory failed:', error as Error) logger.error('Move directory failed:', error as Error)
throw 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<void> {
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<void> => { public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise<void> => {
try { try {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {

View File

@ -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) setBreadcrumbItems(items)
}, [activeNode, notesTree, t]) }, [activeNode, notesTree])
return ( return (
<NavbarHeader <NavbarHeader

View File

@ -80,6 +80,7 @@ const NotesPage: FC = () => {
const isRenamingRef = useRef(false) const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false) const isCreatingNoteRef = useRef(false)
const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null) const pendingScrollRef = useRef<{ lineNumber: number; lineContent?: string } | null>(null)
const noteHistoryRef = useRef<string[]>([]) // Track recently opened notes for smart navigation
const activeFilePathRef = useRef<string | undefined>(activeFilePath) const activeFilePathRef = useRef<string | undefined>(activeFilePath)
const currentContentRef = useRef(currentContent) const currentContentRef = useRef(currentContent)
@ -114,6 +115,31 @@ const NotesPage: FC = () => {
[dispatch, store] [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( const mergeTreeState = useCallback(
(nodes: NotesTreeNode[]): NotesTreeNode[] => { (nodes: NotesTreeNode[]): NotesTreeNode[] => {
return nodes.map((node) => { return nodes.map((node) => {
@ -214,6 +240,26 @@ const NotesPage: FC = () => {
useEffect(() => { useEffect(() => {
activeFilePathRef.current = activeFilePath 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]) }, [activeFilePath])
useEffect(() => { useEffect(() => {
@ -537,6 +583,21 @@ const NotesPage: FC = () => {
const nodeToDelete = findNode(notesTree, nodeId) const nodeToDelete = findNode(notesTree, nodeId)
if (!nodeToDelete) return 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) await delNode(nodeToDelete)
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')) updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
@ -545,7 +606,6 @@ const NotesPage: FC = () => {
) )
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
const isActiveNode = normalizedActivePath === normalizedDeletePath const isActiveNode = normalizedActivePath === normalizedDeletePath
const isActiveDescendant = const isActiveDescendant =
nodeToDelete.type === 'folder' && nodeToDelete.type === 'folder' &&
@ -553,8 +613,17 @@ const NotesPage: FC = () => {
normalizedActivePath.startsWith(`${normalizedDeletePath}/`) normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
if (isActiveNode || isActiveDescendant) { if (isActiveNode || isActiveDescendant) {
dispatch(setActiveFilePath(undefined)) // Try to find the previous note from history
editorRef.current?.clear() 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() await refreshTree()
@ -562,7 +631,16 @@ const NotesPage: FC = () => {
logger.error('Failed to delete node:', error as Error) 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 return
} }
// Cancel any pending debounced saves before moving files
// to prevent the old path from being recreated
if (sourceNode.type === 'file') { if (sourceNode.type === 'file') {
debouncedSaveRef.current?.cancel()
await window.api.file.move(sourceNode.externalPath, destinationPath) 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 { } else {
// For folder moves, cancel saves for all affected files
debouncedSaveRef.current?.cancel()
await window.api.file.moveDir(sourceNode.externalPath, destinationPath) 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) => updateStarredPaths((prev) =>
@ -713,22 +805,40 @@ const NotesPage: FC = () => {
return next 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 const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
if (normalizedActivePath) { if (normalizedActivePath) {
let newActivePath: string | undefined
if (normalizedActivePath === sourceNode.externalPath) { if (normalizedActivePath === sourceNode.externalPath) {
dispatch(setActiveFilePath(destinationPath)) newActivePath = destinationPath
} else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) { } else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) {
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length) 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) { } catch (error) {
logger.error('Failed to move nodes:', error as 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
]
) )
// 处理节点排序 // 处理节点排序