From 7e52d9a00b122fed5863ffc917cb01f89c266ce5 Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 12:43:11 +0800 Subject: [PATCH] Fix note saves and drafts across asynchronous edits --- web/src/app/drafts.ts | 9 ++-- web/src/app/workspace.test.ts | 87 +++++++++++++++++++++++++++++++++++ web/src/app/workspace.ts | 84 +++++++++++++++++++++++++-------- web/src/editor/paste.ts | 18 ++------ 4 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 web/src/app/workspace.test.ts diff --git a/web/src/app/drafts.ts b/web/src/app/drafts.ts index b039377..37af86b 100644 --- a/web/src/app/drafts.ts +++ b/web/src/app/drafts.ts @@ -1,4 +1,4 @@ -// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失 +// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复 export interface Draft { path: string @@ -21,10 +21,11 @@ function db(): Promise { } async function tx(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest): Promise { - const store = (await db()).transaction('drafts', mode).objectStore('drafts') + const transaction = (await db()).transaction('drafts', mode) return new Promise((resolve, reject) => { - const req = fn(store) - req.onsuccess = () => resolve(req.result) + const req = fn(transaction.objectStore('drafts')) + transaction.oncomplete = () => resolve(req.result) + transaction.onabort = () => reject(transaction.error ?? new Error('本地草稿事务已中止')) req.onerror = () => reject(req.error) }) } diff --git a/web/src/app/workspace.test.ts b/web/src/app/workspace.test.ts new file mode 100644 index 0000000..5cb6772 --- /dev/null +++ b/web/src/app/workspace.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +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' + +let view: EditorView | undefined +afterEach(() => { + attachView(null) + view?.destroy() + view = undefined + vi.restoreAllMocks() +}) + +async function edit(path: string) { + vi.spyOn(api, 'readFile').mockResolvedValue({ content: '原文', rev: 'r1' }) + vi.spyOn(drafts, 'delete').mockResolvedValue(undefined) + ws.entries.push({ path, dir: false, size: 0, mtime: 0 }) + await openNote(path) + view = new EditorView({ parent: document.body, state: editorStateOf(path)! }) + attachView(view) + return view +} + +describe('保存与关闭', () => { + it('关闭冲突标签前持久化冲突之后的新输入,草稿失败时保留标签', async () => { + const v = await edit('conflict.md') + vi.spyOn(api, 'writeFile').mockRejectedValue(new ApiError(412, '版本冲突', { rev: 'r2' })) + const saved = new Map() + const put = vi.spyOn(drafts, 'put').mockImplementation(async (draft) => { saved.set(draft.path, draft); return draft.path }) + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突前' } }) + await flush('conflict.md') + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '冲突后新增' } }) + put.mockRejectedValueOnce(new Error('存储失败')) + expect(await closeTab('conflict.md')).toBe(false) + expect(ws.tabs.some((tab) => tab.path === 'conflict.md')).toBe(true) + expect(await closeTab('conflict.md')).toBe(true) + expect(saved.get('conflict.md')?.content).toBe('冲突后新增') + expect(editorStateOf('conflict.md')).toBeUndefined() + }) + + it('冲突保存期间的新输入继续保存,不能标成已保存', async () => { + const v = await edit('race.md') + vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path) + const contents: string[] = [] + let finish!: (rev: string) => void + vi.spyOn(api, 'writeFile').mockImplementation(async (_path, content) => { + contents.push(content) + return contents.length === 1 ? new Promise((resolve) => { finish = resolve }) : 'r3' + }) + const tab = ws.tabs.find((tab) => tab.path === 'race.md')! + Object.assign(tab, { status: 'conflict', conflictRev: 'r2' }) + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '请求发出时' } }) + const saving = keepLocalVersion('race.md') + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '等待期间又输入' } }) + finish('r2-new') + await saving + await flush('race.md') + expect(contents).toEqual(['请求发出时', '等待期间又输入']) + expect(tab.dirty).toBe(false) + await closeTab('race.md') + }) + + it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => { + const v = await edit('image.md') + vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path) + const contents = new Map() + vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' }) + let finish!: (link: string) => void + vi.spyOn(api, 'upload').mockImplementation(() => new Promise((resolve) => { finish = resolve })) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })]) + expect(await closeTab('image.md')).toBe(false) + await openNote('other.md') + v.setState(editorStateOf('other.md')!) + finish('image.assets/ok.png') + await vi.waitFor(() => expect(editorStateOf('image.md')!.doc.toString()).toContain('image.assets/ok.png')) + await flush('image.md') + expect(contents.get('image.md')).toBe('![](image.assets/ok.png)原文') + expect(v.state.doc.toString()).toBe('原文') + await closeTab('image.md') + await closeTab('other.md') + }) +}) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index 0bfee17..6ee8129 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -188,6 +188,19 @@ function buildState(path: string, doc: string): EditorState { }), pasteImages({ upload: (file) => api.upload(ref.path, file), + replace: (text, replacement) => { + const shown = view?.state.facet(notePath) === ref + const state = shown ? view!.state : editorStates.get(ref.path) + if (!state) throw new Error('上传所属笔记的编辑状态不存在') + const at = state.doc.toString().indexOf(text) + if (at < 0) return // 用户已删除占位 + const spec = { changes: { from: at, to: at + text.length, insert: replacement } } + if (shown) view!.dispatch(spec) + else { + editorStates.set(ref.path, state.update(spec).state) + scheduleSave(ref.path) + } + }, onError: (err) => toast('图片上传失败:' + errorText(err)), onPending: (d) => { slot(ref.path).uploads += d @@ -357,19 +370,29 @@ export async function keepLocalVersion(path: string) { const tab = tabOf(path) const content = docOf(path) if (!tab || content === undefined) return - try { - const rev = await api.writeFile(path, content, tab.conflictRev ?? '') - const s = slot(path) - s.savedSeq = s.seq - Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) - await drafts.delete(path) - if (!ws.entries.some((e) => e.path === path)) void refreshTree() - } catch (err) { - if (err instanceof ApiError && err.status === 412) { - tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : '' - toast('服务器上的版本又变化了,请再次选择') - } else toast('保存失败:' + errorText(err)) + const s = slot(path) + if (s.inflight || s.uploads || s.paused) { + toast('笔记正在保存、上传或移动,请完成后再处理冲突') + return } + const seq = s.seq + s.inflight = (async () => { + try { + const rev = await api.writeFile(path, content, tab.conflictRev ?? '') + 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() + } catch (err) { + if (err instanceof ApiError && err.status === 412) { + tab.conflictRev = typeof err.data.rev === 'string' ? err.data.rev : '' + toast('服务器上的版本又变化了,请再次选择') + } else toast('保存失败:' + errorText(err)) + } + })() + await s.inflight + s.inflight = undefined + if (tab.dirty && tab.status === 'ready') void flush(path) } // ---- 打开并聚焦(快速切换、搜索结果跳转) ---- @@ -435,18 +458,39 @@ export async function openNote(path: string) { } } -export async function closeTab(path: string) { +export async function closeTab(path: string): Promise { + const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused) + if (busy()) { + toast('笔记正在上传或移动,完成后再关闭') + return false + } await flush(path) const i = ws.tabs.findIndex((t) => t.path === path) - if (i < 0) return + if (i < 0) return true const tab = ws.tabs[i] - if (tab.dirty && tab.status !== 'ready') { - // 冲突或已删除且有未保存修改:草稿保留在本地,下次打开时可恢复 + if (busy()) { + toast('笔记正在上传或移动,完成后再关闭') + return false + } + if (tab.dirty) { + const seq = slot(path).seq + const content = docOf(path) + if (content === undefined) throw new Error('未保存笔记的编辑状态不存在') + try { + await drafts.put({ path, content, baseRev: tab.rev, updatedAt: Date.now() }) + } catch (err) { + toast('本地草稿保存失败,已保留标签页:' + errorText(err)) + return false + } + if (tabOf(path) !== tab || slot(path).seq !== seq || busy()) return false toast('未保存的修改已保留为本地草稿') } - ws.tabs.splice(i, 1) + const index = ws.tabs.indexOf(tab) + if (index < 0) return true + ws.tabs.splice(index, 1) forget(path) - if (ws.active === path) ws.active = ws.tabs[Math.min(i, ws.tabs.length - 1)]?.path ?? null + if (ws.active === path) ws.active = ws.tabs[Math.min(index, ws.tabs.length - 1)]?.path ?? null + return true } function forget(path: string) { @@ -683,7 +727,9 @@ export async function remove(path: string) { await Promise.all(paths.map((p) => flush(p))) const snapshot = ws.entries ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目 - for (const p of paths) await closeTab(p) + for (const p of paths) { + if (!(await closeTab(p))) { ws.entries = snapshot; return } + } try { const item = await api.deleteFile(path) toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, { diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts index 03b1739..6f22ec3 100644 --- a/web/src/editor/paste.ts +++ b/web/src/editor/paste.ts @@ -4,6 +4,8 @@ import { EditorView } from '@codemirror/view' export interface PasteImagesOptions { // 上传文件,返回写入 Markdown 的链接 upload: (file: File) => Promise + // 按笔记身份修改占位,上传结束时该笔记可能已切到后台 + replace: (text: string, replacement: string) => void onError: (err: unknown) => void // 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件) onPending: (delta: 1 | -1) => void @@ -33,9 +35,9 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number): opts.onPending(1) opts .upload(file) - .then((link) => replaceUrl(view, urls[i], link)) + .then((link) => opts.replace(urls[i], link)) .catch((err) => { - removePlaceholder(view, urls[i]) + opts.replace(`![](${urls[i]})`, '') opts.onError(err) }) .finally(() => { @@ -71,15 +73,3 @@ export function pasteImages(opts: PasteImagesOptions): Extension { }), ] } - -// 占位可能已被用户编辑移动,按 blob 地址在当前文档中查找 -function replaceUrl(view: EditorView, url: string, link: string) { - const at = view.state.doc.toString().indexOf(url) - if (at >= 0) view.dispatch({ changes: { from: at, to: at + url.length, insert: link } }) -} - -function removePlaceholder(view: EditorView, url: string) { - const placeholder = `![](${url})` - const at = view.state.doc.toString().indexOf(placeholder) - if (at >= 0) view.dispatch({ changes: { from: at, to: at + placeholder.length, insert: '' } }) -}