Merge remote-tracking branch 'origin/main' into feat/print-to-pdf

This commit is contained in:
suyao 2025-09-12 13:14:55 +08:00
commit b248a6932e
No known key found for this signature in database
19 changed files with 195 additions and 75 deletions

View File

@ -398,11 +398,15 @@ export function validateFileName(fileName: string, platform = process.platform):
* @returns * @returns
*/ */
export function checkName(fileName: string): string { export function checkName(fileName: string): string {
const validation = validateFileName(fileName) const baseName = path.basename(fileName)
const validation = validateFileName(baseName)
if (!validation.valid) { if (!validation.valid) {
throw new Error(`Invalid file name: ${fileName}. ${validation.error}`) // 自动清理非法字符,而不是抛出错误
const sanitized = sanitizeFilename(baseName)
logger.warn(`File name contains invalid characters, auto-sanitized: "${baseName}" -> "${sanitized}"`)
return sanitized
} }
return fileName return baseName
} }
/** /**

View File

@ -61,7 +61,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const close = useCallback( const close = useCallback(
(action?: QuickPanelCloseAction, searchText?: string) => { (action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false) setIsVisible(false)
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false }) onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
clearTimer.current = setTimeout(() => { clearTimer.current = setTimeout(() => {
setList([]) setList([])
@ -73,7 +73,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setTriggerInfo(undefined) setTriggerInfo(undefined)
}, 200) }, 200)
}, },
[onClose, symbol, triggerInfo] [onClose]
) )
useEffect(() => { useEffect(() => {

View File

@ -8,13 +8,10 @@ export type QuickPanelTriggerInfo = {
} }
export type QuickPanelCallBackOptions = { export type QuickPanelCallBackOptions = {
symbol: string context: QuickPanelContextType
action: QuickPanelCloseAction action: QuickPanelCloseAction
item: QuickPanelListItem item: QuickPanelListItem
searchText?: string searchText?: string
/** 是否处于多选状态 */
multiple?: boolean
triggerInfo?: QuickPanelTriggerInfo
} }
export type QuickPanelOpenOptions = { export type QuickPanelOpenOptions = {

View File

@ -222,11 +222,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
// 创建更新后的item对象用于回调 // 创建更新后的item对象用于回调
const updatedItem = { ...item, isSelected: newSelectedState } const updatedItem = { ...item, isSelected: newSelectedState }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = { const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol, context: ctx,
action, action,
item: updatedItem, item: updatedItem,
searchText: searchText, searchText: searchText
multiple: ctx.multiple
} }
ctx.beforeAction?.(quickPanelCallBackOptions) ctx.beforeAction?.(quickPanelCallBackOptions)
@ -236,11 +235,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
} }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = { const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol, context: ctx,
action, action,
item, item,
searchText: searchText, searchText: searchText
multiple: ctx.multiple
} }
ctx.beforeAction?.(quickPanelCallBackOptions) ctx.beforeAction?.(quickPanelCallBackOptions)

View File

@ -1714,7 +1714,7 @@
"delete_confirm": "Are you sure you want to delete this {{type}}?", "delete_confirm": "Are you sure you want to delete this {{type}}?",
"delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?", "delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?",
"delete_note_confirm": "Are you sure you want to delete the note \"{{name}}\"?", "delete_note_confirm": "Are you sure you want to delete the note \"{{name}}\"?",
"drop_markdown_hint": "Drop markdown files or folders here to import", "drop_markdown_hint": "Drop .md files or folders here to import",
"empty": "No notes available yet", "empty": "No notes available yet",
"expand": "unfold", "expand": "unfold",
"exportPDF": "Export to PDF", "exportPDF": "Export to PDF",
@ -1788,7 +1788,7 @@
"sort_updated_asc": "Update time (oldest first)", "sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)", "sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)", "sort_z2a": "File name (Z-A)",
"star": "Favorite", "star": "Favorite note",
"starred_notes": "Collected notes", "starred_notes": "Collected notes",
"title": "Notes", "title": "Notes",
"unsaved_changes": "You have unsaved content, are you sure you want to leave?", "unsaved_changes": "You have unsaved content, are you sure you want to leave?",

