From b8c9d079305dfb76907662ab40db151678a6d6aa Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 30 Jun 2026 00:27:07 +0200 Subject: [PATCH] feat(notes): auto-merge non-overlapping edits on conflict 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. --- src/components/StackedNote.vue | 53 ++++++++++++++++++++++++++---- src/hooks/useGitHubContent.hook.ts | 7 ++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index 85d4e82..dbfb72f 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -23,6 +23,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage" import { filenameToNoteTitle } from "@/utils/noteTitle" import { errorMessage } from "@/utils/notif" +import { threeWayMerge } from "@/utils/threeWayMerge" const LinkedNotes = defineAsyncComponent( () => import("@/components/LinkedNotes.vue") @@ -160,7 +161,8 @@ const { lastCheckedAt, latestSha, check: checkFreshness, - pullLatest + pullLatest, + resolveMergeSources } = useNoteFreshness({ user: user.value, repo: repo.value, @@ -220,9 +222,7 @@ const performSave = async (overrideSha?: string) => { }) if (conflict) { - await checkFreshness() - conflictOpen.value = true - if (mode.value === "read") toggleMode() + await handleConflict() return } @@ -239,6 +239,47 @@ const performSave = async (overrideSha?: string) => { 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) => { if (newMode === "edit") { void checkFreshness() @@ -258,7 +299,7 @@ watch(mode, async (newMode) => { await checkFreshness() if (freshnessStatus.value === "outdated") { - conflictOpen.value = true + await handleConflict() return } @@ -296,7 +337,7 @@ const onBadgeClick = async () => { const hasUnsavedEdits = rawContent.value !== initialRawContent.value if (hasUnsavedEdits) { - conflictOpen.value = true + await handleConflict() return } diff --git a/src/hooks/useGitHubContent.hook.ts b/src/hooks/useGitHubContent.hook.ts index 8080b79..bd94619 100644 --- a/src/hooks/useGitHubContent.hook.ts +++ b/src/hooks/useGitHubContent.hook.ts @@ -88,18 +88,20 @@ export const useGitHubContent = ({ const putFile = async ({ content, path, - sha + sha, + successMessage = "✅ Note saved" }: { content: string path: string sha?: string + successMessage?: string }): Promise<{ sha: string | null; conflict: boolean }> => putRaw({ contentBase64: encodeUTF8ToBase64(content), path, sha, message: `Updating ${path} from Remanso`, - successMessage: "✅ Note saved", + successMessage, conflictMessage: "⚠ Conflict: this note changed on GitHub", failureMessage: "❌ Note could not be saved" }) @@ -126,6 +128,7 @@ export const useGitHubContent = ({ content: string path: string sha: string + successMessage?: string }) => putFile(props), createFile: async (props: { content: string; path: string }) => putFile(props),