feat(stacked-note): surface note freshness and guard saves on conflict

Adds a Tabler-icon badge in the stacked-note action bar showing whether
the loaded copy still matches GitHub HEAD (verified / outdated / offline
/ checking / unknown / stale-known). The save flow now re-checks before
the PUT and opens a conflict modal when GitHub has moved on, with three
explicit choices: discard local edits and pull, overwrite anyway, or
cancel. Race-condition 409s from the PUT itself are routed through the
same modal.
This commit is contained in:
Julien Calixte
2026-05-03 23:32:54 +02:00
parent d8a59467a0
commit d31c774ace
4 changed files with 547 additions and 25 deletions

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from "vue"
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{
(e: "discard"): void
(e: "overwrite"): void
(e: "cancel"): void
(e: "update:open", value: boolean): void
}>()
const dialogRef = ref<HTMLDialogElement | null>(null)
const close = () => {
if (dialogRef.value?.open) dialogRef.value.close()
emit("update:open", false)
}
const choose = (action: "discard" | "overwrite" | "cancel") => {
emit(action)
close()
}
watch(
() => props.open,
(open) => {
const el = dialogRef.value
if (!el) return
if (open && !el.open) el.showModal()
else if (!open && el.open) el.close()
}
)
onMounted(() => {
if (props.open) dialogRef.value?.showModal()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal"
@close="emit('update:open', false)"
@cancel.prevent="choose('cancel')"
>
<div class="modal-box">
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
<p class="py-3 text-sm">
Someone (or another device) updated this note on GitHub since you
started editing. If you save now, their changes will be overwritten.
</p>
<div class="modal-action flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
class="btn btn-ghost"
@click="choose('cancel')"
>
Cancel
</button>
<button
type="button"
class="btn btn-warning"
@click="choose('overwrite')"
>
Save anyway (overwrite)
</button>
<button
type="button"
class="btn btn-primary"
@click="choose('discard')"
>
Discard my edits, pull latest
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="submit" @click="choose('cancel')">close</button>
</form>
</dialog>
</template>

View File

@@ -0,0 +1,237 @@
<script lang="ts" setup>
import { computed } from "vue"
import type { FreshnessStatus } from "@/hooks/useNoteFreshness.hook"
const props = defineProps<{
status: FreshnessStatus
lastCheckedAt: Date | null
}>()
defineEmits<{ (e: "click"): void }>()
const formatTime = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
const minutesAgo = (d: Date) =>
Math.max(1, Math.round((Date.now() - d.getTime()) / 60000))
const label = computed(() => {
switch (props.status) {
case "verified":
return "Up to date"
case "checking":
return "Checking…"
case "outdated":
return "Outdated"
case "stale-known":
return props.lastCheckedAt
? `Checked ${minutesAgo(props.lastCheckedAt)}m ago`
: "Not checked"
case "offline":
return "Cant reach GitHub"
case "unknown":
default:
return "Not checked"
}
})
const tooltip = computed(() => {
switch (props.status) {
case "verified":
return props.lastCheckedAt
? `Verified at ${formatTime(props.lastCheckedAt)}. Click to re-check.`
: "Click to re-check."
case "outdated":
return "GitHub has a newer version. Click to pull latest."
case "stale-known":
return "Click to verify against GitHub."
case "offline":
return "Could not reach GitHub. Click to retry."
case "checking":
return "Checking against GitHub…"
case "unknown":
default:
return "Click to check against GitHub."
}
})
const stateClass = computed(() => `state-${props.status}`)
const isBusy = computed(() => props.status === "checking")
</script>
<template>
<button
class="freshness button is-text is-light"
:class="stateClass"
:title="tooltip"
:aria-label="tooltip"
:disabled="isBusy"
@click="$emit('click')"
>
<svg
v-if="status === 'verified'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-check"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585 .82 3.138 2.007"
/>
<path d="M15 19l2 2l4 -4" />
</svg>
<svg
v-else-if="status === 'unknown' || status === 'stale-known'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler"
:class="
status === 'stale-known'
? 'icon-tabler-clock'
: 'icon-tabler-cloud-question'
"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<template v-if="status === 'stale-known'">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</template>
<template v-else>
<path
d="M14.5 18.004h-7.843c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99"
/>
<path d="M19 22v.01" />
<path
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
/>
</template>
</svg>
<svg
v-else-if="status === 'outdated'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-download"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"
/>
<path d="M12 13l0 9" />
<path d="M9 19l3 3l3 -3" />
</svg>
<svg
v-else-if="status === 'checking'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-loader-2 spin"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-off"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"
/>
<path d="M3 3l18 18" />
</svg>
<span class="freshness-label">{{ label }}</span>
</button>
</template>
<style lang="scss" scoped>
.freshness {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
background: transparent;
border: 0;
cursor: pointer;
vertical-align: middle;
&[disabled] {
cursor: progress;
}
.icon {
flex-shrink: 0;
}
.freshness-label {
line-height: 1;
}
}
.state-verified {
color: var(--color-success, hsl(140, 60%, 35%));
}
.state-outdated {
color: var(--color-warning, hsl(35, 90%, 45%));
}
.state-offline {
color: var(--color-error, hsl(0, 70%, 45%));
}
.state-unknown,
.state-stale-known,
.state-checking {
color: var(--color-base-content);
opacity: 0.6;
}
.spin {
animation: freshness-spin 1s linear infinite;
}
@keyframes freshness-spin {
to {
transform: rotate(360deg);
}
}
@media screen and (max-width: 768px) {
.freshness-label {
display: none;
}
}
</style>

View File

@@ -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()
}
</script>
<template>
@@ -190,6 +266,12 @@ watch(mode, async (newMode) => {
</div>
</a>
<section class="text-content">
<div class="action-bar">
<note-freshness-badge
:status="freshnessStatus"
:last-checked-at="lastCheckedAt"
@click="checkFreshness"
/>
<button
v-if="isMarkdown"
class="action button is-text is-light"
@@ -240,6 +322,7 @@ watch(mode, async (newMode) => {
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</div>
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" />
</div>
@@ -250,6 +333,12 @@ watch(mode, async (newMode) => {
></div>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal
v-model:open="conflictOpen"
@discard="onConflictDiscard"
@overwrite="onConflictOverwrite"
@cancel="onConflictCancel"
/>
</div>
</template>
@@ -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;

View File

@@ -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<string>
path: Ref<string | undefined>
getEditedSha: () => Promise<string | null>
}) => {
const store = useUserRepoStore()
const { fetchLatestSha } = useGitHubContent({ user, repo })
const rawStatus = ref<FreshnessStatus>("unknown")
const lastCheckedAt = ref<Date | null>(null)
const latestSha = ref<string | null>(null)
const tick = ref(0)
let staleTimer: ReturnType<typeof setTimeout> | null = null
const status = computed<FreshnessStatus>(() => {
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<string | null> => {
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
}
}