fix(desktop): 扩充应用菜单与元数据快捷键

This commit is contained in:
2026-09-08 00:01:03 +08:00
parent acc0ba167d
commit 2b18b8b2a2
9 changed files with 220 additions and 24 deletions
@@ -3,19 +3,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import TitleBarMenu from './TitleBarMenu.vue'
const execute = vi.hoisted(() => vi.fn())
const capabilities = vi.hoisted(() => vi.fn())
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/services/editorCommandService', () => ({
executeEditorCommand: execute,
getEditorCommandCapabilities: capabilities,
subscribeEditorCommandCapabilities: () => () => undefined,
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush }),
useRoute: () => ({ name: 'workspace' }),
}))
beforeEach(() => {
setActivePinia(createPinia())
execute.mockReset().mockResolvedValue({ ok: true })
routerPush.mockReset()
capabilities.mockReturnValue([
{ id: 'editor.paragraph', supported: true, enabled: true },
{ id: 'editor.heading', supported: true, enabled: true },
@@ -25,6 +32,33 @@ beforeEach(() => {
})
describe('桌面顶部段落菜单', () => {
it('文件菜单承载实际工作区命令和笔记导出', async () => {
const editor = useEditorStore()
editor.currentFilePath = '/示例.md'
editor.content = '# 示例'
editor.saveStatus = 'saved'
const wrapper = mount(TitleBarMenu, { global: { stubs: { ExportDialog: { template: '<div data-testid="export-dialog" />' } } } })
await wrapper.get('[data-menu="file"] .menu-trigger').trigger('click')
expect(wrapper.text()).toContain('新建笔记…')
expect(wrapper.text()).toContain('打开其他知识库…')
expect(wrapper.text()).toContain('刷新文件树')
expect(wrapper.text()).toContain('下载 Markdown 副本')
await wrapper.get('.export-command').trigger('click')
expect(wrapper.find('[data-testid="export-dialog"]').exists()).toBe(true)
wrapper.unmount()
})
it('视图和帮助菜单只连接项目已有页面', async () => {
useWorkspaceStore().hasVault = true
const wrapper = mount(TitleBarMenu)
await wrapper.get('[data-menu="view"] .menu-trigger').trigger('click')
expect(wrapper.text()).toContain('工作区搜索AI 对话智能体任务音视频')
expect(wrapper.text()).toContain('Skill 管理Plugin 管理MCP 服务器')
await wrapper.get('[data-menu="help"] .menu-trigger').trigger('click')
expect(wrapper.text()).toContain('运行日志Benchmark 评测社区目录设置与诊断…')
wrapper.unmount()
})
it('源码笔记通过统一编辑命令执行属性导入', async () => {
const editor = useEditorStore()
editor.mode = 'source'
+145 -16
View File
@@ -1,23 +1,47 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
import { executeEditorCommand, getEditorCommandCapabilities, subscribeEditorCommandCapabilities, type EditorCommandId } from '@/services/editorCommandService'
import { calloutMenuCommands, formatMenuSections, paragraphMenuSections, type EditorMenuCommand } from '@/services/editorMenu'
import * as workspaceService from '@/services/workspaceService'
import ExportDialog from '@/features/editor/ExportDialog.vue'
import ActionDialog from './ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
type MenuName = 'file' | 'edit' | 'paragraph' | 'format' | 'view' | 'theme' | 'help'
const editor = useEditorStore()
const workspace = useWorkspaceStore()
const theme = useThemeStore()
const router = useRouter()
const route = useRoute()
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
const open = ref<MenuName | null>(null)
const nestedOpen = ref(false)
const exportOpen = ref(false)
const fileBusy = ref(false)
const bar = ref<HTMLElement | null>(null)
const error = ref('')
const capabilities = ref(new Map<EditorCommandId, boolean>())
const shortcut = computed(() => /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘⌥P' : 'Ctrl+Alt+P')
const menuOrder: readonly MenuName[] = ['file', 'edit', 'paragraph', 'format', 'view', 'theme', 'help']
const editorBlocked = computed(() => !editor.currentFilePath || ['saving', 'conflict', 'external_changed'].includes(editor.saveStatus))
const viewDestinations = computed(() => [
{ name: 'workspace', label: t('工作区', 'Workspace') },
{ name: 'search', label: t('搜索', 'Search') },
{ name: 'chat', label: t('AI 对话', 'AI Chat') },
{ name: 'agent', label: t('智能体', 'Agent') },
{ name: 'tasks', label: t('任务', 'Tasks') },
{ name: 'media', label: t('音视频', 'Media') },
])
const extensionDestinations = computed(() => [
{ name: 'skills', label: t('Skill 管理', 'Skill management') },
{ name: 'plugins', label: t('Plugin 管理', 'Plugin management') },
{ name: 'mcp-servers', label: t('MCP 服务器', 'MCP servers') },
])
function enabled(id: EditorCommandId) {
return capabilities.value.get(id) ?? false
@@ -53,11 +77,92 @@ async function metadataCommand() {
if (imported.ok) { close(); return }
await command('editor.metadata.edit')
}
async function save() { await editor.save(); close() }
async function save() {
await editor.save()
if (['saved', 'idle'].includes(editor.saveStatus)) close()
else error.value = t('当前笔记尚未安全保存。', 'The current note has not been saved safely.')
}
function setMode(mode: 'source' | 'wysiwyg') { editor.setMode(mode); close() }
function toggleTheme() { theme.toggleTheme(); close() }
function applyTheme(id: string) { theme.applyTheme(id); close() }
function navigate(path: string) { void router.push(path); close() }
function navigateNamed(name: string) { void router.push({ name }); close() }
function message(reason: unknown) {
return reason instanceof Error ? reason.message : String(reason)
}
async function ensureCurrentNoteSaved() {
if (!editor.currentFilePath) return true
if (['dirty', 'saving', 'save_failed'].includes(editor.saveStatus)) await editor.save()
if (['saved', 'idle'].includes(editor.saveStatus)) return true
error.value = t('请先处理当前笔记的保存或外部修改冲突。', 'Resolve the current note save or external-change conflict first.')
return false
}
async function runFileAction(action: () => Promise<void>) {
if (fileBusy.value) return
close()
error.value = ''
fileBusy.value = true
try { await action() }
catch (reason) { error.value = message(reason) }
finally { fileBusy.value = false }
}
async function createWorkspaceItem(type: 'file' | 'folder') {
close()
const rawName = (await askPrompt(type === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')))?.trim()
if (!rawName) return
if (/[\\/]/.test(rawName) || ['.', '..'].includes(rawName)) {
error.value = t('名称不能包含路径分隔符。', 'Names cannot contain path separators.')
return
}
await runFileAction(async () => {
if (!(await ensureCurrentNoteSaved())) return
if (type === 'folder') {
const folder = await workspaceService.createFolder('/', rawName)
workspace.addFileToTree('/', folder)
return
}
const title = rawName.replace(/\.md$/i, '')
const file = await workspaceService.createFile('/', rawName, `# ${title}\n\n`)
workspace.addFileToTree('/', file)
await editor.loadFile(file.path)
workspace.openFile(file.path)
await router.push('/workspace')
})
}
function openExport() { close(); exportOpen.value = true }
function downloadMarkdown() {
if (!editor.currentFilePath) return
const url = URL.createObjectURL(new Blob([editor.content], { type: 'text/markdown;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = editor.currentFilePath.split('/').at(-1) ?? 'note.md'
document.body.append(link); link.click(); link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
close()
}
async function chooseVault() {
await runFileAction(async () => {
if (!(await ensureCurrentNoteSaved())) return
await workspace.openVault('')
editor.closeFile()
await router.push('/workspace')
})
}
async function refreshWorkspace() {
await runFileAction(async () => { await workspace.refreshFileTree() })
}
async function closeCurrentNote() {
await runFileAction(async () => {
if (!(await ensureCurrentNoteSaved())) return
const path = editor.currentFilePath
if (!path) return
workspace.closeFile(path)
const next = workspace.activeFilePath
editor.closeFile()
if (next) await editor.loadFile(next)
})
}
async function switchMenu(offset: number, expand = open.value !== null) {
const current = open.value ? menuOrder.indexOf(open.value) : 0
@@ -128,25 +233,39 @@ onBeforeUnmount(() => {
</script>
<template>
<ExportDialog v-if="exportOpen" @close="exportOpen = false" />
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<nav ref="bar" class="desktop-menu-bar" role="menubar" :aria-label="t('应用菜单', 'Application menu')" @keydown="handleKeys">
<div class="menu-group" data-menu="file">
<div class="menu-group" role="none" data-menu="file">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'file'" aria-haspopup="menu" @click="toggle('file')" @pointerenter="open && focusFirst('file')">{{ t('文件', 'File') }}</button>
<div v-if="open === 'file'" class="menu-popover" role="menu" :aria-label="t('文件', 'File')">
<button data-menu-item role="menuitem" @click="navigate('/')"><span>{{ t('选择知识库…', 'Choose knowledge base…') }}</span></button>
<div v-if="open === 'file'" class="menu-popover file-menu" role="menu" :aria-label="t('文件', 'File')">
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault || fileBusy" @click="createWorkspaceItem('file')"><span>{{ t('新建笔记…', 'New note…') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault || fileBusy" @click="createWorkspaceItem('folder')"><span>{{ t('新建文件夹…', 'New folder…') }}</span></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="!editor.currentFilePath || ['saving','conflict','external_changed'].includes(editor.saveStatus)" @click="save">
<button data-menu-item role="menuitem" :disabled="fileBusy" @click="chooseVault"><span>{{ t('打开其他知识库…', 'Open another knowledge base…') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault || fileBusy" @click="navigate('/workspace')"><span>{{ t('返回工作区', 'Return to workspace') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault || fileBusy" @click="refreshWorkspace"><span>{{ t('刷新文件树', 'Refresh file tree') }}</span></button>
<small v-if="workspace.hasVault" class="menu-context">{{ workspace.vaultName }} · {{ workspace.vaultPath }}</small>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="editorBlocked || fileBusy" @click="save">
<span>{{ t('保存', 'Save') }}</span><kbd>Ctrl+S</kbd>
</button>
<button class="export-command" data-menu-item role="menuitem" :disabled="!editor.currentFilePath || !editor.content.trim() || fileBusy" @click="openExport"><span>{{ t('导出…', 'Export…') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!editor.currentFilePath || fileBusy" @click="downloadMarkdown"><span>{{ t('下载 Markdown 副本', 'Download Markdown copy') }}</span></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="!editor.currentFilePath || fileBusy" @click="closeCurrentNote"><span>{{ t('关闭当前笔记', 'Close current note') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="edit">
<div class="menu-group" role="none" data-menu="edit">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'edit'" aria-haspopup="menu" @click="toggle('edit')" @pointerenter="open && focusFirst('edit')">{{ t('编辑', 'Edit') }}</button>
<div v-if="open === 'edit'" class="menu-popover" role="menu" :aria-label="t('编辑', 'Edit')">
<button data-menu-item role="menuitem" :disabled="!enabled('editor.undo')" @click="command('editor.undo')"><span>{{ t('撤销', 'Undo') }}</span><kbd>Ctrl+Z</kbd></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.redo')" @click="command('editor.redo')"><span>{{ t('重做', 'Redo') }}</span><kbd>Ctrl+Shift+Z</kbd></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault" @click="navigate('/search')"><span>{{ t('在知识库中搜索…', 'Search knowledge base…') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="paragraph">
<div class="menu-group" role="none" data-menu="paragraph">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'paragraph'" aria-haspopup="menu" @click="toggle('paragraph')" @pointerenter="open && focusFirst('paragraph')">{{ t('段落', 'Paragraph') }}</button>
<div v-if="open === 'paragraph'" class="menu-popover paragraph-menu" role="menu" :aria-label="t('段落', 'Paragraph')">
<template v-for="(section, sectionIndex) in paragraphMenuSections" :key="sectionIndex">
@@ -160,7 +279,7 @@ onBeforeUnmount(() => {
<small v-if="!enabled('editor.import-note-properties')">{{ t('请在无冲突的 Markdown 源码笔记中使用', 'Available in a conflict-free Markdown source note') }}</small>
</div>
</div>
<div class="menu-group" data-menu="format">
<div class="menu-group" role="none" data-menu="format">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'format'" aria-haspopup="menu" @click="toggle('format')" @pointerenter="open && focusFirst('format')">{{ t('格式', 'Format') }}</button>
<div v-if="open === 'format'" class="menu-popover format-menu" role="menu" :aria-label="t('格式', 'Format')">
<template v-for="item in formatMenuSections[0]" :key="item.id">
@@ -184,17 +303,21 @@ onBeforeUnmount(() => {
<button class="metadata-command" data-menu-item role="menuitem" :disabled="!enabled('editor.import-note-properties') && !enabled('editor.metadata.edit')" @click="metadataCommand"><span>{{ t('元数据 / YAML Front Matter…', 'Metadata / YAML Front Matter…') }}</span><kbd>{{ shortcut }}</kbd></button>
</div>
</div>
<div class="menu-group" data-menu="view">
<div class="menu-group" role="none" data-menu="view">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'view'" aria-haspopup="menu" @click="toggle('view')" @pointerenter="open && focusFirst('view')">{{ t('视图', 'View') }}</button>
<div v-if="open === 'view'" class="menu-popover" role="menu" :aria-label="t('视图', 'View')">
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" @click="setMode('wysiwyg')"><span>{{ editor.mode === 'wysiwyg' ? '✓ ' : '' }}{{ t('写作模式', 'Writing mode') }}</span></button>
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'source'" @click="setMode('source')"><span>{{ editor.mode === 'source' ? '✓ ' : '' }}{{ t('源码模式', 'Source mode') }}</span></button>
<div v-if="open === 'view'" class="menu-popover view-menu" role="menu" :aria-label="t('视图', 'View')">
<button v-for="item in viewDestinations" :key="item.name" data-menu-item role="menuitemradio" :aria-checked="route.name === item.name" :disabled="!workspace.hasVault" @click="navigateNamed(item.name)"><span>{{ route.name === item.name ? '✓ ' : '' }}{{ item.label }}</span></button>
<span class="menu-separator" role="separator" />
<button v-for="item in extensionDestinations" :key="item.name" data-menu-item role="menuitemradio" :aria-checked="route.name === item.name" :disabled="!workspace.hasVault" @click="navigateNamed(item.name)"><span>{{ route.name === item.name ? '✓ ' : '' }}{{ item.label }}</span></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'wysiwyg'" :disabled="!editor.currentFilePath" @click="setMode('wysiwyg')"><span>{{ editor.mode === 'wysiwyg' && editor.currentFilePath ? '✓ ' : '' }}{{ t('写作模式', 'Writing mode') }}</span></button>
<button data-menu-item role="menuitemradio" :aria-checked="editor.mode === 'source'" :disabled="!editor.currentFilePath" @click="setMode('source')"><span>{{ editor.mode === 'source' && editor.currentFilePath ? '✓ ' : '' }}{{ t('源码模式', 'Source mode') }}</span></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.fold-all')" @click="command('editor.heading.fold-all')"><span>{{ t('全部折叠标题', 'Fold all headings') }}</span></button>
<button data-menu-item role="menuitem" :disabled="!enabled('editor.heading.unfold-all')" @click="command('editor.heading.unfold-all')"><span>{{ t('全部展开标题', 'Unfold all headings') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="theme">
<div class="menu-group" role="none" data-menu="theme">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'theme'" aria-haspopup="menu" @click="toggle('theme')" @pointerenter="open && focusFirst('theme')">{{ t('主题', 'Theme') }}</button>
<div v-if="open === 'theme'" class="menu-popover" role="menu" :aria-label="t('主题', 'Theme')">
<button v-for="item in theme.allThemes" :key="item.theme_id" data-menu-item role="menuitemradio" :aria-checked="theme.currentThemeId === item.theme_id" @click="applyTheme(item.theme_id)"><span>{{ theme.currentThemeId === item.theme_id ? '✓ ' : '' }}{{ item.name }}</span></button>
@@ -203,11 +326,14 @@ onBeforeUnmount(() => {
<button data-menu-item role="menuitem" @click="navigate('/themes')"><span>{{ t('管理主题…', 'Manage themes…') }}</span></button>
</div>
</div>
<div class="menu-group" data-menu="help">
<div class="menu-group" role="none" data-menu="help">
<button class="menu-trigger" role="menuitem" :aria-expanded="open === 'help'" aria-haspopup="menu" @click="toggle('help')" @pointerenter="open && focusFirst('help')">{{ t('帮助', 'Help') }}</button>
<div v-if="open === 'help'" class="menu-popover" role="menu" :aria-label="t('帮助', 'Help')">
<button data-menu-item role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/settings')"><span>{{ t('设置与诊断', 'Settings and diagnostics') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/benchmarks')"><span>{{ t('Benchmark 评测', 'Benchmarks') }}</span></button>
<button data-menu-item role="menuitem" @click="navigate('/community')"><span>{{ t('社区目录', 'Community catalog') }}</span></button>
<span class="menu-separator" role="separator" />
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault" @click="navigate('/settings')"><span>{{ t('设置与诊断…', 'Settings and diagnostics…') }}</span></button>
</div>
</div>
<span v-if="error" class="menu-error" role="alert">{{ error }}</span>
@@ -220,8 +346,10 @@ onBeforeUnmount(() => {
.menu-trigger { min-width: 52px; height: 28px; padding: 2px 12px; border-radius: var(--radius-sm); color: var(--color-text-secondary); }
.menu-trigger:hover, .menu-trigger[aria-expanded='true'] { background: var(--color-background-hover); color: var(--color-text-primary); }
.menu-popover { position: absolute; top: calc(100% + 2px); left: 0; z-index: calc(var(--z-titlebar) + 2); display: grid; min-width: 230px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-elevated); box-shadow: var(--shadow-lg); }
.file-menu { min-width: 320px; }
.paragraph-menu { min-width: 270px; }
.format-menu { min-width: 330px; }
.view-menu { min-width: 250px; }
.menu-popover button { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); width: 100%; padding: 7px var(--space-md); border-radius: var(--radius-sm); text-align: left; white-space: nowrap; }
.menu-popover button:hover:not(:disabled), .menu-popover button:focus-visible { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.menu-popover button:disabled { opacity: .45; cursor: not-allowed; }
@@ -236,5 +364,6 @@ onBeforeUnmount(() => {
.menu-popover.format-menu { max-height: none; overflow: visible; }
.menu-separator { height: 1px; margin: var(--space-xs); background: var(--color-border-subtle); }
.menu-popover small { padding: var(--space-xs) var(--space-md); color: var(--color-text-tertiary); white-space: normal; }
.menu-context { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap !important; }
.menu-error { margin-left: var(--space-md); color: var(--color-error); font-size: var(--font-size-xs); }
</style>
@@ -5,6 +5,7 @@ import ExportDialog from './ExportDialog.vue'
import { computed, ref } from 'vue'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
import { isDesktop } from '@/services/platform/desktop'
import { t } from '@/i18n'
const editorStore = useEditorStore()
@@ -12,6 +13,7 @@ const workspaceStore = useWorkspaceStore()
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
const reloadError = ref('')
const exportOpen = ref(false)
const desktop = isDesktop()
const needsRecovery = computed(() => ['conflict', 'external_changed'].includes(editorStore.saveStatus))
const missingFile = computed(() => needsRecovery.value && editorStore.currentFilePath === workspaceStore.activeFilePath && !workspaceStore.activeFile && !workspaceStore.treeRefreshError)
function downloadCopy() {
@@ -49,7 +51,7 @@ const statusText = computed<Record<string, string>>(() => ({
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions">
<button class="button-secondary" @click="exportOpen = true">{{ t('导出', 'Export') }}</button>
<button v-if="!desktop" class="button-secondary" @click="exportOpen = true">{{ t('导出', 'Export') }}</button>
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<button v-if="needsRecovery && !missingFile" class="button-secondary" @click="reload">{{ t('重新加载外部版本', 'Reload external version') }}</button>
<span v-if="missingFile" class="save-status conflict">{{ t('原文件已删除或移动', 'Original file deleted or moved') }}</span>
@@ -1,6 +1,9 @@
import { afterEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { executeEditorCommand, registerEditorCommands, getEditorCommandCapabilities, subscribeEditorCommandCapabilities, updateNativeEditorMenu } from './editorCommandService'
const hostInvoke = vi.hoisted(() => vi.fn(() => Promise.resolve()))
vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
let dispose: (() => void) | undefined
beforeEach(() => hostInvoke.mockClear())
afterEach(() => dispose?.())
it('reports unsupported, disabled and invalid commands without side effects', async () => {
expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
@@ -34,3 +37,7 @@ it('notifies the visible menu when the active editor capability changes', () =>
updateNativeEditorMenu()
expect(listener).toHaveBeenCalledTimes(2)
})
it('keeps the native metadata accelerator enabled in writing mode', () => {
dispose = registerEditorCommands({ available: () => true, handlers: { 'editor.metadata.edit': () => ({ ok: true }) } })
expect(hostInvoke).toHaveBeenLastCalledWith('editor_capabilities', { metadataEnabled: true })
})
@@ -35,7 +35,9 @@ export function registerEditorCommands(target: Target) {
}
export function updateNativeEditorMenu() {
notifyCapabilityListeners()
if (isDesktop()) void hostInvoke('editor_capabilities', { importEnabled: !!active?.handlers['editor.import-note-properties'] && active.available() }).catch(() => undefined)
const metadataEnabled = !!active?.available()
&& (!!active.handlers['editor.import-note-properties'] || !!active.handlers['editor.metadata.edit'])
if (isDesktop()) void hostInvoke('editor_capabilities', { metadataEnabled }).catch(() => undefined)
}
export function getEditorCommandCapabilities() {
return editorCommandIds.map(id => ({ id, supported: !!active?.handlers[id], enabled: !!active?.handlers[id] && active.available() }))
+8 -2
View File
@@ -91,10 +91,13 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading.value = true
try {
const info = await workspaceService.openVault(path)
const tree = await workspaceService.getFileTree()
vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
fileTree.value = tree
openFiles.value = []
activeFilePath.value = null
hasVault.value = true
localStorage.setItem('last-vault-path', info.path)
} finally {
@@ -107,10 +110,13 @@ export const useWorkspaceStore = defineStore('workspace', () => {
isLoading.value = true
try {
const info = await workspaceService.createVault(path, name)
const tree = await workspaceService.getFileTree()
vaultPath.value = info.path
vaultId.value = info.vault_id
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
fileTree.value = tree
openFiles.value = []
activeFilePath.value = null
hasVault.value = true
localStorage.setItem('last-vault-path', info.path)
} finally {
@@ -18,3 +18,15 @@ it('fetches external entries while keeping folder state and ignoring stale respo
release([]); await old
expect(store.fileTree).toHaveLength(1)
})
it('clears document tabs only after another vault opens successfully', async () => {
const store = useWorkspaceStore()
store.openFile('/old.md')
vi.spyOn(service, 'openVault').mockResolvedValue({ vault_id: 'new-vault', path: 'D:/notes', name: 'notes' })
vi.spyOn(service, 'getFileTree').mockResolvedValue([{ id: 'new', path: '/new.md', name: 'new.md', type: 'file' }])
await store.openVault('D:/notes')
expect(store.openFiles).toEqual([])
expect(store.activeFilePath).toBeNull()
expect(store.fileTree.map(item => item.path)).toEqual(['/new.md'])
})