Finish shortcut dispatch and verify file operation safeguards
This commit is contained in:
1 parent
e028614dba
commit
0ce509a0fe
6 files changed
+53
-6
No files matched your search
@@ -71,3 +71,17 @@ func TestExportRejectsMissingAssetsAndSymlinks(t *testing.T) {
|
||||
t.Fatalf("应拒绝符号链接:%d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTokenMayExportButCannotWrite(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("文章.md", "原文")
|
||||
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"只读导出","scope":"read"}`, nil))
|
||||
headers := map[string]string{"Authorization": "Bearer " + token.Token}
|
||||
guest := newClient()
|
||||
if code := f.status(guest, "POST", "/api/v1/export", `{"paths":["文章.md"]}`, headers); code != 200 {
|
||||
t.Fatalf("只读导出被拒绝:%d", code)
|
||||
}
|
||||
if code := f.status(guest, "POST", "/api/v1/move", `{"from":"文章.md","to":"其他.md"}`, headers); code != 403 {
|
||||
t.Fatalf("只读令牌获得写权限:%d", code)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ func (v *Vault) Export(ctx context.Context, paths []string) (result *os.File, na
|
||||
}
|
||||
var linkError error
|
||||
transformMarkdownLines(content, importRef, func(line []byte) []byte {
|
||||
if linkError != nil {
|
||||
return line
|
||||
}
|
||||
return transformOutsideCode(line, func(text []byte) []byte {
|
||||
for _, span := range importLinkSpans(text) {
|
||||
target := string(text[span.start:span.end])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user