Compare commits

...

12 Commits

Author SHA1 Message Date
kangfenmao
5365fddec9 chore: bump version to 1.6.2
- Updated release notes to reflect recent optimizations and bug fixes, including improvements to the note-taking feature and resolution of issues with CherryAI and VertexAI.
- Bumped version number from 1.6.1 to 1.6.2 in package.json.
2025-09-28 15:07:21 +08:00
kangfenmao
e401685449 lint: fix code format 2025-09-28 14:57:01 +08:00
MyPrototypeWhat
e195ad4a8f 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
2025-09-28 14:56:04 +08:00
one
20f5271682 fix: quick assistant avatar and search (#10281) 2025-09-28 14:15:56 +08:00
Phantom
5524571c80 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).
2025-09-28 14:09:11 +08:00
Phantom
cd3031479c fix(reasoning): correct regex pattern for deepseek model detection (#10407) 2025-09-28 14:07:30 +08:00
Pleasure1234
1df6e8c732 refactor(notes): improve notes management with local state and file handling (#10395)
* 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 <noreply@anthropic.com>

* Revert "Merge remote-tracking branch 'origin/main' into refactor/note"

This reverts commit 389386ace8, reversing
changes made to 4428f511b0.

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-09-28 13:50:52 +08:00
Xin Rui
ed2e01491e fix: clear @ and other input text when exiting model selection menu w… (#10427)
fix: clear @ and other input text when exiting model selection menu with Esc
2025-09-28 13:44:27 +08:00
kangfenmao
228ed474ce 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.
2025-09-28 13:31:18 +08:00
亢奋猫
6829a03437 fix: AI_APICallError for Gemini via proxy #10366 (#10429)
When sending requests to Gemini via proxy, the system returns:
"模型不存在或者请求路径错误".
2025-09-28 13:01:49 +08:00
Phantom
dabfb8dc0e style(settings): remove unnecessary padding from ContentContainer (#10379) 2025-09-26 17:50:00 +08:00
Zhaokun
4aa9c9f225 feat: improve content protection during file operations (#10378)
* feat: improve content protection during file operations

- Add validation for knowledge base configuration before saving
- Enhance error handling for note content reading
- Implement content backup and restoration during file rename
- Add content verification after rename operations
- Improve user feedback with specific error messages

* fix: format check

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
2025-09-26 17:49:24 +08:00
24 changed files with 1280 additions and 1471 deletions

View File

@@ -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')) {
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return `models/${modelId}`;
+ }
return modelId.includes("/") ? modelId : `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

View File

@@ -125,59 +125,7 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
🚀 New Features:
- Refactored AI core engine for more efficient and stable content generation
- Added support for multiple AI model providers: CherryIN, AiOnly
- Added API server functionality for external application integration
- Added PaddleOCR document recognition for enhanced document processing
- Added Anthropic OAuth authentication support
- Added data storage space limit notifications
- Added font settings for global and code fonts customization
- Added auto-copy feature after translation completion
- Added keyboard shortcuts: rename topic, edit last message, etc.
- Added text attachment preview for viewing file contents in messages
- Added custom window control buttons (minimize, maximize, close)
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
- Support for Qwen image recognition models (Qwen-Image)
- Added iFlow CLI support
- Converted knowledge base and web search to tool-calling approach for better flexibility
🎨 UI Improvements & Bug Fixes:
- Integrated HeroUI and Tailwind CSS framework
- Optimized message notification styles with unified toast component
- Moved free models to bottom with fixed position for easier access
- Refactored quick panel and input bar tools for smoother operation
- Optimized responsive design for navbar and sidebar
- Improved scrollbar component with horizontal scrolling support
- Fixed multiple translation issues: paste handling, file processing, state management
- Various UI optimizations and bug fixes
<!--LANG:zh-CN-->
🚀 新功能:
- 重构 AI 核心引擎,提供更高效稳定的内容生成
- 新增多个 AI 模型提供商支持CherryIN、AiOnly
- 新增 API 服务器功能,支持外部应用集成
- 新增 PaddleOCR 文档识别,增强文档处理能力
- 新增 Anthropic OAuth 认证支持
- 新增数据存储空间限制提醒
- 新增字体设置,支持全局字体和代码字体自定义
- 新增翻译完成后自动复制功能
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
- 新增文本附件预览,可查看消息中的文件内容
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
- 支持通义千问长文本qwen-long和文档分析qwen-doc模型原生文件上传
- 支持通义千问图像识别模型Qwen-Image
- 新增 iFlow CLI 支持
- 知识库和网页搜索转换为工具调用方式,提升灵活性
🎨 界面改进与问题修复:
- 集成 HeroUI 和 Tailwind CSS 框架
- 优化消息通知样式,统一 toast 组件
- 免费模型移至底部固定位置,便于访问
- 重构快捷面板和输入栏工具,操作更流畅
- 优化导航栏和侧边栏响应式设计
- 改进滚动条组件,支持水平滚动
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
- 各种界面优化和问题修复
<!--LANG:END-->
Optimized note-taking feature, now able to quickly rename by modifying the title
Fixed issue where CherryAI free model could not be used
Fixed issue where VertexAI proxy address could not be called normally
Fixed issue where built-in tools from service providers could not be called normally

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.1",
"version": "1.6.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",

View File

@@ -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",

View File

@@ -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'

View File

@@ -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使用原生配置

View File

@@ -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

View File

@@ -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

View File

@@ -253,12 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
let savedCount = 0
try {
// Validate knowledge base configuration before proceeding
if (!selectedBaseId) {
throw new Error('No knowledge base selected')
}
const selectedBase = bases.find((base) => base.id === selectedBaseId)
if (!selectedBase) {
throw new Error('Selected knowledge base not found')
}
if (!selectedBase.version) {
throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.')
}
if (isNoteMode) {
const note = source.data as NotesTreeNode
const content = note.externalPath
? await window.api.file.readExternal(note.externalPath)
: await window.api.file.read(note.id + '.md')
logger.debug('Note content:', content)
if (!note.externalPath) {
throw new Error('Note external path is required for export')
}
let content = ''
try {
content = await window.api.file.readExternal(note.externalPath)
} catch (error) {
logger.error('Failed to read note file:', error as Error)
throw new Error('Failed to read note content. Please ensure the file exists and is accessible.')
}
if (!content || content.trim() === '') {
throw new Error('Note content is empty. Cannot export empty notes to knowledge base.')
}
logger.debug('Note content loaded', { contentLength: content.length })
await addNote(content)
savedCount = 1
} else {
@@ -283,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
resolve({ success: true, savedCount })
} catch (error) {
logger.error('save failed:', error as Error)
window.toast.error(
t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed')
// Provide more specific error messages
let errorMessage = t(
isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed'
)
if (error instanceof Error) {
if (error.message.includes('not properly configured')) {
errorMessage = error.message
} else if (error.message.includes('empty')) {
errorMessage = error.message
} else if (error.message.includes('read note content')) {
errorMessage = error.message
}
}
window.toast.error(errorMessage)
setLoading(false)
}
}

View File

@@ -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

View File

@@ -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<QuickPhrase, 'id'>
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
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

View File

@@ -250,21 +250,19 @@ const MentionModelsButton: FC<Props> = ({
// ESC关闭时的处理删除 @ 和搜索文本
if (action === 'esc') {
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
if (
hasModelActionRef.current &&
ctx.triggerInfo?.type === 'input' &&
ctx.triggerInfo?.position !== undefined
) {
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
if (hasModelActionRef.current && 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
}
})
},

View File

@@ -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)
}

View File

@@ -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<any>(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<NotesTreeNode>[] = []
// 逐级展开从根到目标路径的所有文件夹
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<HTMLInputElement>) => {
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 }) => {
</HStack>
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
<BreadcrumbsContainer>
<Breadcrumbs>
{breadcrumbItems.map((item, index) => (
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
<Breadcrumbs style={{ borderRadius: 0 }}>
{breadcrumbItems.map((item, index) => {
const isLastItem = index === breadcrumbItems.length - 1
const isCurrentNote = isLastItem && !item.isFolder
return (
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
{isCurrentNote ? (
<TitleInputWrapper>
<TitleInput
ref={titleInputRef}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
size="small"
variant="borderless"
style={{
fontSize: 'inherit',
padding: 0,
height: 'auto',
lineHeight: 'inherit'
}}
/>
</TitleInputWrapper>
) : (
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
$clickable={item.isFolder && !isLastItem}>
{item.title}
</BreadcrumbTitle>
)}
</BreadcrumbItem>
))}
)
})}
</Breadcrumbs>
</BreadcrumbsContainer>
</NavbarCenter>
@@ -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

View File

@@ -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<RichEditorRef | null>
onMarkdownChange: (content: string) => void
}
const NotesEditor: FC<NotesEditorProps> = 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<NotesEditorProps> = memo(
)
}
if (isLoading) {
return (
<LoadingContainer>
<Spin tip={t('common.loading')} />
</LoadingContainer>
)
}
return (
<>
<RichEditorContainer>
@@ -122,14 +113,6 @@ const NotesEditor: FC<NotesEditorProps> = 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;

View File

@@ -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<NotesTreeNode[]>([])
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<string | null>(null)
const watcherRef = useRef<(() => void) | null>(null)
const isSyncingTreeRef = useRef(false)
const lastContentRef = useRef<string>('')
const lastFilePathRef = useRef<string | undefined>(undefined)
const isInitialSortApplied = useRef(false)
const isRenamingRef = useRef(false)
const isCreatingNoteRef = useRef(false)
const activeFilePathRef = useRef<string | undefined>(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
useEffect(() => {
refreshTree()
}, [refreshTree])
// Re-merge tree state when starred or expanded paths change
useEffect(() => {
if (notesTree.length > 0) {
setNotesTree((prev) => mergeTreeState(prev))
}
if (node.children) {
const found = findNodeById(node.children, nodeId)
if (found) return found
}
}
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) => {
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
}
},
[toggleNodeExpanded]
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)
)
// 切换收藏状态
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,29 +537,30 @@ const NotesPage: FC = () => {
try {
isRenamingRef.current = true
const tree = await getNotesTree()
const node = findNodeById(tree, nodeId)
const node = findNode(notesTree, nodeId)
if (!node || node.name === newName) {
return
}
if (node && node.name !== newName) {
const oldExternalPath = node.externalPath
const renamedNode = await renameNode(nodeId, newName)
const oldPath = node.externalPath
const renamed = await renameEntry(node, newName)
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
dispatch(setActiveFilePath(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))
}
await sortAllLevels(sortType)
if (renamedNode.name !== newName) {
window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
}
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 {
@@ -518,7 +569,7 @@ const NotesPage: FC = () => {
}, 500)
}
},
[activeFilePath, dispatch, findNodeById, sortType, t]
[activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
)
// 处理文件上传
@@ -535,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) {
@@ -544,7 +595,8 @@ const NotesPage: FC = () => {
}
// 排序并显示成功信息
await sortAllLevels(sortType)
updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath)))
await refreshTree()
const successMessage = t('notes.upload_success')
@@ -554,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') => {
try {
const result = await moveNode(sourceNodeId, targetNodeId, position)
if (result.success && result.type !== 'manual_reorder') {
await sortAllLevels(sortType)
if (!notesPath) {
return
}
try {
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
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(() => {
@@ -631,12 +787,13 @@ const NotesPage: FC = () => {
notesTree={notesTree}
getCurrentNoteContent={getCurrentNoteContent}
onToggleStar={handleToggleStar}
onExpandPath={handleExpandPath}
onRenameNode={handleRenameNode}
/>
<NotesEditor
activeNodeId={activeNode?.id}
currentContent={currentContent}
tokenCount={tokenCount}
isLoading={isContentLoading}
onMarkdownChange={handleMarkdownChange}
editorRef={editorRef}
/>

View File

@@ -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<TreeNodeProps>(
({
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 (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<TreeNodeContainer
active={isActive}
depth={depth}
isDragging={isDragging}
isDragOver={isDragOver}
isDragBefore={isDragBefore}
isDragInside={isDragInside}
isDragAfter={isDragAfter}
draggable={!isEditing}
data-node-id={node.id}
onDragStart={(e) => onDragStart(e, node)}
onDragOver={(e) => onDragOver(e, node)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node)}
onDragEnd={onDragEnd}>
<TreeNodeContent onClick={() => onSelectNode(node)}>
<NodeIndent depth={depth} />
{node.type === 'folder' && (
<ExpandIcon
onClick={(e) => {
e.stopPropagation()
onToggleExpanded(node.id)
}}
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ExpandIcon>
)}
<NodeIcon>
{node.type === 'folder' ? (
node.expanded ? (
<FolderOpen size={16} />
) : (
<Folder size={16} />
)
) : (
<File size={16} />
)}
</NodeIcon>
{isEditing ? (
<EditInput
ref={inPlaceEdit.inputRef as Ref<InputRef>}
value={inPlaceEdit.editValue}
onChange={inPlaceEdit.handleInputChange}
onBlur={inPlaceEdit.saveEdit}
onKeyDown={inPlaceEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
</div>
</Dropdown>
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
<div>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
activeNodeId={activeNodeId}
editingNodeId={editingNodeId}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragEnd={onDragEnd}
renderChildren={renderChildren}
/>
))}
</div>
)}
</div>
)
}
)
const NotesSidebar: FC<NotesSidebarProps> = ({
onCreateFolder,
onCreateNote,
@@ -268,9 +420,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
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<NotesSidebarProps> = ({
}
}
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<HTMLDivElement>(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<NotesSidebarProps> = ({
[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 (
<div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<TreeNodeContainer
active={isActive}
depth={depth}
isDragging={isDragging}
isDragOver={isDragOver}
isDragBefore={isDragBefore}
isDragInside={isDragInside}
isDragAfter={isDragAfter}
draggable={!isEditing}
data-node-id={node.id}
onDragStart={(e) => handleDragStart(e, node)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}>
<TreeNodeContent onClick={() => onSelectNode(node)}>
<NodeIndent depth={depth} />
{node.type === 'folder' && (
<ExpandIcon
onClick={(e) => {
e.stopPropagation()
onToggleExpanded(node.id)
}}
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ExpandIcon>
)}
<NodeIcon>
{node.type === 'folder' ? (
node.expanded ? (
<FolderOpen size={16} />
) : (
<Folder size={16} />
)
) : (
<File size={16} />
)}
</NodeIcon>
{isEditing ? (
<EditInput
ref={inPlaceEdit.inputRef as Ref<InputRef>}
value={inPlaceEdit.editValue}
onChange={inPlaceEdit.handleInputChange}
onPressEnter={inPlaceEdit.saveEdit}
onBlur={inPlaceEdit.saveEdit}
onKeyDown={inPlaceEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
size="small"
/>
) : (
<NodeName>{node.name}</NodeName>
)}
</TreeNodeContent>
</TreeNodeContainer>
</div>
</Dropdown>
{node.type === 'folder' && node.expanded && hasChildren && (
<div>{node.children!.map((child) => renderTreeNode(child, depth + 1))}</div>
)}
</div>
)
},
[
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,115 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
/>
<NotesTreeContainer>
{shouldUseVirtualization ? (
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
/>
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
) : (
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{filteredTree.map((node) => renderTreeNode(node))}
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
@@ -582,6 +774,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
)}
</TreeContent>
</StyledScrollbar>
)}
</NotesTreeContainer>
{isDragOverSidebar && <DragOverIndicator />}
@@ -592,7 +785,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
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)

View File

@@ -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)

View File

@@ -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')}>
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
placeholder={t('settings.models.quick_assistant_selection')}
showSearch
options={[
{
key: defaultAssistant.id,
value: defaultAssistant.id,
title: defaultAssistant.name,
label: (
<AssistantItem>
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
</AssistantItem>
</Select.Option>
{assistants
)
},
...assistants
.filter((a) => a.id !== defaultAssistant.id)
.map((a) => (
<Select.Option key={a.id} value={a.id}>
.map((a) => ({
key: a.id,
value: a.id,
title: a.name,
label: (
<AssistantItem>
<ModelAvatar model={a.model || defaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>
</Select.Option>
))}
</Select>
)
}))
]}
filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')}
/>
</HStack>
)}
<HStack alignItems="center" gap={0}>

View File

@@ -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<void> {
const tree = await window.api.file.getDirectoryStructure(folderPath)
await sortAllLevels(sortType, tree)
}
/**
* 创建新文件夹
*/
export async function createFolder(name: string, folderPath: string): Promise<NotesTreeNode> {
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<NotesTreeNode> {
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<UploadResult> {
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<NotesTreeNode[]> {
return window.api.file.getDirectoryStructure(normalizePath(rootPath))
}
/**
* 删除笔记或文件夹
*/
export async function deleteNode(nodeId: string): Promise<void> {
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<void> {
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<NotesTreeNode> {
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') {
return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName }
}
await window.api.file.renameDir(node.externalPath, safeName)
}
return renameNodeFromTree(tree, nodeId, safeName)
return { path: `${parentDir}/${safeName}`, name: safeName }
}
/**
* 移动节点
*/
export async function moveNode(
sourceNodeId: string,
targetNodeId: string,
position: 'before' | 'after' | 'inside'
): Promise<MoveNodeResult> {
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
const basePath = normalizePath(targetPath)
const markdownFiles = filterMarkdown(files)
const skippedFiles = files.length - markdownFiles.length
if (markdownFiles.length === 0) {
return {
uploadedNodes: [],
totalFiles: files.length,
skippedFiles,
fileCount: 0,
folderCount: 0
}
}
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 tree = await getNotesTree()
// 找到源节点和目标节点
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 (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
logger.error('Move nodes failed: cannot move a folder inside a file')
return { success: false }
}
// 不允许将节点移动到自身内部
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}`)
const content = await file.text()
await window.api.file.write(finalPath, content)
fileCount += 1
} catch (error) {
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
return { success: false }
logger.error('Failed to write uploaded file:', error as Error)
}
}
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<void> {
function getTime(value?: string): number {
return value ? new Date(value).getTime() : 0
}
function normalizePath(value: string): string {
return value.replace(/\\/g, '/')
}
function filterMarkdown(files: File[]): File[] {
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
}
function collectFolders(files: File[], basePath: string): Set<string> {
const folders = new Set<string>()
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)
}
})
return folders
}
async function createFolders(folders: Set<string>): Promise<void> {
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
for (const folder of ordered) {
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}`)
await window.api.file.mkdir(folder)
} catch (error) {
logger.error('Failed to sort all levels of notes:', error as Error)
throw error
}
}
/**
* 递归对节点中的子节点进行排序
*/
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 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
}
/**
* 过滤出 Markdown 文件
*/
function filterMarkdownFiles(files: File[]): File[] {
return Array.from(files).filter((file) => {
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
return true
}
logger.warn(`Skipping non-markdown file: ${file.name}`)
return false
logger.debug('Skip existing folder while uploading notes', {
folder,
error: (error as Error).message
})
}
/**
* 创建空的上传结果
*/
function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult {
return {
uploadedNodes: [],
totalFiles,
skippedFiles,
fileCount: 0,
folderCount: 0
}
}
}
/**
* 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath
*/
async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise<File[]> {
// 按根文件夹名称分组文件
const filesByRootFolder = new Map<string, File[]>()
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)
}
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 }
}
// 为每个根文件夹组生成唯一的文件夹名称
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
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
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<string, File[]>; foldersToCreate: Set<string> } {
const filesByPath = new Map<string, File[]>()
const foldersToCreate = new Set<string>()
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<string>,
targetFolderPath: string,
tree: NotesTreeNode[],
uploadedNodes: NotesTreeNode[]
): Promise<Map<string, NotesTreeNode>> {
const createdFolders = new Map<string, NotesTreeNode>()
const sortedFolders = Array.from(foldersToCreate).sort()
const folderCreationLock = new Set<string>()
for (const folderPath of sortedFolders) {
if (folderCreationLock.has(folderPath)) {
continue
}
folderCreationLock.add(folderPath)
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}`)
}
} catch (error) {
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
} finally {
folderCreationLock.delete(folderPath)
}
}
return createdFolders
}
/**
* 创建单个文件夹
*/
async function createSingleFolder(
folderPath: string,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
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<string> {
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<string, File[]>,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>,
uploadedNodes: NotesTreeNode[]
): Promise<void> {
const uploadPromises: Promise<NotesTreeNode | null>[] = []
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)
}
}
const results = await Promise.all(uploadPromises)
results.forEach((result) => {
if (result) {
uploadedNodes.push(result)
}
})
}
/**
* 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点
*/
async function uploadSingleFile(
file: File,
originalDirPath: string,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
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 }
}

