Fix note saves and drafts across asynchronous edits

This commit is contained in:
eezd committed 2026-10-20 12:43:11 +08:00
1 parent 8ce4a11b33
commit 7e52d9a00b
4 files changed
+153 -29

No files matched your search

+5 -4
View File
@@ -1,4 +1,4 @@
// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失 // 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
export interface Draft { export interface Draft {
path: string path: string
@@ -21,10 +21,11 @@ function db(): Promise<IDBDatabase> {
} }
async function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> { async function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const store = (await db()).transaction('drafts', mode).objectStore('drafts') const transaction = (await db()).transaction('drafts', mode)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = fn(store) const req = fn(transaction.objectStore('drafts'))
req.onsuccess = () => resolve(req.result) transaction.oncomplete = () => resolve(req.result)
transaction.onabort = () => reject(transaction.error ?? new Error('本地草稿事务已中止'))
req.onerror = () => reject(req.error) req.onerror = () => reject(req.error)
}) })
} }
+87
View File
@@ -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<string, Draft>()
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<string>((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<string, string>()
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<string>((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')
})
})
+57 -11
View File
@@ -188,6 +188,19 @@ function buildState(path: string, doc: string): EditorState {
}), }),
pasteImages({ pasteImages({
upload: (file) => api.upload(ref.path, file), 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)), onError: (err) => toast('图片上传失败:' + errorText(err)),
onPending: (d) => { onPending: (d) => {
slot(ref.path).uploads += d slot(ref.path).uploads += d
@@ -357,12 +370,18 @@ export async function keepLocalVersion(path: string) {
const tab = tabOf(path) const tab = tabOf(path)
const content = docOf(path) const content = docOf(path)
if (!tab || content === undefined) return if (!tab || content === undefined) return
const s = slot(path)
if (s.inflight || s.uploads || s.paused) {
toast('笔记正在保存、上传或移动,请完成后再处理冲突')
return
}
const seq = s.seq
s.inflight = (async () => {
try { try {
const rev = await api.writeFile(path, content, tab.conflictRev ?? '') const rev = await api.writeFile(path, content, tab.conflictRev ?? '')
const s = slot(path) s.savedSeq = seq
s.savedSeq = s.seq Object.assign(tab, { rev, status: 'ready', dirty: s.seq !== seq, conflictRev: undefined, saveError: undefined })
Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined }) if (s.seq === seq) await drafts.delete(path)
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) { } catch (err) {
if (err instanceof ApiError && err.status === 412) { if (err instanceof ApiError && err.status === 412) {
@@ -370,6 +389,10 @@ export async function keepLocalVersion(path: string) {
toast('服务器上的版本又变化了,请再次选择') toast('服务器上的版本又变化了,请再次选择')
} else toast('保存失败:' + errorText(err)) } 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<boolean> {
const busy = () => Boolean(slots.get(path)?.uploads || slots.get(path)?.paused)
if (busy()) {
toast('笔记正在上传或移动,完成后再关闭')
return false
}
await flush(path) await flush(path)
const i = ws.tabs.findIndex((t) => t.path === path) const i = ws.tabs.findIndex((t) => t.path === path)
if (i < 0) return if (i < 0) return true
const tab = ws.tabs[i] 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('未保存的修改已保留为本地草稿') toast('未保存的修改已保留为本地草稿')
} }
ws.tabs.splice(i, 1) const index = ws.tabs.indexOf(tab)
if (index < 0) return true
ws.tabs.splice(index, 1)
forget(path) 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) { function forget(path: string) {
@@ -683,7 +727,9 @@ export async function remove(path: string) {
await Promise.all(paths.map((p) => flush(p))) await Promise.all(paths.map((p) => flush(p)))
const snapshot = ws.entries const snapshot = ws.entries
ws.entries = ws.entries.filter((e) => remapPath(e.path, path, path) === null) // 去掉自身及其下所有条目 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 { try {
const item = await api.deleteFile(path) const item = await api.deleteFile(path)
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, { toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
+4 -14
View File
@@ -4,6 +4,8 @@ import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions { export interface PasteImagesOptions {
// 上传文件,返回写入 Markdown 的链接 // 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise<string> upload: (file: File) => Promise<string>
// 按笔记身份修改占位,上传结束时该笔记可能已切到后台
replace: (text: string, replacement: string) => void
onError: (err: unknown) => void onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件) // 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
onPending: (delta: 1 | -1) => void onPending: (delta: 1 | -1) => void
@@ -33,9 +35,9 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
opts.onPending(1) opts.onPending(1)
opts opts
.upload(file) .upload(file)
.then((link) => replaceUrl(view, urls[i], link)) .then((link) => opts.replace(urls[i], link))
.catch((err) => { .catch((err) => {
removePlaceholder(view, urls[i]) opts.replace(`![](${urls[i]})`, '')
opts.onError(err) opts.onError(err)
}) })
.finally(() => { .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: '' } })
}