Propagate synchronization and plugin failures correctly
This commit is contained in:
1 parent
1e33f50c5e
commit
ac81544e03
12 files changed
+240
-61
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user