View File

@@ -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<NotesTreeNode[]> => {
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<NotesTreeNode[]> {
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<boolean> {
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<boolean> {
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})`)
return false
}
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
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
}
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
}
} catch (error) {
logger.error('Move nodes in tree failed:', error as Error)
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))
})
}
/**
* 重命名节点
*/
export async function renameNodeFromTree(
tree: NotesTreeNode[],
nodeId: string,
newName: string
): Promise<NotesTreeNode> {
const node = findNodeInTree(tree, nodeId)
if (!node) {
throw new Error('Node not found')
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
}
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
if (deep && item.startsWith(prefix)) {
return `${newNormalized}${item.slice(oldNormalized.length)}`
}
return item
})
}
/**
* 修改节点键值
*/
export async function updateNodeInTree(
tree: NotesTreeNode[],
nodeId: string,
updates: Partial<NotesTreeNode>
): Promise<NotesTreeNode> {
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) {
if (!node.children) {
continue
}
if (node.children.some((child) => child.id === nodeId)) {
return node
}
const parent = findParentNode(node.children, targetNodeId)
if (parent) {
return parent
}
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
let changed = false
const nextNodes = nodes.map((node) => {
if (!node.children || node.children.length === 0) {
return node
}
if (parentNode.children.some((child) => child.id === childId)) {
return true
const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position)
if (reorderedChildren !== node.children) {
changed = true
return { ...node, children: reorderedChildren }
}
for (const child of parentNode.children) {
if (isParentNode(tree, child.id, childId)) {
return true
}
}
return node
})
return false
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]
}
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]
}

View File

@@ -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<NotesSortType>) => {
state.sortType = action.payload
},
setStarredPaths: (state, action: PayloadAction<string[]>) => {
state.starredPaths = action.payload ?? []
},
setExpandedPaths: (state, action: PayloadAction<string[]>) => {
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

View File

@@ -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<NotesTreeNode> => {
export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise<void> => {
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<NotesTreeNode> => {
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<void> => {
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'))

View File

@@ -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"