一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
201 行
7.1 KiB
TypeScript
201 行
7.1 KiB
TypeScript
import boundedDiff3MergeRegions, { ArrayChange, createDiffLines, DiffLines, diffOptions, DiffOptions, Region, viewerDiffOptions } from './boundedDiff3';
|
|
|
|
export type MergedSectionType = 'unchanged' | 'auto-merged' | 'conflict';
|
|
|
|
export interface MergedSection {
|
|
text: string;
|
|
type: MergedSectionType;
|
|
localText?: string;
|
|
remoteText?: string;
|
|
localLineCount?: number;
|
|
remoteLineCount?: number;
|
|
}
|
|
|
|
export interface AutoMergeResult {
|
|
mergedText: string;
|
|
sections: MergedSection[];
|
|
}
|
|
|
|
// These markers only appears in mergedText, which is never saved while conflicts still exist
|
|
const conflictPlaceholder = (local: string, remote: string): string => {
|
|
return `<<<<<<< local\n${local}\n=======\n${remote}\n>>>>>>> remote`;
|
|
};
|
|
|
|
// All three line endings are split on, otherwise a note written on Windows keeps a \r on every line
|
|
const splitLines = (text: string) => text.split(/\r\n|\n|\r/);
|
|
|
|
// True when the given side edited at or next to two or more identical lines in a row
|
|
const touchesDuplicateRun = (base: string[], side: string[], diffLines: DiffLines): boolean => {
|
|
const changes: ArrayChange[] | undefined = diffLines(base, side);
|
|
if (!changes) return true;
|
|
|
|
let baseIndex = 0;
|
|
let changedStart = -1;
|
|
let changedLength = 0;
|
|
|
|
const changedRangeTouchesRun = () => {
|
|
if (changedStart < 0) return false;
|
|
|
|
const lastLine = changedLength > 0 ? changedStart + changedLength - 1 : changedStart;
|
|
const from = Math.max(0, changedStart - 1);
|
|
const to = Math.min(base.length - 2, lastLine);
|
|
|
|
for (let i = from; i <= to; i++) {
|
|
if (base[i] === base[i + 1]) return true;
|
|
}
|
|
|
|
// An insertion past the last line still sits against the final pair
|
|
if (changedLength === 0 && changedStart > to && base.length >= 2) {
|
|
return base[base.length - 2] === base[base.length - 1];
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
for (const change of changes) {
|
|
if (!change.added && !change.removed) {
|
|
if (changedRangeTouchesRun()) return true;
|
|
changedStart = -1;
|
|
changedLength = 0;
|
|
baseIndex += change.count;
|
|
} else {
|
|
if (changedStart < 0) changedStart = baseIndex;
|
|
if (change.removed) {
|
|
changedLength += change.count;
|
|
baseIndex += change.count;
|
|
}
|
|
}
|
|
}
|
|
|
|
return changedRangeTouchesRun();
|
|
};
|
|
|
|
const bothSidesChanged = (base: string[], local: string[], remote: string[]): boolean => {
|
|
const same = (a: string[], b: string[]) => a.length === b.length && a.every((line, i) => line === b[i]);
|
|
return !same(base, local) && !same(base, remote) && !same(local, remote);
|
|
};
|
|
|
|
// Used by the conflict UI when a note has no base
|
|
// Without an ancestor every difference is a conflict
|
|
export const twoWayDiff = (localRaw: string, remoteRaw: string, options: DiffOptions = viewerDiffOptions): AutoMergeResult => {
|
|
const localLines = splitLines(localRaw);
|
|
const remoteLines = splitLines(remoteRaw);
|
|
const changes = createDiffLines(options)(localLines, remoteLines);
|
|
|
|
// Too different to compare without blocking app, so the whole note will be a conflict
|
|
if (!changes) {
|
|
const text = conflictPlaceholder(localRaw, remoteRaw);
|
|
return { mergedText: text, sections: [{ text, type: 'conflict', localText: localRaw, remoteText: remoteRaw }] };
|
|
}
|
|
|
|
const sections: MergedSection[] = [];
|
|
const mergedParts: string[] = [];
|
|
|
|
const addConflict = (local: string[], remote: string[]) => {
|
|
if (!local.length && !remote.length) return;
|
|
const localText = local.join('\n');
|
|
const remoteText = remote.join('\n');
|
|
const text = conflictPlaceholder(localText, remoteText);
|
|
sections.push({ text, type: 'conflict', localText, remoteText });
|
|
mergedParts.push(text);
|
|
};
|
|
|
|
// A replacement comes as a removal followed by an addition,
|
|
// so combined them into one conflict.
|
|
let removed: string[] = [];
|
|
let added: string[] = [];
|
|
|
|
for (const change of changes) {
|
|
if (change.added) {
|
|
added = added.concat(change.value);
|
|
continue;
|
|
}
|
|
if (change.removed) {
|
|
removed = removed.concat(change.value);
|
|
continue;
|
|
}
|
|
|
|
addConflict(removed, added);
|
|
removed = [];
|
|
added = [];
|
|
|
|
const text = change.value.join('\n');
|
|
sections.push({ text, type: 'unchanged' });
|
|
mergedParts.push(text);
|
|
}
|
|
|
|
addConflict(removed, added);
|
|
|
|
return { mergedText: mergedParts.join('\n'), sections };
|
|
};
|
|
|
|
const singleSection = (text: string, type: MergedSectionType): AutoMergeResult => ({
|
|
mergedText: text,
|
|
sections: [{ text, type }],
|
|
});
|
|
|
|
const merge = (baseRaw: string, localRaw: string, remoteRaw: string, diffLines: DiffLines): AutoMergeResult => {
|
|
// The result is already known, so diffing is skipped. One side changing still counts it as a merge
|
|
if (localRaw === baseRaw && remoteRaw === baseRaw) return singleSection(localRaw, 'unchanged');
|
|
if (localRaw === remoteRaw) return singleSection(localRaw, 'auto-merged');
|
|
if (localRaw === baseRaw) return singleSection(remoteRaw, 'auto-merged');
|
|
if (remoteRaw === baseRaw) return singleSection(localRaw, 'auto-merged');
|
|
|
|
const baseLines = splitLines(baseRaw);
|
|
const localLines = splitLines(localRaw);
|
|
const remoteLines = splitLines(remoteRaw);
|
|
|
|
// With duplicate lines, diff3 can't tell which copy was changed, so it may apply both
|
|
// edits and duplicate content. A merge too large to diff is treated the same
|
|
// way, since blocking the UI is worse than falling back to a conflict.
|
|
const ambiguous = bothSidesChanged(baseLines, localLines, remoteLines) &&
|
|
(touchesDuplicateRun(baseLines, localLines, diffLines) || touchesDuplicateRun(baseLines, remoteLines, diffLines));
|
|
|
|
const regions: Region[]|null = ambiguous ? null : boundedDiff3MergeRegions(localLines, baseLines, remoteLines, diffLines);
|
|
|
|
// A null result means the diff was too large to compute, so a conflict is safer than blocking the app
|
|
if (!regions) {
|
|
const localText = localLines.join('\n');
|
|
const remoteText = remoteLines.join('\n');
|
|
return {
|
|
mergedText: conflictPlaceholder(localText, remoteText),
|
|
sections: [{ text: conflictPlaceholder(localText, remoteText), type: 'conflict', localText, remoteText }],
|
|
};
|
|
}
|
|
|
|
const sections: MergedSection[] = [];
|
|
const mergedParts: string[] = [];
|
|
|
|
for (const region of regions) {
|
|
if (region.stable === true) {
|
|
const text = region.bufferContent.join('\n');
|
|
// buffer 'o' = all sides agreed; 'a'/'b' = one side's change, taken cleanly
|
|
sections.push({ text, type: region.buffer === 'o' ? 'unchanged' : 'auto-merged' });
|
|
mergedParts.push(text);
|
|
} else {
|
|
const localText = region.aContent.join('\n');
|
|
const remoteText = region.bContent.join('\n');
|
|
|
|
// Both sides made the same change, so it isn't a real conflict. Keep deleted
|
|
// regions deleted.
|
|
if (localText === remoteText) {
|
|
if (region.aContent.length) {
|
|
sections.push({ text: localText, type: 'auto-merged' });
|
|
mergedParts.push(localText);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const text = conflictPlaceholder(localText, remoteText);
|
|
sections.push({ text, type: 'conflict', localText, remoteText, localLineCount: region.aContent.length, remoteLineCount: region.bContent.length });
|
|
mergedParts.push(text);
|
|
}
|
|
}
|
|
|
|
return { mergedText: mergedParts.join('\n'), sections };
|
|
};
|
|
|
|
export const autoMerge = (baseRaw: string, localRaw: string, remoteRaw: string, options: DiffOptions = diffOptions): AutoMergeResult => {
|
|
return merge(baseRaw, localRaw, remoteRaw, createDiffLines(options));
|
|
};
|