From c2437fab46a1845dd814e3d89c8091c6c54de4a5 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Thu, 1 Oct 2026 00:53:58 +0800 Subject: [PATCH] fix(ui): restore panel toggles and refine workspace settings --- DESIGN.md | 15 +++-- src/main/config/defaults.ts | 8 +-- src/main/services/ShortcutManager.ts | 66 ++++--------------- src/renderer/src/components/home/Home.tsx | 9 ++- .../components/layouts/ResizableLayout.tsx | 37 ++++------- .../source/reader/PdfSourceReader.tsx | 2 +- .../source/reader/TextSourceReader.tsx | 23 +++++-- .../src/components/pages/NotebookListPage.tsx | 20 +++++- .../components/settings/ModelsSettings.tsx | 33 ++++++---- .../components/settings/SettingsDialog.tsx | 40 +++++------ src/renderer/src/hooks/useShortcutExecutor.ts | 6 +- .../src/hooks/useSourceAnchorNavigation.ts | 2 + src/renderer/src/lib/workspaceEvents.ts | 1 + src/renderer/src/locales/en-US/shortcuts.json | 4 +- src/renderer/src/locales/zh-CN/shortcuts.json | 4 +- src/shared/utils/shortcutMigration.ts | 20 ++++++ test/shortcutMigration.test.ts | 44 +++++++++++++ 17 files changed, 188 insertions(+), 146 deletions(-) create mode 100644 src/shared/utils/shortcutMigration.ts create mode 100644 test/shortcutMigration.test.ts diff --git a/DESIGN.md b/DESIGN.md index d8066c1..c18ecfb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -94,12 +94,13 @@ the citation chip, not the row. - **Open a citation:** `Tab` to the chip, then `Enter` / `Space`. Chips are real buttons, so this is the platform behaviour, not a custom keybinding. - **Return:** `Escape` (or Back) returns focus to the chip that opened the reader. -- **Move between the zones:** `Cmd/Ctrl+[` reveals Library (if collapsed) and - moves focus into it; `Cmd/Ctrl+]` does the same for Notes. The persisted action - IDs (`toggle_knowledge_base` / `toggle_creative_space`) are unchanged, but the - behaviour is “go to the zone”, not “show the panel” — this is the “move between - zones” path. Collapsing a side stays on its panel-header button. The centre - composer is reached through `FOCUS_CHAT_EVENT` (the reader's back fallback). +- **Toggle side panels:** `Cmd/Ctrl+[` expands/collapses Library and + `Cmd/Ctrl+]` expands/collapses Notes, exactly like the panel-header buttons. + Reopening restores the remembered width. Citation navigation is separate: + it only reveals Library, never collapses it. The centre composer is reached + through `FOCUS_CHAT_EVENT` (the reader's back fallback). +- **Close notebook:** `Cmd/Ctrl+D`, with the unsaved-note guard. Existing + Escape and Cmd/Ctrl+W defaults migrate to this binding; other custom bindings stay. The chip for the citation the reader is currently showing carries `aria-current` and a selected fill. That state is **derived** from `uiStore.focusedSource` @@ -418,7 +419,7 @@ Two rules for the composer and for leaving the workspace: `pinToBottom` handle and `handleSend` calls it, so the follow state cannot hide the message the user just wrote. - **Leaving the workspace asks first when the note editor is dirty.** Closing a - tab, switching tabs, the Home tab and `Cmd+W` all unmount it. `NotePanel` + tab, switching tabs, the Home tab and `Cmd/Ctrl+D` all unmount it. `NotePanel` publishes its dirty state to `uiStore` (only it knows), and `NotebookLayout` owns the single `UnsavedChangesDialog` that every one of those paths goes through. The in-panel Back button is not special-cased — it uses the same diff --git a/src/main/config/defaults.ts b/src/main/config/defaults.ts index 33d695c..25e164e 100644 --- a/src/main/config/defaults.ts +++ b/src/main/config/defaults.ts @@ -276,12 +276,8 @@ export const defaultShortcuts: ShortcutConfig[] = [ }, { action: ShortcutAction.CLOSE_NOTEBOOK, - // Not bare Escape. Escape is the key that dismisses a field, a dialog and a - // dropdown, and this action closes the whole workspace — including unsaved - // note text, because the main process claims the key before the renderer's - // unsaved-changes guard can see it. Ctrl/Cmd+W is the platform idiom for - // "close this tab" and cannot be pressed by accident. - accelerator: 'CommandOrControl+W', + // Keep Escape available to dialogs and avoid the window-close Cmd/Ctrl+W. + accelerator: 'CommandOrControl+D', enabled: true, description: 'shortcuts:closeNotebook' }, diff --git a/src/main/services/ShortcutManager.ts b/src/main/services/ShortcutManager.ts index 2e2d4bd..7d41d8e 100644 --- a/src/main/services/ShortcutManager.ts +++ b/src/main/services/ShortcutManager.ts @@ -3,21 +3,7 @@ import type Store from 'electron-store' import { ShortcutConfig, ShortcutAction } from '../../shared/types' import { defaultShortcuts } from '../config/defaults' import type { StoreSchema } from '../config/types' - -/** - * Accelerators that shipped as a default and must not survive an upgrade. - * - * The merge in `registerShortcuts` only adds actions a stored config has never - * seen, so *changing* a default never reaches anyone who has already run the app. - * Bare `Escape` was bound to closing the notebook, which the main process claims - * before the renderer sees it: it destroyed the workspace — unsaved note text - * included — and made Escape unusable for dismissing anything. Replacing it by - * value is the migration; there is no need for a version counter because a - * modifier-less accelerator is no longer accepted by the recorder either. - */ -const SUPERSEDED_ACCELERATORS: Partial> = { - [ShortcutAction.CLOSE_NOTEBOOK]: ['Escape'] -} +import { migrateShortcuts } from '../../shared/utils/shortcutMigration' /** * 快捷键管理器 @@ -27,7 +13,7 @@ export class ShortcutManager { private store: Store private mainWindow: BrowserWindow | null = null private shortcuts: ShortcutConfig[] = [] - private keyboardHandler: ((event: Event, input: Electron.Input) => void) | null = null + private keyboardHandler: ((event: Electron.Event, input: Electron.Input) => void) | null = null constructor(store: Store) { this.store = store @@ -53,27 +39,8 @@ export class ShortcutManager { return } - // 合并新增的默认快捷键(向后兼容旧版本配置), - // 并替换掉已废弃的默认加速键(见 SUPERSEDED_ACCELERATORS)。 - const mergedShortcuts = [...shortcuts] - let hasNewShortcut = false - for (const defaultShortcut of defaultShortcuts) { - const index = mergedShortcuts.findIndex((s) => s.action === defaultShortcut.action) - if (index === -1) { - mergedShortcuts.push({ ...defaultShortcut }) - hasNewShortcut = true - continue - } - const superseded = SUPERSEDED_ACCELERATORS[defaultShortcut.action] - if (superseded?.includes(mergedShortcuts[index].accelerator)) { - mergedShortcuts[index] = { - ...mergedShortcuts[index], - accelerator: defaultShortcut.accelerator - } - hasNewShortcut = true - } - } - if (hasNewShortcut) { + const mergedShortcuts = migrateShortcuts(shortcuts, defaultShortcuts) + if (JSON.stringify(mergedShortcuts) !== JSON.stringify(shortcuts)) { this.store.set('shortcuts', mergedShortcuts) } @@ -86,31 +53,23 @@ export class ShortcutManager { // 移除旧的监听器 if (this.keyboardHandler) { - ;(this.mainWindow.webContents as any).removeListener( - 'before-input-event', - this.keyboardHandler - ) + this.mainWindow.webContents.removeListener('before-input-event', this.keyboardHandler) } // 创建新的处理器 - this.keyboardHandler = (event: Event, input: Electron.Input) => { + this.keyboardHandler = (event: Electron.Event, input: Electron.Input) => { this.handleKeyboardEvent(event, input) } - // 等待 webContents ready 后再注册 - if (this.mainWindow.webContents.isLoading()) { - this.mainWindow.webContents.once('did-finish-load', () => { - ;(this.mainWindow!.webContents as any).on('before-input-event', this.keyboardHandler!) - }) - } else { - ;(this.mainWindow.webContents as any).on('before-input-event', this.keyboardHandler) - } + // Input belongs to webContents, not to a loaded document. Attach immediately + // so failed/restarted loads cannot leave the window without shortcuts. + this.mainWindow.webContents.on('before-input-event', this.keyboardHandler) } /** * 处理键盘事件 */ - private handleKeyboardEvent(event: Event, input: Electron.Input): void { + private handleKeyboardEvent(event: Electron.Event, input: Electron.Input): void { // 只处理 keyDown 事件 if (input.type !== 'keyDown') return @@ -148,7 +107,10 @@ export class ShortcutManager { * 注销所有快捷键 */ unregisterShortcuts(): void { - // 清空菜单(移除所有快捷键) + if (this.keyboardHandler && this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.removeListener('before-input-event', this.keyboardHandler) + } + this.keyboardHandler = null Menu.setApplicationMenu(null) } diff --git a/src/renderer/src/components/home/Home.tsx b/src/renderer/src/components/home/Home.tsx index a76250d..bb4929c 100644 --- a/src/renderer/src/components/home/Home.tsx +++ b/src/renderer/src/components/home/Home.tsx @@ -43,9 +43,8 @@ function greetingKey(hour: number): string { * * Four sections at most, in the order they are used: a greeting, the search entry, * the notebooks, then what the user was doing and what recently arrived. The - * greeting is small on purpose; the entry below it is the thing the eye should land - * on, because a long-lived tool's Home is opened a hundred times and a hero banner - * is paid for on every one of them. + * greeting is the page's focus heading; search stays immediately below it without + * turning the workspace into a promotional hero. * * Every section is allowed to render nothing. A workspace with no conversations and * no sources shows a greeting, a search box and the shelf — not four empty boxes @@ -85,10 +84,10 @@ export default function Home({ const header = (
-

+

{t(greetingKey(new Date().getHours()))}

-

{t('homePrompt')}

+

{t('homePrompt')}

) diff --git a/src/renderer/src/components/layouts/ResizableLayout.tsx b/src/renderer/src/components/layouts/ResizableLayout.tsx index bf5503a..afc66c3 100644 --- a/src/renderer/src/components/layouts/ResizableLayout.tsx +++ b/src/renderer/src/components/layouts/ResizableLayout.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState, ReactNode, ReactElement } from 'react' import { useTranslation } from 'react-i18next' import DragHandle from './DragHandle' +import { REVEAL_LIBRARY_EVENT } from '../../lib/workspaceEvents' import { lockDrag } from '../../lib/dragLock' import { DEFAULT_LEFT_WIDTH, @@ -303,42 +304,26 @@ export default function ResizableLayout({ const leftZoneRef = useRef(null) const rightZoneRef = useRef(null) - /** - * Zone shortcuts enter the workspace (#65). - * - * The persisted action IDs are unchanged, but the behaviour is “go to the - * zone”, not “toggle the panel”: reveal a collapsed zone, then move focus into - * it. Collapsing stays on the panel-header buttons. Listening to the same - * `shortcut:toggle-*` events keeps the shortcut IDs stable. - */ + // Shortcuts toggle just like the header buttons. Citation navigation only + // reveals the library, so repeated citation clicks never hide the reader. useEffect(() => { - const enterLibrary = (): void => { + const revealLibrary = (): void => { setIsLeftCollapsed((collapsed) => { const entry = planZoneEntry(collapsed, lastSizeRef.current.left, DEFAULT_LEFT_WIDTH) if (entry.width !== null) setPanelWidth('left', entry.width) return entry.collapsed }) - // The wrapper mounts on this tick when it was collapsed, so focus after the - // commit rather than on an unmounted node. - requestAnimationFrame(() => leftZoneRef.current?.focus()) - } - - const enterNotes = (): void => { - setIsRightCollapsed((collapsed) => { - const entry = planZoneEntry(collapsed, lastSizeRef.current.right, DEFAULT_RIGHT_WIDTH) - if (entry.width !== null) setPanelWidth('right', entry.width) - return entry.collapsed - }) - requestAnimationFrame(() => rightZoneRef.current?.focus()) } - window.addEventListener('shortcut:toggle-knowledge-base', enterLibrary) - window.addEventListener('shortcut:toggle-creative-space', enterNotes) + window.addEventListener(REVEAL_LIBRARY_EVENT, revealLibrary) + window.addEventListener('shortcut:toggle-knowledge-base', toggleLeftPanel) + window.addEventListener('shortcut:toggle-creative-space', toggleRightPanel) return () => { - window.removeEventListener('shortcut:toggle-knowledge-base', enterLibrary) - window.removeEventListener('shortcut:toggle-creative-space', enterNotes) + window.removeEventListener(REVEAL_LIBRARY_EVENT, revealLibrary) + window.removeEventListener('shortcut:toggle-knowledge-base', toggleLeftPanel) + window.removeEventListener('shortcut:toggle-creative-space', toggleRightPanel) } - }, [setPanelWidth]) + }, [setPanelWidth, toggleLeftPanel, toggleRightPanel]) // Stable, so a drag frame does not hand the centre panel a new element. const centerPanelProps = useMemo( diff --git a/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx b/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx index 1ac30db..7bce694 100644 --- a/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx +++ b/src/renderer/src/components/notebook/source/reader/PdfSourceReader.tsx @@ -380,7 +380,7 @@ const PdfSourceReader = forwardRef(function ) : (
diff --git a/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx b/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx index c5a84c8..da982bb 100644 --- a/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx +++ b/src/renderer/src/components/notebook/source/reader/TextSourceReader.tsx @@ -45,9 +45,11 @@ const TextSourceReader = forwardRef(functio const contentRef = useRef(null) const lastSelection = useRef(null) const [highlightRects, setHighlightRects] = useState([]) + const activeAnchor = useRef(null) const applyAnchor = useCallback( - (target: ReaderAnchor): void => { + (target: ReaderAnchor, scroll = true): void => { + activeAnchor.current = target const { block } = resolveAnchor(blocks, target) const start = target.startOffset ?? block?.startOffset ?? null const end = target.endOffset ?? block?.endOffset ?? null @@ -58,7 +60,7 @@ const TextSourceReader = forwardRef(functio // 定位不到就清掉上一次的高亮,并回到文本顶部:否则从一条 citation 切到 document-only // anchor 时会清掉高亮、却停在旧滚动位置。 setHighlightRects([]) - container?.scrollTo({ top: 0, behavior: 'auto' }) + if (scroll) container?.scrollTo({ top: 0, behavior: 'auto' }) return } @@ -77,7 +79,8 @@ const TextSourceReader = forwardRef(functio setHighlightRects(rects) const first = rects[0] - if (first) container.scrollTo({ top: Math.max(0, first.top - 80), behavior: 'auto' }) + if (scroll && first) + container.scrollTo({ top: Math.max(0, first.top - 80), behavior: 'auto' }) }, [blocks] ) @@ -101,6 +104,18 @@ const TextSourceReader = forwardRef(functio return () => cancelAnimationFrame(frame) }, [anchor, blocks, applyAnchor]) + useEffect(() => { + const container = scrollRef.current + const body = contentRef.current + if (!container || !body) return + const observer = new ResizeObserver(() => { + if (activeAnchor.current) applyAnchor(activeAnchor.current, false) + }) + observer.observe(container) + observer.observe(body) + return () => observer.disconnect() + }, [applyAnchor, content]) + const handleSelection = useCallback((): void => { const selection = window.getSelection() if (!selection || selection.isCollapsed || selection.rangeCount === 0) { @@ -127,7 +142,7 @@ const TextSourceReader = forwardRef(functio return (
diff --git a/src/renderer/src/components/pages/NotebookListPage.tsx b/src/renderer/src/components/pages/NotebookListPage.tsx index 5579c75..b58d06d 100644 --- a/src/renderer/src/components/pages/NotebookListPage.tsx +++ b/src/renderer/src/components/pages/NotebookListPage.tsx @@ -64,7 +64,7 @@ export default function NotebookListPage(): ReactElement { }) }, []) - const handleCreateNotebook = async (): Promise => { + const handleCreateNotebook = useCallback(async (): Promise => { const newId = await addNotebook({ title: t('newNotebook', { index: notebooks.length + 1 }), description: t('notebookDescription') @@ -74,7 +74,23 @@ export default function NotebookListPage(): ReactElement { setCurrentNotebook(newId) rememberOpen(newId) navigate(`/notebook/${newId}`) - } + }, [ + addNotebook, + t, + notebooks.length, + addOpenedNotebook, + setCurrentNotebook, + rememberOpen, + navigate + ]) + + useEffect(() => { + const create = (): void => { + void handleCreateNotebook() + } + window.addEventListener('shortcut:create-notebook', create) + return () => window.removeEventListener('shortcut:create-notebook', create) + }, [handleCreateNotebook]) const handleNotebookClick = (id: string): void => { addOpenedNotebook(id) diff --git a/src/renderer/src/components/settings/ModelsSettings.tsx b/src/renderer/src/components/settings/ModelsSettings.tsx index ee69997..6bf82b5 100644 --- a/src/renderer/src/components/settings/ModelsSettings.tsx +++ b/src/renderer/src/components/settings/ModelsSettings.tsx @@ -4,11 +4,13 @@ import ModelConnectionForm, { type ProtocolInfo } from './ModelConnectionForm' import EmbeddingSettings from './EmbeddingSettings' interface ModelsSettingsProps { + capability: ModelCapability connections: ConnectionMap onConnectionsChange: (connections: ConnectionMap) => void } export default function ModelsSettings({ + capability, connections, onConnectionsChange }: ModelsSettingsProps): ReactElement { @@ -37,20 +39,23 @@ export default function ModelsSettings({ return (
- setConnection('chat', connection)} - onClear={() => clearConnection('chat')} - /> -
- setConnection('embedding', connection)} - onClear={() => clearConnection('embedding')} - /> + {capability === 'chat' && ( + setConnection('chat', connection)} + onClear={() => clearConnection('chat')} + /> + )} + {capability === 'embedding' && ( + setConnection('embedding', connection)} + onClear={() => clearConnection('embedding')} + /> + )}
) } diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx index dc2a8df..bfc1c8c 100644 --- a/src/renderer/src/components/settings/SettingsDialog.tsx +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import GeneralSettings from './GeneralSettings' import { useUIStore } from '../../store/uiStore' import ModelsSettings from './ModelsSettings' -import PromptsSettings from './PromptsSettings' import ShortcutSettings from './ShortcutSettings' import AboutSettings from './AboutSettings' import SettingsContentPanel from './SettingsContentPanel' @@ -69,18 +68,18 @@ export default function SettingsDialog(): ReactElement { description: t('generalSettingsDesc') }, { - id: 'models', - icon: Database, - label: t('modelConnections'), - title: t('modelConnections'), - description: t('modelConnectionsDesc') + id: 'chat', + icon: MessageSquare, + label: t('chatModel'), + title: t('chatModel'), + description: t('chatModelDesc') }, { - id: 'prompts', - icon: MessageSquare, - label: t('promptSettings'), - title: t('promptSettings'), - description: t('mindMapPromptDesc') + id: 'embedding', + icon: Database, + label: t('embeddingModel'), + title: t('embeddingModel'), + description: t('embeddingModelDesc') }, { id: 'shortcuts', @@ -169,13 +168,18 @@ export default function SettingsDialog(): ReactElement { const isActive = activeSection === item.id return ( + {item.id === 'chat' && ( +

+ {t('modelConnections')} +

+ )} setActiveSection(item.id)} isActive={isActive} className={ isActive - ? 'bg-surface-selected! text-foreground! font-medium' - : 'hover:bg-surface-hover hover:text-foreground' + ? `bg-surface-selected! text-foreground! font-medium ${item.id === 'chat' || item.id === 'embedding' ? 'pl-4' : ''}` + : `hover:bg-surface-hover hover:text-foreground ${item.id === 'chat' || item.id === 'embedding' ? 'pl-4' : ''}` } > @@ -208,18 +212,14 @@ export default function SettingsDialog(): ReactElement { onSettingsChange={updatePendingSettings} /> )} - {activeSection === 'models' && ( + {(activeSection === 'chat' || activeSection === 'embedding') && ( )} - {pendingSettings && activeSection === 'prompts' && ( - - )} {activeSection === 'shortcuts' && } {activeSection === 'about' && } diff --git a/src/renderer/src/hooks/useShortcutExecutor.ts b/src/renderer/src/hooks/useShortcutExecutor.ts index b5cea98..b8b4a33 100644 --- a/src/renderer/src/hooks/useShortcutExecutor.ts +++ b/src/renderer/src/hooks/useShortcutExecutor.ts @@ -39,10 +39,6 @@ export function useShortcutExecutor() { } // 监听来自主进程的快捷键触发事件 - window.electron.ipcRenderer.on('shortcut:triggered', handleShortcut) - - return () => { - window.electron.ipcRenderer.removeAllListeners('shortcut:triggered') - } + return window.electron.ipcRenderer.on('shortcut:triggered', handleShortcut) }, []) } diff --git a/src/renderer/src/hooks/useSourceAnchorNavigation.ts b/src/renderer/src/hooks/useSourceAnchorNavigation.ts index 5fa4434..c3c2017 100644 --- a/src/renderer/src/hooks/useSourceAnchorNavigation.ts +++ b/src/renderer/src/hooks/useSourceAnchorNavigation.ts @@ -3,6 +3,7 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import type { SourceAnchor } from '../../../shared/types/source' import { withSourceAnchor } from '../../../shared/utils/sourceAnchor' import { useUIStore } from '../store/uiStore' +import { REVEAL_LIBRARY_EVENT } from '../lib/workspaceEvents' import { rememberSourceOrigin, type FocusableOrigin } from './sourceFocusReturn' /** @@ -49,6 +50,7 @@ export function useSourceAnchorNavigation(): { rememberSourceOrigin( origin ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null) ) + window.dispatchEvent(new CustomEvent(REVEAL_LIBRARY_EVENT)) setFocusedSource(anchor) navigateTo(anchor) }, diff --git a/src/renderer/src/lib/workspaceEvents.ts b/src/renderer/src/lib/workspaceEvents.ts index 0fdbfc7..6a4ac7a 100644 --- a/src/renderer/src/lib/workspaceEvents.ts +++ b/src/renderer/src/lib/workspaceEvents.ts @@ -8,3 +8,4 @@ * the keyboard is*. */ export const FOCUS_CHAT_EVENT = 'workspace:focus-chat' +export const REVEAL_LIBRARY_EVENT = 'workspace:reveal-library' diff --git a/src/renderer/src/locales/en-US/shortcuts.json b/src/renderer/src/locales/en-US/shortcuts.json index 6924036..1a3b28b 100644 --- a/src/renderer/src/locales/en-US/shortcuts.json +++ b/src/renderer/src/locales/en-US/shortcuts.json @@ -19,7 +19,7 @@ "createNotebook": "Create New Notebook", "closeNotebook": "Close Notebook", - "toggleKnowledgeBase": "Go to Library", - "toggleCreativeSpace": "Go to Notes", + "toggleKnowledgeBase": "Toggle Library", + "toggleCreativeSpace": "Toggle Notes", "saveNote": "Save Note" } diff --git a/src/renderer/src/locales/zh-CN/shortcuts.json b/src/renderer/src/locales/zh-CN/shortcuts.json index 1bbb576..c37f121 100644 --- a/src/renderer/src/locales/zh-CN/shortcuts.json +++ b/src/renderer/src/locales/zh-CN/shortcuts.json @@ -19,7 +19,7 @@ "createNotebook": "新建笔记本", "closeNotebook": "关闭笔记本", - "toggleKnowledgeBase": "跳转到资料库", - "toggleCreativeSpace": "跳转到笔记", + "toggleKnowledgeBase": "展开 / 收起资料库", + "toggleCreativeSpace": "展开 / 收起笔记", "saveNote": "保存笔记" } diff --git a/src/shared/utils/shortcutMigration.ts b/src/shared/utils/shortcutMigration.ts new file mode 100644 index 0000000..fa0c24c --- /dev/null +++ b/src/shared/utils/shortcutMigration.ts @@ -0,0 +1,20 @@ +import { ShortcutAction, type ShortcutConfig } from '../types' + +/** Replace retired defaults while preserving enabled state and custom bindings. */ +export function migrateShortcuts( + stored: ShortcutConfig[], + defaults: ShortcutConfig[] +): ShortcutConfig[] { + const retired: Partial> = { + [ShortcutAction.CLOSE_NOTEBOOK]: ['Escape', 'CommandOrControl+W'] + } + const result = stored.map((shortcut) => ({ ...shortcut })) + for (const fallback of defaults) { + const index = result.findIndex((shortcut) => shortcut.action === fallback.action) + if (index === -1) result.push({ ...fallback }) + else if (retired[fallback.action]?.includes(result[index].accelerator)) { + result[index].accelerator = fallback.accelerator + } + } + return result +} diff --git a/test/shortcutMigration.test.ts b/test/shortcutMigration.test.ts new file mode 100644 index 0000000..e61c41f --- /dev/null +++ b/test/shortcutMigration.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { defaultShortcuts } from '../src/main/config/defaults.ts' +import { ShortcutAction } from '../src/shared/types/index.ts' +import { migrateShortcuts } from '../src/shared/utils/shortcutMigration.ts' + +const close = defaultShortcuts.find((entry) => entry.action === ShortcutAction.CLOSE_NOTEBOOK)! + +test('close notebook defaults to Ctrl/Cmd+D and migrates retired defaults', () => { + assert.equal(close.accelerator, 'CommandOrControl+D') + for (const accelerator of ['Escape', 'CommandOrControl+W']) { + const stored = [{ ...close, accelerator, enabled: false }] + const migrated = migrateShortcuts(stored, defaultShortcuts) + assert.equal(migrated[0].accelerator, 'CommandOrControl+D') + assert.equal(migrated[0].enabled, false) + assert.equal(stored[0].accelerator, accelerator) + assert.deepEqual(migrateShortcuts(migrated, defaultShortcuts), migrated) + } +}) + +test('migration preserves custom close shortcuts and adds missing actions', () => { + const migrated = migrateShortcuts( + [{ ...close, accelerator: 'CommandOrControl+Shift+D' }], + defaultShortcuts + ) + assert.equal(migrated[0].accelerator, 'CommandOrControl+Shift+D') + assert.equal(migrated.length, defaultShortcuts.length) +}) + +test('panel shortcuts use header toggle handlers, citation requests use reveal only', () => { + const source = readFileSync( + new URL('../src/renderer/src/components/layouts/ResizableLayout.tsx', import.meta.url), + 'utf8' + ) + for (const [event, handler] of [ + ['shortcut:toggle-knowledge-base', 'toggleLeftPanel'], + ['shortcut:toggle-creative-space', 'toggleRightPanel'] + ]) { + assert.ok(source.includes(`window.addEventListener('${event}', ${handler})`)) + assert.ok(source.includes(`window.removeEventListener('${event}', ${handler})`)) + } + assert.ok(source.includes('window.addEventListener(REVEAL_LIBRARY_EVENT, revealLibrary)')) +})