From a88d0dbac7a640f80340342b3615538b7e374090 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 11:11:51 +0200 Subject: [PATCH] feat(notes): share an always-latest link, not just a snapshot 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. --- src/components/FluxNote.vue | 5 + src/components/HeaderNote.vue | 2 + src/components/ShareNote.vue | 168 ++++++++++++++++++++++++++ src/hooks/useResolveLiveNotes.hook.ts | 50 ++++++++ src/modules/note/liveNotes.spec.ts | 62 ++++++++++ src/modules/note/liveNotes.ts | 38 ++++++ 6 files changed, 325 insertions(+) create mode 100644 src/components/ShareNote.vue create mode 100644 src/hooks/useResolveLiveNotes.hook.ts create mode 100644 src/modules/note/liveNotes.spec.ts create mode 100644 src/modules/note/liveNotes.ts diff --git a/src/components/FluxNote.vue b/src/components/FluxNote.vue index 7a0435b..d522741 100644 --- a/src/components/FluxNote.vue +++ b/src/components/FluxNote.vue @@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook" import { useNoteView } from "@/hooks/useNoteView.hook" import { useResizeContainer } from "@/hooks/useResizeContainer.hook" +import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook" import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue" @@ -46,6 +47,10 @@ const { listenToClick } = useLinks("note-display") const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } = 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 { isLogged } = useGitHubLogin() useResizeContainer("note-container", stackedNotes) diff --git a/src/components/HeaderNote.vue b/src/components/HeaderNote.vue index 4b1b07e..2cd313d 100644 --- a/src/components/HeaderNote.vue +++ b/src/components/HeaderNote.vue @@ -1,6 +1,7 @@ @@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>() + +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(() => { + 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") + } +} + + + + + diff --git a/src/hooks/useResolveLiveNotes.hook.ts b/src/hooks/useResolveLiveNotes.hook.ts new file mode 100644 index 0000000..c76e084 --- /dev/null +++ b/src/hooks/useResolveLiveNotes.hook.ts @@ -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 }) +} diff --git a/src/modules/note/liveNotes.spec.ts b/src/modules/note/liveNotes.spec.ts new file mode 100644 index 0000000..be5b458 --- /dev/null +++ b/src/modules/note/liveNotes.spec.ts @@ -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 + ) + }) +}) diff --git a/src/modules/note/liveNotes.ts b/src/modules/note/liveNotes.ts new file mode 100644 index 0000000..3fc3120 --- /dev/null +++ b/src/modules/note/liveNotes.ts @@ -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, + 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, + 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)