View File

@ -1715,7 +1715,7 @@
"delete_confirm": "确定要删除这个{{type}}吗?", "delete_confirm": "确定要删除这个{{type}}吗?",
"delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?", "delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?",
"delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?", "delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?",
"drop_markdown_hint": "拖拽 Markdown 文件或目录到此处导入", "drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
"empty": "暂无笔记", "empty": "暂无笔记",
"expand": "展开", "expand": "展开",
"exportPDF": "导出为PDF", "exportPDF": "导出为PDF",
@ -1789,7 +1789,7 @@
"sort_updated_asc": "更新时间(从旧到新)", "sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)", "sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名Z-A", "sort_z2a": "文件名Z-A",
"star": "收藏", "star": "收藏笔记",
"starred_notes": "收藏的笔记", "starred_notes": "收藏的笔记",
"title": "笔记", "title": "笔记",
"unsaved_changes": "你有未保存的内容,确定要离开吗?", "unsaved_changes": "你有未保存的内容,确定要离开吗?",

View File

@ -1714,7 +1714,7 @@
"delete_confirm": "確定要刪除此 {{type}} 嗎?", "delete_confirm": "確定要刪除此 {{type}} 嗎?",
"delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?", "delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?",
"delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?", "delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?",
"drop_markdown_hint": "拖拽 Markdown 文件或資料夾到此處導入", "drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
"empty": "暫無筆記", "empty": "暫無筆記",
"expand": "展開", "expand": "展開",
"exportPDF": "匯出為PDF", "exportPDF": "匯出為PDF",
@ -1788,7 +1788,7 @@
"sort_updated_asc": "更新時間(從舊到新)", "sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)", "sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名Z-A", "sort_z2a": "文件名Z-A",
"star": "收藏", "star": "收藏筆記",
"starred_notes": "收藏的筆記", "starred_notes": "收藏的筆記",
"title": "筆記", "title": "筆記",
"unsaved_changes": "你有未儲存的內容,確定要離開嗎?", "unsaved_changes": "你有未儲存的內容,確定要離開嗎?",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};", "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};",
"delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;", "delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;",
"delete_note_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη σημείωση \"{{name}}\";;", "delete_note_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη σημείωση \"{{name}}\";;",
"drop_markdown_hint": "Σύρετε και αποθέστε αρχεία ή φακέλους Markdown εδώ για εισαγωγή", "drop_markdown_hint": "Σύρετε και αποθέστε αρχεία ή φακέλους .md εδώ για εισαγωγή",
"empty": "δεν υπάρχει σημείωση για τώρα", "empty": "δεν υπάρχει σημείωση για τώρα",
"expand": "να ανοίξει", "expand": "να ανοίξει",
"export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε", "export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)", "sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)", "sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
"sort_z2a": "όνομα αρχείου (Z-A)", "sort_z2a": "όνομα αρχείου (Z-A)",
"star": "Αποθήκευση", "star": "Αγαπημένες σημειώσεις",
"starred_notes": "Σημειώσεις συλλογής", "starred_notes": "Σημειώσεις συλλογής",
"title": "σημειώσεις", "title": "σημειώσεις",
"unsaved_changes": "Έχετε μη αποθηκευμένο περιεχόμενο, είστε βέβαιοι ότι θέλετε να φύγετε;", "unsaved_changes": "Έχετε μη αποθηκευμένο περιεχόμενο, είστε βέβαιοι ότι θέλετε να φύγετε;",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?", "delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?",
"delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?", "delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?",
"delete_note_confirm": "¿Está seguro de que desea eliminar la nota \"{{name}}\"?", "delete_note_confirm": "¿Está seguro de que desea eliminar la nota \"{{name}}\"?",
"drop_markdown_hint": "Arrastre y suelte archivos o carpetas de Markdown aquí para importar", "drop_markdown_hint": "Arrastre y suelte archivos o carpetas de .md aquí para importar",
"empty": "Sin notas por el momento", "empty": "Sin notas por el momento",
"expand": "expandir", "expand": "expandir",
"export_failed": "Exportación a la base de conocimientos fallida", "export_failed": "Exportación a la base de conocimientos fallida",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)", "sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)", "sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
"sort_z2a": "Nombre de archivo (Z-A)", "sort_z2a": "Nombre de archivo (Z-A)",
"star": "Colección", "star": "Notas guardadas",
"starred_notes": "notas guardadas", "starred_notes": "notas guardadas",
"title": "notas", "title": "notas",
"unsaved_changes": "Tienes contenido no guardado, ¿estás seguro de que quieres salir?", "unsaved_changes": "Tienes contenido no guardado, ¿estás seguro de que quieres salir?",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?",
"delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?", "delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?",
"delete_note_confirm": "Êtes-vous sûr de vouloir supprimer la note \"{{name}}\" ?", "delete_note_confirm": "Êtes-vous sûr de vouloir supprimer la note \"{{name}}\" ?",
"drop_markdown_hint": "Déposez ici des fichiers ou dossiers Markdown pour les importer", "drop_markdown_hint": "Déposez ici des fichiers ou dossiers .md pour les importer",
"empty": "Aucune note pour le moment", "empty": "Aucune note pour le moment",
"expand": "développer", "expand": "développer",
"export_failed": "Échec de l'exportation vers la base de connaissances", "export_failed": "Échec de l'exportation vers la base de connaissances",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)", "sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)", "sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
"sort_z2a": "Nom de fichier (Z-A)", "sort_z2a": "Nom de fichier (Z-A)",
"star": "Favori", "star": "Notes enregistrées",
"starred_notes": "notes de collection", "starred_notes": "notes de collection",
"title": "notes", "title": "notes",
"unsaved_changes": "Vous avez des modifications non enregistrées, êtes-vous sûr de vouloir quitter ?", "unsaved_changes": "Vous avez des modifications non enregistrées, êtes-vous sûr de vouloir quitter ?",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "この{{type}}を本当に削除しますか?", "delete_confirm": "この{{type}}を本当に削除しますか?",
"delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?", "delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?",
"delete_note_confirm": "メモ \"{{name}}\" を削除してもよろしいですか?", "delete_note_confirm": "メモ \"{{name}}\" を削除してもよろしいですか?",
"drop_markdown_hint": "Markdown ファイルまたはディレクトリをここにドラッグ&ドロップしてインポートしてください", "drop_markdown_hint": ".md ファイルまたはディレクトリをここにドラッグ&ドロップしてインポートしてください",
"empty": "暫無ノート", "empty": "暫無ノート",
"expand": "展開", "expand": "展開",
"export_failed": "知識ベースへのエクスポートに失敗しました", "export_failed": "知識ベースへのエクスポートに失敗しました",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "更新日時(古い順)", "sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)", "sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名Z-A", "sort_z2a": "ファイル名Z-A",
"star": "お気に入りに追加する", "star": "お気に入りのノート",
"starred_notes": "収集したノート", "starred_notes": "収集したノート",
"title": "ノート", "title": "ノート",
"unsaved_changes": "保存されていないコンテンツがあります。本当に離れますか?", "unsaved_changes": "保存されていないコンテンツがあります。本当に離れますか?",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?", "delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?",
"delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?", "delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?",
"delete_note_confirm": "Tem a certeza de que deseja eliminar a nota \"{{name}}\"?", "delete_note_confirm": "Tem a certeza de que deseja eliminar a nota \"{{name}}\"?",
"drop_markdown_hint": "Arraste e solte arquivos ou pastas Markdown aqui para importar", "drop_markdown_hint": "Arraste e solte arquivos ou pastas .md aqui para importar",
"empty": "Ainda não existem notas", "empty": "Ainda não existem notas",
"expand": "expandir", "expand": "expandir",
"export_failed": "Falha ao exportar para a base de conhecimento", "export_failed": "Falha ao exportar para a base de conhecimento",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)", "sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)", "sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
"sort_z2a": "Nome do arquivo (Z-A)", "sort_z2a": "Nome do arquivo (Z-A)",
"star": "coleções", "star": "Notas favoritas",
"starred_notes": "notas salvas", "starred_notes": "notas salvas",
"title": "nota", "title": "nota",
"unsaved_changes": "Você tem conteúdo não salvo, tem certeza que deseja sair?", "unsaved_changes": "Você tem conteúdo não salvo, tem certeza que deseja sair?",

