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:
82
src/components/NoteConflictModal.vue
Normal file
82
src/components/NoteConflictModal.vue
Normal 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>
|
||||||
237
src/components/NoteFreshnessBadge.vue
Normal file
237
src/components/NoteFreshnessBadge.vue
Normal 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 "Can’t 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>
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
runMermaid,
|
runMermaid,
|
||||||
useShikiji
|
useShikiji
|
||||||
} from "@/hooks/useMarkdown.hook"
|
} from "@/hooks/useMarkdown.hook"
|
||||||
|
import { useNoteFreshness } from "@/hooks/useNoteFreshness.hook"
|
||||||
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
import { useNoteOverlay } from "@/hooks/useNoteOverlay.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
import { useTitleNotes } from "@/hooks/useTitleNotes.hook"
|
||||||
@@ -30,6 +31,14 @@ const LinkedNotes = defineAsyncComponent(
|
|||||||
() => import("@/components/LinkedNotes.vue")
|
() => import("@/components/LinkedNotes.vue")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const NoteFreshnessBadge = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteFreshnessBadge.vue")
|
||||||
|
)
|
||||||
|
|
||||||
|
const NoteConflictModal = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteConflictModal.vue")
|
||||||
|
)
|
||||||
|
|
||||||
const EditNote = defineAsyncComponent(
|
const EditNote = defineAsyncComponent(
|
||||||
() => import("@/modules/note/components/EditNote.vue")
|
() => import("@/modules/note/components/EditNote.vue")
|
||||||
)
|
)
|
||||||
@@ -104,10 +113,34 @@ const { updateFile } = useGitHubContent({
|
|||||||
repo: repo.value
|
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 () => {
|
onMounted(async () => {
|
||||||
initialRawContent.value = await getRawContent()
|
initialRawContent.value = await getRawContent()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
path,
|
||||||
|
(p) => {
|
||||||
|
if (p) void checkFreshness()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const { mode, toggleMode } = useEditionMode()
|
const { mode, toggleMode } = useEditionMode()
|
||||||
|
|
||||||
watch([content, mode], () => {
|
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) => {
|
watch(mode, async (newMode) => {
|
||||||
|
if (newMode === "edit") {
|
||||||
|
void checkFreshness()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const hasUserFinishedToEdit =
|
const hasUserFinishedToEdit =
|
||||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||||
|
|
||||||
@@ -141,28 +210,35 @@ watch(mode, async (newMode) => {
|
|||||||
}
|
}
|
||||||
if (!path.value) {
|
if (!path.value) {
|
||||||
console.warn("no path found for this file")
|
console.warn("no path found for this file")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const editedSha = (await getEditedSha()) ?? sha.value
|
await checkFreshness()
|
||||||
const newSha = await updateFile({
|
if (freshnessStatus.value === "outdated") {
|
||||||
content: rawContent.value,
|
conflictOpen.value = true
|
||||||
path: path.value,
|
|
||||||
sha: editedSha
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!newSha) {
|
|
||||||
console.warn("no new SHA found for this file")
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
await performSave()
|
||||||
editedSha: newSha
|
|
||||||
})
|
|
||||||
initialRawContent.value = rawContent.value
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -190,6 +266,12 @@ watch(mode, async (newMode) => {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<section class="text-content">
|
<section class="text-content">
|
||||||
|
<div class="action-bar">
|
||||||
|
<note-freshness-badge
|
||||||
|
:status="freshnessStatus"
|
||||||
|
:last-checked-at="lastCheckedAt"
|
||||||
|
@click="checkFreshness"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="isMarkdown"
|
v-if="isMarkdown"
|
||||||
class="action button is-text is-light"
|
class="action button is-text is-light"
|
||||||
@@ -240,6 +322,7 @@ watch(mode, async (newMode) => {
|
|||||||
<path d="M14 4l0 4l-6 0l0 -4" />
|
<path d="M14 4l0 4l-6 0l0 -4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||||
<edit-note v-model="rawContent" />
|
<edit-note v-model="rawContent" />
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +333,12 @@ watch(mode, async (newMode) => {
|
|||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||||
|
<note-conflict-modal
|
||||||
|
v-model:open="conflictOpen"
|
||||||
|
@discard="onConflictDiscard"
|
||||||
|
@overwrite="onConflictOverwrite"
|
||||||
|
@cancel="onConflictCancel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -296,14 +385,22 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
div {
|
> .edit,
|
||||||
|
> .note-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
float: right;
|
margin: 0;
|
||||||
margin: 0.2rem;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
|
|||||||
106
src/hooks/useNoteFreshness.hook.ts
Normal file
106
src/hooks/useNoteFreshness.hook.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user