fix(notes): don't save empty content over a note that hasn't loaded
All checks were successful
CI / verify (push) Successful in 1m9s

Entering and exiting edit mode while a note was still loading (or after a
failed fetch) triggered a save of the empty placeholder, clobbering the
real file on GitHub with an empty commit. Gate the dirty check, performSave,
and the edit button on a loaded baseline.
This commit is contained in:
Julien Calixte
2026-07-11 12:51:27 +02:00
parent 40b0af6b01
commit 9581dc88aa

View File

@@ -188,6 +188,19 @@ const loadNote = async () => {
onMounted(loadNote) onMounted(loadNote)
// A note only has a trustworthy baseline once it has finished loading. While
// it is still loading (or after a failed load) rawContent holds the empty
// placeholder and initialRawContent is null, so a naive
// `rawContent !== initialRawContent` check reads as a huge edit — and saving
// that would clobber the real file on GitHub with an empty commit. Gate every
// "is this dirty?" decision on a loaded baseline.
const isDirty = computed(
() =>
loadStatus.value === "ready" &&
initialRawContent.value !== null &&
rawContent.value !== initialRawContent.value
)
watch( watch(
path, path,
(p) => { (p) => {
@@ -215,6 +228,13 @@ const performSave = async (overrideSha?: string) => {
return return
} }
// Defence in depth: never push content we didn't successfully load, or we'd
// overwrite the file on GitHub with the empty placeholder.
if (loadStatus.value !== "ready" || initialRawContent.value === null) {
console.warn("refusing to save a note that hasn't finished loading")
return
}
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
const { sha: newSha, conflict } = await updateFile({ const { sha: newSha, conflict } = await updateFile({
content: rawContent.value, content: rawContent.value,
@@ -287,8 +307,7 @@ watch(mode, async (newMode) => {
return return
} }
const hasUserFinishedToEdit = const hasUserFinishedToEdit = newMode === "read" && isDirty.value
newMode === "read" && rawContent.value !== initialRawContent.value
if (!hasUserFinishedToEdit) { if (!hasUserFinishedToEdit) {
return return
@@ -336,7 +355,7 @@ const onBadgeClick = async () => {
return return
} }
const hasUnsavedEdits = rawContent.value !== initialRawContent.value const hasUnsavedEdits = isDirty.value
if (hasUnsavedEdits) { if (hasUnsavedEdits) {
await handleConflict() await handleConflict()
return return
@@ -377,7 +396,7 @@ const onBadgeClick = async () => {
class="action" class="action"
/> />
<button <button
v-if="isMarkdown && canPush" v-if="isMarkdown && canPush && loadStatus === 'ready'"
class="action button is-text is-light" class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }" :class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''" :style="mode === 'edit' ? 'color: var(--color-primary)' : ''"