Propagate synchronization and plugin failures correctly

This commit is contained in:
eezd committed 2026-10-20 12:58:41 +08:00
1 parent 1e33f50c5e
commit ac81544e03
12 files changed
+240 -61

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { api } from './api'
import { api, ApiError } from './api'
import { registerCommand } from './commands'
import EditorPane from './EditorPane.vue'
import Icon from './Icon.vue'
@@ -79,7 +79,7 @@ function onTouchMove(e: TouchEvent) {
const unregisterLogout = registerCommand({ id: 'app.logout', title: '退出登录', run: () => void logout() })
onMounted(() => void startWorkspace())
onMounted(() => void startWorkspace().catch((err) => toast('工作区初始化失败,请重新加载:' + (err instanceof ApiError ? err.message : '网络或本地存储错误'))))
onBeforeUnmount(() => {
unregisterLogout()
stopWorkspace()
+52 -1
View File
@@ -4,16 +4,67 @@ import { EditorView } from '@codemirror/view'
import { insertImageFiles } from '../editor/paste'
import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, ws } from './workspace'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
let view: EditorView | undefined
afterEach(() => {
stopWorkspace()
attachView(null)
view?.destroy()
view = undefined
vi.restoreAllMocks()
})
describe('事件同步', () => {
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
vi.spyOn(api, 'tree').mockResolvedValue(entries)
vi.spyOn(api, 'settings').mockResolvedValue({})
vi.spyOn(api, 'plugins').mockResolvedValue([])
vi.spyOn(drafts, 'all').mockResolvedValue([])
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
const read = vi.spyOn(api, 'readFile').mockResolvedValue({ content: '旧正文', rev: 'r1' })
await startWorkspace()
await openNote('sync.md')
const handlers = vi.mocked(connectEvents).mock.lastCall![0]
const observed: string[] = []
let resyncs = 0
const changed = () => observed.push(editorStateOf('sync.md')!.doc.toString())
const resync = () => { resyncs++ }
pluginEvents.addEventListener('changed', changed)
pluginEvents.addEventListener('resync', resync)
try {
let finish!: (value: { content: string; rev: string }) => void
read.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r2' }])
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(observed).toEqual([])
finish({ content: '新正文', rev: 'r2' })
await vi.waitFor(() => expect(observed).toEqual(['新正文']))
read.mockRejectedValueOnce(new ApiError(500, '正文读取失败'))
handlers.onEvents([{ type: 'changed', path: 'sync.md', rev: 'r3' }])
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('正文读取失败'))).toBe(true))
expect(observed).toEqual(['新正文'])
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
handlers.onResync()
await vi.waitFor(() => expect(ws.toasts.some((toast) => toast.text.includes('目录读取失败'))).toBe(true))
expect(resyncs).toBe(0)
read.mockResolvedValue({ content: '恢复后的正文', rev: 'r3' })
handlers.onResync()
await vi.waitFor(() => expect(resyncs).toBe(1))
expect(editorStateOf('sync.md')!.doc.toString()).toBe('恢复后的正文')
} finally {
pluginEvents.removeEventListener('changed', changed)
pluginEvents.removeEventListener('resync', resync)
await closeTab('sync.md')
}
})
})
async function edit(path: string) {
vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' })
vi.spyOn(drafts, 'delete').mockResolvedValue(undefined)
+26 -20
View File
@@ -382,7 +382,7 @@ export async function keepLocalVersion(path: string) {
s.savedSeq = seq
Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined })
if (s.seq === seq) await drafts.delete(path)
if (!ws.entries.some((e) => e.path === path)) void refreshTree()
if (!ws.entries.some((e) => e.path === path)) void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
} catch (err) {
if (err instanceof ApiError && err.status === 412) {
tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : ''
@@ -544,11 +544,11 @@ let treeTimer: ReturnType<typeof setTimeout> | undefined
let treeFetching = false
let treeAgain = false
let localTreeEdits = 0
const treeWaiters: (() => void)[] = []
const treeWaiters: { resolve: () => void; reject: (reason: unknown) => void }[] = []
export function refreshTree(): Promise<void> {
return new Promise((resolve) => {
treeWaiters.push(resolve)
return new Promise((resolve, reject) => {
treeWaiters.push({ resolve, reject })
scheduleTreeRefresh()
})
}
@@ -566,23 +566,23 @@ async function fetchTree() {
treeFetching = true
treeAgain = false
const edits = localTreeEdits
let entries: Entry[] | null = null
let entries: Entry[]
try {
entries = await api.tree()
} catch (err) {
toast('刷新目录失败:' + errorText(err))
treeFetching = false
for (const waiter of treeWaiters.splice(0)) waiter.reject(err)
return
}
treeFetching = false
if (entries && (treeAgain || edits !== localTreeEdits || opsInFlight > 0)) {
if (treeAgain || edits !== localTreeEdits || opsInFlight > 0) {
// 有操作进行中时由 withOp 在全部结束后重新调度
if (opsInFlight === 0) scheduleTreeRefresh()
return
}
if (entries) {
ws.entries = entries
markMissingTabsDeleted()
}
for (const resolve of treeWaiters.splice(0)) resolve()
ws.entries = entries
markMissingTabsDeleted()
for (const waiter of treeWaiters.splice(0)) waiter.resolve()
}
function markMissingTabsDeleted() {
@@ -716,7 +716,7 @@ export async function move(from: string, to: string) {
} else await syncOpenTab(tab)
}))
})
void refreshTree()
void refreshTree().catch((err) => toast('刷新目录失败:' + errorText(err)))
}
export async function remove(path: string) {
@@ -767,14 +767,13 @@ async function syncOpenTab(tab: Tab) {
if (err instanceof ApiError && err.status === 404) {
tab.status = 'deleted'
tab.conflictRev = ''
}
} else throw err
}
}
function handleEvents(evs: VaultEvent[]) {
async function handleEvents(evs: VaultEvent[]) {
for (const ev of evs) {
if (ev.type === 'tree') void refreshTree()
else if (ev.type === 'settings') {
if (ev.type === 'settings') {
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
void refreshPlugins()
}
@@ -782,7 +781,7 @@ function handleEvents(evs: VaultEvent[]) {
else if (ev.type === 'changed') {
const tab = tabOf(ev.path!)
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) void syncOpenTab(tab)
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
@@ -795,7 +794,7 @@ function handleEvents(evs: VaultEvent[]) {
}
if (ev.type === 'changed' || ev.type === 'moved' || ev.type === 'deleted') pluginEvents.dispatchEvent(new CustomEvent(ev.type, { detail: ev.type === 'moved' ? { from: ev.from!, path: ev.path! } : ev.type === 'changed' ? { path: ev.path!, rev: ev.rev! } : { path: ev.path! } }))
}
if (evs.some((event) => event.type === 'tree')) void resync().catch((err) => toast('插件同步失败:' + errorText(err)))
if (evs.some((event) => event.type === 'tree')) await resync()
}
async function resync() {
@@ -851,7 +850,14 @@ export async function startWorkspace() {
toast('恢复本地草稿失败:' + errorText(err))
}
ws.active = saved.active && tabOf(saved.active) ? saved.active : (ws.tabs[0]?.path ?? null)
disconnect = connectEvents({ onEvents: handleEvents, onResync: () => void resync() })
let eventQueue = Promise.resolve()
let stopped = false
const enqueue = (work: () => Promise<void>) => {
// 同步读取是异步的,后续批次不能越过前一批次的移动和正文更新。
eventQueue = eventQueue.then(() => { if (!stopped) return work() }).catch((err) => toast('同步失败:' + errorText(err)))
}
const stopEvents = connectEvents({ onEvents: (events) => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
disconnect = () => { stopped = true; stopEvents() }
setPluginEditorHandlers(reconfigurePluginStates, pluginEditorFailure)
startPlugins({
notice: toast,