Finish shortcut dispatch and verify file operation safeguards

This commit is contained in:
eezd committed 2026-10-21 16:34:29 +08:00
1 parent e028614dba
commit 0ce509a0fe
6 files changed
+53 -6

No files matched your search

+3 -2
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { basename } from './paths'
import { commandTitle } from './commands'
import { tree } from './tree'
import TreeNode from './TreeNode.vue'
import SearchPanel from './SearchPanel.vue'
@@ -40,7 +41,7 @@ function onRootDrop(e: DragEvent) {
<div class="rm-sidebar-inner">
<div class="rm-sidebar-head">
<span class="rm-brand">RawMark</span>
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" title="搜索(Ctrl+Shift+F)" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()">
<button class="rm-icon-btn" :class="{ on: ws.searchOpen }" :title="commandTitle('app.search')" @click="ws.searchOpen ? (ws.searchOpen = false) : openSearch()">
<Icon name="search" />
</button>
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
@@ -64,7 +65,7 @@ function onRootDrop(e: DragEvent) {
<div class="rm-menu rm-context-menu" :style="{ left: rootMenu.x + 'px', top: rootMenu.y + 'px' }"><button @click="ws.images = ''; rootMenu = null"><Icon name="image" />图片管理</button></div>
</template>
<div class="rm-sidebar-foot">
<button class="rm-foot-btn" title="设置(Ctrl+,)" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" :title="commandTitle('app.settings')" @click="ws.settings = 'index'"><Icon name="settings" />设置</button>
<button class="rm-foot-btn" @click="emit('logout')"><Icon name="logout" />退出</button>
</div>
</div>
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { commandTitle } from './commands'
import Icon from './Icon.vue'
import { noteTitle } from './paths'
import { closeTab, ws } from './workspace'
@@ -71,7 +72,7 @@ function closeRight(path: string) {
<template>
<nav class="rm-tabs" :class="{ empty: !ws.tabs.length && !ws.sidebarCollapsed }" role="tablist">
<button v-if="ws.sidebarCollapsed" class="rm-icon-btn rm-tabs-side" title="显示侧栏(Ctrl+\)" @click="ws.sidebarCollapsed = false">
<button v-if="ws.sidebarCollapsed" class="rm-icon-btn rm-tabs-side" :title="commandTitle('view.sidebar')" @click="ws.sidebarCollapsed = false">
<Icon name="menu" />
</button>
<div
@@ -102,7 +103,7 @@ function closeRight(path: string) {
<Icon name="close" :size="12" />
</button>
</div>
<button v-if="ws.active" class="rm-icon-btn rm-tabs-outline" :class="{ on: ws.outlineOpen }" title="大纲(Ctrl+Shift+O)" @click="ws.outlineOpen = !ws.outlineOpen">
<button v-if="ws.active" class="rm-icon-btn rm-tabs-outline" :class="{ on: ws.outlineOpen }" :title="commandTitle('view.outline')" @click="ws.outlineOpen = !ws.outlineOpen">
<Icon name="outline" />
</button>
<template v-if="menu">
+10 -1
View File
@@ -1,7 +1,7 @@
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
import { shallowReactive } from 'vue'
import { settings } from './settings'
import { matchKeys, physicalKeys } from '../keys'
import { formatKeys, matchKeys, physicalKeys } from '../keys'
export { formatKeys, matchKeys } from '../keys'
export interface Command {
@@ -49,6 +49,13 @@ export function commandKeys(command: Command): string[] {
return settings.shortcuts[command.id] ?? command.keys ?? []
}
export function commandTitle(id: string): string {
const command = commands.get(id)
if (!command) throw new Error('命令不存在:' + id)
const keys = commandKeys(command)
return command.title + (keys.length ? `(${keys.map(formatKeys).join(' / ')})` : '')
}
export function shortcutConflict(id: string, keys: string[]): Command | undefined {
return allCommands().find((command) => command.id !== id && commandKeys(command).some((existing) => keys.some((key) => physicalKeys(existing) === physicalKeys(key))))
}
@@ -60,6 +67,8 @@ export function handleShortcut(e: KeyboardEvent) {
if (cmd.id.startsWith('editor.') && !(e.target instanceof Element && e.target.closest('.cm-content'))) continue
if (commandKeys(cmd).some((k) => matchKeys(e, k))) {
e.preventDefault()
// 捕获阶段已执行命令,不再让编辑器或弹窗重复处理同一次按键。
e.stopPropagation()
cmd.run()
return
}
+20 -1
View File
@@ -6,7 +6,7 @@ import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, remove, readyToReload, openNote, refreshTree, replaceImage, restore, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -306,3 +306,22 @@ describe('图片管理', () => {
expect(ws.active).toBeNull()
})
})
describe('文件操作的保存前提', () => {
it.each(['move', 'delete'] as const)('%s 保存失败时返回失败并保留原文档', async (action) => {
const path = `保存失败-${action}.md`
const v = await edit(path)
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const saving = vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(403, '禁止写入'))
vi.spyOn(api, 'tree').mockResolvedValue([{ path, dir: false, size: 0, mtime: 0 }])
v.dispatch({ changes: { from: 0, insert: '未保存' } })
const result = action === 'move' ? await move(path, '目标.md') : await remove(path)
expect(result.ok).toBe(false)
expect(ws.active).toBe(path)
expect(editorStateOf(path)?.doc.toString()).toBe('未保存原文')
saving.mockResolvedValue('r2')
await flush(path)
await refreshTree()
await closeTab(path)
})
})