View File

@ -1708,7 +1708,7 @@
"delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?", "delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?",
"delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?", "delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?",
"delete_note_confirm": "Вы действительно хотите удалить заметку \"{{name}}\"?", "delete_note_confirm": "Вы действительно хотите удалить заметку \"{{name}}\"?",
"drop_markdown_hint": "Перетащите сюда файлы или папки Markdown для импорта", "drop_markdown_hint": "Перетащите сюда файлы или папки .md для импорта",
"empty": "заметок пока нет", "empty": "заметок пока нет",
"expand": "развернуть", "expand": "развернуть",
"export_failed": "Экспорт в базу знаний не выполнен", "export_failed": "Экспорт в базу знаний не выполнен",
@ -1781,7 +1781,7 @@
"sort_updated_asc": "Время обновления (от старого к новому)", "sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)", "sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)", "sort_z2a": "Имя файла (Я-А)",
"star": "Сохранить", "star": "Избранные заметки",
"starred_notes": "Сохраненные заметки", "starred_notes": "Сохраненные заметки",
"title": "заметки", "title": "заметки",
"unsaved_changes": "Вы не сохранили содержимое. Вы уверены, что хотите уйти?", "unsaved_changes": "Вы не сохранили содержимое. Вы уверены, что хотите уйти?",

