feat(notes): share an always-latest link, not just a snapshot
Some checks failed
CI / verify (push) Has been cancelled

The Share button now offers two links: the pinned snapshot (default) and
an "always latest" link that carries note paths in `?liveNotes` instead of
blob shas. On open they resolve to the current shas against the HEAD file
list and the URL is rewritten to the ordinary pinned form, so freshness,
editing and the snapshot banner keep working unchanged.
This commit is contained in:
Julien Calixte
2026-07-11 11:11:51 +02:00
parent 70fbe5156f
commit a88d0dbac7
6 changed files with 325 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook" import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
import { useNoteView } from "@/hooks/useNoteView.hook" import { useNoteView } from "@/hooks/useNoteView.hook"
import { useResizeContainer } from "@/hooks/useResizeContainer.hook" import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook" import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue" import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
@@ -46,6 +47,10 @@ const { listenToClick } = useLinks("note-display")
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } = const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
useRouteQueryStackedNotes() useRouteQueryStackedNotes()
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
// shas, then focus the deepest note just like an ordinary shared link does.
useResolveLiveNotes(() => scrollToLastStackedNote())
const { titles } = useNoteView() const { titles } = useNoteView()
const { isLogged } = useGitHubLogin() const { isLogged } = useGitHubLogin()
useResizeContainer("note-container", stackedNotes) useResizeContainer("note-container", stackedNotes)

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import FontChange from "@/components/FontChange.vue" import FontChange from "@/components/FontChange.vue"
import HomeButton from "@/components/HomeButton.vue" import HomeButton from "@/components/HomeButton.vue"
import ShareNote from "@/components/ShareNote.vue"
defineProps<{ user: string; repo: string }>() defineProps<{ user: string; repo: string }>()
</script> </script>
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
<path d="M5 20l6 -16l2 0l7 16" /> <path d="M5 20l6 -16l2 0l7 16" />
</svg> </svg>
</button> </button>
<share-note />
<router-link <router-link
class="btn btn-ghost btn-circle" class="btn btn-ghost btn-circle"
:to="{ name: 'FluxNoteView', params: { user, repo } }" :to="{ name: 'FluxNoteView', params: { user, repo } }"

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { computed } from "vue"
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
import { stackToLivePaths } from "@/modules/note/liveNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { confirmMessage, errorMessage } from "@/utils/notif"
const route = useRoute()
const router = useRouter()
const store = useUserRepoStore()
const stackedShas = computed<string[]>(() => {
const raw = route.query.stackedNotes
if (!raw) return []
return (Array.isArray(raw) ? raw : [raw]).filter(
(sha): sha is string => typeof sha === "string"
)
})
const hasStack = computed(() => stackedShas.value.length > 0)
const absoluteUrl = (query: LocationQueryRaw): string => {
const { href } = router.resolve({ path: route.path, query })
return `${window.location.origin}${href}`
}
// The exact snapshot: the current URL, with each note pinned to its blob sha.
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
// The living link: the same notes referenced by path, so the recipient always
// lands on the latest version. See useResolveLiveNotes for the read side.
const livingUrl = (): string => {
const query = { ...route.query }
delete query.stackedNotes
return absoluteUrl({
...query,
liveNotes: stackToLivePaths(stackedShas.value, store.files)
})
}
const close = () =>
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
const copy = async (url: string, label: string) => {
close()
try {
await navigator.clipboard.writeText(url)
confirmMessage(`🔗 ${label} link copied`)
} catch {
errorMessage("❌ Couldn't copy the link")
}
}
</script>
<template>
<button
class="btn btn-ghost btn-circle text-base-content"
title="Share this view"
aria-label="Share this view"
onclick="share_modal.showModal()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-share"
width="30"
height="30"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
</svg>
</button>
<dialog id="share_modal" class="modal">
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Share this view</h3>
<button
type="button"
class="share-option"
@click="copy(snapshotUrl(), 'Snapshot')"
>
<span class="share-option-icon">📌</span>
<span class="share-option-text">
<span class="share-option-title">This exact version</span>
<span class="share-option-desc">
Pinned to what you see now it never changes.
</span>
</span>
</button>
<button
v-if="hasStack"
type="button"
class="share-option"
@click="copy(livingUrl(), 'Always-latest')"
>
<span class="share-option-icon">🔄</span>
<span class="share-option-text">
<span class="share-option-title">Always latest</span>
<span class="share-option-desc">
Follows these notes the reader gets the current version.
</span>
</span>
</button>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-ghost">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
<style scoped lang="scss">
.share-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid var(--color-base-300);
border-radius: var(--radius-box, 0.5rem);
background-color: var(--color-base-100);
color: var(--color-base-content);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
.share-option-icon {
font-size: 1.4rem;
line-height: 1.6rem;
}
.share-option-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.share-option-title {
font-weight: 600;
}
.share-option-desc {
font-size: 0.85em;
opacity: 0.75;
}
</style>

