feat(notes): auto-merge non-overlapping edits on conflict
All checks were successful
CI / verify (push) Successful in 1m13s
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user