View File

@ -64,14 +64,14 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
description: t('settings.input.clear.knowledge_base'), description: t('settings.input.clear.knowledge_base'),
icon: <CircleX />, icon: <CircleX />,
isSelected: false, isSelected: false,
action: () => { action: ({ context: ctx }) => {
onSelect([]) onSelect([])
quickPanel.close() ctx.close()
} }
}) })
return items return items
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect, quickPanel]) }, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
const openQuickPanel = useCallback(() => { const openQuickPanel = useCallback(() => {
quickPanel.open({ quickPanel.open({

View File

@ -202,7 +202,7 @@ const MentionModelsButton: FC<Props> = ({
icon: <CircleX />, icon: <CircleX />,
alwaysVisible: true, alwaysVisible: true,
isSelected: false, isSelected: false,
action: () => { action: ({ context: ctx }) => {
onClearMentionModels() onClearMentionModels()
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除) // 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
@ -214,7 +214,7 @@ const MentionModelsButton: FC<Props> = ({
}) })
} }
quickPanel.close() ctx.close()
} }
}) })
@ -227,7 +227,6 @@ const MentionModelsButton: FC<Props> = ({
mentionedModels, mentionedModels,
onMentionModel, onMentionModel,
navigate, navigate,
quickPanel,
onClearMentionModels, onClearMentionModels,
setText, setText,
removeAtSymbolAndText removeAtSymbolAndText
@ -249,20 +248,20 @@ const MentionModelsButton: FC<Props> = ({
afterAction({ item }) { afterAction({ item }) {
item.isSelected = !item.isSelected item.isSelected = !item.isSelected
}, },
onClose({ action, triggerInfo: closeTriggerInfo, searchText }) { onClose({ action, searchText, context: ctx }) {
// ESC关闭时的处理删除 @ 和搜索文本 // ESC关闭时的处理删除 @ 和搜索文本
if (action === 'esc') { if (action === 'esc') {
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本 // 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
if ( if (
hasModelActionRef.current && hasModelActionRef.current &&
closeTriggerInfo?.type === 'input' && ctx.triggerInfo?.type === 'input' &&
closeTriggerInfo?.position !== undefined 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 || '', closeTriggerInfo.position!) return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!)
}) })
} }
} }

View File

@ -1,11 +1,13 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar' import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout' 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 { findNodeInTree } from '@renderer/services/NotesTreeService' import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd' import { NotesTreeNode } from '@types'
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, useState } from 'react'
@ -19,7 +21,9 @@ const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRef, currentContent }) => { const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRef, currentContent }) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree) const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<Required<BreadcrumbProps>['items']>([]) const [breadcrumbItems, setBreadcrumbItems] = useState<
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
>([])
const { settings, updateSettings } = useNotesSettings() const { settings, updateSettings } = useNotesSettings()
const canShowStarButton = activeNode?.type === 'file' && onToggleStar const canShowStarButton = activeNode?.type === 'file' && onToggleStar
@ -48,6 +52,40 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe
} }
}, [getCurrentNoteContent]) }, [getCurrentNoteContent])
const handleBreadcrumbClick = useCallback(
async (item: { treePath: string; isFolder: boolean }) => {
if (item.isFolder && notesTree) {
try {
// 获取从根目录到点击目录的所有路径片段
const pathParts = item.treePath.split('/').filter(Boolean)
const expandPromises: Promise<NotesTreeNode>[] = []
// 逐级展开从根到目标路径的所有文件夹
for (let i = 0; i < pathParts.length; i++) {
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
const folderNode = findNodeByPath(notesTree, currentPath)
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
}
}
// 并行执行所有展开操作
if (expandPromises.length > 0) {
await Promise.all(expandPromises)
logger.info('Expanded folder path from breadcrumb:', {
targetPath: item.treePath,
expandedCount: expandPromises.length
})
}
} catch (error) {
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
}
}
},
[notesTree]
)
const handleExportPDFAction = useCallback(async () => { const handleExportPDFAction = useCallback(async () => {
const menuContext: ExportContext = { const menuContext: ExportContext = {
editorRef, editorRef,
@ -118,9 +156,13 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe
const pathParts = node.treePath.split('/').filter(Boolean) const pathParts = node.treePath.split('/').filter(Boolean)
const items = pathParts.map((part, index) => { const items = pathParts.map((part, index) => {
const currentPath = '/' + pathParts.slice(0, index + 1).join('/')
const isLastItem = index === pathParts.length - 1
return { return {
key: `path-${index}`, key: `path-${index}`,
title: part title: part,
treePath: currentPath,
isFolder: !isLastItem || node.type === 'folder'
} }
}) })
@ -147,8 +189,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe
</Tooltip> </Tooltip>
)} )}
</HStack> </HStack>
<NavbarCenter style={{ flex: 1 }}> <NavbarCenter style={{ flex: 1, minWidth: 0 }}>
<Breadcrumb items={breadcrumbItems} /> <BreadcrumbsContainer>
<Breadcrumbs>
{breadcrumbItems.map((item, index) => (
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
{item.title}
</BreadcrumbTitle>
</BreadcrumbItem>
))}
</Breadcrumbs>
</BreadcrumbsContainer>
</NavbarCenter> </NavbarCenter>
<NavbarRight style={{ paddingRight: 0 }}> <NavbarRight style={{ paddingRight: 0 }}>
{canShowStarButton && ( {canShowStarButton && (
@ -237,4 +291,55 @@ export const StarButton = styled.div`
} }
` `
export const BreadcrumbsContainer = styled.div`
width: 100%;
overflow: hidden;
/* 确保 HeroUI Breadcrumbs 组件保持在一行 */
& > nav {
white-space: nowrap;
overflow: hidden;
}
& ol {
flex-wrap: nowrap !important;
overflow: hidden;
display: flex;
align-items: center;
}
& li {
flex-shrink: 1;
min-width: 0;
display: flex;
align-items: center;
}
/* 确保分隔符不会与标题重叠 */
& li:not(:last-child)::after {
flex-shrink: 0;
margin: 0 8px;
}
`
export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>`
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
flex-shrink: 1;
min-width: 0;
${({ $clickable }) =>
$clickable &&
`
cursor: pointer;
&:hover {
color: var(--color-primary);
text-decoration: underline;
}
`}
`
export default HeaderNavbar export default HeaderNavbar

View File

@ -538,6 +538,25 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
[onUploadFiles] [onUploadFiles]
) )
const handleClickToSelectFiles = useCallback(() => {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.multiple = true
fileInput.accept = '.md,.markdown'
fileInput.webkitdirectory = false
fileInput.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const selectedFiles = Array.from(target.files)
onUploadFiles(selectedFiles)
}
fileInput.remove()
}
fileInput.click()
}, [onUploadFiles])
return ( return (
<SidebarContainer <SidebarContainer
onDragOver={(e) => { onDragOver={(e) => {
@ -576,7 +595,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
<NodeIcon> <NodeIcon>
<FilePlus size={16} /> <FilePlus size={16} />
</NodeIcon> </NodeIcon>
<DropHintText>{t('notes.drop_markdown_hint')}</DropHintText> <DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent> </TreeNodeContent>
</TreeNodeContainer> </TreeNodeContainer>
</DropHintNode> </DropHintNode>
@ -738,12 +757,6 @@ const NodeName = styled.div`
const EditInput = styled(Input)` const EditInput = styled(Input)`
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
.ant-input {
font-size: 13px;
padding: 2px 6px;
border: 0.5px solid var(--color-primary);
}
` `
const DragOverIndicator = styled.div` const DragOverIndicator = styled.div`

View File

@ -69,18 +69,18 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
<HeaderActions> <HeaderActions>
{!isShowStarred && !isShowSearch && ( {!isShowStarred && !isShowSearch && (
<> <>
<Tooltip title={t('notes.new_folder')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCreateFolder}>
<FolderPlus size={18} />
</ActionButton>
</Tooltip>
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}> <Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCreateNote}> <ActionButton onClick={onCreateNote}>
<FilePlus2 size={18} /> <FilePlus2 size={18} />
</ActionButton> </ActionButton>
</Tooltip> </Tooltip>
<Tooltip title={t('notes.new_folder')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCreateFolder}>
<FolderPlus size={18} />
</ActionButton>
</Tooltip>
<Dropdown <Dropdown
menu={{ menu={{
items: sortMenuWithCheck, items: sortMenuWithCheck,

View File

@ -684,10 +684,15 @@ async function uploadSingleFile(
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
let actualDirPath = originalDirPath let actualDirPath = originalDirPath
let parentNode: NotesTreeNode | null let parentNode: NotesTreeNode | null = null
if (originalDirPath === targetFolderPath) { if (originalDirPath === targetFolderPath) {
parentNode = parentNode =
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
if (!parentNode) {
logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`)
}
} else { } else {
parentNode = createdFolders.get(originalDirPath) || null parentNode = createdFolders.get(originalDirPath) || null
if (!parentNode) { if (!parentNode) {
@ -696,22 +701,21 @@ async function uploadSingleFile(
parentNode = findNodeByExternalPath(tree, originalDirPath) parentNode = findNodeByExternalPath(tree, originalDirPath)
} }
} }
}
// 如果找不到父节点,尝试通过 createdFolders 找到实际路径 if (!parentNode) {
if (!parentNode && originalDirPath !== targetFolderPath) { for (const [originalPath, createdNode] of createdFolders.entries()) {
for (const [originalPath, createdNode] of createdFolders.entries()) { if (originalPath === originalDirPath) {
if (originalPath === originalDirPath) { parentNode = createdNode
parentNode = createdNode actualDirPath = createdNode.externalPath
actualDirPath = createdNode.externalPath break
break }
} }
} }
}
if (!parentNode) { if (!parentNode) {
logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`) logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`)
return null return null
}
} }
const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true) const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true)