Fix note saves and drafts across asynchronous edits
This commit is contained in:
1 parent
8ce4a11b33
commit
7e52d9a00b
4 files changed
+161
-37
No files matched your search
@@ -1,4 +1,4 @@
|
||||
// 未成功保存到服务器的内容暂存在 IndexedDB,刷新页面或断网都不会丢失
|
||||
// 未成功保存到服务器的内容暂存在 IndexedDB,供下次启动时恢复
|
||||
|
||||
export interface Draft {
|
||||
path: string
|
||||
@@ -21,10 +21,11 @@ function db(): Promise<IDBDatabase> {
|
||||
}
|
||||
|
||||
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) => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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('原文')
|
||||
expect(v.state.doc.toString()).toBe('原文')
|
||||
await closeTab('image.md')
|
||||
await closeTab('other.md')
|
||||
})
|
||||
})
|
||||
+65
-19
@@ -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<boolean> {
|
||||
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$/, '')}”`, {
|
||||
|
||||
Reference in new issue
Block a user