feat(notes): auto-merge non-overlapping edits on conflict
All checks were successful
CI / verify (push) Successful in 1m13s

When a save hits a GitHub conflict, try a three-way merge first: if our
edits and the remote ones don't overlap, commit the result silently with
a tailored toast. Only genuinely overlapping edits open the conflict
modal. Adds an optional successMessage override to updateFile.
This commit is contained in:
Julien Calixte
2026-06-30 00:27:07 +02:00
parent cb22c755df
commit b8c9d07930
2 changed files with 52 additions and 8 deletions

View File

@@ -23,6 +23,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage" import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle" import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif" import { errorMessage } from "@/utils/notif"
import { threeWayMerge } from "@/utils/threeWayMerge"
const LinkedNotes = defineAsyncComponent( const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue") () => import("@/components/LinkedNotes.vue")
@@ -160,7 +161,8 @@ const {
lastCheckedAt, lastCheckedAt,
latestSha, latestSha,
check: checkFreshness, check: checkFreshness,
pullLatest pullLatest,
resolveMergeSources
} = useNoteFreshness({ } = useNoteFreshness({
user: user.value, user: user.value,
repo: repo.value, repo: repo.value,
@@ -220,9 +222,7 @@ const performSave = async (overrideSha?: string) => {
}) })
if (conflict) { if (conflict) {
await checkFreshness() await handleConflict()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
return return
} }
@@ -239,6 +239,47 @@ const performSave = async (overrideSha?: string) => {
advanceStackTo(newSha) advanceStackTo(newSha)
} }
// On a save/freshness conflict, try a 3-way merge first: if our edits and the
// remote ones don't overlap, commit the merge silently (just a toast) so the
// user is never interrupted. Only genuinely overlapping edits open the modal.
const handleConflict = async () => {
if (!path.value) return
const sources = await resolveMergeSources()
if (sources) {
const { clean, merged } = threeWayMerge(
sources.base,
rawContent.value,
sources.theirs
)
if (clean) {
const { sha: newSha, conflict } = await updateFile({
content: merged,
path: path.value,
sha: sources.remoteSha,
successMessage: "✅ Merged remote changes & saved"
})
if (!conflict && newSha) {
rawContent.value = merged
await saveCacheNote(encodeUTF8ToBase64(merged), {
editedSha: newSha,
path: path.value
})
initialRawContent.value = merged
advanceStackTo(newSha)
return
}
// Remote moved again between fetch and commit — fall back to the modal.
}
}
// Overlapping edits, or merge sources unavailable — let the user decide.
// Ensure latestSha is set so the modal's "overwrite" has a target.
if (!latestSha.value) await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
}
watch(mode, async (newMode) => { watch(mode, async (newMode) => {
if (newMode === "edit") { if (newMode === "edit") {
void checkFreshness() void checkFreshness()
@@ -258,7 +299,7 @@ watch(mode, async (newMode) => {
await checkFreshness() await checkFreshness()
if (freshnessStatus.value === "outdated") { if (freshnessStatus.value === "outdated") {
conflictOpen.value = true await handleConflict()
return return
} }
@@ -296,7 +337,7 @@ const onBadgeClick = async () => {
const hasUnsavedEdits = rawContent.value !== initialRawContent.value const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) { if (hasUnsavedEdits) {
conflictOpen.value = true await handleConflict()
return return
} }

View File

@@ -88,18 +88,20 @@ export const useGitHubContent = ({
const putFile = async ({ const putFile = async ({
content, content,
path, path,
sha sha,
successMessage = "✅ Note saved"
}: { }: {
content: string content: string
path: string path: string
sha?: string sha?: string
successMessage?: string
}): Promise<{ sha: string | null; conflict: boolean }> => }): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({ putRaw({
contentBase64: encodeUTF8ToBase64(content), contentBase64: encodeUTF8ToBase64(content),
path, path,
sha, sha,
message: `Updating ${path} from Remanso`, message: `Updating ${path} from Remanso`,
successMessage: "✅ Note saved", successMessage,
conflictMessage: "⚠ Conflict: this note changed on GitHub", conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved" failureMessage: "❌ Note could not be saved"
}) })
@@ -126,6 +128,7 @@ export const useGitHubContent = ({
content: string content: string
path: string path: string
sha: string sha: string
successMessage?: string
}) => putFile(props), }) => putFile(props),
createFile: async (props: { content: string; path: string }) => createFile: async (props: { content: string; path: string }) =>
putFile(props), putFile(props),