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)