Compare commits
12 Commits
feat/messa
...
v1.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5365fddec9 | ||
|
|
e401685449 | ||
|
|
e195ad4a8f | ||
|
|
20f5271682 | ||
|
|
5524571c80 | ||
|
|
cd3031479c | ||
|
|
1df6e8c732 | ||
|
|
ed2e01491e | ||
|
|
228ed474ce | ||
|
|
6829a03437 | ||
|
|
dabfb8dc0e | ||
|
|
4aa9c9f225 |
@@ -1,36 +1,13 @@
|
|||||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
|
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce11076974 100644
|
||||||
--- a/dist/index.mjs
|
--- a/dist/index.mjs
|
||||||
+++ b/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
|
// src/get-model-path.ts
|
||||||
-function getModelPath(modelId) {
|
function getModelPath(modelId) {
|
||||||
+function getModelPath(modelId, baseURL) {
|
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||||
+ if (baseURL?.includes('cherryin')) {
|
+ return `models/${modelId}`;
|
||||||
+ return `models/${modelId}`;
|
|
||||||
+ }
|
|
||||||
return modelId.includes("/") ? modelId : `models/${modelId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
|
// src/google-generative-ai-options.ts
|
||||||
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,
|
|
||||||
|
|||||||
@@ -125,59 +125,7 @@ afterSign: scripts/notarize.js
|
|||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
<!--LANG:en-->
|
Optimized note-taking feature, now able to quickly rename by modifying the title
|
||||||
🚀 New Features:
|
Fixed issue where CherryAI free model could not be used
|
||||||
- Refactored AI core engine for more efficient and stable content generation
|
Fixed issue where VertexAI proxy address could not be called normally
|
||||||
- Added support for multiple AI model providers: CherryIN, AiOnly
|
Fixed issue where built-in tools from service providers could not be called normally
|
||||||
- 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-->
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.6.1",
|
"version": "1.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"@ai-sdk/anthropic": "^2.0.17",
|
"@ai-sdk/anthropic": "^2.0.17",
|
||||||
"@ai-sdk/azure": "^2.0.30",
|
"@ai-sdk/azure": "^2.0.30",
|
||||||
"@ai-sdk/deepseek": "^1.0.17",
|
"@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": "^2.0.30",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import os from 'node:os'
|
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) {
|
if (entry.isDirectory() && options.includeDirectories) {
|
||||||
const stats = await fs.promises.stat(entryPath)
|
const stats = await fs.promises.stat(entryPath)
|
||||||
|
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||||
const dirTreeNode: NotesTreeNode = {
|
const dirTreeNode: NotesTreeNode = {
|
||||||
id: uuidv4(),
|
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
treePath: treePath,
|
treePath: treePath,
|
||||||
externalPath: entryPath,
|
externalPath: externalDirPath,
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
updatedAt: stats.mtime.toISOString(),
|
updatedAt: stats.mtime.toISOString(),
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
@@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
|||||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||||
: `/${nameWithoutExt}`
|
: `/${nameWithoutExt}`
|
||||||
|
|
||||||
|
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||||
const fileTreeNode: NotesTreeNode = {
|
const fileTreeNode: NotesTreeNode = {
|
||||||
id: uuidv4(),
|
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||||
name: name,
|
name: name,
|
||||||
treePath: fileTreePath,
|
treePath: fileTreePath,
|
||||||
externalPath: entryPath,
|
externalPath: externalFilePath,
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
updatedAt: stats.mtime.toISOString(),
|
updatedAt: stats.mtime.toISOString(),
|
||||||
type: 'file'
|
type: 'file'
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService'
|
|||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||||
import { formatApiHost } from '@renderer/utils/api'
|
import { formatApiHost } from '@renderer/utils/api'
|
||||||
import { cloneDeep, isEmpty } from 'lodash'
|
import { cloneDeep, trim } from 'lodash'
|
||||||
|
|
||||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||||
import { getAiSdkProviderId } from './factory'
|
import { getAiSdkProviderId } from './factory'
|
||||||
@@ -120,7 +120,7 @@ export function providerToAiSdkConfig(
|
|||||||
|
|
||||||
// 构建基础配置
|
// 构建基础配置
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
baseURL: actualProvider.apiHost,
|
baseURL: trim(actualProvider.apiHost),
|
||||||
apiKey: getRotatedApiKey(actualProvider)
|
apiKey: getRotatedApiKey(actualProvider)
|
||||||
}
|
}
|
||||||
// 处理OpenAI模式
|
// 处理OpenAI模式
|
||||||
@@ -195,7 +195,10 @@ export function providerToAiSdkConfig(
|
|||||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
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,使用原生配置
|
// 如果AI SDK支持该provider,使用原生配置
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ export const knowledgeSearchTool = (
|
|||||||
) => {
|
) => {
|
||||||
return tool({
|
return tool({
|
||||||
name: 'builtin_knowledge_search',
|
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(', ')}"
|
This tool has been configured with search parameters based on the conversation context:
|
||||||
Rewritten query: "${extractedKeywords.rewrite}"
|
- 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({
|
inputSchema: z.object({
|
||||||
additionalContext: z
|
additionalContext: z
|
||||||
|
|||||||
@@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = (
|
|||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
name: 'builtin_web_search',
|
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(', ')}"${
|
This tool has been configured with search parameters based on the conversation context:
|
||||||
extractedKeywords.links
|
- 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({
|
inputSchema: z.object({
|
||||||
additionalContext: z
|
additionalContext: z
|
||||||
|
|||||||
@@ -253,12 +253,39 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
|||||||
let savedCount = 0
|
let savedCount = 0
|
||||||
|
|
||||||
try {
|
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) {
|
if (isNoteMode) {
|
||||||
const note = source.data as NotesTreeNode
|
const note = source.data as NotesTreeNode
|
||||||
const content = note.externalPath
|
if (!note.externalPath) {
|
||||||
? await window.api.file.readExternal(note.externalPath)
|
throw new Error('Note external path is required for export')
|
||||||
: await window.api.file.read(note.id + '.md')
|
}
|
||||||
logger.debug('Note content:', content)
|
|
||||||
|
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)
|
await addNote(content)
|
||||||
savedCount = 1
|
savedCount = 1
|
||||||
} else {
|
} else {
|
||||||
@@ -283,9 +310,23 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
|
|||||||
resolve({ success: true, savedCount })
|
resolve({ success: true, savedCount })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('save failed:', error as 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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
|||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同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
|
export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
// Import necessary types for blocks and new message structure
|
// Import necessary types for blocks and new message structure
|
||||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { NotesTreeNode } from '@renderer/types/note'
|
|
||||||
import { Dexie, type EntityTable } from 'dexie'
|
import { Dexie, type EntityTable } from 'dexie'
|
||||||
|
|
||||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||||
@@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
|||||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
@@ -118,8 +116,7 @@ db.version(10).stores({
|
|||||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||||
translate_languages: '&id, langCode',
|
translate_languages: '&id, langCode',
|
||||||
quick_phrases: 'id',
|
quick_phrases: 'id',
|
||||||
message_blocks: 'id, messageId, file.id',
|
message_blocks: 'id, messageId, file.id'
|
||||||
notes_tree: '&id'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -250,21 +250,19 @@ const MentionModelsButton: FC<Props> = ({
|
|||||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||||
if (action === 'esc') {
|
if (action === 'esc') {
|
||||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||||
if (
|
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
||||||
hasModelActionRef.current &&
|
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
||||||
ctx.triggerInfo?.type === 'input' &&
|
|
||||||
ctx.triggerInfo?.position !== undefined
|
|
||||||
) {
|
|
||||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||||
setText((currentText) => {
|
setText((currentText) => {
|
||||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
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删除@的情况(delete-symbol):
|
||||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||||
|
triggerInfoRef.current = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
|||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const onRemoveBlock = () => {
|
const onRemoveBlock = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout'
|
|||||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
import { findNode } from '@renderer/services/NotesTreeService'
|
||||||
import { NotesTreeNode } from '@types'
|
import { Dropdown, Input, Tooltip } from 'antd'
|
||||||
import { Dropdown, Tooltip } from 'antd'
|
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
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 styled from 'styled-components'
|
||||||
|
|
||||||
import { menuItems } from './MenuConfig'
|
import { menuItems } from './MenuConfig'
|
||||||
|
|
||||||
const logger = loggerService.withContext('HeaderNavbar')
|
const logger = loggerService.withContext('HeaderNavbar')
|
||||||
|
|
||||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
|
||||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||||
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
||||||
>([])
|
>([])
|
||||||
|
const [titleValue, setTitleValue] = useState('')
|
||||||
|
const titleInputRef = useRef<any>(null)
|
||||||
const { settings, updateSettings } = useNotesSettings()
|
const { settings, updateSettings } = useNotesSettings()
|
||||||
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
||||||
|
|
||||||
@@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
|||||||
}, [getCurrentNoteContent])
|
}, [getCurrentNoteContent])
|
||||||
|
|
||||||
const handleBreadcrumbClick = useCallback(
|
const handleBreadcrumbClick = useCallback(
|
||||||
async (item: { treePath: string; isFolder: boolean }) => {
|
(item: { treePath: string; isFolder: boolean }) => {
|
||||||
if (item.isFolder && notesTree) {
|
if (item.isFolder && onExpandPath) {
|
||||||
try {
|
onExpandPath(item.treePath)
|
||||||
// 获取从根目录到点击目录的所有路径片段
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[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) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeNode || !notesTree) {
|
if (!activeNode || !notesTree) {
|
||||||
setBreadcrumbItems([])
|
setBreadcrumbItems([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const node = findNodeInTree(notesTree, activeNode.id)
|
const node = findNode(notesTree, activeNode.id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||||
@@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
||||||
<BreadcrumbsContainer>
|
<BreadcrumbsContainer>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs style={{ borderRadius: 0 }}>
|
||||||
{breadcrumbItems.map((item, index) => (
|
{breadcrumbItems.map((item, index) => {
|
||||||
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
|
const isLastItem = index === breadcrumbItems.length - 1
|
||||||
<BreadcrumbTitle
|
const isCurrentNote = isLastItem && !item.isFolder
|
||||||
onClick={() => handleBreadcrumbClick(item)}
|
|
||||||
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
|
return (
|
||||||
{item.title}
|
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
|
||||||
</BreadcrumbTitle>
|
{isCurrentNote ? (
|
||||||
</BreadcrumbItem>
|
<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 && !isLastItem}>
|
||||||
|
{item.title}
|
||||||
|
</BreadcrumbTitle>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</BreadcrumbsContainer>
|
</BreadcrumbsContainer>
|
||||||
</NavbarCenter>
|
</NavbarCenter>
|
||||||
@@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div`
|
|||||||
align-items: center;
|
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 {
|
& li:not(:last-child)::after {
|
||||||
flex-shrink: 0;
|
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
|
export default HeaderNavbar
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
|||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { EditorView } from '@renderer/types'
|
import { EditorView } from '@renderer/types'
|
||||||
import { Empty, Spin } from 'antd'
|
import { Empty } from 'antd'
|
||||||
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@@ -14,13 +14,12 @@ interface NotesEditorProps {
|
|||||||
activeNodeId?: string
|
activeNodeId?: string
|
||||||
currentContent: string
|
currentContent: string
|
||||||
tokenCount: number
|
tokenCount: number
|
||||||
isLoading: boolean
|
|
||||||
editorRef: RefObject<RichEditorRef | null>
|
editorRef: RefObject<RichEditorRef | null>
|
||||||
onMarkdownChange: (content: string) => void
|
onMarkdownChange: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotesEditor: FC<NotesEditorProps> = memo(
|
const NotesEditor: FC<NotesEditorProps> = memo(
|
||||||
({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => {
|
({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { settings } = useNotesSettings()
|
const { settings } = useNotesSettings()
|
||||||
const currentViewMode = useMemo(() => {
|
const currentViewMode = useMemo(() => {
|
||||||
@@ -47,14 +46,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<LoadingContainer>
|
|
||||||
<Spin tip={t('common.loading')} />
|
|
||||||
</LoadingContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RichEditorContainer>
|
<RichEditorContainer>
|
||||||
@@ -122,14 +113,6 @@ const NotesEditor: FC<NotesEditorProps> = memo(
|
|||||||
|
|
||||||
NotesEditor.displayName = 'NotesEditor'
|
NotesEditor.displayName = 'NotesEditor'
|
||||||
|
|
||||||
const LoadingContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
`
|
|
||||||
|
|
||||||
const EmptyContainer = styled.div`
|
const EmptyContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo
|
|||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||||
import {
|
import {
|
||||||
createFolder,
|
addDir,
|
||||||
createNote,
|
addNote,
|
||||||
deleteNode,
|
delNode,
|
||||||
initWorkSpace,
|
loadTree,
|
||||||
moveNode,
|
renameNode as renameEntry,
|
||||||
renameNode,
|
sortTree,
|
||||||
sortAllLevels,
|
uploadNotes
|
||||||
uploadFiles
|
|
||||||
} from '@renderer/services/NotesService'
|
} from '@renderer/services/NotesService'
|
||||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
addUniquePath,
|
||||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
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 { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
import { FileChangeEvent } from '@shared/config/types'
|
import { FileChangeEvent } from '@shared/config/types'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -37,27 +54,98 @@ const NotesPage: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showWorkspace } = useShowWorkspace()
|
const { showWorkspace } = useShowWorkspace()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const store = useAppStore()
|
||||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||||
const sortType = useAppSelector(selectSortType)
|
const sortType = useAppSelector(selectSortType)
|
||||||
|
const starredPaths = useAppSelector(selectStarredPaths)
|
||||||
|
const expandedPaths = useAppSelector(selectExpandedPaths)
|
||||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||||
|
|
||||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||||
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
|
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
|
||||||
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
|
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
|
||||||
|
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const { invalidateFileContent } = useFileContentSync()
|
const { invalidateFileContent } = useFileContentSync()
|
||||||
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
|
const { data: currentContent = '' } = useFileContent(activeFilePath)
|
||||||
|
|
||||||
const [tokenCount, setTokenCount] = useState(0)
|
const [tokenCount, setTokenCount] = useState(0)
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||||
const watcherRef = useRef<(() => void) | null>(null)
|
const watcherRef = useRef<(() => void) | null>(null)
|
||||||
const isSyncingTreeRef = useRef(false)
|
|
||||||
const lastContentRef = useRef<string>('')
|
const lastContentRef = useRef<string>('')
|
||||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||||
const isInitialSortApplied = useRef(false)
|
|
||||||
const isRenamingRef = useRef(false)
|
const isRenamingRef = useRef(false)
|
||||||
const isCreatingNoteRef = 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(() => {
|
useEffect(() => {
|
||||||
const updateCharCount = () => {
|
const updateCharCount = () => {
|
||||||
const textContent = editorRef.current?.getContent() || currentContent
|
const textContent = editorRef.current?.getContent() || currentContent
|
||||||
@@ -67,19 +155,16 @@ const NotesPage: FC = () => {
|
|||||||
updateCharCount()
|
updateCharCount()
|
||||||
}, [currentContent])
|
}, [currentContent])
|
||||||
|
|
||||||
// 查找树节点 by ID
|
useEffect(() => {
|
||||||
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
refreshTree()
|
||||||
for (const node of tree) {
|
}, [refreshTree])
|
||||||
if (node.id === nodeId) {
|
|
||||||
return node
|
// Re-merge tree state when starred or expanded paths change
|
||||||
}
|
useEffect(() => {
|
||||||
if (node.children) {
|
if (notesTree.length > 0) {
|
||||||
const found = findNodeById(node.children, nodeId)
|
setNotesTree((prev) => mergeTreeState(prev))
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
}, [starredPaths, expandedPaths, mergeTreeState, notesTree.length])
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 保存当前笔记内容
|
// 保存当前笔记内容
|
||||||
const saveCurrentNote = useCallback(
|
const saveCurrentNote = useCallback(
|
||||||
@@ -107,6 +192,11 @@ const NotesPage: FC = () => {
|
|||||||
[saveCurrentNote]
|
[saveCurrentNote]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const saveCurrentNoteRef = useRef(saveCurrentNote)
|
||||||
|
const debouncedSaveRef = useRef(debouncedSave)
|
||||||
|
const invalidateFileContentRef = useRef(invalidateFileContent)
|
||||||
|
const refreshTreeRef = useRef(refreshTree)
|
||||||
|
|
||||||
const handleMarkdownChange = useCallback(
|
const handleMarkdownChange = useCallback(
|
||||||
(newMarkdown: string) => {
|
(newMarkdown: string) => {
|
||||||
// 记录最新内容和文件路径,用于兜底保存
|
// 记录最新内容和文件路径,用于兜底保存
|
||||||
@@ -118,6 +208,30 @@ const NotesPage: FC = () => {
|
|||||||
[debouncedSave, activeFilePath]
|
[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(() => {
|
useEffect(() => {
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
if (!notesPath) {
|
if (!notesPath) {
|
||||||
@@ -133,29 +247,12 @@ const NotesPage: FC = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [notesPath])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (notesTree.length === 0) return
|
if (notesTree.length === 0) return
|
||||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||||
const shouldClearPath =
|
const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
|
||||||
|
|
||||||
if (shouldClearPath) {
|
if (shouldClearPath) {
|
||||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||||
@@ -167,7 +264,7 @@ const NotesPage: FC = () => {
|
|||||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notesPath || notesTree.length === 0) return
|
if (!notesPath) return
|
||||||
|
|
||||||
async function startFileWatcher() {
|
async function startFileWatcher() {
|
||||||
// 清理之前的监控
|
// 清理之前的监控
|
||||||
@@ -181,31 +278,14 @@ const NotesPage: FC = () => {
|
|||||||
try {
|
try {
|
||||||
if (!notesPath) return
|
if (!notesPath) return
|
||||||
const { eventType, filePath } = data
|
const { eventType, filePath } = data
|
||||||
|
const normalizedEventPath = normalizePathValue(filePath)
|
||||||
|
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
case 'change': {
|
case 'change': {
|
||||||
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
||||||
if (activeFilePath === filePath) {
|
const activePath = activeFilePathRef.current
|
||||||
try {
|
if (activePath && normalizePathValue(activePath) === normalizedEventPath) {
|
||||||
// 读取文件最新内容
|
invalidateFileContentRef.current?.(normalizedEventPath)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -215,20 +295,18 @@ const NotesPage: FC = () => {
|
|||||||
case 'unlink':
|
case 'unlink':
|
||||||
case 'unlinkDir': {
|
case 'unlinkDir': {
|
||||||
// 如果删除的是当前活动文件,清空选择
|
// 如果删除的是当前活动文件,清空选择
|
||||||
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
|
if (
|
||||||
|
(eventType === 'unlink' || eventType === 'unlinkDir') &&
|
||||||
|
activeFilePathRef.current &&
|
||||||
|
normalizePathValue(activeFilePathRef.current) === normalizedEventPath
|
||||||
|
) {
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
|
editorRef.current?.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置同步标志,避免竞态条件
|
const refresh = refreshTreeRef.current
|
||||||
isSyncingTreeRef.current = true
|
if (refresh) {
|
||||||
|
await refresh()
|
||||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
|
||||||
try {
|
|
||||||
await initWorkSpace(notesPath, sortType)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to sync database:', error as Error)
|
|
||||||
} finally {
|
|
||||||
isSyncingTreeRef.current = false
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -261,26 +339,19 @@ const NotesPage: FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 如果有未保存的内容,立即保存
|
// 如果有未保存的内容,立即保存
|
||||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) {
|
||||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
const saveFn = saveCurrentNoteRef.current
|
||||||
logger.error('Emergency save failed:', error as Error)
|
if (saveFn) {
|
||||||
})
|
saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||||
|
logger.error('Emergency save failed:', error as Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理防抖函数
|
// 清理防抖函数
|
||||||
debouncedSave.cancel()
|
debouncedSaveRef.current?.cancel()
|
||||||
}
|
}
|
||||||
}, [
|
}, [dispatch, notesPath])
|
||||||
notesPath,
|
|
||||||
notesTree.length,
|
|
||||||
activeFilePath,
|
|
||||||
invalidateFileContent,
|
|
||||||
dispatch,
|
|
||||||
currentContent,
|
|
||||||
debouncedSave,
|
|
||||||
saveCurrentNote,
|
|
||||||
sortType
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@@ -316,13 +387,13 @@ const NotesPage: FC = () => {
|
|||||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||||
const getTargetFolderPath = useCallback(() => {
|
const getTargetFolderPath = useCallback(() => {
|
||||||
if (selectedFolderId) {
|
if (selectedFolderId) {
|
||||||
const selectedNode = findNodeById(notesTree, selectedFolderId)
|
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||||
if (selectedNode && selectedNode.type === 'folder') {
|
if (selectedNode && selectedNode.type === 'folder') {
|
||||||
return selectedNode.externalPath
|
return selectedNode.externalPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return notesPath // 默认返回根目录
|
return notesPath // 默认返回根目录
|
||||||
}, [selectedFolderId, notesTree, notesPath, findNodeById])
|
}, [selectedFolderId, notesTree, notesPath])
|
||||||
|
|
||||||
// 创建文件夹
|
// 创建文件夹
|
||||||
const handleCreateFolder = useCallback(
|
const handleCreateFolder = useCallback(
|
||||||
@@ -332,12 +403,14 @@ const NotesPage: FC = () => {
|
|||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to create folder:', error as Error)
|
logger.error('Failed to create folder:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getTargetFolderPath]
|
[getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建笔记
|
// 创建笔记
|
||||||
@@ -350,11 +423,13 @@ const NotesPage: FC = () => {
|
|||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
const newNote = await createNote(name, '', targetPath)
|
const { path: notePath } = await addNote(name, '', targetPath)
|
||||||
dispatch(setActiveFilePath(newNote.externalPath))
|
const normalizedParent = normalizePathValue(targetPath)
|
||||||
|
updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent))
|
||||||
|
dispatch(setActiveFilePath(notePath))
|
||||||
setSelectedFolderId(null)
|
setSelectedFolderId(null)
|
||||||
|
|
||||||
await sortAllLevels(sortType)
|
await refreshTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create note:', error as Error)
|
logger.error('Failed to create note:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -364,73 +439,41 @@ const NotesPage: FC = () => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, getTargetFolderPath, sortType]
|
[dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths]
|
||||||
)
|
|
||||||
|
|
||||||
// 切换展开状态
|
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback(
|
const handleToggleExpanded = useCallback(
|
||||||
async (nodeId: string) => {
|
(nodeId: string) => {
|
||||||
try {
|
const targetNode = findNode(notesTree, nodeId)
|
||||||
await toggleNodeExpanded(nodeId)
|
if (!targetNode || targetNode.type !== 'folder') {
|
||||||
} catch (error) {
|
return
|
||||||
logger.error('Failed to toggle expanded:', error as Error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextExpanded = !targetNode.expanded
|
||||||
|
// Update Redux state first, then let mergeTreeState handle the UI update
|
||||||
|
updateExpandedPaths((prev) =>
|
||||||
|
nextExpanded
|
||||||
|
? addUniquePath(prev, targetNode.externalPath)
|
||||||
|
: removePathEntries(prev, targetNode.externalPath, false)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[toggleNodeExpanded]
|
[notesTree, updateExpandedPaths]
|
||||||
)
|
|
||||||
|
|
||||||
// 切换收藏状态
|
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleStar = useCallback(
|
const handleToggleStar = useCallback(
|
||||||
async (nodeId: string) => {
|
(nodeId: string) => {
|
||||||
try {
|
const node = findNode(notesTree, nodeId)
|
||||||
await toggleStarred(nodeId)
|
if (!node) {
|
||||||
} catch (error) {
|
return
|
||||||
logger.error('Failed to toggle star:', error as Error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
} else if (node.type === 'folder') {
|
||||||
setSelectedFolderId(node.id)
|
setSelectedFolderId(node.id)
|
||||||
await handleToggleExpanded(node.id)
|
handleToggleExpanded(node.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||||
@@ -457,28 +500,35 @@ const NotesPage: FC = () => {
|
|||||||
const handleDeleteNode = useCallback(
|
const handleDeleteNode = useCallback(
|
||||||
async (nodeId: string) => {
|
async (nodeId: string) => {
|
||||||
try {
|
try {
|
||||||
const nodeToDelete = findNodeById(notesTree, nodeId)
|
const nodeToDelete = findNode(notesTree, nodeId)
|
||||||
if (!nodeToDelete) return
|
if (!nodeToDelete) return
|
||||||
|
|
||||||
const isActiveNodeOrParent =
|
await delNode(nodeToDelete)
|
||||||
activeFilePath &&
|
|
||||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
|
||||||
|
|
||||||
await deleteNode(nodeId)
|
updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder'))
|
||||||
await sortAllLevels(sortType)
|
updateExpandedPaths((prev) =>
|
||||||
|
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||||
|
)
|
||||||
|
|
||||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||||
if (isActiveNodeOrParent) {
|
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
|
||||||
|
const isActiveNode = normalizedActivePath === normalizedDeletePath
|
||||||
|
const isActiveDescendant =
|
||||||
|
nodeToDelete.type === 'folder' &&
|
||||||
|
normalizedActivePath &&
|
||||||
|
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
|
||||||
|
|
||||||
|
if (isActiveNode || isActiveDescendant) {
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
if (editorRef.current) {
|
editorRef.current?.clear()
|
||||||
editorRef.current.clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete node:', error as 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 {
|
try {
|
||||||
isRenamingRef.current = true
|
isRenamingRef.current = true
|
||||||
|
|
||||||
const tree = await getNotesTree()
|
const node = findNode(notesTree, nodeId)
|
||||||
const node = findNodeById(tree, nodeId)
|
if (!node || node.name === newName) {
|
||||||
|
return
|
||||||
if (node && node.name !== newName) {
|
|
||||||
const oldExternalPath = node.externalPath
|
|
||||||
const renamedNode = await renameNode(nodeId, 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 }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldPath = node.externalPath
|
||||||
|
const renamed = await renameEntry(node, newName)
|
||||||
|
|
||||||
|
if (node.type === 'file' && activeFilePath === oldPath) {
|
||||||
|
debouncedSaveRef.current?.cancel()
|
||||||
|
lastFilePathRef.current = renamed.path
|
||||||
|
dispatch(setActiveFilePath(renamed.path))
|
||||||
|
} else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) {
|
||||||
|
const suffix = activeFilePath.slice(oldPath.length)
|
||||||
|
const nextActivePath = `${renamed.path}${suffix}`
|
||||||
|
debouncedSaveRef.current?.cancel()
|
||||||
|
lastFilePathRef.current = nextActivePath
|
||||||
|
dispatch(setActiveFilePath(nextActivePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||||
|
updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder'))
|
||||||
|
|
||||||
|
await refreshTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to rename node:', error as Error)
|
logger.error('Failed to rename node:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -518,7 +569,7 @@ const NotesPage: FC = () => {
|
|||||||
}, 500)
|
}, 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')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await uploadFiles(files, targetFolderPath)
|
const result = await uploadNotes(files, targetFolderPath)
|
||||||
|
|
||||||
// 检查上传结果
|
// 检查上传结果
|
||||||
if (result.fileCount === 0) {
|
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')
|
const successMessage = t('notes.upload_success')
|
||||||
|
|
||||||
@@ -554,37 +606,141 @@ const NotesPage: FC = () => {
|
|||||||
window.toast.error(t('notes.upload_failed'))
|
window.toast.error(t('notes.upload_failed'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getTargetFolderPath, sortType, t]
|
[getTargetFolderPath, refreshTree, t, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点移动
|
// 处理节点移动
|
||||||
const handleMoveNode = useCallback(
|
const handleMoveNode = useCallback(
|
||||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||||
|
if (!notesPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
const sourceNode = findNode(notesTree, sourceNodeId)
|
||||||
if (result.success && result.type !== 'manual_reorder') {
|
const targetNode = findNode(notesTree, targetNodeId)
|
||||||
await sortAllLevels(sortType)
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to move nodes:', error as Error)
|
logger.error('Failed to move nodes:', error as Error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sortType]
|
[activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理节点排序
|
// 处理节点排序
|
||||||
const handleSortNodes = useCallback(
|
const handleSortNodes = useCallback(
|
||||||
async (newSortType: NotesSortType) => {
|
async (newSortType: NotesSortType) => {
|
||||||
try {
|
dispatch(setSortType(newSortType))
|
||||||
// 更新Redux中的排序类型
|
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
|
||||||
dispatch(setSortType(newSortType))
|
},
|
||||||
await sortAllLevels(newSortType)
|
[dispatch, mergeTreeState]
|
||||||
} catch (error) {
|
)
|
||||||
logger.error('Failed to sort notes:', error as Error)
|
|
||||||
throw error
|
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(() => {
|
const getCurrentNoteContent = useCallback(() => {
|
||||||
@@ -631,12 +787,13 @@ const NotesPage: FC = () => {
|
|||||||
notesTree={notesTree}
|
notesTree={notesTree}
|
||||||
getCurrentNoteContent={getCurrentNoteContent}
|
getCurrentNoteContent={getCurrentNoteContent}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
|
onExpandPath={handleExpandPath}
|
||||||
|
onRenameNode={handleRenameNode}
|
||||||
/>
|
/>
|
||||||
<NotesEditor
|
<NotesEditor
|
||||||
activeNodeId={activeNode?.id}
|
activeNodeId={activeNode?.id}
|
||||||
currentContent={currentContent}
|
currentContent={currentContent}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
isLoading={isContentLoading}
|
|
||||||
onMarkdownChange={handleMarkdownChange}
|
onMarkdownChange={handleMarkdownChange}
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
|||||||
import { useAppSelector } from '@renderer/store'
|
import { useAppSelector } from '@renderer/store'
|
||||||
import { selectSortType } from '@renderer/store/note'
|
import { selectSortType } from '@renderer/store/note'
|
||||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
StarOff
|
StarOff
|
||||||
} from 'lucide-react'
|
} 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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -43,6 +44,157 @@ interface NotesSidebarProps {
|
|||||||
|
|
||||||
const logger = loggerService.withContext('NotesSidebar')
|
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> = ({
|
const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
@@ -268,9 +420,26 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
setIsShowSearch(!isShowSearch)
|
setIsShowSearch(!isShowSearch)
|
||||||
}, [isShowSearch])
|
}, [isShowSearch])
|
||||||
|
|
||||||
const filteredTree = useMemo(() => {
|
// Flatten tree nodes for virtualization and filtering
|
||||||
if (!isShowStarred && !isShowSearch) return notesTree
|
const flattenedNodes = useMemo(() => {
|
||||||
const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
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[] = []
|
let result: NotesTreeNode[] = []
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -284,15 +453,41 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.children && node.children.length > 0) {
|
if (node.children && node.children.length > 0) {
|
||||||
result = [...result, ...flattenNodes(node.children)]
|
result = [...result, ...flattenForFiltering(node.children)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
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])
|
}, [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(
|
const getMenuItems = useCallback(
|
||||||
(node: NotesTreeNode) => {
|
(node: NotesTreeNode) => {
|
||||||
const baseMenuItems: MenuProps['items'] = [
|
const baseMenuItems: MenuProps['items'] = [
|
||||||
@@ -351,115 +546,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
[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(
|
const handleDropFiles = useCallback(
|
||||||
async (e: React.DragEvent) => {
|
async (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -565,9 +651,54 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotesTreeContainer>
|
<NotesTreeContainer>
|
||||||
<StyledScrollbar ref={scrollbarRef}>
|
{shouldUseVirtualization ? (
|
||||||
<TreeContent>
|
<VirtualizedTreeContainer ref={parentRef}>
|
||||||
{filteredTree.map((node) => renderTreeNode(node))}
|
<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 && (
|
{!isShowStarred && !isShowSearch && (
|
||||||
<DropHintNode>
|
<DropHintNode>
|
||||||
<TreeNodeContainer active={false} depth={0}>
|
<TreeNodeContainer active={false} depth={0}>
|
||||||
@@ -580,8 +711,70 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
</TreeNodeContainer>
|
</TreeNodeContainer>
|
||||||
</DropHintNode>
|
</DropHintNode>
|
||||||
)}
|
)}
|
||||||
</TreeContent>
|
</VirtualizedTreeContainer>
|
||||||
</StyledScrollbar>
|
) : (
|
||||||
|
<StyledScrollbar ref={scrollbarRef}>
|
||||||
|
<TreeContent>
|
||||||
|
{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}>
|
||||||
|
<TreeNodeContent>
|
||||||
|
<NodeIcon>
|
||||||
|
<FilePlus size={16} />
|
||||||
|
</NodeIcon>
|
||||||
|
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||||
|
</TreeNodeContent>
|
||||||
|
</TreeNodeContainer>
|
||||||
|
</DropHintNode>
|
||||||
|
)}
|
||||||
|
</TreeContent>
|
||||||
|
</StyledScrollbar>
|
||||||
|
)}
|
||||||
</NotesTreeContainer>
|
</NotesTreeContainer>
|
||||||
|
|
||||||
{isDragOverSidebar && <DragOverIndicator />}
|
{isDragOverSidebar && <DragOverIndicator />}
|
||||||
@@ -592,7 +785,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
const SidebarContainer = styled.div`
|
const SidebarContainer = styled.div`
|
||||||
width: 250px;
|
width: 250px;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
height: 100vh;
|
height: calc(100vh - var(--navbar-height));
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-right: 0.5px solid var(--color-border);
|
border-right: 0.5px solid var(--color-border);
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
@@ -606,7 +799,15 @@ const NotesTreeContainer = styled.div`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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)`
|
const StyledScrollbar = styled(Scrollbar)`
|
||||||
@@ -752,7 +953,8 @@ const DragOverIndicator = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const DropHintNode = styled.div`
|
const DropHintNode = styled.div`
|
||||||
margin-top: 8px;
|
margin: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
${TreeNodeContainer} {
|
${TreeNodeContainer} {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -773,4 +975,4 @@ const DropHintText = styled.div`
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default NotesSidebar
|
export default memo(NotesSidebar)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
|
|||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { initWorkSpace } from '@renderer/services/NotesService'
|
|
||||||
import { EditorView } from '@renderer/types'
|
import { EditorView } from '@renderer/types'
|
||||||
import { Button, Input, message, Slider, Switch } from 'antd'
|
import { Button, Input, message, Slider, Switch } from 'antd'
|
||||||
import { FolderOpen } from 'lucide-react'
|
import { FolderOpen } from 'lucide-react'
|
||||||
@@ -70,7 +69,6 @@ const NotesSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateNotesPath(tempPath)
|
updateNotesPath(tempPath)
|
||||||
initWorkSpace(tempPath, 'sort_a2z')
|
|
||||||
window.toast.success(t('notes.settings.data.path_updated'))
|
window.toast.success(t('notes.settings.data.path_updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to apply notes path:', error as Error)
|
logger.error('Failed to apply notes path:', error as Error)
|
||||||
@@ -83,7 +81,6 @@ const NotesSettings: FC = () => {
|
|||||||
const info = await window.api.getAppInfo()
|
const info = await window.api.getAppInfo()
|
||||||
setTempPath(info.notesPath)
|
setTempPath(info.notesPath)
|
||||||
updateNotesPath(info.notesPath)
|
updateNotesPath(info.notesPath)
|
||||||
initWorkSpace(info.notesPath, 'sort_a2z')
|
|
||||||
window.toast.success(t('notes.settings.data.reset_to_default'))
|
window.toast.success(t('notes.settings.data.reset_to_default'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to reset to default:', error as Error)
|
logger.error('Failed to reset to default:', error as Error)
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
setEnableQuickAssistant,
|
setEnableQuickAssistant,
|
||||||
setReadClipboardAtStartup
|
setReadClipboardAtStartup
|
||||||
} from '@renderer/store/settings'
|
} from '@renderer/store/settings'
|
||||||
|
import { matchKeywordsInString } from '@renderer/utils'
|
||||||
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
|
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
|
||||||
import { Button, Select, Switch, Tooltip } from 'antd'
|
import { Button, Select, Switch, Tooltip } from 'antd'
|
||||||
import { FC } from 'react'
|
import { FC, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -26,9 +27,15 @@ const QuickAssistantSettings: FC = () => {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { assistants } = useAssistants()
|
const { assistants } = useAssistants()
|
||||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||||
const { defaultAssistant } = useDefaultAssistant()
|
const { defaultAssistant: _defaultAssistant } = useDefaultAssistant()
|
||||||
const { defaultModel } = useDefaultModel()
|
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) => {
|
const handleEnableQuickAssistant = async (enable: boolean) => {
|
||||||
dispatch(setEnableQuickAssistant(enable))
|
dispatch(setEnableQuickAssistant(enable))
|
||||||
await window.api.config.set('enableQuickAssistant', enable, true)
|
await window.api.config.set('enableQuickAssistant', enable, true)
|
||||||
@@ -110,27 +117,39 @@ const QuickAssistantSettings: FC = () => {
|
|||||||
value={quickAssistantId || defaultAssistant.id}
|
value={quickAssistantId || defaultAssistant.id}
|
||||||
style={{ width: 300, height: 34 }}
|
style={{ width: 300, height: 34 }}
|
||||||
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
||||||
placeholder={t('settings.models.quick_assistant_selection')}>
|
placeholder={t('settings.models.quick_assistant_selection')}
|
||||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
showSearch
|
||||||
<AssistantItem>
|
options={[
|
||||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
{
|
||||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
key: defaultAssistant.id,
|
||||||
<Spacer />
|
value: defaultAssistant.id,
|
||||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
title: defaultAssistant.name,
|
||||||
</AssistantItem>
|
label: (
|
||||||
</Select.Option>
|
|
||||||
{assistants
|
|
||||||
.filter((a) => a.id !== defaultAssistant.id)
|
|
||||||
.map((a) => (
|
|
||||||
<Select.Option key={a.id} value={a.id}>
|
|
||||||
<AssistantItem>
|
<AssistantItem>
|
||||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||||
<AssistantName>{a.name}</AssistantName>
|
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||||
</AssistantItem>
|
</AssistantItem>
|
||||||
</Select.Option>
|
)
|
||||||
))}
|
},
|
||||||
</Select>
|
...assistants
|
||||||
|
.filter((a) => a.id !== defaultAssistant.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>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
<HStack alignItems="center" gap={0}>
|
<HStack alignItems="center" gap={0}>
|
||||||
|
|||||||
@@ -1,100 +1,10 @@
|
|||||||
import { loggerService } from '@logger'
|
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 { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||||
import { getFileDirectory } from '@renderer/utils'
|
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')
|
const logger = loggerService.withContext('NotesService')
|
||||||
|
|
||||||
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
|
const MARKDOWN_EXT = '.md'
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化/同步笔记树结构
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
uploadedNodes: NotesTreeNode[]
|
uploadedNodes: NotesTreeNode[]
|
||||||
@@ -104,641 +14,195 @@ export interface UploadResult {
|
|||||||
folderCount: number
|
folderCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function loadTree(rootPath: string): Promise<NotesTreeNode[]> {
|
||||||
* 上传文件或文件夹,支持单个或批量上传,保持文件夹结构
|
return window.api.file.getDirectoryStructure(normalizePath(rootPath))
|
||||||
*/
|
|
||||||
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 function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] {
|
||||||
* 删除笔记或文件夹
|
const cloned = nodes.map((node) => ({
|
||||||
*/
|
...node,
|
||||||
export async function deleteNode(nodeId: string): Promise<void> {
|
children: node.children ? sortTree(node.children, sortType) : undefined
|
||||||
const tree = await getNotesTree()
|
}))
|
||||||
const node = findNodeInTree(tree, nodeId)
|
|
||||||
if (!node) {
|
const sorter = getSorter(sortType)
|
||||||
throw new Error('Node not found')
|
|
||||||
}
|
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') {
|
if (node.type === 'folder') {
|
||||||
await window.api.file.deleteExternalDir(node.externalPath)
|
await window.api.file.deleteExternalDir(node.externalPath)
|
||||||
} else if (node.type === 'file') {
|
} else {
|
||||||
await window.api.file.deleteExternalFile(node.externalPath)
|
await window.api.file.deleteExternalFile(node.externalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeNodeFromTree(tree, nodeId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> {
|
||||||
* 重命名笔记或文件夹
|
const isFile = node.type === 'file'
|
||||||
*/
|
const parentDir = normalizePath(getFileDirectory(node.externalPath))
|
||||||
export async function renameNode(nodeId: string, newName: string): Promise<NotesTreeNode> {
|
const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile)
|
||||||
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')
|
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
logger.warn(`Target name already exists: ${safeName}`)
|
|
||||||
throw new Error(`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)
|
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)
|
|
||||||
|
await window.api.file.renameDir(node.externalPath, safeName)
|
||||||
|
return { path: `${parentDir}/${safeName}`, name: safeName }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
|
||||||
* 移动节点
|
const basePath = normalizePath(targetPath)
|
||||||
*/
|
const markdownFiles = filterMarkdown(files)
|
||||||
export async function moveNode(
|
const skippedFiles = files.length - markdownFiles.length
|
||||||
sourceNodeId: string,
|
|
||||||
targetNodeId: string,
|
|
||||||
position: 'before' | 'after' | 'inside'
|
|
||||||
): Promise<MoveNodeResult> {
|
|
||||||
try {
|
|
||||||
const tree = await getNotesTree()
|
|
||||||
|
|
||||||
// 找到源节点和目标节点
|
if (markdownFiles.length === 0) {
|
||||||
const sourceNode = findNodeInTree(tree, sourceNodeId)
|
return {
|
||||||
const targetNode = findNodeInTree(tree, targetNodeId)
|
uploadedNodes: [],
|
||||||
|
totalFiles: files.length,
|
||||||
if (!sourceNode || !targetNode) {
|
skippedFiles,
|
||||||
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
fileCount: 0,
|
||||||
return { success: false }
|
folderCount: 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 不允许文件夹被放入文件中
|
const folders = collectFolders(markdownFiles, basePath)
|
||||||
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
|
await createFolders(folders)
|
||||||
logger.error('Move nodes failed: cannot move a folder inside a file')
|
|
||||||
return { success: false }
|
let fileCount = 0
|
||||||
|
|
||||||
|
for (const file of markdownFiles) {
|
||||||
|
const { dir, name } = resolveFileTarget(file, basePath)
|
||||||
|
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||||
|
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await file.text()
|
||||||
|
await window.api.file.write(finalPath, content)
|
||||||
|
fileCount += 1
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to write uploaded file:', error as Error)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 不允许将节点移动到自身内部
|
return {
|
||||||
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
|
uploadedNodes: [],
|
||||||
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
|
totalFiles: files.length,
|
||||||
return { success: false }
|
skippedFiles,
|
||||||
}
|
fileCount,
|
||||||
|
folderCount: folders.size
|
||||||
let targetPath: string = ''
|
|
||||||
|
|
||||||
if (position === 'inside') {
|
|
||||||
// 目标是文件夹内部
|
|
||||||
if (targetNode.type === 'folder') {
|
|
||||||
targetPath = targetNode.externalPath
|
|
||||||
} else {
|
|
||||||
logger.error('Cannot move node inside a file node')
|
|
||||||
return { success: false }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const targetParent = findParentNode(tree, targetNodeId)
|
|
||||||
if (targetParent) {
|
|
||||||
targetPath = targetParent.externalPath
|
|
||||||
} else {
|
|
||||||
targetPath = getFileDirectory(targetNode.externalPath!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为同级拖动排序
|
|
||||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
|
||||||
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
|
|
||||||
|
|
||||||
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
|
|
||||||
|
|
||||||
if (isSameLevelReorder) {
|
|
||||||
// 同级拖动排序:跳过文件系统操作,只更新树结构
|
|
||||||
logger.debug(`Same level reorder detected, skipping file system operations`)
|
|
||||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
|
||||||
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
|
|
||||||
return success ? { success: true, type: 'manual_reorder' } : { success: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建新的文件路径
|
|
||||||
const sourceName = sourceNode.externalPath!.split('/').pop()!
|
|
||||||
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
|
|
||||||
|
|
||||||
const { safeName } = await window.api.file.checkFileName(
|
|
||||||
targetPath,
|
|
||||||
sourceNameWithoutExt,
|
|
||||||
sourceNode.type === 'file'
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '')
|
|
||||||
const newPath = `${targetPath}/${baseName}`
|
|
||||||
|
|
||||||
if (sourceNode.externalPath !== newPath) {
|
|
||||||
try {
|
|
||||||
if (sourceNode.type === 'folder') {
|
|
||||||
await window.api.file.moveDir(sourceNode.externalPath, newPath)
|
|
||||||
} else {
|
|
||||||
await window.api.file.move(sourceNode.externalPath, newPath)
|
|
||||||
}
|
|
||||||
sourceNode.externalPath = newPath
|
|
||||||
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
|
|
||||||
return { success: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
|
||||||
return success ? { success: true, type: 'file_system_move' } : { success: false }
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Move nodes failed:', error as Error)
|
|
||||||
return { success: false }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||||
* 对节点数组进行排序
|
|
||||||
*/
|
|
||||||
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 {
|
|
||||||
switch (sortType) {
|
switch (sortType) {
|
||||||
case 'sort_a2z':
|
case 'sort_a2z':
|
||||||
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
|
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
|
||||||
|
|
||||||
case 'sort_z2a':
|
case 'sort_z2a':
|
||||||
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
|
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
|
||||||
|
|
||||||
case 'sort_updated_desc':
|
case 'sort_updated_desc':
|
||||||
return (a, b) => {
|
return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt)
|
||||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
|
||||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
|
||||||
return timeB - timeA
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sort_updated_asc':
|
case 'sort_updated_asc':
|
||||||
return (a, b) => {
|
return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt)
|
||||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
|
||||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
|
||||||
return timeA - timeB
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sort_created_desc':
|
case 'sort_created_desc':
|
||||||
return (a, b) => {
|
return (a, b) => getTime(b.createdAt) - getTime(a.createdAt)
|
||||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
|
||||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
|
||||||
return timeB - timeA
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sort_created_asc':
|
case 'sort_created_asc':
|
||||||
return (a, b) => {
|
return (a, b) => getTime(a.createdAt) - getTime(b.createdAt)
|
||||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
|
||||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
|
||||||
return timeA - timeB
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (a, b) => a.name.localeCompare(b.name)
|
return (a, b) => a.name.localeCompare(b.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getTime(value?: string): number {
|
||||||
* 递归排序笔记树中的所有层级
|
return value ? new Date(value).getTime() : 0
|
||||||
*/
|
|
||||||
export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!tree) {
|
|
||||||
tree = await getNotesTree()
|
|
||||||
}
|
|
||||||
sortNodesArray(tree, sortType)
|
|
||||||
recursiveSortNodes(tree, sortType)
|
|
||||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
|
||||||
logger.info(`Sorted all levels of notes successfully: ${sortType}`)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to sort all levels of notes:', error as Error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function normalizePath(value: string): string {
|
||||||
* 递归对节点中的子节点进行排序
|
return value.replace(/\\/g, '/')
|
||||||
*/
|
|
||||||
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 filterMarkdown(files: File[]): File[] {
|
||||||
* 根据外部路径查找节点(递归查找)
|
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
|
||||||
*/
|
|
||||||
function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.externalPath === externalPath) {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
const found = findNodeByExternalPath(node.children, externalPath)
|
|
||||||
if (found) {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function collectFolders(files: File[], basePath: string): Set<string> {
|
||||||
* 过滤出 Markdown 文件
|
const folders = new Set<string>()
|
||||||
*/
|
|
||||||
function filterMarkdownFiles(files: File[]): File[] {
|
files.forEach((file) => {
|
||||||
return Array.from(files).filter((file) => {
|
const relativePath = file.webkitRelativePath || ''
|
||||||
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
if (!relativePath.includes('/')) {
|
||||||
return true
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = relativePath.split('/')
|
||||||
|
parts.pop()
|
||||||
|
|
||||||
|
let current = basePath
|
||||||
|
for (const part of parts) {
|
||||||
|
current = `${current}/${part}`
|
||||||
|
folders.add(current)
|
||||||
}
|
}
|
||||||
logger.warn(`Skipping non-markdown file: ${file.name}`)
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return folders
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function createFolders(folders: Set<string>): Promise<void> {
|
||||||
* 创建空的上传结果
|
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个根文件夹组生成唯一的文件夹名称
|
|
||||||
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
|
|
||||||
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
// 创建一个新的 File 对象,并修改 webkitRelativePath
|
|
||||||
const originalPath = file.webkitRelativePath || file.name
|
|
||||||
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
|
|
||||||
const newPath = `${safeName}/${relativePath}`
|
|
||||||
|
|
||||||
const newFile = new File([file], file.name, {
|
|
||||||
type: file.type,
|
|
||||||
lastModified: file.lastModified
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.defineProperty(newFile, 'webkitRelativePath', {
|
|
||||||
value: newPath,
|
|
||||||
writable: false
|
|
||||||
})
|
|
||||||
|
|
||||||
processedFiles.push(newFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按路径分组文件并收集需要创建的文件夹
|
|
||||||
*/
|
|
||||||
function groupFilesByPath(
|
|
||||||
markdownFiles: File[],
|
|
||||||
targetFolderPath: string
|
|
||||||
): { filesByPath: Map<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)
|
|
||||||
|
|
||||||
|
for (const folder of ordered) {
|
||||||
try {
|
try {
|
||||||
const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders)
|
await window.api.file.mkdir(folder)
|
||||||
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) {
|
} catch (error) {
|
||||||
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
|
logger.debug('Skip existing folder while uploading notes', {
|
||||||
} finally {
|
folder,
|
||||||
folderCreationLock.delete(folderPath)
|
error: (error as Error).message
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
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
|
||||||
async function uploadAllFiles(
|
return { dir: basePath, name: nameWithoutExt }
|
||||||
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)
|
const parts = file.webkitRelativePath.split('/')
|
||||||
|
const fileName = parts.pop() || file.name
|
||||||
|
const dirPath = `${basePath}/${parts.join('/')}`
|
||||||
|
const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName
|
||||||
|
|
||||||
results.forEach((result) => {
|
return { dir: dirPath, name: nameWithoutExt }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +1,47 @@
|
|||||||
import { loggerService } from '@logger'
|
|
||||||
import db from '@renderer/databases'
|
|
||||||
import { NotesTreeNode } from '@renderer/types/note'
|
import { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
|
||||||
const MARKDOWN_EXT = '.md'
|
export function normalizePathValue(path: string): string {
|
||||||
const NOTES_TREE_ID = 'notes-tree-structure'
|
return path.replace(/\\/g, '/')
|
||||||
|
|
||||||
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 addUniquePath(list: string[], path: string): string[] {
|
||||||
* 在树中插入节点
|
const normalized = normalizePathValue(path)
|
||||||
*/
|
return list.includes(normalized) ? list : [...list, normalized]
|
||||||
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 removePathEntries(list: string[], path: string, deep: boolean): string[] {
|
||||||
* 从树中删除节点
|
const normalized = normalizePathValue(path)
|
||||||
*/
|
const prefix = `${normalized}/`
|
||||||
export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise<boolean> {
|
return list.filter((item) => {
|
||||||
const removed = removeNodeFromTreeInMemory(tree, nodeId)
|
if (item === normalized) {
|
||||||
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
|
return false
|
||||||
}
|
}
|
||||||
|
return !(deep && item.startsWith(prefix))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
|
export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] {
|
||||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
const oldNormalized = normalizePathValue(oldPath)
|
||||||
const targetParent = findParentNode(tree, targetNodeId)
|
const newNormalized = normalizePathValue(newPath)
|
||||||
|
const prefix = `${oldNormalized}/`
|
||||||
// 从原位置移除节点(不保存数据库,只在内存中操作)
|
return list.map((item) => {
|
||||||
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
|
if (item === oldNormalized) {
|
||||||
if (!removed) {
|
return newNormalized
|
||||||
logger.error('Move nodes in tree failed: could not remove source node')
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
if (deep && item.startsWith(prefix)) {
|
||||||
try {
|
return `${newNormalized}${item.slice(oldNormalized.length)}`
|
||||||
// 根据位置进行放置
|
|
||||||
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) {
|
return item
|
||||||
logger.error('Move nodes in tree failed:', error as Error)
|
})
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||||
* 重命名节点
|
|
||||||
*/
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
node.name = newName
|
|
||||||
|
|
||||||
const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1)
|
|
||||||
node.treePath = dirPath + newName
|
|
||||||
|
|
||||||
const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1)
|
|
||||||
node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName
|
|
||||||
|
|
||||||
node.updatedAt = new Date().toISOString()
|
|
||||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改节点键值
|
|
||||||
*/
|
|
||||||
export async function updateNodeInTree(
|
|
||||||
tree: NotesTreeNode[],
|
|
||||||
nodeId: string,
|
|
||||||
updates: Partial<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 {
|
|
||||||
for (const node of tree) {
|
for (const node of tree) {
|
||||||
if (node.id === nodeId) {
|
if (node.id === nodeId) {
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
const found = findNodeInTree(node.children, nodeId)
|
const found = findNode(node.children, nodeId)
|
||||||
if (found) {
|
if (found) {
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
@@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null {
|
||||||
* 根据路径查找节点
|
|
||||||
*/
|
|
||||||
export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null {
|
|
||||||
for (const node of tree) {
|
for (const node of tree) {
|
||||||
if (node.treePath === path) {
|
if (node.treePath === targetPath || node.externalPath === targetPath) {
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
const found = findNodeByPath(node.children, path)
|
const found = findNodeByPath(node.children, targetPath)
|
||||||
if (found) {
|
if (found) {
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
@@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---
|
export function updateTreeNode(
|
||||||
// 辅助函数
|
nodes: NotesTreeNode[],
|
||||||
// ---
|
nodeId: string,
|
||||||
|
updater: (node: NotesTreeNode) => NotesTreeNode
|
||||||
|
): NotesTreeNode[] {
|
||||||
|
let changed = false
|
||||||
|
|
||||||
/**
|
const nextNodes = nodes.map((node) => {
|
||||||
* 查找节点的父节点
|
if (node.id === nodeId) {
|
||||||
*/
|
changed = true
|
||||||
export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null {
|
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) {
|
for (const node of tree) {
|
||||||
if (node.children) {
|
if (!node.children) {
|
||||||
const isDirectChild = node.children.some((child) => child.id === targetNodeId)
|
continue
|
||||||
if (isDirectChild) {
|
}
|
||||||
return node
|
if (node.children.some((child) => child.id === nodeId)) {
|
||||||
}
|
return node
|
||||||
|
}
|
||||||
const parent = findParentNode(node.children, targetNodeId)
|
const found = findParent(node.children, nodeId)
|
||||||
if (parent) {
|
if (found) {
|
||||||
return parent
|
return found
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function reorderTreeNodes(
|
||||||
* 判断节点是否为另一个节点的父节点
|
nodes: NotesTreeNode[],
|
||||||
*/
|
sourceId: string,
|
||||||
export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean {
|
targetId: string,
|
||||||
const childNode = findNodeInTree(tree, childId)
|
position: 'before' | 'after'
|
||||||
if (!childNode) {
|
): NotesTreeNode[] {
|
||||||
return false
|
const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position)
|
||||||
|
if (moved) {
|
||||||
|
return updatedNodes
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentNode = findNodeInTree(tree, parentId)
|
let changed = false
|
||||||
if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) {
|
const nextNodes = nodes.map((node) => {
|
||||||
return false
|
if (!node.children || node.children.length === 0) {
|
||||||
}
|
return node
|
||||||
|
|
||||||
if (parentNode.children.some((child) => child.id === childId)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of parentNode.children) {
|
|
||||||
if (isParentNode(tree, child.id, childId)) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position)
|
||||||
|
if (reorderedChildren !== node.children) {
|
||||||
|
changed = true
|
||||||
|
return { ...node, children: reorderedChildren }
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? nextNodes : nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderSiblings(
|
||||||
|
nodes: NotesTreeNode[],
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
position: 'before' | 'after'
|
||||||
|
): [NotesTreeNode[], boolean] {
|
||||||
|
const sourceIndex = nodes.findIndex((node) => node.id === sourceId)
|
||||||
|
const targetIndex = nodes.findIndex((node) => node.id === targetId)
|
||||||
|
|
||||||
|
if (sourceIndex === -1 || targetIndex === -1) {
|
||||||
|
return [nodes, false]
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
const updated = [...nodes]
|
||||||
|
const [sourceNode] = updated.splice(sourceIndex, 1)
|
||||||
|
|
||||||
|
let insertIndex = targetIndex
|
||||||
|
if (sourceIndex < targetIndex) {
|
||||||
|
insertIndex -= 1
|
||||||
|
}
|
||||||
|
if (position === 'after') {
|
||||||
|
insertIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertIndex < 0) {
|
||||||
|
insertIndex = 0
|
||||||
|
}
|
||||||
|
if (insertIndex > updated.length) {
|
||||||
|
insertIndex = updated.length
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.splice(insertIndex, 0, sourceNode)
|
||||||
|
return [updated, true]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface NoteState {
|
|||||||
settings: NotesSettings
|
settings: NotesSettings
|
||||||
notesPath: string
|
notesPath: string
|
||||||
sortType: NotesSortType
|
sortType: NotesSortType
|
||||||
|
starredPaths: string[]
|
||||||
|
expandedPaths: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: NoteState = {
|
export const initialState: NoteState = {
|
||||||
@@ -36,7 +38,9 @@ export const initialState: NoteState = {
|
|||||||
showWorkspace: true
|
showWorkspace: true
|
||||||
},
|
},
|
||||||
notesPath: '',
|
notesPath: '',
|
||||||
sortType: 'sort_a2z'
|
sortType: 'sort_a2z',
|
||||||
|
starredPaths: [],
|
||||||
|
expandedPaths: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteSlice = createSlice({
|
const noteSlice = createSlice({
|
||||||
@@ -57,16 +61,32 @@ const noteSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setSortType: (state, action: PayloadAction<NotesSortType>) => {
|
setSortType: (state, action: PayloadAction<NotesSortType>) => {
|
||||||
state.sortType = action.payload
|
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 selectActiveNodeId = (state: RootState) => state.note.activeNodeId
|
||||||
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
|
export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath
|
||||||
export const selectNotesSettings = (state: RootState) => state.note.settings
|
export const selectNotesSettings = (state: RootState) => state.note.settings
|
||||||
export const selectNotesPath = (state: RootState) => state.note.notesPath
|
export const selectNotesPath = (state: RootState) => state.note.notesPath
|
||||||
export const selectSortType = (state: RootState) => state.note.sortType
|
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
|
export default noteSlice.reducer
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { Client } from '@notionhq/client'
|
|||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import { getProviderLabel } from '@renderer/i18n/label'
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
import { getMessageTitle } from '@renderer/services/MessagesService'
|
import { getMessageTitle } from '@renderer/services/MessagesService'
|
||||||
import { createNote } from '@renderer/services/NotesService'
|
import { addNote } from '@renderer/services/NotesService'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import { setExportState } from '@renderer/store/runtime'
|
import { setExportState } from '@renderer/store/runtime'
|
||||||
import type { Topic } from '@renderer/types'
|
import type { Topic } from '@renderer/types'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { NotesTreeNode } from '@renderer/types/note'
|
|
||||||
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
||||||
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
|
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
|
||||||
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||||
@@ -1052,18 +1051,12 @@ async function createSiyuanDoc(
|
|||||||
* @param content
|
* @param content
|
||||||
* @param folderPath
|
* @param folderPath
|
||||||
*/
|
*/
|
||||||
export const exportMessageToNotes = async (
|
export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise<void> => {
|
||||||
title: string,
|
|
||||||
content: string,
|
|
||||||
folderPath: string
|
|
||||||
): Promise<NotesTreeNode> => {
|
|
||||||
try {
|
try {
|
||||||
const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '')
|
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'))
|
window.toast.success(i18n.t('message.success.notes.export'))
|
||||||
|
|
||||||
return note
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('导出到笔记失败:', error as Error)
|
logger.error('导出到笔记失败:', error as Error)
|
||||||
window.toast.error(i18n.t('message.error.notes.export'))
|
window.toast.error(i18n.t('message.error.notes.export'))
|
||||||
@@ -1077,14 +1070,12 @@ export const exportMessageToNotes = async (
|
|||||||
* @param folderPath
|
* @param folderPath
|
||||||
* @returns 创建的笔记节点
|
* @returns 创建的笔记节点
|
||||||
*/
|
*/
|
||||||
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<NotesTreeNode> => {
|
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const content = await topicToMarkdown(topic)
|
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'))
|
window.toast.success(i18n.t('message.success.notes.export'))
|
||||||
|
|
||||||
return note
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('导出到笔记失败:', error as Error)
|
logger.error('导出到笔记失败:', error as Error)
|
||||||
window.toast.error(i18n.t('message.error.notes.export'))
|
window.toast.error(i18n.t('message.error.notes.export'))
|
||||||
|
|||||||
@@ -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":
|
"@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
|
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:
|
dependencies:
|
||||||
"@ai-sdk/provider": "npm:2.0.0"
|
"@ai-sdk/provider": "npm:2.0.0"
|
||||||
"@ai-sdk/provider-utils": "npm:3.0.9"
|
"@ai-sdk/provider-utils": "npm:3.0.9"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.76 || ^4
|
zod: ^3.25.76 || ^4
|
||||||
checksum: 10c0/5ec33dc9898457b1f48ed14cb767817345032c539dd21b7e21985ed47bc21b0820922b581bf349bb3898136790b12da3a0a7c9903c333a28ead0c3c2cd5230f2
|
checksum: 10c0/2a0a09debab8de0603243503ff5044bd3fff87d6c5de2d76d43839fa459cc85d5412b59ec63d0dcf1a6d6cab02882eb3c69f0f155129d0fc153bcde4deecbd32
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -2328,7 +2328,6 @@ __metadata:
|
|||||||
"@ai-sdk/anthropic": "npm:^2.0.17"
|
"@ai-sdk/anthropic": "npm:^2.0.17"
|
||||||
"@ai-sdk/azure": "npm:^2.0.30"
|
"@ai-sdk/azure": "npm:^2.0.30"
|
||||||
"@ai-sdk/deepseek": "npm:^1.0.17"
|
"@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": "npm:^2.0.30"
|
||||||
"@ai-sdk/openai-compatible": "npm:^1.0.17"
|
"@ai-sdk/openai-compatible": "npm:^1.0.17"
|
||||||
"@ai-sdk/provider": "npm:^2.0.0"
|
"@ai-sdk/provider": "npm:^2.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user