From 6829a03437589b22f132522ed5f6a9460e608efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Sun, 28 Sep 2025 13:01:49 +0800 Subject: [PATCH 1/8] fix: AI_APICallError for Gemini via proxy #10366 (#10429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sending requests to Gemini via proxy, the system returns: "模型不存在或者请求路径错误". --- src/renderer/src/aiCore/provider/providerConfig.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index b91dad9cf..4c5c18109 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -18,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService' import store from '@renderer/store' import { isSystemProvider, type Model, type Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' -import { cloneDeep, isEmpty } from 'lodash' +import { cloneDeep, trim } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' import { getAiSdkProviderId } from './factory' @@ -120,7 +120,7 @@ export function providerToAiSdkConfig( // 构建基础配置 const baseConfig = { - baseURL: actualProvider.apiHost, + baseURL: trim(actualProvider.apiHost), apiKey: getRotatedApiKey(actualProvider) } // 处理OpenAI模式 @@ -195,7 +195,10 @@ export function providerToAiSdkConfig( } else if (baseConfig.baseURL.endsWith('/v1')) { baseConfig.baseURL = baseConfig.baseURL.slice(0, -3) } - baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL + + if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) { + baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google` + } } // 如果AI SDK支持该provider,使用原生配置 From 228ed474ce3949d0d5e1905e5b2bf94223307094 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 13:31:18 +0800 Subject: [PATCH 2/8] chore: update @ai-sdk/google patch and modify getModelPath function - Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock. - Removed the patch reference from package.json for @ai-sdk/google. - Modified the getModelPath function to simplify its implementation, removing the baseURL parameter. --- ...@ai-sdk-google-npm-2.0.14-376d8b03cc.patch | 35 ++++--------------- packages/aiCore/package.json | 1 - yarn.lock | 5 ++- 3 files changed, 8 insertions(+), 33 deletions(-) diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch index a1ae65f02..49bcec27d 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch @@ -1,36 +1,13 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644 +index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce11076974 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { - } +@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { // src/get-model-path.ts --function getModelPath(modelId) { -+function getModelPath(modelId, baseURL) { -+ if (baseURL?.includes('cherryin')) { -+ return `models/${modelId}`; -+ } - return modelId.includes("/") ? modelId : `models/${modelId}`; + function getModelPath(modelId) { +- return modelId.includes("/") ? modelId : `models/${modelId}`; ++ return `models/${modelId}`; } -@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class { - rawValue: rawResponse - } = await postJsonToApi2({ - url: `${this.config.baseURL}/${getModelPath( -- this.modelId -+ this.modelId, -+ this.config.baseURL - )}:generateContent`, - headers: mergedHeaders, - body: args, -@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class { - ); - const { responseHeaders, value: response } = await postJsonToApi2({ - url: `${this.config.baseURL}/${getModelPath( -- this.modelId -+ this.modelId, -+ this.config.baseURL - )}:streamGenerateContent?alt=sse`, - headers, - body: args, + // src/google-generative-ai-options.ts diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 28ae7c8e2..93bf7b641 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -39,7 +39,6 @@ "@ai-sdk/anthropic": "^2.0.17", "@ai-sdk/azure": "^2.0.30", "@ai-sdk/deepseek": "^1.0.17", - "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch", "@ai-sdk/openai": "^2.0.30", "@ai-sdk/openai-compatible": "^1.0.17", "@ai-sdk/provider": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 192c8e207..748d52512 100644 --- a/yarn.lock +++ b/yarn.lock @@ -169,13 +169,13 @@ __metadata: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch": version: 2.0.14 - resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=a91bb2" + resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=c6aff2" dependencies: "@ai-sdk/provider": "npm:2.0.0" "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/5ec33dc9898457b1f48ed14cb767817345032c539dd21b7e21985ed47bc21b0820922b581bf349bb3898136790b12da3a0a7c9903c333a28ead0c3c2cd5230f2 + checksum: 10c0/2a0a09debab8de0603243503ff5044bd3fff87d6c5de2d76d43839fa459cc85d5412b59ec63d0dcf1a6d6cab02882eb3c69f0f155129d0fc153bcde4deecbd32 languageName: node linkType: hard @@ -2328,7 +2328,6 @@ __metadata: "@ai-sdk/anthropic": "npm:^2.0.17" "@ai-sdk/azure": "npm:^2.0.30" "@ai-sdk/deepseek": "npm:^1.0.17" - "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch" "@ai-sdk/openai": "npm:^2.0.30" "@ai-sdk/openai-compatible": "npm:^1.0.17" "@ai-sdk/provider": "npm:^2.0.0" From ed2e01491edc94a0bda6a66d433d3e5c13badc11 Mon Sep 17 00:00:00 2001 From: Xin Rui <71483384+Konjac-XZ@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:44:27 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20clear=20@=20and=20other=20input=20te?= =?UTF-8?q?xt=20when=20exiting=20model=20selection=20menu=20w=E2=80=A6=20(?= =?UTF-8?q?#10427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: clear @ and other input text when exiting model selection menu with Esc --- .../src/pages/home/Inputbar/MentionModelsButton.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 6bb36f988..23c8fd13f 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -250,21 +250,23 @@ const MentionModelsButton: FC = ({ // ESC关闭时的处理:删除 @ 和搜索文本 if (action === 'esc') { // 只有在输入触发且有模型选择动作时才删除@字符和搜索文本 + const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current if ( hasModelActionRef.current && - ctx.triggerInfo?.type === 'input' && - ctx.triggerInfo?.position !== undefined + triggerInfo?.type === 'input' && + triggerInfo?.position !== undefined ) { // 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底 setText((currentText) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length - return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!) + return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!) }) } } // Backspace删除@的情况(delete-symbol): // @ 已经被Backspace自然删除,面板关闭,不需要额外操作 + triggerInfoRef.current = undefined } }) }, From 1df6e8c73206228063ddafb31496000fac1bf21e Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Sun, 28 Sep 2025 06:50:52 +0100 Subject: [PATCH 4/8] refactor(notes): improve notes management with local state and file handling (#10395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(notes): improve notes management with local state and file handling - Replace UUID-based IDs with SHA1 hash of file paths for better consistency - Remove database storage for notes tree, use local state management instead - Add localStorage persistence for starred and expanded states - Improve cross-platform path normalization (replace backslashes with forward slashes) - Refactor tree operations to use optimized in-memory operations - Enhance file watcher integration for better sync performance - Simplify notes service with direct file system operations - Remove database dependencies from notes tree management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Revert "Merge remote-tracking branch 'origin/main' into refactor/note" This reverts commit 389386ace8f30c43f4383ed59b04174672c67556, reversing changes made to 4428f511b0a3c0636bf47bdf814a8ccc69542301. * fix: format error * refactor: noteservice * refactor(notes): 完成笔记状态从localStorage向Redux的迁移 - 将starred和expanded路径状态从localStorage迁移到Redux store - 添加版本159迁移逻辑,自动从localStorage迁移现有数据到Redux - 优化NotesPage组件,使用Redux状态管理替代本地localStorage操作 - 改进SaveToKnowledgePopup的错误处理和验证逻辑 - 删除NotesTreeService中已废弃的localStorage写入函数 - 增强组件性能,使用ref避免不必要的依赖更新 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: ci * feat(notes): add in-place renaming for notes in HeaderNavbar - Implemented an input field for renaming the current note directly in the HeaderNavbar. - Added handlers for title change, blur, and key events to manage renaming logic. - Updated the breadcrumb display to accommodate the new title input. - Enhanced styling for the title input to ensure seamless integration with the existing UI. This feature improves user experience by allowing quick edits without navigating away from the notes list. * Update NotesEditor.tsx --------- Co-authored-by: Claude Co-authored-by: kangfenmao --- src/main/utils/file.ts | 11 +- src/renderer/src/databases/index.ts | 5 +- src/renderer/src/pages/notes/HeaderNavbar.tsx | 211 ++++- src/renderer/src/pages/notes/NotesEditor.tsx | 21 +- src/renderer/src/pages/notes/NotesPage.tsx | 615 ++++++++------ src/renderer/src/pages/notes/NotesSidebar.tsx | 450 +++++++--- .../src/pages/settings/NotesSettings.tsx | 3 - .../src/pages/settings/SettingsPage.tsx | 1 + src/renderer/src/services/NotesService.ts | 802 +++--------------- src/renderer/src/services/NotesTreeService.ts | 365 +++----- src/renderer/src/store/note.ts | 24 +- src/renderer/src/utils/export.ts | 19 +- 12 files changed, 1158 insertions(+), 1369 deletions(-) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 5c197e897..20305d1c9 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import * as fs from 'node:fs' import { readFile } from 'node:fs/promises' import os from 'node:os' @@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr if (entry.isDirectory() && options.includeDirectories) { const stats = await fs.promises.stat(entryPath) + const externalDirPath = entryPath.replace(/\\/g, '/') const dirTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalDirPath).digest('hex'), name: entry.name, treePath: treePath, - externalPath: entryPath, + externalPath: externalDirPath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'folder', @@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr ? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}` : `/${nameWithoutExt}` + const externalFilePath = entryPath.replace(/\\/g, '/') const fileTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalFilePath).digest('hex'), name: name, treePath: fileTreePath, - externalPath: entryPath, + externalPath: externalFilePath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'file' diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 05bda8661..83ad6b663 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -7,7 +7,6 @@ import { } from '@renderer/types' // Import necessary types for blocks and new message structure import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { Dexie, type EntityTable } from 'dexie' import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades' @@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', { quick_phrases: EntityTable message_blocks: EntityTable // Correct type for message_blocks translate_languages: EntityTable - notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'> } db.version(1).stores({ @@ -118,8 +116,7 @@ db.version(10).stores({ translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', translate_languages: '&id, langCode', quick_phrases: 'id', - message_blocks: 'id, messageId, file.id', - notes_tree: '&id' + message_blocks: 'id, messageId, file.id' }) export default db diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index c9eb18930..81f566839 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' -import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { NotesTreeNode } from '@types' -import { Dropdown, Tooltip } from 'antd' +import { findNode } from '@renderer/services/NotesTreeService' +import { Dropdown, Input, Tooltip } from 'antd' import { t } from 'i18next' import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' import { menuItems } from './MenuConfig' const logger = loggerService.withContext('HeaderNavbar') -const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { +const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => { const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() const { activeNode } = useActiveNode(notesTree) const [breadcrumbItems, setBreadcrumbItems] = useState< Array<{ key: string; title: string; treePath: string; isFolder: boolean }> >([]) + const [titleValue, setTitleValue] = useState('') + const titleInputRef = useRef(null) const { settings, updateSettings } = useNotesSettings() const canShowStarButton = activeNode?.type === 'file' && onToggleStar @@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { }, [getCurrentNoteContent]) const handleBreadcrumbClick = useCallback( - async (item: { treePath: string; isFolder: boolean }) => { - if (item.isFolder && notesTree) { - try { - // 获取从根目录到点击目录的所有路径片段 - const pathParts = item.treePath.split('/').filter(Boolean) - const expandPromises: Promise[] = [] - - // 逐级展开从根到目标路径的所有文件夹 - for (let i = 0; i < pathParts.length; i++) { - const currentPath = '/' + pathParts.slice(0, i + 1).join('/') - const folderNode = findNodeByPath(notesTree, currentPath) - - if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) { - expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true })) - } - } - - // 并行执行所有展开操作 - if (expandPromises.length > 0) { - await Promise.all(expandPromises) - logger.info('Expanded folder path from breadcrumb:', { - targetPath: item.treePath, - expandedCount: expandPromises.length - }) - } - } catch (error) { - logger.error('Failed to expand folder path from breadcrumb:', error as Error) - } + (item: { treePath: string; isFolder: boolean }) => { + if (item.isFolder && onExpandPath) { + onExpandPath(item.treePath) } }, - [notesTree] + [onExpandPath] + ) + + const handleTitleChange = useCallback((e: React.ChangeEvent) => { + setTitleValue(e.target.value) + }, []) + + const handleTitleBlur = useCallback(() => { + if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) { + onRenameNode?.(activeNode.id, titleValue.trim()) + } else if (activeNode) { + // 如果没有更改或为空,恢复原始值 + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode, titleValue, onRenameNode]) + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + titleInputRef.current?.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + if (activeNode) { + setTitleValue(activeNode.name.replace('.md', '')) + } + titleInputRef.current?.blur() + } + }, + [activeNode] ) const buildMenuItem = (item: any) => { @@ -133,13 +138,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { } } + // 同步标题值 + useEffect(() => { + if (activeNode?.type === 'file') { + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode]) + // 构建面包屑路径 useEffect(() => { if (!activeNode || !notesTree) { setBreadcrumbItems([]) return } - const node = findNodeInTree(notesTree, activeNode.id) + const node = findNode(notesTree, activeNode.id) if (!node) return const pathParts = node.treePath.split('/').filter(Boolean) @@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { - - {breadcrumbItems.map((item, index) => ( - - handleBreadcrumbClick(item)} - $clickable={item.isFolder && index < breadcrumbItems.length - 1}> - {item.title} - - - ))} + + {breadcrumbItems.map((item, index) => { + const isLastItem = index === breadcrumbItems.length - 1 + const isCurrentNote = isLastItem && !item.isFolder + + return ( + + {isCurrentNote ? ( + + + + ) : ( + handleBreadcrumbClick(item)} + $clickable={item.isFolder && !isLastItem}> + {item.title} + + )} + + ) + })} @@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div` align-items: center; } + /* 最后一个面包屑项(当前笔记)可以扩展 */ + & li:last-child { + flex: 1 !important; + min-width: 0 !important; + max-width: none !important; + } + + /* 覆盖 HeroUI BreadcrumbItem 的样式 */ + & li:last-child [data-slot="item"] { + flex: 1 !important; + width: 100% !important; + max-width: none !important; + } + + /* 更强的样式覆盖 */ + & li:last-child * { + max-width: none !important; + } + + & li:last-child > * { + flex: 1 !important; + width: 100% !important; + } + /* 确保分隔符不会与标题重叠 */ & li:not(:last-child)::after { flex-shrink: 0; @@ -330,4 +391,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>` `} ` +export const TitleInputWrapper = styled.div` + width: 100%; + flex: 1; + min-width: 0; + max-width: none; + display: flex; + align-items: center; +` + +export const TitleInput = styled(Input)` + &&& { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + flex: 1 !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + + &::placeholder { + color: var(--color-text-3) !important; + } + + input { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + } + } +` + export default HeaderNavbar diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index a9ed8f592..8bdd44d12 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -5,7 +5,7 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types' import Selector from '@renderer/components/Selector' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { EditorView } from '@renderer/types' -import { Empty, Spin } from 'antd' +import { Empty } from 'antd' import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -14,13 +14,12 @@ interface NotesEditorProps { activeNodeId?: string currentContent: string tokenCount: number - isLoading: boolean editorRef: RefObject onMarkdownChange: (content: string) => void } const NotesEditor: FC = memo( - ({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => { + ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { const { t } = useTranslation() const { settings } = useNotesSettings() const currentViewMode = useMemo(() => { @@ -47,14 +46,6 @@ const NotesEditor: FC = memo( ) } - if (isLoading) { - return ( - - - - ) - } - return ( <> @@ -122,14 +113,6 @@ const NotesEditor: FC = memo( NotesEditor.displayName = 'NotesEditor' -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; -` - const EmptyContainer = styled.div` display: flex; justify-content: center; diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index bc039e5ef..0bad446ac 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' import { - createFolder, - createNote, - deleteNode, - initWorkSpace, - moveNode, - renameNode, - sortAllLevels, - uploadFiles + addDir, + addNote, + delNode, + loadTree, + renameNode as renameEntry, + sortTree, + uploadNotes } from '@renderer/services/NotesService' -import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note' +import { + addUniquePath, + findNode, + findNodeByPath, + findParent, + normalizePathValue, + removePathEntries, + reorderTreeNodes, + replacePathEntries, + updateTreeNode +} from '@renderer/services/NotesTreeService' +import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store' +import { + selectActiveFilePath, + selectExpandedPaths, + selectSortType, + selectStarredPaths, + setActiveFilePath, + setExpandedPaths, + setSortType, + setStarredPaths +} from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { FileChangeEvent } from '@shared/config/types' -import { useLiveQuery } from 'dexie-react-hooks' import { debounce } from 'lodash' import { AnimatePresence, motion } from 'motion/react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -37,27 +54,98 @@ const NotesPage: FC = () => { const { t } = useTranslation() const { showWorkspace } = useShowWorkspace() const dispatch = useAppDispatch() + const store = useAppStore() const activeFilePath = useAppSelector(selectActiveFilePath) const sortType = useAppSelector(selectSortType) + const starredPaths = useAppSelector(selectStarredPaths) + const expandedPaths = useAppSelector(selectExpandedPaths) const { settings, notesPath, updateNotesPath } = useNotesSettings() // 混合策略:useLiveQuery用于笔记树,React Query用于文件内容 - const notesTreeQuery = useLiveQuery(() => getNotesTree(), []) - const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery]) + const [notesTree, setNotesTree] = useState([]) + const starredSet = useMemo(() => new Set(starredPaths), [starredPaths]) + const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths]) const { activeNode } = useActiveNode(notesTree) const { invalidateFileContent } = useFileContentSync() - const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath) + const { data: currentContent = '' } = useFileContent(activeFilePath) const [tokenCount, setTokenCount] = useState(0) const [selectedFolderId, setSelectedFolderId] = useState(null) const watcherRef = useRef<(() => void) | null>(null) - const isSyncingTreeRef = useRef(false) const lastContentRef = useRef('') const lastFilePathRef = useRef(undefined) - const isInitialSortApplied = useRef(false) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) + const activeFilePathRef = useRef(activeFilePath) + const currentContentRef = useRef(currentContent) + + const updateStarredPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.starredPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setStarredPaths(next)) + } + }, + [dispatch, store] + ) + + const updateExpandedPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.expandedPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setExpandedPaths(next)) + } + }, + [dispatch, store] + ) + + const mergeTreeState = useCallback( + (nodes: NotesTreeNode[]): NotesTreeNode[] => { + return nodes.map((node) => { + const normalizedPath = normalizePathValue(node.externalPath) + const merged: NotesTreeNode = { + ...node, + externalPath: normalizedPath, + isStarred: starredSet.has(normalizedPath) + } + + if (node.type === 'folder') { + merged.expanded = expandedSet.has(normalizedPath) + merged.children = node.children ? mergeTreeState(node.children) : [] + } + + return merged + }) + }, + [starredSet, expandedSet] + ) + + const refreshTree = useCallback(async () => { + if (!notesPath) { + setNotesTree([]) + return + } + + try { + const rawTree = await loadTree(notesPath) + const sortedTree = sortTree(rawTree, sortType) + setNotesTree(mergeTreeState(sortedTree)) + } catch (error) { + logger.error('Failed to refresh notes tree:', error as Error) + } + }, [mergeTreeState, notesPath, sortType]) + useEffect(() => { const updateCharCount = () => { const textContent = editorRef.current?.getContent() || currentContent @@ -67,19 +155,16 @@ const NotesPage: FC = () => { updateCharCount() }, [currentContent]) - // 查找树节点 by ID - const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => { - for (const node of tree) { - if (node.id === nodeId) { - return node - } - if (node.children) { - const found = findNodeById(node.children, nodeId) - if (found) return found - } + useEffect(() => { + refreshTree() + }, [refreshTree]) + + // Re-merge tree state when starred or expanded paths change + useEffect(() => { + if (notesTree.length > 0) { + setNotesTree((prev) => mergeTreeState(prev)) } - return null - }, []) + }, [starredPaths, expandedPaths, mergeTreeState, notesTree.length]) // 保存当前笔记内容 const saveCurrentNote = useCallback( @@ -107,6 +192,11 @@ const NotesPage: FC = () => { [saveCurrentNote] ) + const saveCurrentNoteRef = useRef(saveCurrentNote) + const debouncedSaveRef = useRef(debouncedSave) + const invalidateFileContentRef = useRef(invalidateFileContent) + const refreshTreeRef = useRef(refreshTree) + const handleMarkdownChange = useCallback( (newMarkdown: string) => { // 记录最新内容和文件路径,用于兜底保存 @@ -118,6 +208,30 @@ const NotesPage: FC = () => { [debouncedSave, activeFilePath] ) + useEffect(() => { + activeFilePathRef.current = activeFilePath + }, [activeFilePath]) + + useEffect(() => { + currentContentRef.current = currentContent + }, [currentContent]) + + useEffect(() => { + saveCurrentNoteRef.current = saveCurrentNote + }, [saveCurrentNote]) + + useEffect(() => { + debouncedSaveRef.current = debouncedSave + }, [debouncedSave]) + + useEffect(() => { + invalidateFileContentRef.current = invalidateFileContent + }, [invalidateFileContent]) + + useEffect(() => { + refreshTreeRef.current = refreshTree + }, [refreshTree]) + useEffect(() => { async function initialize() { if (!notesPath) { @@ -133,29 +247,12 @@ const NotesPage: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [notesPath]) - // 应用初始排序 - useEffect(() => { - async function applyInitialSort() { - if (notesTree.length > 0 && !isInitialSortApplied.current) { - try { - await sortAllLevels(sortType) - isInitialSortApplied.current = true - } catch (error) { - logger.error('Failed to apply initial sorting:', error as Error) - } - } - } - - applyInitialSort() - }, [notesTree.length, sortType]) - // 处理树同步时的状态管理 useEffect(() => { if (notesTree.length === 0) return // 如果有activeFilePath但找不到对应节点,清空选择 // 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空 - const shouldClearPath = - activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current + const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current if (shouldClearPath) { logger.warn('Clearing activeFilePath - node not found in tree', { @@ -167,7 +264,7 @@ const NotesPage: FC = () => { }, [notesTree, activeFilePath, activeNode, dispatch]) useEffect(() => { - if (!notesPath || notesTree.length === 0) return + if (!notesPath) return async function startFileWatcher() { // 清理之前的监控 @@ -181,31 +278,14 @@ const NotesPage: FC = () => { try { if (!notesPath) return const { eventType, filePath } = data + const normalizedEventPath = normalizePathValue(filePath) switch (eventType) { case 'change': { // 处理文件内容变化 - 只有内容真正改变时才触发更新 - if (activeFilePath === filePath) { - try { - // 读取文件最新内容 - // const newFileContent = await window.api.file.readExternal(filePath) - // // 获取当前编辑器/缓存中的内容 - // const currentEditorContent = editorRef.current?.getMarkdown() - // // 如果编辑器还未初始化完成,忽略FileWatcher事件 - // if (!isEditorInitialized.current) { - // return - // } - // // 比较内容是否真正发生变化 - // if (newFileContent.trim() !== currentEditorContent?.trim()) { - // invalidateFileContent(filePath) - // } - } catch (error) { - logger.error('Failed to read file for content comparison:', error as Error) - // 读取失败时,还是执行原来的逻辑 - invalidateFileContent(filePath) - } - } else { - await initWorkSpace(notesPath, sortType) + const activePath = activeFilePathRef.current + if (activePath && normalizePathValue(activePath) === normalizedEventPath) { + invalidateFileContentRef.current?.(normalizedEventPath) } break } @@ -215,20 +295,18 @@ const NotesPage: FC = () => { case 'unlink': case 'unlinkDir': { // 如果删除的是当前活动文件,清空选择 - if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) { + if ( + (eventType === 'unlink' || eventType === 'unlinkDir') && + activeFilePathRef.current && + normalizePathValue(activeFilePathRef.current) === normalizedEventPath + ) { dispatch(setActiveFilePath(undefined)) + editorRef.current?.clear() } - // 设置同步标志,避免竞态条件 - isSyncingTreeRef.current = true - - // 重新同步数据库,useLiveQuery会自动响应数据库变化 - try { - await initWorkSpace(notesPath, sortType) - } catch (error) { - logger.error('Failed to sync database:', error as Error) - } finally { - isSyncingTreeRef.current = false + const refresh = refreshTreeRef.current + if (refresh) { + await refresh() } break } @@ -261,26 +339,19 @@ const NotesPage: FC = () => { }) // 如果有未保存的内容,立即保存 - if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) { - saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => { - logger.error('Emergency save failed:', error as Error) - }) + if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) { + const saveFn = saveCurrentNoteRef.current + if (saveFn) { + saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => { + logger.error('Emergency save failed:', error as Error) + }) + } } // 清理防抖函数 - debouncedSave.cancel() + debouncedSaveRef.current?.cancel() } - }, [ - notesPath, - notesTree.length, - activeFilePath, - invalidateFileContent, - dispatch, - currentContent, - debouncedSave, - saveCurrentNote, - sortType - ]) + }, [dispatch, notesPath]) useEffect(() => { const editor = editorRef.current @@ -316,13 +387,13 @@ const NotesPage: FC = () => { // 获取目标文件夹路径(选中文件夹或根目录) const getTargetFolderPath = useCallback(() => { if (selectedFolderId) { - const selectedNode = findNodeById(notesTree, selectedFolderId) + const selectedNode = findNode(notesTree, selectedFolderId) if (selectedNode && selectedNode.type === 'folder') { return selectedNode.externalPath } } return notesPath // 默认返回根目录 - }, [selectedFolderId, notesTree, notesPath, findNodeById]) + }, [selectedFolderId, notesTree, notesPath]) // 创建文件夹 const handleCreateFolder = useCallback( @@ -332,12 +403,14 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - await createFolder(name, targetPath) + await addDir(name, targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath))) + await refreshTree() } catch (error) { logger.error('Failed to create folder:', error as Error) } }, - [getTargetFolderPath] + [getTargetFolderPath, refreshTree, updateExpandedPaths] ) // 创建笔记 @@ -350,11 +423,13 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - const newNote = await createNote(name, '', targetPath) - dispatch(setActiveFilePath(newNote.externalPath)) + const { path: notePath } = await addNote(name, '', targetPath) + const normalizedParent = normalizePathValue(targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent)) + dispatch(setActiveFilePath(notePath)) setSelectedFolderId(null) - await sortAllLevels(sortType) + await refreshTree() } catch (error) { logger.error('Failed to create note:', error as Error) } finally { @@ -364,73 +439,41 @@ const NotesPage: FC = () => { }, 500) } }, - [dispatch, getTargetFolderPath, sortType] - ) - - // 切换展开状态 - const toggleNodeExpanded = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'folder') { - await updateNodeInTree(tree, nodeId, { - expanded: !node.expanded - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) - throw error - } - }, - [findNodeById] + [dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths] ) const handleToggleExpanded = useCallback( - async (nodeId: string) => { - try { - await toggleNodeExpanded(nodeId) - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) + (nodeId: string) => { + const targetNode = findNode(notesTree, nodeId) + if (!targetNode || targetNode.type !== 'folder') { + return } + + const nextExpanded = !targetNode.expanded + // Update Redux state first, then let mergeTreeState handle the UI update + updateExpandedPaths((prev) => + nextExpanded + ? addUniquePath(prev, targetNode.externalPath) + : removePathEntries(prev, targetNode.externalPath, false) + ) }, - [toggleNodeExpanded] - ) - - // 切换收藏状态 - const toggleStarred = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'file') { - await updateNodeInTree(tree, nodeId, { - isStarred: !node.isStarred - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle star:', error as Error) - throw error - } - }, - [findNodeById] + [notesTree, updateExpandedPaths] ) const handleToggleStar = useCallback( - async (nodeId: string) => { - try { - await toggleStarred(nodeId) - } catch (error) { - logger.error('Failed to toggle star:', error as Error) + (nodeId: string) => { + const node = findNode(notesTree, nodeId) + if (!node) { + return } + + const nextStarred = !node.isStarred + // Update Redux state first, then let mergeTreeState handle the UI update + updateStarredPaths((prev) => + nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false) + ) }, - [toggleStarred] + [notesTree, updateStarredPaths] ) // 选择节点 @@ -447,7 +490,7 @@ const NotesPage: FC = () => { } } else if (node.type === 'folder') { setSelectedFolderId(node.id) - await handleToggleExpanded(node.id) + handleToggleExpanded(node.id) } }, [dispatch, handleToggleExpanded, invalidateFileContent] @@ -457,28 +500,35 @@ const NotesPage: FC = () => { const handleDeleteNode = useCallback( async (nodeId: string) => { try { - const nodeToDelete = findNodeById(notesTree, nodeId) + const nodeToDelete = findNode(notesTree, nodeId) if (!nodeToDelete) return - const isActiveNodeOrParent = - activeFilePath && - (nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || '')) + await delNode(nodeToDelete) - await deleteNode(nodeId) - await sortAllLevels(sortType) + updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')) + updateExpandedPaths((prev) => + removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder') + ) - // 如果删除的是当前活动节点或其父节点,清空编辑器 - if (isActiveNodeOrParent) { + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath) + const isActiveNode = normalizedActivePath === normalizedDeletePath + const isActiveDescendant = + nodeToDelete.type === 'folder' && + normalizedActivePath && + normalizedActivePath.startsWith(`${normalizedDeletePath}/`) + + if (isActiveNode || isActiveDescendant) { dispatch(setActiveFilePath(undefined)) - if (editorRef.current) { - editorRef.current.clear() - } + editorRef.current?.clear() } + + await refreshTree() } catch (error) { logger.error('Failed to delete node:', error as Error) } }, - [findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch] + [notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 重命名节点 @@ -487,63 +537,30 @@ const NotesPage: FC = () => { try { isRenamingRef.current = true - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.name !== newName) { - const oldExternalPath = node.externalPath - let currentContent = '' - - // Save current content before rename to prevent content loss - if (node.type === 'file' && activeFilePath === oldExternalPath) { - // Get content from editor or current cache - currentContent = editorRef.current?.getMarkdown() || lastContentRef.current || currentContent - - // Save current content to the file before renaming - if (currentContent.trim()) { - try { - await saveCurrentNote(currentContent, oldExternalPath) - } catch (error) { - logger.warn('Failed to save content before rename:', error as Error) - } - } - } - - const renamedNode = await renameNode(nodeId, newName) - - if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) { - // Restore content to the new file path if content was lost during rename - if (currentContent.trim()) { - try { - const newFileContent = await window.api.file.readExternal(renamedNode.externalPath) - if (!newFileContent || newFileContent.trim() === '') { - await window.api.file.write(renamedNode.externalPath, currentContent) - logger.info('Restored content to renamed file') - } - } catch (error) { - logger.error('Failed to restore content after rename:', error as Error) - } - } - - dispatch(setActiveFilePath(renamedNode.externalPath)) - // Invalidate cache for the new path to ensure content is loaded correctly - invalidateFileContent(renamedNode.externalPath) - } else if ( - renamedNode.type === 'folder' && - activeFilePath && - activeFilePath.startsWith(oldExternalPath + '/') - ) { - const relativePath = activeFilePath.substring(oldExternalPath.length) - const newFilePath = renamedNode.externalPath + relativePath - dispatch(setActiveFilePath(newFilePath)) - // Invalidate cache for the new file path after folder rename - invalidateFileContent(newFilePath) - } - await sortAllLevels(sortType) - if (renamedNode.name !== newName) { - window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name })) - } + const node = findNode(notesTree, nodeId) + if (!node || node.name === newName) { + return } + + const oldPath = node.externalPath + const renamed = await renameEntry(node, newName) + + if (node.type === 'file' && activeFilePath === oldPath) { + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = renamed.path + dispatch(setActiveFilePath(renamed.path)) + } else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) { + const suffix = activeFilePath.slice(oldPath.length) + const nextActivePath = `${renamed.path}${suffix}` + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = nextActivePath + dispatch(setActiveFilePath(nextActivePath)) + } + + updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + + await refreshTree() } catch (error) { logger.error('Failed to rename node:', error as Error) } finally { @@ -552,7 +569,7 @@ const NotesPage: FC = () => { }, 500) } }, - [activeFilePath, dispatch, findNodeById, sortType, t, invalidateFileContent, saveCurrentNote] + [activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理文件上传 @@ -569,7 +586,7 @@ const NotesPage: FC = () => { throw new Error('No folder path selected') } - const result = await uploadFiles(files, targetFolderPath) + const result = await uploadNotes(files, targetFolderPath) // 检查上传结果 if (result.fileCount === 0) { @@ -578,7 +595,8 @@ const NotesPage: FC = () => { } // 排序并显示成功信息 - await sortAllLevels(sortType) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath))) + await refreshTree() const successMessage = t('notes.upload_success') @@ -588,37 +606,141 @@ const NotesPage: FC = () => { window.toast.error(t('notes.upload_failed')) } }, - [getTargetFolderPath, sortType, t] + [getTargetFolderPath, refreshTree, t, updateExpandedPaths] ) // 处理节点移动 const handleMoveNode = useCallback( async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => { + if (!notesPath) { + return + } + try { - const result = await moveNode(sourceNodeId, targetNodeId, position) - if (result.success && result.type !== 'manual_reorder') { - await sortAllLevels(sortType) + const sourceNode = findNode(notesTree, sourceNodeId) + const targetNode = findNode(notesTree, targetNodeId) + + if (!sourceNode || !targetNode) { + return } + + if (position === 'inside' && targetNode.type !== 'folder') { + return + } + + const rootPath = normalizePathValue(notesPath) + const sourceParentNode = findParent(notesTree, sourceNodeId) + const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId) + + const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath + const targetParentPath = + position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath + + const normalizedSourceParent = normalizePathValue(sourceParentPath) + const normalizedTargetParent = normalizePathValue(targetParentPath) + + const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent + + if (isManualReorder) { + // For manual reordering within the same parent, we can optimize by only updating the affected parent + setNotesTree((prev) => + reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after') + ) + return + } + + const { safeName } = await window.api.file.checkFileName( + normalizedTargetParent, + sourceNode.name, + sourceNode.type === 'file' + ) + + const destinationPath = + sourceNode.type === 'file' + ? `${normalizedTargetParent}/${safeName}.md` + : `${normalizedTargetParent}/${safeName}` + + if (destinationPath === sourceNode.externalPath) { + return + } + + if (sourceNode.type === 'file') { + await window.api.file.move(sourceNode.externalPath, destinationPath) + } else { + await window.api.file.moveDir(sourceNode.externalPath, destinationPath) + } + + updateStarredPaths((prev) => + replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + ) + updateExpandedPaths((prev) => { + let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + next = addUniquePath(next, normalizedTargetParent) + return next + }) + + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + if (normalizedActivePath) { + if (normalizedActivePath === sourceNode.externalPath) { + dispatch(setActiveFilePath(destinationPath)) + } else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) { + const suffix = normalizedActivePath.slice(sourceNode.externalPath.length) + dispatch(setActiveFilePath(`${destinationPath}${suffix}`)) + } + } + + await refreshTree() } catch (error) { logger.error('Failed to move nodes:', error as Error) } }, - [sortType] + [activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理节点排序 const handleSortNodes = useCallback( async (newSortType: NotesSortType) => { - try { - // 更新Redux中的排序类型 - dispatch(setSortType(newSortType)) - await sortAllLevels(newSortType) - } catch (error) { - logger.error('Failed to sort notes:', error as Error) - throw error + dispatch(setSortType(newSortType)) + setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType))) + }, + [dispatch, mergeTreeState] + ) + + const handleExpandPath = useCallback( + (treePath: string) => { + if (!treePath) { + return + } + + const segments = treePath.split('/').filter(Boolean) + if (segments.length === 0) { + return + } + + let nextTree = notesTree + const pathsToAdd: string[] = [] + + segments.forEach((_, index) => { + const currentPath = '/' + segments.slice(0, index + 1).join('/') + const node = findNodeByPath(nextTree, currentPath) + if (node && node.type === 'folder' && !node.expanded) { + pathsToAdd.push(node.externalPath) + nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true })) + } + }) + + if (pathsToAdd.length > 0) { + setNotesTree(nextTree) + updateExpandedPaths((prev) => { + let updated = prev + pathsToAdd.forEach((path) => { + updated = addUniquePath(updated, path) + }) + return updated + }) } }, - [dispatch] + [notesTree, updateExpandedPaths] ) const getCurrentNoteContent = useCallback(() => { @@ -665,12 +787,13 @@ const NotesPage: FC = () => { notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} onToggleStar={handleToggleStar} + onExpandPath={handleExpandPath} + onRenameNode={handleRenameNode} /> diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 09a76b615..4588c3761 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import { useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { useVirtualizer } from '@tanstack/react-virtual' import { Dropdown, Input, InputRef, MenuProps } from 'antd' import { ChevronDown, @@ -22,7 +23,7 @@ import { Star, StarOff } from 'lucide-react' -import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -43,6 +44,157 @@ interface NotesSidebarProps { const logger = loggerService.withContext('NotesSidebar') +interface TreeNodeProps { + node: NotesTreeNode + depth: number + selectedFolderId?: string | null + activeNodeId?: string + editingNodeId: string | null + draggedNodeId: string | null + dragOverNodeId: string | null + dragPosition: 'before' | 'inside' | 'after' + inPlaceEdit: any + getMenuItems: (node: NotesTreeNode) => any[] + onSelectNode: (node: NotesTreeNode) => void + onToggleExpanded: (nodeId: string) => void + onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void + onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent, node: NotesTreeNode) => void + onDragEnd: () => void + renderChildren?: boolean // 控制是否渲染子节点 +} + +const TreeNode = memo( + ({ + node, + depth, + selectedFolderId, + activeNodeId, + editingNodeId, + draggedNodeId, + dragOverNodeId, + dragPosition, + inPlaceEdit, + getMenuItems, + onSelectNode, + onToggleExpanded, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, + renderChildren = true + }) => { + const { t } = useTranslation() + + const isActive = selectedFolderId + ? node.type === 'folder' && node.id === selectedFolderId + : node.id === activeNodeId + const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + const isDragBefore = isDragOver && dragPosition === 'before' + const isDragInside = isDragOver && dragPosition === 'inside' + const isDragAfter = isDragOver && dragPosition === 'after' + + return ( +
+ +
+ onDragStart(e, node)} + onDragOver={(e) => onDragOver(e, node)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node)} + onDragEnd={onDragEnd}> + onSelectNode(node)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + } + value={inPlaceEdit.editValue} + onChange={inPlaceEdit.handleInputChange} + onBlur={inPlaceEdit.saveEdit} + onKeyDown={inPlaceEdit.handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + size="small" + /> + ) : ( + {node.name} + )} + + +
+
+ + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ) + } +) + const NotesSidebar: FC = ({ onCreateFolder, onCreateNote, @@ -268,9 +420,26 @@ const NotesSidebar: FC = ({ setIsShowSearch(!isShowSearch) }, [isShowSearch]) - const filteredTree = useMemo(() => { - if (!isShowStarred && !isShowSearch) return notesTree - const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => { + // Flatten tree nodes for virtualization and filtering + const flattenedNodes = useMemo(() => { + const flattenForVirtualization = ( + nodes: NotesTreeNode[], + depth: number = 0 + ): Array<{ node: NotesTreeNode; depth: number }> => { + let result: Array<{ node: NotesTreeNode; depth: number }> = [] + + for (const node of nodes) { + result.push({ node, depth }) + + // Include children only if the folder is expanded + if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) { + result = [...result, ...flattenForVirtualization(node.children, depth + 1)] + } + } + return result + } + + const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => { let result: NotesTreeNode[] = [] for (const node of nodes) { @@ -284,15 +453,41 @@ const NotesSidebar: FC = ({ } } if (node.children && node.children.length > 0) { - result = [...result, ...flattenNodes(node.children)] + result = [...result, ...flattenForFiltering(node.children)] } } return result } - return flattenNodes(notesTree) + if (isShowStarred || isShowSearch) { + // For filtered views, return flat list without virtualization for simplicity + const filteredNodes = flattenForFiltering(notesTree) + return filteredNodes.map((node) => ({ node, depth: 0 })) + } + + // For normal tree view, use hierarchical flattening for virtualization + return flattenForVirtualization(notesTree) }, [notesTree, isShowStarred, isShowSearch, searchKeyword]) + // Use virtualization only for normal tree view with many items + const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 + + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: flattenedNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, // Estimated height of each tree item + overscan: 10 + }) + + const filteredTree = useMemo(() => { + if (isShowStarred || isShowSearch) { + return flattenedNodes.map(({ node }) => node) + } + return notesTree + }, [flattenedNodes, isShowStarred, isShowSearch, notesTree]) + const getMenuItems = useCallback( (node: NotesTreeNode) => { const baseMenuItems: MenuProps['items'] = [ @@ -351,115 +546,6 @@ const NotesSidebar: FC = ({ [t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode] ) - const renderTreeNode = useCallback( - (node: NotesTreeNode, depth: number = 0) => { - const isActive = selectedFolderId - ? node.type === 'folder' && node.id === selectedFolderId - : node.id === activeNode?.id - const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing - const hasChildren = node.children && node.children.length > 0 - const isDragging = draggedNodeId === node.id - const isDragOver = dragOverNodeId === node.id - const isDragBefore = isDragOver && dragPosition === 'before' - const isDragInside = isDragOver && dragPosition === 'inside' - const isDragAfter = isDragOver && dragPosition === 'after' - - return ( -
- -
- handleDragStart(e, node)} - onDragOver={(e) => handleDragOver(e, node)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, node)} - onDragEnd={handleDragEnd}> - onSelectNode(node)}> - - - {node.type === 'folder' && ( - { - e.stopPropagation() - onToggleExpanded(node.id) - }} - title={node.expanded ? t('notes.collapse') : t('notes.expand')}> - {node.expanded ? : } - - )} - - - {node.type === 'folder' ? ( - node.expanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {isEditing ? ( - } - value={inPlaceEdit.editValue} - onChange={inPlaceEdit.handleInputChange} - onPressEnter={inPlaceEdit.saveEdit} - onBlur={inPlaceEdit.saveEdit} - onKeyDown={inPlaceEdit.handleKeyDown} - onClick={(e) => e.stopPropagation()} - autoFocus - size="small" - /> - ) : ( - {node.name} - )} - - -
-
- - {node.type === 'folder' && node.expanded && hasChildren && ( -
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
- )} -
- ) - }, - [ - selectedFolderId, - activeNode?.id, - editingNodeId, - inPlaceEdit.isEditing, - inPlaceEdit.inputRef, - inPlaceEdit.editValue, - inPlaceEdit.handleInputChange, - inPlaceEdit.saveEdit, - inPlaceEdit.handleKeyDown, - draggedNodeId, - dragOverNodeId, - dragPosition, - getMenuItems, - handleDragLeave, - handleDragEnd, - t, - handleDragStart, - handleDragOver, - handleDrop, - onSelectNode, - onToggleExpanded - ] - ) - const handleDropFiles = useCallback( async (e: React.DragEvent) => { e.preventDefault() @@ -565,9 +651,54 @@ const NotesSidebar: FC = ({ /> - - - {filteredTree.map((node) => renderTreeNode(node))} + {shouldUseVirtualization ? ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const { node, depth } = flattenedNodes[virtualItem.index] + return ( +
+
+ +
+
+ ) + })} +
{!isShowStarred && !isShowSearch && ( @@ -580,8 +711,70 @@ const NotesSidebar: FC = ({ )} -
-
+ + ) : ( + + + {isShowStarred || isShowSearch + ? filteredTree.map((node) => ( + + )) + : notesTree.map((node) => ( + + ))} + {!isShowStarred && !isShowSearch && ( + + + + + + + {t('notes.drop_markdown_hint')} + + + + )} + + + )}
{isDragOverSidebar && } @@ -592,7 +785,7 @@ const NotesSidebar: FC = ({ const SidebarContainer = styled.div` width: 250px; min-width: 250px; - height: 100vh; + height: calc(100vh - var(--navbar-height)); background-color: var(--color-background); border-right: 0.5px solid var(--color-border); border-top-left-radius: 10px; @@ -606,7 +799,15 @@ const NotesTreeContainer = styled.div` overflow: hidden; display: flex; flex-direction: column; - height: calc(100vh - 45px); + height: calc(100vh - var(--navbar-height) - 45px); +` + +const VirtualizedTreeContainer = styled.div` + flex: 1; + height: 100%; + overflow: auto; + position: relative; + padding-top: 10px; ` const StyledScrollbar = styled(Scrollbar)` @@ -752,7 +953,8 @@ const DragOverIndicator = styled.div` ` const DropHintNode = styled.div` - margin-top: 8px; + margin: 8px; + margin-bottom: 20px; ${TreeNodeContainer} { background-color: transparent; @@ -773,4 +975,4 @@ const DropHintText = styled.div` font-style: italic; ` -export default NotesSidebar +export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/settings/NotesSettings.tsx b/src/renderer/src/pages/settings/NotesSettings.tsx index 1a062145d..99dc8ba1d 100644 --- a/src/renderer/src/pages/settings/NotesSettings.tsx +++ b/src/renderer/src/pages/settings/NotesSettings.tsx @@ -2,7 +2,6 @@ import { loggerService } from '@logger' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' -import { initWorkSpace } from '@renderer/services/NotesService' import { EditorView } from '@renderer/types' import { Button, Input, message, Slider, Switch } from 'antd' import { FolderOpen } from 'lucide-react' @@ -70,7 +69,6 @@ const NotesSettings: FC = () => { } updateNotesPath(tempPath) - initWorkSpace(tempPath, 'sort_a2z') window.toast.success(t('notes.settings.data.path_updated')) } catch (error) { logger.error('Failed to apply notes path:', error as Error) @@ -83,7 +81,6 @@ const NotesSettings: FC = () => { const info = await window.api.getAppInfo() setTempPath(info.notesPath) updateNotesPath(info.notesPath) - initWorkSpace(info.notesPath, 'sort_a2z') window.toast.success(t('notes.settings.data.reset_to_default')) } catch (error) { logger.error('Failed to reset to default:', error as Error) diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 00032484b..ca83e149f 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -191,6 +191,7 @@ const ContentContainer = styled.div` flex: 1; flex-direction: row; height: calc(100vh - var(--navbar-height)); + padding: 1px 0; ` const SettingMenus = styled(Scrollbar)` diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 45383344d..a76df9d84 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -1,100 +1,10 @@ import { loggerService } from '@logger' -import db from '@renderer/databases' -import { - findNodeInTree, - findParentNode, - getNotesTree, - insertNodeIntoTree, - isParentNode, - moveNodeInTree, - removeNodeFromTree, - renameNodeFromTree -} from '@renderer/services/NotesTreeService' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { getFileDirectory } from '@renderer/utils' -import { v4 as uuidv4 } from 'uuid' - -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' const logger = loggerService.withContext('NotesService') -export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' } - -/** - * 初始化/同步笔记树结构 - */ -export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise { - const tree = await window.api.file.getDirectoryStructure(folderPath) - await sortAllLevels(sortType, tree) -} - -/** - * 创建新文件夹 - */ -export async function createFolder(name: string, folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false) - if (exists) { - logger.warn(`Folder already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const folderId = uuidv4() - - const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const folder: NotesTreeNode = { - id: folderId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: targetPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, folder, parentNode?.id) - - return folder -} - -/** - * 创建新笔记文件 - */ -export async function createNote(name: string, content: string = '', folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true) - if (exists) { - logger.warn(`Note already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const noteId = uuidv4() - const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` - - await window.api.file.write(notePath, content) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, note, parentNode?.id) - - return note -} +const MARKDOWN_EXT = '.md' export interface UploadResult { uploadedNodes: NotesTreeNode[] @@ -104,641 +14,195 @@ export interface UploadResult { folderCount: number } -/** - * 上传文件或文件夹,支持单个或批量上传,保持文件夹结构 - */ -export async function uploadFiles(files: File[], targetFolderPath: string): Promise { - const tree = await getNotesTree() - const uploadedNodes: NotesTreeNode[] = [] - let skippedFiles = 0 - - const markdownFiles = filterMarkdownFiles(files) - skippedFiles = files.length - markdownFiles.length - - if (markdownFiles.length === 0) { - return createEmptyUploadResult(files.length, skippedFiles) - } - - // 处理重复的根文件夹名称 - const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath) - - const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath) - - const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes) - - await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes) - - const fileCount = uploadedNodes.filter((node) => node.type === 'file').length - const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length - - return { - uploadedNodes, - totalFiles: files.length, - skippedFiles, - fileCount, - folderCount - } +export async function loadTree(rootPath: string): Promise { + return window.api.file.getDirectoryStructure(normalizePath(rootPath)) } -/** - * 删除笔记或文件夹 - */ -export async function deleteNode(nodeId: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } +export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] { + const cloned = nodes.map((node) => ({ + ...node, + children: node.children ? sortTree(node.children, sortType) : undefined + })) + + const sorter = getSorter(sortType) + + cloned.sort((a, b) => { + if (a.type === b.type) { + return sorter(a, b) + } + return a.type === 'folder' ? -1 : 1 + }) + + return cloned +} + +export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, false) + const fullPath = `${basePath}/${safeName}` + await window.api.file.mkdir(fullPath) + return { path: fullPath, name: safeName } +} + +export async function addNote( + name: string, + content: string = '', + parentPath: string +): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, true) + const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}` + await window.api.file.write(notePath, content) + return { path: notePath, name: safeName } +} + +export async function delNode(node: NotesTreeNode): Promise { if (node.type === 'folder') { await window.api.file.deleteExternalDir(node.externalPath) - } else if (node.type === 'file') { + } else { await window.api.file.deleteExternalFile(node.externalPath) } - - await removeNodeFromTree(tree, nodeId) } -/** - * 重命名笔记或文件夹 - */ -export async function renameNode(nodeId: string, newName: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - const dirPath = getFileDirectory(node.externalPath) - const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file') +export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> { + const isFile = node.type === 'file' + const parentDir = normalizePath(getFileDirectory(node.externalPath)) + const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile) if (exists) { - logger.warn(`Target name already exists: ${safeName}`) throw new Error(`Target name already exists: ${safeName}`) } - if (node.type === 'file') { + if (isFile) { await window.api.file.rename(node.externalPath, safeName) - } else if (node.type === 'folder') { - await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName } } - return renameNodeFromTree(tree, nodeId, safeName) + + await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}`, name: safeName } } -/** - * 移动节点 - */ -export async function moveNode( - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const tree = await getNotesTree() +export async function uploadNotes(files: File[], targetPath: string): Promise { + const basePath = normalizePath(targetPath) + const markdownFiles = filterMarkdown(files) + const skippedFiles = files.length - markdownFiles.length - // 找到源节点和目标节点 - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) - return { success: false } + if (markdownFiles.length === 0) { + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount: 0, + folderCount: 0 } + } - // 不允许文件夹被放入文件中 - if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') { - logger.error('Move nodes failed: cannot move a folder inside a file') - return { success: false } + const folders = collectFolders(markdownFiles, basePath) + await createFolders(folders) + + let fileCount = 0 + + for (const file of markdownFiles) { + const { dir, name } = resolveFileTarget(file, basePath) + const { safeName } = await window.api.file.checkFileName(dir, name, true) + const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}` + + try { + const content = await file.text() + await window.api.file.write(finalPath, content) + fileCount += 1 + } catch (error) { + logger.error('Failed to write uploaded file:', error as Error) } + } - // 不允许将节点移动到自身内部 - if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) { - logger.error('Move nodes failed: cannot move a node inside itself or its descendants') - return { success: false } - } - - let targetPath: string = '' - - if (position === 'inside') { - // 目标是文件夹内部 - if (targetNode.type === 'folder') { - targetPath = targetNode.externalPath - } else { - logger.error('Cannot move node inside a file node') - return { success: false } - } - } else { - const targetParent = findParentNode(tree, targetNodeId) - if (targetParent) { - targetPath = targetParent.externalPath - } else { - targetPath = getFileDirectory(targetNode.externalPath!) - } - } - - // 检查是否为同级拖动排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!) - - const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath - - if (isSameLevelReorder) { - // 同级拖动排序:跳过文件系统操作,只更新树结构 - logger.debug(`Same level reorder detected, skipping file system operations`) - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - // 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序 - return success ? { success: true, type: 'manual_reorder' } : { success: false } - } - - // 构建新的文件路径 - const sourceName = sourceNode.externalPath!.split('/').pop()! - const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '') - - const { safeName } = await window.api.file.checkFileName( - targetPath, - sourceNameWithoutExt, - sourceNode.type === 'file' - ) - - const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '') - const newPath = `${targetPath}/${baseName}` - - if (sourceNode.externalPath !== newPath) { - try { - if (sourceNode.type === 'folder') { - await window.api.file.moveDir(sourceNode.externalPath, newPath) - } else { - await window.api.file.move(sourceNode.externalPath, newPath) - } - sourceNode.externalPath = newPath - logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`) - } catch (error) { - logger.error(`Failed to move external ${sourceNode.type}:`, error as Error) - return { success: false } - } - } - - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - return success ? { success: true, type: 'file_system_move' } : { success: false } - } catch (error) { - logger.error('Move nodes failed:', error as Error) - return { success: false } + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount, + folderCount: folders.size } } -/** - * 对节点数组进行排序 - */ -function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void { - // 首先分离文件夹和文件 - const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder') - const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file') - - // 根据排序类型对文件夹和文件分别进行排序 - const sortFunction = getSortFunction(sortType) - folders.sort(sortFunction) - files.sort(sortFunction) - - // 清空原数组并重新填入排序后的节点 - nodes.length = 0 - nodes.push(...folders, ...files) -} - -/** - * 根据排序类型获取相应的排序函数 - */ -function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { +function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { switch (sortType) { case 'sort_a2z': return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' }) - case 'sort_z2a': return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' }) - case 'sort_updated_desc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt) case 'sort_updated_asc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt) case 'sort_created_desc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.createdAt) - getTime(a.createdAt) case 'sort_created_asc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.createdAt) - getTime(b.createdAt) default: return (a, b) => a.name.localeCompare(b.name) } } -/** - * 递归排序笔记树中的所有层级 - */ -export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise { - try { - if (!tree) { - tree = await getNotesTree() - } - sortNodesArray(tree, sortType) - recursiveSortNodes(tree, sortType) - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - logger.info(`Sorted all levels of notes successfully: ${sortType}`) - } catch (error) { - logger.error('Failed to sort all levels of notes:', error as Error) - throw error - } +function getTime(value?: string): number { + return value ? new Date(value).getTime() : 0 } -/** - * 递归对节点中的子节点进行排序 - */ -function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void { - for (const node of nodes) { - if (node.type === 'folder' && node.children && node.children.length > 0) { - sortNodesArray(node.children, sortType) - recursiveSortNodes(node.children, sortType) - } - } +function normalizePath(value: string): string { + return value.replace(/\\/g, '/') } -/** - * 根据外部路径查找节点(递归查找) - */ -function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null { - for (const node of nodes) { - if (node.externalPath === externalPath) { - return node - } - if (node.children && node.children.length > 0) { - const found = findNodeByExternalPath(node.children, externalPath) - if (found) { - return found - } - } - } - return null +function filterMarkdown(files: File[]): File[] { + return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT)) } -/** - * 过滤出 Markdown 文件 - */ -function filterMarkdownFiles(files: File[]): File[] { - return Array.from(files).filter((file) => { - if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) { - return true +function collectFolders(files: File[], basePath: string): Set { + const folders = new Set() + + files.forEach((file) => { + const relativePath = file.webkitRelativePath || '' + if (!relativePath.includes('/')) { + return + } + + const parts = relativePath.split('/') + parts.pop() + + let current = basePath + for (const part of parts) { + current = `${current}/${part}` + folders.add(current) } - logger.warn(`Skipping non-markdown file: ${file.name}`) - return false }) + + return folders } -/** - * 创建空的上传结果 - */ -function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult { - return { - uploadedNodes: [], - totalFiles, - skippedFiles, - fileCount: 0, - folderCount: 0 - } -} - -/** - * 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath - */ -async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise { - // 按根文件夹名称分组文件 - const filesByRootFolder = new Map() - const processedFiles: File[] = [] - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - - if (filePath.includes('/')) { - const rootFolderName = filePath.substring(0, filePath.indexOf('/')) - if (!filesByRootFolder.has(rootFolderName)) { - filesByRootFolder.set(rootFolderName, []) - } - filesByRootFolder.get(rootFolderName)!.push(file) - } else { - // 单个文件,直接添加 - processedFiles.push(file) - } - } - - // 为每个根文件夹组生成唯一的文件夹名称 - for (const [rootFolderName, files] of filesByRootFolder.entries()) { - const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false) - - for (const file of files) { - // 创建一个新的 File 对象,并修改 webkitRelativePath - const originalPath = file.webkitRelativePath || file.name - const relativePath = originalPath.substring(originalPath.indexOf('/') + 1) - const newPath = `${safeName}/${relativePath}` - - const newFile = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(newFile, 'webkitRelativePath', { - value: newPath, - writable: false - }) - - processedFiles.push(newFile) - } - } - - return processedFiles -} - -/** - * 按路径分组文件并收集需要创建的文件夹 - */ -function groupFilesByPath( - markdownFiles: File[], - targetFolderPath: string -): { filesByPath: Map; foldersToCreate: Set } { - const filesByPath = new Map() - const foldersToCreate = new Set() - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : '' - const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath - - if (relativeDirPath) { - const pathParts = relativeDirPath.split('/') - - let currentPath = targetFolderPath - for (const part of pathParts) { - currentPath = `${currentPath}/${part}` - foldersToCreate.add(currentPath) - } - } - - if (!filesByPath.has(fullDirPath)) { - filesByPath.set(fullDirPath, []) - } - filesByPath.get(fullDirPath)!.push(file) - } - - return { filesByPath, foldersToCreate } -} - -/** - * 顺序创建文件夹(避免竞争条件) - */ -async function createFoldersSequentially( - foldersToCreate: Set, - targetFolderPath: string, - tree: NotesTreeNode[], - uploadedNodes: NotesTreeNode[] -): Promise> { - const createdFolders = new Map() - const sortedFolders = Array.from(foldersToCreate).sort() - const folderCreationLock = new Set() - - for (const folderPath of sortedFolders) { - if (folderCreationLock.has(folderPath)) { - continue - } - folderCreationLock.add(folderPath) +async function createFolders(folders: Set): Promise { + const ordered = Array.from(folders).sort((a, b) => a.length - b.length) + for (const folder of ordered) { try { - const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders) - if (result) { - createdFolders.set(folderPath, result) - if (result.externalPath !== folderPath) { - createdFolders.set(result.externalPath, result) - } - uploadedNodes.push(result) - logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`) - } + await window.api.file.mkdir(folder) } catch (error) { - logger.error(`Failed to create folder ${folderPath}:`, error as Error) - } finally { - folderCreationLock.delete(folderPath) + logger.debug('Skip existing folder while uploading notes', { + folder, + error: (error as Error).message + }) } } - - return createdFolders -} - -/** - * 创建单个文件夹 - */ -async function createSingleFolder( - folderPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const existingNode = findNodeByExternalPath(tree, folderPath) - if (existingNode) { - return existingNode - } - - const relativePath = folderPath.replace(targetFolderPath + '/', '') - const originalFolderName = relativePath.split('/').pop()! - const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/')) - - const { safeName: safeFolderName, exists } = await window.api.file.checkFileName( - parentFolderPath, - originalFolderName, - false - ) - - const actualFolderPath = `${parentFolderPath}/${safeFolderName}` - - if (exists) { - logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`) - } - - try { - await window.api.file.mkdir(actualFolderPath) - } catch (error) { - logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error) - } - - let parentNode: NotesTreeNode | null - if (parentFolderPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - } else { - parentNode = createdFolders.get(parentFolderPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, parentFolderPath) - } - } - } - - const folderId = uuidv4() - const folder: NotesTreeNode = { - id: folderId, - name: safeFolderName, - treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`, - externalPath: actualFolderPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - await insertNodeIntoTree(tree, folder, parentNode?.id) - return folder -} - -/** - * 读取文件内容(支持大文件处理) - */ -async function readFileContent(file: File): Promise { - const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB - - if (file.size > MAX_FILE_SIZE) { - logger.warn( - `Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.` - ) - } - - try { - return await file.text() - } catch (error) { - logger.error(`Failed to read file content for ${file.name}:`, error as Error) - throw new Error(`Failed to read file content: ${file.name}`) - } } -/** - * 上传所有文件 - */ -async function uploadAllFiles( - filesByPath: Map, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map, - uploadedNodes: NotesTreeNode[] -): Promise { - const uploadPromises: Promise[] = [] - - for (const [dirPath, dirFiles] of filesByPath.entries()) { - for (const file of dirFiles) { - const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders) - .then((result) => { - if (result) { - logger.debug(`Uploaded file: ${result.externalPath}`) - } - return result - }) - .catch((error) => { - logger.error(`Failed to upload file ${file.name}:`, error as Error) - return null - }) - - uploadPromises.push(uploadPromise) - } +function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } { + if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) { + const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name + return { dir: basePath, name: nameWithoutExt } } - const results = await Promise.all(uploadPromises) + const parts = file.webkitRelativePath.split('/') + const fileName = parts.pop() || file.name + const dirPath = `${basePath}/${parts.join('/')}` + const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName - results.forEach((result) => { - if (result) { - uploadedNodes.push(result) - } - }) -} - -/** - * 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点 - */ -async function uploadSingleFile( - file: File, - originalDirPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const fileName = (file.webkitRelativePath || file.name).split('/').pop()! - const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') - - let actualDirPath = originalDirPath - let parentNode: NotesTreeNode | null = null - - if (originalDirPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - - if (!parentNode) { - logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`) - } - } else { - parentNode = createdFolders.get(originalDirPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === originalDirPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, originalDirPath) - } - } - - if (!parentNode) { - for (const [originalPath, createdNode] of createdFolders.entries()) { - if (originalPath === originalDirPath) { - parentNode = createdNode - actualDirPath = createdNode.externalPath - break - } - } - } - - if (!parentNode) { - logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`) - return null - } - } - - const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true) - if (exists) { - logger.warn(`Note already exists, will be overwritten: ${safeName}`) - } - - const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}` - - const noteId = uuidv4() - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - const content = await readFileContent(file) - await window.api.file.write(notePath, content) - await insertNodeIntoTree(tree, note, parentNode?.id) - - return note + return { dir: dirPath, name: nameWithoutExt } } diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts index 415994832..676b4996a 100644 --- a/src/renderer/src/services/NotesTreeService.ts +++ b/src/renderer/src/services/NotesTreeService.ts @@ -1,217 +1,47 @@ -import { loggerService } from '@logger' -import db from '@renderer/databases' import { NotesTreeNode } from '@renderer/types/note' -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' - -const logger = loggerService.withContext('NotesTreeService') - -/** - * 获取树结构 - */ -export const getNotesTree = async (): Promise => { - const record = await db.notes_tree.get(NOTES_TREE_ID) - return record?.tree || [] +export function normalizePathValue(path: string): string { + return path.replace(/\\/g, '/') } -/** - * 在树中插入节点 - */ -export async function insertNodeIntoTree( - tree: NotesTreeNode[], - node: NotesTreeNode, - parentId?: string -): Promise { - try { - if (!parentId) { - tree.push(node) - } else { - const parent = findNodeInTree(tree, parentId) - if (parent && parent.type === 'folder') { - if (!parent.children) { - parent.children = [] - } - parent.children.push(node) - } - } - - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return tree - } catch (error) { - logger.error('Failed to insert node into tree:', error as Error) - throw error - } +export function addUniquePath(list: string[], path: string): string[] { + const normalized = normalizePathValue(path) + return list.includes(normalized) ? list : [...list, normalized] } -/** - * 从树中删除节点 - */ -export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise { - const removed = removeNodeFromTreeInMemory(tree, nodeId) - if (removed) { - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - } - return removed -} - -/** - * 从树中删除节点(仅在内存中操作,不保存数据库) - */ -function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean { - for (let i = 0; i < tree.length; i++) { - if (tree[i].id === nodeId) { - tree.splice(i, 1) - return true - } - if (tree[i].children) { - const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId) - if (removed) { - return true - } - } - } - return false -} - -export async function moveNodeInTree( - tree: NotesTreeNode[], - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) +export function removePathEntries(list: string[], path: string, deep: boolean): string[] { + const normalized = normalizePathValue(path) + const prefix = `${normalized}/` + return list.filter((item) => { + if (item === normalized) { return false } + return !(deep && item.startsWith(prefix)) + }) +} - // 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const targetParent = findParentNode(tree, targetNodeId) - - // 从原位置移除节点(不保存数据库,只在内存中操作) - const removed = removeNodeFromTreeInMemory(tree, sourceNodeId) - if (!removed) { - logger.error('Move nodes in tree failed: could not remove source node') - return false +export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] { + const oldNormalized = normalizePathValue(oldPath) + const newNormalized = normalizePathValue(newPath) + const prefix = `${oldNormalized}/` + return list.map((item) => { + if (item === oldNormalized) { + return newNormalized } - - try { - // 根据位置进行放置 - if (position === 'inside' && targetNode.type === 'folder') { - if (!targetNode.children) { - targetNode.children = [] - } - targetNode.children.push(sourceNode) - targetNode.expanded = true - - sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}` - } else { - const targetList = targetParent ? targetParent.children! : tree - const targetIndex = targetList.findIndex((node) => node.id === targetNodeId) - - if (targetIndex === -1) { - logger.error('Move nodes in tree failed: target position not found') - return false - } - - // 根据position确定插入位置 - const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 - targetList.splice(insertIndex, 0, sourceNode) - - // 检查是否为同级排序,如果是则保持原有的 treePath - const isSameLevelReorder = sourceParent === targetParent - - // 只有在跨级移动时才更新节点路径 - if (!isSameLevelReorder) { - if (targetParent) { - sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}` - } else { - sourceNode.treePath = `/${sourceNode.name}` - } - } - } - - // 更新修改时间 - sourceNode.updatedAt = new Date().toISOString() - - // 只有在所有操作成功后才保存到数据库 - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return true - } catch (error) { - logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error) - // 如果放置失败,尝试恢复原始节点到原位置 - // 这里需要重新实现恢复逻辑,暂时返回false - return false + if (deep && item.startsWith(prefix)) { + return `${newNormalized}${item.slice(oldNormalized.length)}` } - } catch (error) { - logger.error('Move nodes in tree failed:', error as Error) - return false - } + return item + }) } -/** - * 重命名节点 - */ -export async function renameNodeFromTree( - tree: NotesTreeNode[], - nodeId: string, - newName: string -): Promise { - const node = findNodeInTree(tree, nodeId) - - if (!node) { - throw new Error('Node not found') - } - - node.name = newName - - const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1) - node.treePath = dirPath + newName - - const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1) - node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName - - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return node -} - -/** - * 修改节点键值 - */ -export async function updateNodeInTree( - tree: NotesTreeNode[], - nodeId: string, - updates: Partial -): Promise { - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - Object.assign(node, updates) - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return node -} - -/** - * 在树中查找节点 - */ -export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { +export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { if (node.id === nodeId) { return node } if (node.children) { - const found = findNodeInTree(node.children, nodeId) + const found = findNode(node.children, nodeId) if (found) { return found } @@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree return null } -/** - * 根据路径查找节点 - */ -export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null { +export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null { for (const node of tree) { - if (node.treePath === path) { + if (node.treePath === targetPath || node.externalPath === targetPath) { return node } if (node.children) { - const found = findNodeByPath(node.children, path) + const found = findNodeByPath(node.children, targetPath) if (found) { return found } @@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo return null } -// --- -// 辅助函数 -// --- +export function updateTreeNode( + nodes: NotesTreeNode[], + nodeId: string, + updater: (node: NotesTreeNode) => NotesTreeNode +): NotesTreeNode[] { + let changed = false -/** - * 查找节点的父节点 - */ -export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null { + const nextNodes = nodes.map((node) => { + if (node.id === nodeId) { + changed = true + const updated = updater(node) + if (updated.type === 'folder' && !updated.children) { + return { ...updated, children: [] } + } + return updated + } + + if (node.children && node.children.length > 0) { + const updatedChildren = updateTreeNode(node.children, nodeId, updater) + if (updatedChildren !== node.children) { + changed = true + return { ...node, children: updatedChildren } + } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { - if (node.children) { - const isDirectChild = node.children.some((child) => child.id === targetNodeId) - if (isDirectChild) { - return node - } - - const parent = findParentNode(node.children, targetNodeId) - if (parent) { - return parent - } + if (!node.children) { + continue + } + if (node.children.some((child) => child.id === nodeId)) { + return node + } + const found = findParent(node.children, nodeId) + if (found) { + return found } } return null } -/** - * 判断节点是否为另一个节点的父节点 - */ -export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean { - const childNode = findNodeInTree(tree, childId) - if (!childNode) { - return false +export function reorderTreeNodes( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): NotesTreeNode[] { + const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position) + if (moved) { + return updatedNodes } - const parentNode = findNodeInTree(tree, parentId) - if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) { - return false - } - - if (parentNode.children.some((child) => child.id === childId)) { - return true - } - - for (const child of parentNode.children) { - if (isParentNode(tree, child.id, childId)) { - return true + let changed = false + const nextNodes = nodes.map((node) => { + if (!node.children || node.children.length === 0) { + return node } + + const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position) + if (reorderedChildren !== node.children) { + changed = true + return { ...node, children: reorderedChildren } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +function reorderSiblings( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): [NotesTreeNode[], boolean] { + const sourceIndex = nodes.findIndex((node) => node.id === sourceId) + const targetIndex = nodes.findIndex((node) => node.id === targetId) + + if (sourceIndex === -1 || targetIndex === -1) { + return [nodes, false] } - return false + const updated = [...nodes] + const [sourceNode] = updated.splice(sourceIndex, 1) + + let insertIndex = targetIndex + if (sourceIndex < targetIndex) { + insertIndex -= 1 + } + if (position === 'after') { + insertIndex += 1 + } + + if (insertIndex < 0) { + insertIndex = 0 + } + if (insertIndex > updated.length) { + insertIndex = updated.length + } + + updated.splice(insertIndex, 0, sourceNode) + return [updated, true] } diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts index df4478c07..38f331c76 100644 --- a/src/renderer/src/store/note.ts +++ b/src/renderer/src/store/note.ts @@ -20,6 +20,8 @@ export interface NoteState { settings: NotesSettings notesPath: string sortType: NotesSortType + starredPaths: string[] + expandedPaths: string[] } export const initialState: NoteState = { @@ -36,7 +38,9 @@ export const initialState: NoteState = { showWorkspace: true }, notesPath: '', - sortType: 'sort_a2z' + sortType: 'sort_a2z', + starredPaths: [], + expandedPaths: [] } const noteSlice = createSlice({ @@ -57,16 +61,32 @@ const noteSlice = createSlice({ }, setSortType: (state, action: PayloadAction) => { state.sortType = action.payload + }, + setStarredPaths: (state, action: PayloadAction) => { + state.starredPaths = action.payload ?? [] + }, + setExpandedPaths: (state, action: PayloadAction) => { + state.expandedPaths = action.payload ?? [] } } }) -export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions +export const { + setActiveNodeId, + setActiveFilePath, + updateNotesSettings, + setNotesPath, + setSortType, + setStarredPaths, + setExpandedPaths +} = noteSlice.actions export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath export const selectNotesSettings = (state: RootState) => state.note.settings export const selectNotesPath = (state: RootState) => state.note.notesPath export const selectSortType = (state: RootState) => state.note.sortType +export const selectStarredPaths = (state: RootState) => state.note.starredPaths ?? [] +export const selectExpandedPaths = (state: RootState) => state.note.expandedPaths ?? [] export default noteSlice.reducer diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index d4fed4d02..f3ce321d6 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -3,12 +3,11 @@ import { Client } from '@notionhq/client' import i18n from '@renderer/i18n' import { getProviderLabel } from '@renderer/i18n/label' import { getMessageTitle } from '@renderer/services/MessagesService' -import { createNote } from '@renderer/services/NotesService' +import { addNote } from '@renderer/services/NotesService' import store from '@renderer/store' import { setExportState } from '@renderer/store/runtime' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { removeSpecialCharactersForFileName } from '@renderer/utils/file' import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown' import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' @@ -1052,18 +1051,12 @@ async function createSiyuanDoc( * @param content * @param folderPath */ -export const exportMessageToNotes = async ( - title: string, - content: string, - folderPath: string -): Promise => { +export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise => { try { const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '') - const note = await createNote(title, cleanedContent, folderPath) + await addNote(title, cleanedContent, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) @@ -1077,14 +1070,12 @@ export const exportMessageToNotes = async ( * @param folderPath * @returns 创建的笔记节点 */ -export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { +export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { try { const content = await topicToMarkdown(topic) - const note = await createNote(topic.name, content, folderPath) + await addNote(topic.name, content, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) From cd3031479c73ca57b20d5b8f3db9d1161f92a68d Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:07:30 +0800 Subject: [PATCH 5/8] fix(reasoning): correct regex pattern for deepseek model detection (#10407) --- src/renderer/src/config/models/reasoning.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 607df8fd9..f309811a9 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -335,7 +335,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) // deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别 // openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险 - return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1') + return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1') } export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel From 5524571c8091bd09304679029d03836bfc4522be Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:09:11 +0800 Subject: [PATCH 6/8] fix(ErrorBlock): prevent event propagation when removing block (#10368) This PR correctly addresses an event propagation issue where clicking the close button on an error alert was unintentionally triggering the parent click handler (which opens the detail modal). --- src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 298d3c274..2cf85a3d6 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }> const [showDetailModal, setShowDetailModal] = useState(false) const { t } = useTranslation() - const onRemoveBlock = () => { + const onRemoveBlock = (e: React.MouseEvent) => { + e.stopPropagation() setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) } From 20f527168290b772bed279e843d4e15653d468a3 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 28 Sep 2025 14:15:56 +0800 Subject: [PATCH 7/8] fix: quick assistant avatar and search (#10281) --- .../pages/settings/QuickAssistantSettings.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx index b7279fce6..a547a5450 100644 --- a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx @@ -11,9 +11,10 @@ import { setEnableQuickAssistant, setReadClipboardAtStartup } from '@renderer/store/settings' +import { matchKeywordsInString } from '@renderer/utils' import HomeWindow from '@renderer/windows/mini/home/HomeWindow' import { Button, Select, Switch, Tooltip } from 'antd' -import { FC } from 'react' +import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -26,9 +27,15 @@ const QuickAssistantSettings: FC = () => { const dispatch = useAppDispatch() const { assistants } = useAssistants() const { quickAssistantId } = useAppSelector((state) => state.llm) - const { defaultAssistant } = useDefaultAssistant() + const { defaultAssistant: _defaultAssistant } = useDefaultAssistant() const { defaultModel } = useDefaultModel() + // Take the "default assistant" from the assistant list first. + const defaultAssistant = useMemo( + () => assistants.find((a) => a.id === _defaultAssistant.id) || _defaultAssistant, + [assistants, _defaultAssistant] + ) + const handleEnableQuickAssistant = async (enable: boolean) => { dispatch(setEnableQuickAssistant(enable)) await window.api.config.set('enableQuickAssistant', enable, true) @@ -110,27 +117,39 @@ const QuickAssistantSettings: FC = () => { value={quickAssistantId || defaultAssistant.id} style={{ width: 300, height: 34 }} onChange={(value) => dispatch(setQuickAssistantId(value))} - placeholder={t('settings.models.quick_assistant_selection')}> - - - - {defaultAssistant.name} - - {t('settings.models.quick_assistant_default_tag')} - - - {assistants - .filter((a) => a.id !== defaultAssistant.id) - .map((a) => ( - + placeholder={t('settings.models.quick_assistant_selection')} + showSearch + options={[ + { + key: defaultAssistant.id, + value: defaultAssistant.id, + title: defaultAssistant.name, + label: ( - - {a.name} + + {defaultAssistant.name} + {t('settings.models.quick_assistant_default_tag')} - - ))} - + ) + }, + ...assistants + .filter((a) => a.id !== defaultAssistant.id) + .map((a) => ({ + key: a.id, + value: a.id, + title: a.name, + label: ( + + + {a.name} + + + ) + })) + ]} + filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')} + /> )} From e195ad4a8f3010e7c512955456bf45182ab5b49a Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Sun, 28 Sep 2025 14:56:04 +0800 Subject: [PATCH 8/8] refactor(tools): enhance descriptions for knowledge and web search tools (#10433) * refactor(tools): enhance descriptions for knowledge and web search tools - Updated the descriptions for the knowledgeSearchTool and webSearchTool to provide clearer context on their functionality. - Improved the formatting of prepared queries and relevant links in the descriptions to enhance user understanding. - Added information on how to use the tools with additional context for refined searches. * fix:format lint --- src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts | 9 +++++---- src/renderer/src/aiCore/tools/WebSearchTool.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts index 7764fd81e..314eb9ba0 100644 --- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts +++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts @@ -18,12 +18,13 @@ export const knowledgeSearchTool = ( ) => { return tool({ name: 'builtin_knowledge_search', - description: `Search the knowledge base for relevant information using pre-analyzed search intent. + description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored. -Pre-extracted search queries: "${extractedKeywords.question.join(', ')}" -Rewritten query: "${extractedKeywords.rewrite}" +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')} +- Query rewrite: "${extractedKeywords.rewrite}" -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`, inputSchema: z.object({ additionalContext: z diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts index 2b0b7fb13..2d6e31830 100644 --- a/src/renderer/src/aiCore/tools/WebSearchTool.ts +++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts @@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = ( return tool({ name: 'builtin_web_search', - description: `Search the web and return citable sources using pre-analyzed search intent. + description: `Web search tool for finding current information, news, and real-time data from the internet. -Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${ - extractedKeywords.links +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${ + extractedKeywords.links?.length ? ` -Relevant links: ${extractedKeywords.links.join(', ')}` +- Relevant URLs: ${extractedKeywords.links.join(', ')}` : '' } -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`, inputSchema: z.object({ additionalContext: z