diff --git a/src/components/NoteConflictModal.vue b/src/components/NoteConflictModal.vue new file mode 100644 index 0000000..21c0bf0 --- /dev/null +++ b/src/components/NoteConflictModal.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/NoteFreshnessBadge.vue b/src/components/NoteFreshnessBadge.vue new file mode 100644 index 0000000..9920d1c --- /dev/null +++ b/src/components/NoteFreshnessBadge.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index 573afab..dd1864c 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -18,6 +18,7 @@ import { runMermaid, useShikiji } from "@/hooks/useMarkdown.hook" +import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook" import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook" import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook" import { useTitleNotes } from "@/hooks/useTitleNotes.hook" @@ -30,6 +31,14 @@ const LinkedNotes = defineAsyncComponent( () => import("@/components/LinkedNotes.vue") ) +const NoteFreshnessBadge = defineAsyncComponent( + () => import("@/components/NoteFreshnessBadge.vue") +) + +const NoteConflictModal = defineAsyncComponent( + () => import("@/components/NoteConflictModal.vue") +) + const EditNote = defineAsyncComponent( () => import("@/modules/note/components/EditNote.vue") ) @@ -104,10 +113,34 @@ const { updateFile } = useGitHubContent({ repo: repo.value }) +const { + status: freshnessStatus, + lastCheckedAt, + latestSha, + check: checkFreshness, + pullLatest +} = useNoteFreshness({ + user: user.value, + repo: repo.value, + sha, + path, + getEditedSha +}) + +const conflictOpen = ref(false) + onMounted(async () => { initialRawContent.value = await getRawContent() }) +watch( + path, + (p) => { + if (p) void checkFreshness() + }, + { immediate: true } +) + const { mode, toggleMode } = useEditionMode() watch([content, mode], () => { @@ -132,7 +165,43 @@ watch([content, mode], () => { }) }) +const performSave = async (overrideSha?: string) => { + if (!path.value) { + console.warn("no path found for this file") + return + } + + const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value + const { sha: newSha, conflict } = await updateFile({ + content: rawContent.value, + path: path.value, + sha: editedSha + }) + + if (conflict) { + await checkFreshness() + conflictOpen.value = true + if (mode.value === "read") toggleMode() + return + } + + if (!newSha) { + console.warn("no new SHA found for this file") + return + } + + await saveCacheNote(encodeUTF8ToBase64(rawContent.value), { + editedSha: newSha + }) + initialRawContent.value = rawContent.value +} + watch(mode, async (newMode) => { + if (newMode === "edit") { + void checkFreshness() + return + } + const hasUserFinishedToEdit = newMode === "read" && rawContent.value !== initialRawContent.value @@ -141,28 +210,35 @@ watch(mode, async (newMode) => { } if (!path.value) { console.warn("no path found for this file") - return } - const editedSha = (await getEditedSha()) ?? sha.value - const newSha = await updateFile({ - content: rawContent.value, - path: path.value, - sha: editedSha - }) - - if (!newSha) { - console.warn("no new SHA found for this file") - + await checkFreshness() + if (freshnessStatus.value === "outdated") { + conflictOpen.value = true return } - await saveCacheNote(encodeUTF8ToBase64(rawContent.value), { - editedSha: newSha - }) - initialRawContent.value = rawContent.value + await performSave() }) + +const onConflictDiscard = async () => { + const newRaw = await pullLatest() + if (newRaw !== null) { + rawContent.value = newRaw + initialRawContent.value = newRaw + } +} + +const onConflictOverwrite = async () => { + if (latestSha.value) { + await performSave(latestSha.value) + } +} + +const onConflictCancel = () => { + if (mode.value === "read") toggleMode() +} @@ -296,14 +385,22 @@ $border-color: rgba(18, 19, 58, 0.2); flex: 1; scrollbar-width: none; - div { + > .edit, + > .note-content { height: 100%; } } +.action-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + margin: 0.2rem 0; +} + .action { - float: right; - margin: 0.2rem; + margin: 0; img { vertical-align: bottom; diff --git a/src/hooks/useNoteFreshness.hook.ts b/src/hooks/useNoteFreshness.hook.ts new file mode 100644 index 0000000..c1968c4 --- /dev/null +++ b/src/hooks/useNoteFreshness.hook.ts @@ -0,0 +1,106 @@ +import { computed, Ref, ref } from "vue" + +import { useGitHubContent } from "@/hooks/useGitHubContent.hook" +import { markdownBuilder } from "@/hooks/useMarkdown.hook" +import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" +import { queryFileContent } from "@/modules/repo/services/repo" +import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" + +export type FreshnessStatus = + | "unknown" + | "checking" + | "verified" + | "stale-known" + | "outdated" + | "offline" + +const STALE_AFTER_MS = 2 * 60 * 1000 + +export const useNoteFreshness = ({ + user, + repo, + sha, + path, + getEditedSha +}: { + user: string + repo: string + sha: Ref + path: Ref + getEditedSha: () => Promise +}) => { + const store = useUserRepoStore() + const { fetchLatestSha } = useGitHubContent({ user, repo }) + + const rawStatus = ref("unknown") + const lastCheckedAt = ref(null) + const latestSha = ref(null) + const tick = ref(0) + let staleTimer: ReturnType | null = null + + const status = computed(() => { + void tick.value + if (rawStatus.value !== "verified") return rawStatus.value + if (!lastCheckedAt.value) return rawStatus.value + const age = Date.now() - lastCheckedAt.value.getTime() + return age > STALE_AFTER_MS ? "stale-known" : "verified" + }) + + const armStaleTimer = () => { + if (staleTimer) clearTimeout(staleTimer) + staleTimer = setTimeout(() => { + tick.value++ + }, STALE_AFTER_MS + 100) + } + + const expectedSha = async () => (await getEditedSha()) ?? sha.value + + const check = async () => { + if (!path.value) return + rawStatus.value = "checking" + const remoteSha = await fetchLatestSha(path.value) + if (remoteSha === null) { + rawStatus.value = "offline" + return + } + latestSha.value = remoteSha + lastCheckedAt.value = new Date() + const local = await expectedSha() + rawStatus.value = remoteSha === local ? "verified" : "outdated" + armStaleTimer() + } + + const pullLatest = async (): Promise => { + if (!path.value) return null + const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value)) + if (!remoteSha) { + rawStatus.value = "offline" + return null + } + const fileContent = await queryFileContent(user, repo, remoteSha) + if (!fileContent) { + rawStatus.value = "offline" + return null + } + const { saveCacheNote } = prepareNoteCache(sha.value, path.value) + await saveCacheNote(fileContent, { + editedSha: remoteSha, + path: path.value + }) + store.addFile({ path: path.value, sha: remoteSha }) + latestSha.value = remoteSha + lastCheckedAt.value = new Date() + rawStatus.value = "verified" + armStaleTimer() + const { getRawContent } = markdownBuilder(sha.value) + return getRawContent(fileContent) + } + + return { + status, + lastCheckedAt, + latestSha, + check, + pullLatest + } +}