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)