refactor: migrate showWorkspace setting from global settings to notes module (#9814)

* refactor: migrate showWorkspace setting from global settings to notes module

- Move showWorkspace state from settings store to notes store for better module cohesion
- Add useShowWorkspace hook in useNotesSettings for consistent access pattern
- Add smooth animation for workspace panel show/hide transition
- Relocate save to notes action to message toolbar for better accessibility
- Add migration v146 to handle state migration for existing users

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: cli lint error

* feat: add open outside menu

* fix: hooks error

* Update useShowWorkspace.ts

* fix: update icon import in NotesSidebarHeader component

- Replaced FilePlus icon with FilePlus2 in the NotesSidebarHeader for consistency with the latest icon set.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
Pleasure1234 2025-09-03 17:02:24 +08:00 committed by GitHub
parent 16d5f5c299
commit 8a4c635c97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 161 additions and 76 deletions

View File

@ -0,0 +1,14 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectNotesSettings, updateNotesSettings } from '@renderer/store/note'
export function useShowWorkspace() {
const dispatch = useAppDispatch()
const settings = useAppSelector(selectNotesSettings)
const showWorkspace = settings.showWorkspace
return {
showWorkspace,
setShowWorkspace: (show: boolean) => dispatch(updateNotesSettings({ showWorkspace: show })),
toggleShowWorkspace: () => dispatch(updateNotesSettings({ showWorkspace: !showWorkspace }))
}
}

View File

@ -3,10 +3,8 @@ import {
setAssistantsTabSortType,
setShowAssistants,
setShowTopics,
setShowWorkspace,
toggleShowAssistants,
toggleShowTopics,
toggleShowWorkspace
toggleShowTopics
} from '@renderer/store/settings'
import { AssistantsSortType } from '@renderer/types'
@ -41,14 +39,3 @@ export function useAssistantsTabSortType() {
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
}
}
export function useShowWorkspace() {
const showWorkspace = useAppSelector((state) => state.settings.showWorkspace)
const dispatch = useAppDispatch()
return {
showWorkspace,
setShowWorkspace: (show: boolean) => dispatch(setShowWorkspace(show)),
toggleShowWorkspace: () => dispatch(toggleShowWorkspace())
}
}

View File

@ -1626,6 +1626,7 @@
"only_markdown": "Only Markdown files are supported",
"only_one_file_allowed": "Only one file can be uploaded",
"open_folder": "Open an external folder",
"open_outside": "Open from external",
"rename": "Rename",
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
"save": "Save to Notes",

View File

@ -1626,6 +1626,7 @@
"only_markdown": "Markdown ファイルのみをアップロードできます",
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
"open_folder": "外部フォルダーを開きます",
"open_outside": "外部から開く",
"rename": "名前の変更",
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
"save": "メモに保存する",

View File

@ -1626,6 +1626,7 @@
"only_markdown": "Только Markdown",
"only_one_file_allowed": "Можно загрузить только один файл",
"open_folder": "Откройте внешнюю папку",
"open_outside": "открыть снаружи",
"rename": "переименовать",
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
"save": "Сохранить в заметки",

View File

@ -1626,6 +1626,7 @@
"only_markdown": "仅支持 Markdown 格式",
"only_one_file_allowed": "只能上传一个文件",
"open_folder": "打开外部文件夹",
"open_outside": "从外部打开",
"rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记",

View File

@ -1626,6 +1626,7 @@
"only_markdown": "僅支援 Markdown 格式",
"only_one_file_allowed": "只能上傳一個文件",
"open_folder": "打開外部文件夾",
"open_outside": "從外部打開",
"rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記",

View File

