mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 06:49:02 +08:00
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:
parent
d8bbd3fdb9
commit
795fb715e3
@ -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)) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点排序
|
// 处理节点排序
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user