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 })
}
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<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> => {
try {
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)
}, [activeNode, notesTree, t])
}, [activeNode, notesTree])
return (
<NavbarHeader

View File

@ -80,6 +80,7 @@ const NotesPage: FC = () => {
const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false)
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 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
]
)
// 处理节点排序