@ -677,6 +677,7 @@
"model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε",
"model_required": "Επιλέξτε μοντέλο",
"select_folder": "Επιλογή φακέλου",
"supported_providers": "υποστηριζόμενοι πάροχοι",
"title": "Εργαλεία κώδικα",
"update_options": "Ενημέρωση επιλογών",
"working_directory": "κατάλογος εργασίας"
@ -1319,7 +1320,8 @@
"delete": {
"content": "Η διαγραφή της ομάδας θα διαγράψει τις ερωτήσεις των χρηστών και όλες τις απαντήσεις του αστρόναυτη",
"title": "Διαγραφή ομάδας"
}
},
"retry_failed": "Αποτυχημένο μήνυμα επανάληψης"
},
"ignore": {
"knowledge": {
@ -1620,9 +1622,13 @@
"new_folder": "Νέος φάκελος",
"new_note": "Δημιουργία νέας σημείωσης",
"no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή",
"no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση",
"only_markdown": "Υποστηρίζεται μόνο η μορφή Markdown",
"only_one_file_allowed": "Μπορείτε να ανεβάσετε μόνο ένα αρχείο",
"open_folder": "Άνοιγμα εξωτερικού φακέλου",
"open_outside": "Από το εξωτερικό",
"rename": "μετονομασία",
"rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}",
"save": "αποθήκευση στις σημειώσεις",
"settings": {
"data": {
@ -3344,6 +3350,8 @@
"label": "Καταγραφή στοιχείων στο grid"
},
"input": {
"confirm_delete_message": "Επιβεβαίωση πριν τη διαγραφή μηνύματος",
"confirm_regenerate_message": "Επιβεβαίωση πριν από την επαναδημιουργία του μηνύματος",
"enable_quick_triggers": "Ενεργοποίηση των '/' και '@' για γρήγορη πρόσβαση σε μενού",
"paste_long_text_as_file": "Επικόλληση μεγάλου κειμένου ως αρχείο",
"paste_long_text_threshold": "Όριο μεγάλου κειμένου",

View File

@ -677,6 +677,7 @@
"model_placeholder": "Seleccionar el modelo que se va a utilizar",
"model_required": "Seleccione el modelo",
"select_folder": "Seleccionar carpeta",
"supported_providers": "Proveedores de servicios compatibles",
"title": "Herramientas de código",
"update_options": "Opciones de actualización",
"working_directory": "directorio de trabajo"
@ -1319,7 +1320,8 @@
"delete": {
"content": "Eliminar el mensaje del grupo eliminará la pregunta del usuario y todas las respuestas del asistente",
"title": "Eliminar mensaje del grupo"
}
},
"retry_failed": "Reintentar el mensaje con error"
},
"ignore": {
"knowledge": {
@ -1620,9 +1622,13 @@
"new_folder": "Nueva carpeta",
"new_note": "Crear nota nueva",
"no_content_to_copy": "No hay contenido para copiar",
"no_file_selected": "Por favor, seleccione el archivo a subir",
"only_markdown": "Solo se admite el formato Markdown",
"only_one_file_allowed": "solo se puede subir un archivo",
"open_folder": "abrir carpeta externa",
"open_outside": "Abrir desde el exterior",
"rename": "renombrar",
"rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}",
"save": "Guardar en notas",
"settings": {
"data": {
@ -3344,6 +3350,8 @@
"label": "Desencadenante de detalles de cuadrícula"
},
"input": {
"confirm_delete_message": "Confirmar antes de eliminar mensaje",
"confirm_regenerate_message": "confirmar antes de regenerar el mensaje",
"enable_quick_triggers": "Habilitar menú rápido con '/' y '@'",
"paste_long_text_as_file": "Pegar texto largo como archivo",
"paste_long_text_threshold": "Límite de longitud de texto largo",

View File

@ -677,6 +677,7 @@
"model_placeholder": "Sélectionnez le modèle à utiliser",
"model_required": "Veuillez sélectionner le modèle",
"select_folder": "Sélectionner le dossier",
"supported_providers": "fournisseurs pris en charge",
"title": "Outils de code",
"update_options": "Options de mise à jour",
"working_directory": "répertoire de travail"
@ -1319,7 +1320,8 @@
"delete": {
"content": "La suppression du groupe de messages supprimera les questions des utilisateurs et toutes les réponses des assistants",
"title": "Supprimer le groupe de messages"
}
},
"retry_failed": "message d'erreur de nouvelle tentative"
},
"ignore": {
"knowledge": {
@ -1620,9 +1622,13 @@
"new_folder": "Nouveau dossier",
"new_note": "Nouvelle note",
"no_content_to_copy": "Aucun contenu à copier",
"no_file_selected": "Veuillez sélectionner le fichier à télécharger",
"only_markdown": "uniquement le format Markdown est pris en charge",
"only_one_file_allowed": "On ne peut télécharger qu'un seul fichier",
"open_folder": "ouvrir le dossier externe",
"open_outside": "Ouvrir depuis l'extérieur",
"rename": "renommer",
"rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}",
"save": "sauvegarder dans les notes",
"settings": {
"data": {
@ -3344,6 +3350,8 @@
"label": "Déclencheur de popover de la grille"
},
"input": {
"confirm_delete_message": "Confirmer avant de supprimer le message",
"confirm_regenerate_message": "Confirmer avant de régénérer le message",
"enable_quick_triggers": "Activer les menus rapides avec '/' et '@'",
"paste_long_text_as_file": "Coller le texte long sous forme de fichier",
"paste_long_text_threshold": "Seuil de longueur de texte",

View File

@ -677,6 +677,7 @@
"model_placeholder": "Selecione o modelo a ser utilizado",
"model_required": "Selecione o modelo",
"select_folder": "Selecionar pasta",
"supported_providers": "Provedores de serviço suportados",
"title": "Ferramenta de código",
"update_options": "Opções de atualização",
"working_directory": "diretório de trabalho"
@ -1319,7 +1320,8 @@
"delete": {
"content": "Excluir mensagens de grupo removerá as perguntas dos usuários e todas as respostas do assistente",
"title": "Excluir mensagens de grupo"
}
},
"retry_failed": "Repetir mensagem com erro"
},
"ignore": {
"knowledge": {
@ -1620,9 +1622,13 @@
"new_folder": "Nova pasta",
"new_note": "Nova nota",
"no_content_to_copy": "Não há conteúdo para copiar",
"no_file_selected": "Selecione o arquivo a ser enviado",
"only_markdown": "Apenas o formato Markdown é suportado",
"only_one_file_allowed": "só é possível enviar um arquivo",
"open_folder": "Abrir pasta externa",
"open_outside": "Abrir externamente",
"rename": "renomear",
"rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}",
"save": "salvar em notas",
"settings": {
"data": {
@ -3344,6 +3350,8 @@
"label": "Disparador de detalhes da grade"
},
"input": {
"confirm_delete_message": "confirmar antes de excluir a mensagem",
"confirm_regenerate_message": "Confirmar antes de regenerar a mensagem",
"enable_quick_triggers": "Ativar menu rápido com '/' e '@'",
"paste_long_text_as_file": "Colar texto longo como arquivo",
"paste_long_text_threshold": "Limite de texto longo",

View File

@ -42,7 +42,19 @@ import {
} from '@renderer/utils/messageUtils/find'
import { Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { AtSign, Check, FilePenLine, Languages, ListChecks, Menu, Save, Split, ThumbsUp, Upload } from 'lucide-react'
import {
AtSign,
Check,
FilePenLine,
Languages,
ListChecks,
Menu,
NotebookPen,
Save,
Split,
ThumbsUp,
Upload
} from 'lucide-react'
import { FC, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
@ -255,15 +267,6 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: () => {
SaveToKnowledgePopup.showForMessage(message)
}
},
{
label: t('notes.save'),
key: 'clipboard',
onClick: async () => {
const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMessageToNotes(title, markdown, notesPath)
}
}
]
},
@ -382,7 +385,6 @@ const MessageMenubar: FC<Props> = (props) => {
toggleMultiSelectMode,
message,
mainTextContent,
notesPath,
messageContainerRef,
topic.name
]
@ -620,6 +622,21 @@ const MessageMenubar: FC<Props> = (props) => {
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && (
<Tooltip title={t('notes.save')} mouseEnterDelay={0.8}>
<ActionButton
className="message-action-button"
onClick={async (e) => {
e.stopPropagation()
const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMessageToNotes(title, markdown, notesPath)
}}
$softHoverBg={softHoverBg}>
<NotebookPen size={15} />
</ActionButton>
</Tooltip>
)}
{confirmDeleteMessage ? (
<Popconfirm
title={t('message.message.delete.content')}

View File

@ -37,6 +37,7 @@ import {
FolderOpen,
HelpCircle,
MenuIcon,
NotebookPen,
PackagePlus,
PinIcon,
PinOffIcon,
@ -276,6 +277,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
onPinTopic(topic)
}
},
{
label: t('notes.save'),
key: 'notes',
icon: <NotebookPen size={14} />,
onClick: async () => {
exportTopicToNotes(topic, notesPath)
}
},
{
label: t('chat.topics.clear.title'),
key: 'clear-messages',
@ -345,13 +354,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
window.message.error(t('chat.save.topic.knowledge.error.save_failed'))
}
}
},
{
label: t('notes.save'),
key: 'notes',
onClick: async () => {
exportTopicToNotes(topic, notesPath)
}
}
]
},

View File

@ -3,7 +3,7 @@ import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/ap
import { HStack } from '@renderer/components/Layout'
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useStore'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { findNodeInTree } from '@renderer/services/NotesTreeService'
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd'
import { t } from 'i18next'

View File

@ -2,7 +2,7 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar
import { HStack } from '@renderer/components/Layout'
import { isMac } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useShowWorkspace } from '@renderer/hooks/useStore'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { Tooltip } from 'antd'
import { PanelLeftClose, PanelRightClose } from 'lucide-react'
import { useCallback } from 'react'

View File

@ -3,7 +3,7 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import {
createFolder,
createNote,
@ -20,6 +20,7 @@ import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType }
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
import { FileChangeEvent } from '@shared/config/types'
import { useLiveQuery } from 'dexie-react-hooks'
import { AnimatePresence, motion } from 'framer-motion'
import { debounce } from 'lodash'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -34,7 +35,7 @@ const logger = loggerService.withContext('NotesPage')
const NotesPage: FC = () => {
const editorRef = useRef<RichEditorRef>(null)
const { t } = useTranslation()
const { showWorkspace } = useSettings()
const { showWorkspace } = useShowWorkspace()
const dispatch = useAppDispatch()
const activeFilePath = useAppSelector(selectActiveFilePath)
const sortType = useAppSelector(selectSortType)
@ -113,8 +114,7 @@ const NotesPage: FC = () => {
lastContentRef.current = newMarkdown
lastFilePathRef.current = activeFilePath
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
const currentFilePath = activeFilePath
debouncedSave(newMarkdown, currentFilePath)
debouncedSave(newMarkdown, activeFilePath)
},
[debouncedSave, activeFilePath]
)
@ -593,22 +593,31 @@ const NotesPage: FC = () => {
<NavbarCenter style={{ borderRight: 'none' }}>{t('notes.title')}</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
{showWorkspace && (
<NotesSidebar
notesTree={notesTree}
selectedFolderId={selectedFolderId}
onSelectNode={handleSelectNode}
onCreateFolder={handleCreateFolder}
onCreateNote={handleCreateNote}
onDeleteNode={handleDeleteNode}
onRenameNode={handleRenameNode}
onToggleExpanded={handleToggleExpanded}
onToggleStar={handleToggleStar}
onMoveNode={handleMoveNode}
onSortNodes={handleSortNodes}
onUploadFiles={handleUploadFiles}
/>
)}
<AnimatePresence initial={false}>
{showWorkspace && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 250, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}>
<NotesSidebar
notesTree={notesTree}
selectedFolderId={selectedFolderId}
onSelectNode={handleSelectNode}
onCreateFolder={handleCreateFolder}
onCreateNote={handleCreateNote}
onDeleteNode={handleDeleteNode}
onRenameNode={handleRenameNode}
onToggleExpanded={handleToggleExpanded}
onToggleStar={handleToggleStar}
onMoveNode={handleMoveNode}
onSortNodes={handleSortNodes}
onUploadFiles={handleUploadFiles}
/>
</motion.div>
)}
</AnimatePresence>
<EditorWrapper>
<HeaderNavbar notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} />
<NotesEditor

View File

@ -303,6 +303,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onClick: () => {
handleStartEdit(node)
}
},
{
label: t('notes.open_outside'),
key: 'open_outside',
icon: <FolderOpen size={14} />,
onClick: () => {
window.api.openPath(node.externalPath)
}
}
]
if (node.type !== 'folder') {
@ -520,6 +528,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const SidebarContainer = styled.div`
width: 250px;
min-width: 250px;
height: 100vh;
background-color: var(--color-background);
border-right: 0.5px solid var(--color-border);

View File

@ -1,7 +1,7 @@
import { CheckOutlined } from '@ant-design/icons'
import { NotesSortType } from '@renderer/types/note'
import { Dropdown, Input, MenuProps, Tooltip } from 'antd'
import { ArrowLeft, ArrowUpNarrowWide, FilePlus, FolderPlus, Search, Star } from 'lucide-react'
import { ArrowLeft, ArrowUpNarrowWide, FilePlus2, FolderPlus, Search, Star } from 'lucide-react'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -77,7 +77,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCreateNote}>
<FilePlus size={18} />
<FilePlus2 size={18} />
</ActionButton>
</Tooltip>

View File

@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 145,
version: 146,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@ -2346,6 +2346,26 @@ const migrateConfig = {
logger.error('migrate 145 error', error as Error)
return state
}
},
'146': (state: RootState) => {
try {
// Migrate showWorkspace from settings to note store
if (state.settings && state.note) {
const showWorkspaceValue = (state.settings as any)?.showWorkspace
if (showWorkspaceValue !== undefined) {
state.note.settings.showWorkspace = showWorkspaceValue
// Remove from settings
delete (state.settings as any).showWorkspace
} else if (state.note.settings.showWorkspace === undefined) {
// Set default value if not exists
state.note.settings.showWorkspace = true
}
}
return state
} catch (error) {
logger.error('migrate 146 error', error as Error)
return state
}
}
}

View File

@ -9,6 +9,7 @@ export interface NotesSettings {
defaultViewMode: 'edit' | 'read'
defaultEditMode: Omit<EditorView, 'read'>
showTabStatus: boolean
showWorkspace: boolean
}
export interface NoteState {
@ -27,7 +28,8 @@ export const initialState: NoteState = {
fontFamily: 'default',
defaultViewMode: 'edit',
defaultEditMode: 'preview',
showTabStatus: true
showTabStatus: true,
showWorkspace: true
},
notesPath: '',
sortType: 'sort_a2z'

View File

@ -215,8 +215,6 @@ export interface SettingsState {
// API Server
apiServer: ApiServerConfig
showMessageOutline: boolean
// Notes Related
showWorkspace: boolean
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -409,9 +407,7 @@ export const initialState: SettingsState = {
port: 23333,
apiKey: `cs-sk-${uuid()}`
},
showMessageOutline: false,
// Notes Related
showWorkspace: true
showMessageOutline: false
}
const settingsSlice = createSlice({
@ -846,12 +842,6 @@ const settingsSlice = createSlice({
},
setShowMessageOutline: (state, action: PayloadAction<boolean>) => {
state.showMessageOutline = action.payload
},
setShowWorkspace: (state, action: PayloadAction<boolean>) => {
state.showWorkspace = action.payload
},
toggleShowWorkspace: (state) => {
state.showWorkspace = !state.showWorkspace
}
}
})
@ -982,9 +972,7 @@ export const {
// API Server actions
setApiServerEnabled,
setApiServerPort,
setApiServerApiKey,
setShowWorkspace,
toggleShowWorkspace
setApiServerApiKey
} = settingsSlice.actions
export default settingsSlice.reducer