View File

@@ -0,0 +1,50 @@
import { watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { resolveLivePathsToShas } from "@/modules/note/liveNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
// A living link (`?liveNotes=path&liveNotes=path`) carries file paths instead of
// pinned blob shas. On open we resolve each path to the latest sha against the
// HEAD file list and rewrite the URL to the ordinary pinned `stackedNotes` form
// — so the recipient lands on the current version, and from there everything
// (freshness, editing, the snapshot banner) behaves exactly as a shared snapshot
// would. The rewrite is a `replace`, so no history entry and no view transition.
export const useResolveLiveNotes = (onResolved?: () => void) => {
const route = useRoute()
const router = useRouter()
const store = useUserRepoStore()
let resolved = false
const tryResolve = () => {
if (resolved) return
const raw = route.query.liveNotes
if (!raw) {
resolved = true
return
}
// Wait for the HEAD file list before resolving paths to shas.
if (store.files.length === 0) return
resolved = true
const entries = (Array.isArray(raw) ? raw : [raw]).filter(
(entry): entry is string => typeof entry === "string"
)
const shas = resolveLivePathsToShas(entries, store.files)
const query = { ...route.query }
delete query.liveNotes
if (shas.length) {
query.stackedNotes = shas
} else {
delete query.stackedNotes
}
router.replace({ path: route.path, query }).then(() => onResolved?.())
}
watch(() => store.files.length, tryResolve, { immediate: true })
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest"
import { resolveLivePathsToShas, stackToLivePaths } from "./liveNotes"
const files = [
{ path: "README.md", sha: "a".repeat(40) },
{ path: "notes/one.md", sha: "b".repeat(40) },
{ path: "notes/two.md", sha: "c".repeat(40) }
]
describe("stackToLivePaths", () => {
it("maps each stacked sha to its current file path", () => {
expect(stackToLivePaths(["b".repeat(40), "c".repeat(40)], files)).toEqual([
"notes/one.md",
"notes/two.md"
])
})
it("keeps a sha verbatim when no path resolves (drifted snapshot)", () => {
const orphan = "d".repeat(40)
expect(stackToLivePaths(["b".repeat(40), orphan], files)).toEqual([
"notes/one.md",
orphan
])
})
it("preserves order", () => {
expect(stackToLivePaths(["c".repeat(40), "a".repeat(40)], files)).toEqual([
"notes/two.md",
"README.md"
])
})
})
describe("resolveLivePathsToShas", () => {
it("resolves each path to its latest sha", () => {
expect(
resolveLivePathsToShas(["notes/one.md", "notes/two.md"], files)
).toEqual(["b".repeat(40), "c".repeat(40)])
})
it("passes a sha-shaped entry through as a pinned fallback", () => {
const orphan = "d".repeat(40)
expect(resolveLivePathsToShas(["notes/one.md", orphan], files)).toEqual([
"b".repeat(40),
orphan
])
})
it("drops a renamed or deleted path so the view degrades gracefully", () => {
expect(
resolveLivePathsToShas(["notes/gone.md", "notes/two.md"], files)
).toEqual(["c".repeat(40)])
})
it("round-trips a live-shared stack back to the same shas", () => {
const shas = ["a".repeat(40), "b".repeat(40)]
expect(resolveLivePathsToShas(stackToLivePaths(shas, files), files)).toEqual(
shas
)
})
})

View File

@@ -0,0 +1,38 @@
// A shared link pins each stacked note to a blob sha, so the recipient sees the
// exact snapshot forever. A "living" link trades that pin for freshness: notes
// are referenced by their file path instead, and re-resolved to the latest blob
// sha against the repo's HEAD file list every time the link is opened.
const SHA_PATTERN = /^[0-9a-f]{40}$/i
/**
* Encode the current stack (blob shas) as a living reference list. Each note
* becomes its file path when one resolves from the HEAD file list, so the link
* re-resolves to the latest version on open. A sha with no known path (e.g. an
* already-drifted snapshot the sharer never pulled) is kept verbatim, staying
* pinned — order is preserved either way.
*/
export const stackToLivePaths = (
shas: ReadonlyArray<string>,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string[] =>
shas.map((sha) => files.find((file) => file.sha === sha)?.path ?? sha)
/**
* Resolve a living reference list back to current blob shas against the HEAD
* file list: a path maps to its latest sha; an entry already shaped like a sha
* is passed through (the pinned fallback above); anything else — a renamed or
* deleted path — is dropped so the view degrades gracefully instead of trying
* to fetch a blob that no longer exists.
*/
export const resolveLivePathsToShas = (
entries: ReadonlyArray<string>,
files: ReadonlyArray<{ path?: string; sha?: string }>
): string[] =>
entries
.map((entry) => {
const latestSha = files.find((file) => file.path === entry)?.sha
if (latestSha) return latestSha
return SHA_PATTERN.test(entry) ? entry : null
})
.filter((sha): sha is string => sha !== null)