feat(notes): banner when viewing an older shared version
All checks were successful
CI / verify (push) Successful in 1m3s

Cache the note path on each snapshot so an old shared sha can find its
current version, and show a calm "older shared version — View latest"
banner. The exact snapshot is shown as-is; the banner only offers a jump
to the latest, never swapping content.
This commit is contained in:
Julien Calixte
2026-06-29 01:04:51 +02:00
parent 2f8d3c24a4
commit 9d27aa024f
6 changed files with 106 additions and 2 deletions

View File

@@ -68,12 +68,17 @@ const advanceStackTo = (newSha: string | null) => {
const {
path,
newerSha,
content,
rawContent,
getRawContent,
saveCacheNote,
getEditedSha
} = useFile(sha)
// When this is an older snapshot of a still-existing note, jump the stack to
// its current version (the exact snapshot is never swapped underneath you).
const viewLatest = () => advanceStackTo(newerSha.value)
const initialRawContent = ref<string | null>(null)
const isMarkdown = computed(() =>
path.value ? isMarkdownPath(path.value) : true
@@ -438,6 +443,16 @@ const onBadgeClick = async () => {
<edit-note :key="editKey" v-model="rawContent" />
</div>
<template v-else-if="mode === 'read'">
<div v-if="newerSha" class="snapshot-banner">
<span>You're viewing an older shared version.</span>
<button
type="button"
class="snapshot-banner-action"
@click="viewLatest"
>
View latest
</button>
</div>
<div
v-if="displayedContent"
class="note-content"
@@ -519,6 +534,28 @@ $border-color: rgba(18, 19, 58, 0.2);
display: none;
}
.snapshot-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin: 0 0 1rem;
padding: 0.4rem 0.75rem;
border: 1px solid $border-color;
border-radius: 0.375rem;
font-size: 0.85em;
}
.snapshot-banner-action {
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--color-primary);
cursor: pointer;
text-decoration: underline;
}
.action {
margin: 0;

View File

@@ -2,6 +2,7 @@ import { computed, Ref, ref, toValue } from "vue"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { latestShaIfOlder } from "@/modules/note/snapshotStatus"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -14,6 +15,16 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return file?.path
})
// Path recovered from the cached note doc — lets us locate the latest version
// even when this (old) sha is no longer in store.files.
const cachedNotePath = ref<string | undefined>()
// When viewing an older snapshot of a known note, the note's current sha
// (null when already viewing the latest).
const newerSha = computed(() =>
latestShaIfOlder(shaValue, path.value ?? cachedNotePath.value, store.files)
)
const {
render,
renderFromUTF8,
@@ -47,6 +58,8 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
fromCache.value = !!cachedNote
if (cachedNote) {
cachedNotePath.value = cachedNote.path ?? cachedNotePath.value
if (from === "path") {
queryFileContent(store.user, store.repo, shaValue).then(
(fileContent) => {
@@ -104,6 +117,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
return {
path,
newerSha,
content,
rawContent,
getRawContent,

View File

@@ -21,7 +21,8 @@ export const buildNoteDocs = (
_id: generateId(DataType.Note, sha),
$type: DataType.Note,
content,
editedSha
editedSha,
path
}
return path
? [base, { ...base, _id: generateId(DataType.Note, path) }]
@@ -71,7 +72,8 @@ export const prepareNoteCache = (sha: string, path?: string) => {
_id: contentId,
$type: DataType.Note,
content,
editedSha: params?.editedSha
editedSha: params?.editedSha,
path: params?.path ?? path
}
if (params && params.path) {

View File

@@ -4,6 +4,9 @@ import { Model } from "@/data/models/Model"
export interface Note extends Model<DataType.Note> {
content: string
editedSha?: string
// The note's path, stored so a cached snapshot can find its latest version
// even when its (old) sha is no longer in the repo file list.
path?: string
}
export interface PublicNoteListItem {

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest"
import { latestShaIfOlder } from "./snapshotStatus"
const files = [
{ path: "notes/a.md", sha: "current" },
{ path: "notes/b.md", sha: "other" }
]
describe("latestShaIfOlder", () => {
it("returns the current sha when viewing an older version of a known note", () => {
expect(latestShaIfOlder("old", "notes/a.md", files)).toBe("current")
})
it("returns null when viewing the current version", () => {
expect(latestShaIfOlder("current", "notes/a.md", files)).toBeNull()
})
it("returns null when the path is unknown", () => {
expect(latestShaIfOlder("old", undefined, files)).toBeNull()
expect(latestShaIfOlder("old", "notes/missing.md", files)).toBeNull()
})
})

View File

@@ -0,0 +1,25 @@
/**
* Return the note's current (latest) sha when `viewedSha` is NOT it — i.e. you
* are viewing an older version — otherwise null. "Older" is not inferred from
* the sha (a content hash has no order); it means "not the current sha for this
* path" per the repo file list.
*
* The caller must supply `notePath`. For a current sha it comes from the file
* list; for an older sha it comes from the cached snapshot's stored `path`
* (notes viewed while current carry it). When the path can't be resolved
* (foreign / evicted / pre-upgrade snapshot) this returns null and no banner is
* shown — graceful, never a false claim. Content is never swapped.
*/
export const latestShaIfOlder = (
viewedSha: string,
notePath: string | undefined,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string | null => {
if (!notePath) {
return null
}
const currentSha = files.find((file) => file.path === notePath)?.sha
return currentSha && currentSha !== viewedSha ? currentSha : null
}