feat(notes): keep old shas as immutable snapshots
All checks were successful
CI / verify (push) Successful in 1m5s
All checks were successful
CI / verify (push) Successful in 1m5s
Cache content under its own sha (write-once) with the path key as the latest pointer, and advance the stack handle to the new sha after an edit or pull. A shared link keeps showing exactly what was shared, while the live view follows your changes.
This commit is contained in:
@@ -57,7 +57,14 @@ const repo = computed(() => props.repo)
|
|||||||
const sha = computed(() => props.sha)
|
const sha = computed(() => props.sha)
|
||||||
const index = computed(() => props.index)
|
const index = computed(() => props.index)
|
||||||
|
|
||||||
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
|
const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes()
|
||||||
|
|
||||||
|
// When this note's content changes (edit / pull) its sha changes too; advance
|
||||||
|
// the stack handle so the live view follows it, leaving the old sha as an
|
||||||
|
// immutable snapshot for any link already shared.
|
||||||
|
const advanceStackTo = (newSha: string | null) => {
|
||||||
|
if (newSha) replaceStackedNote(sha.value, newSha)
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
path,
|
path,
|
||||||
@@ -224,6 +231,7 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
path: path.value
|
path: path.value
|
||||||
})
|
})
|
||||||
initialRawContent.value = rawContent.value
|
initialRawContent.value = rawContent.value
|
||||||
|
advanceStackTo(newSha)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(mode, async (newMode) => {
|
watch(mode, async (newMode) => {
|
||||||
@@ -257,6 +265,7 @@ const onConflictDiscard = async () => {
|
|||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
rawContent.value = raw
|
rawContent.value = raw
|
||||||
initialRawContent.value = raw
|
initialRawContent.value = raw
|
||||||
|
advanceStackTo(latestSha.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +299,7 @@ const onBadgeClick = async () => {
|
|||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
rawContent.value = raw
|
rawContent.value = raw
|
||||||
initialRawContent.value = raw
|
initialRawContent.value = raw
|
||||||
|
advanceStackTo(latestSha.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (failureStatus === "unauthorized") {
|
if (failureStatus === "unauthorized") {
|
||||||
|
|||||||
@@ -149,9 +149,25 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance a note's handle in place when its content changes (edit / pull):
|
||||||
|
// the live view follows to the new sha, while any previously-shared link
|
||||||
|
// keeps pointing at the old, now-immutable snapshot.
|
||||||
|
const replaceStackedNote = (oldSha: string, newSha: string) => {
|
||||||
|
if (!oldSha || !newSha || oldSha === newSha) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!stackedNotes.value.includes(oldSha)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stackedNotes.value = stackedNotes.value.map((note) =>
|
||||||
|
note === oldSha ? newSha : note
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stackedNotes: readonly(stackedNotes),
|
stackedNotes: readonly(stackedNotes),
|
||||||
addStackedNote,
|
addStackedNote,
|
||||||
|
replaceStackedNote,
|
||||||
scrollToFocusedNote
|
scrollToFocusedNote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/modules/note/cache/prepareNoteCache.spec.ts
vendored
Normal file
65
src/modules/note/cache/prepareNoteCache.spec.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/data/data", () => ({
|
||||||
|
data: {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
|
},
|
||||||
|
generateId: (type: string, id: string) => `${type}-${id}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const addFile = vi.fn()
|
||||||
|
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||||
|
useUserRepoStore: () => ({ addFile })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { data, generateId } from "@/data/data"
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
|
|
||||||
|
import { prepareNoteCache } from "./prepareNoteCache"
|
||||||
|
|
||||||
|
const writtenIds = () =>
|
||||||
|
vi.mocked(data.update).mock.calls.map((c) => (c[0] as { _id: string })._id)
|
||||||
|
|
||||||
|
describe("prepareNoteCache.saveCacheNote", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(data.update).mockClear()
|
||||||
|
addFile.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("on edit, keys content by the NEW sha and leaves the viewed sha untouched", async () => {
|
||||||
|
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
|
||||||
|
|
||||||
|
await saveCacheNote("new content", {
|
||||||
|
editedSha: "newSha",
|
||||||
|
path: "notes/a.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = writtenIds()
|
||||||
|
// immutable snapshot under the content's own (new) sha
|
||||||
|
expect(ids).toContain(generateId(DataType.Note, "newSha"))
|
||||||
|
// latest pointer under the path
|
||||||
|
expect(ids).toContain(generateId(DataType.Note, "notes/a.md"))
|
||||||
|
// the previously-viewed sha stays immutable
|
||||||
|
expect(ids).not.toContain(generateId(DataType.Note, "oldSha"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("on a fresh load (no editedSha), keys content by the viewed sha", async () => {
|
||||||
|
const { saveCacheNote } = prepareNoteCache("sha0", "notes/a.md")
|
||||||
|
|
||||||
|
await saveCacheNote("content")
|
||||||
|
|
||||||
|
expect(writtenIds()).toContain(generateId(DataType.Note, "sha0"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("registers the new sha against the path in the store", async () => {
|
||||||
|
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
|
||||||
|
|
||||||
|
await saveCacheNote("new content", {
|
||||||
|
editedSha: "newSha",
|
||||||
|
path: "notes/a.md"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(addFile).toHaveBeenCalledWith({ path: "notes/a.md", sha: "newSha" })
|
||||||
|
})
|
||||||
|
})
|
||||||
8
src/modules/note/cache/prepareNoteCache.ts
vendored
8
src/modules/note/cache/prepareNoteCache.ts
vendored
@@ -61,8 +61,14 @@ export const prepareNoteCache = (sha: string, path?: string) => {
|
|||||||
content: string,
|
content: string,
|
||||||
params?: { editedSha?: string; path?: string }
|
params?: { editedSha?: string; path?: string }
|
||||||
) => {
|
) => {
|
||||||
|
// Content is addressed by its OWN sha so snapshots stay immutable: an edit
|
||||||
|
// writes under the new sha and never overwrites the previously-viewed one.
|
||||||
|
// The path key (notePath) always holds the latest content (live pointer).
|
||||||
|
const contentId = params?.editedSha
|
||||||
|
? generateId(DataType.Note, params.editedSha)
|
||||||
|
: noteId
|
||||||
const newNote: Note = {
|
const newNote: Note = {
|
||||||
_id: noteId,
|
_id: contentId,
|
||||||
$type: DataType.Note,
|
$type: DataType.Note,
|
||||||
content,
|
content,
|
||||||
editedSha: params?.editedSha
|
editedSha: params?.editedSha
|
||||||
|
|||||||
Reference in New Issue
Block a user