mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
Add conflict resolution in unified view
This commit is contained in:
parent
68c3b4e6df
commit
bb21bf6c2e
2 changed files with 346 additions and 13 deletions
|
|
@ -1,8 +1,16 @@
|
|||
import * as React from "react";
|
||||
import CodeMirror, { RangeSetBuilder, StateField } from "@uiw/react-codemirror";
|
||||
import { Decoration, EditorView } from "@codemirror/view";
|
||||
import CodeMirror, {
|
||||
EditorState,
|
||||
RangeSetBuilder,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
||||
|
||||
import diff, { DiffChunk } from "./diff";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import UnifiedResolutionBar from "./unified-resolution-bar";
|
||||
|
||||
interface UnifiedDiffViewProps {
|
||||
initialOldText: string;
|
||||
|
|
@ -94,24 +102,54 @@ const createUnifiedDocument = (
|
|||
return { doc: result.join("\n"), lineRanges };
|
||||
};
|
||||
|
||||
type RangeChangeSourceOperation = {
|
||||
index: number;
|
||||
newSource: "old" | "new" | "both";
|
||||
};
|
||||
|
||||
type RangeRemoveOperation = {
|
||||
index: number;
|
||||
};
|
||||
|
||||
type RangeUpdateOperation = RangeChangeSourceOperation | RangeRemoveOperation;
|
||||
|
||||
const updateRangesEffect = StateEffect.define<RangeUpdateOperation>();
|
||||
|
||||
const createRangesStateField = (
|
||||
initialRanges: ConflictRange[],
|
||||
): StateField<ConflictRange[]> => {
|
||||
return StateField.define<ConflictRange[]>({
|
||||
create: () => initialRanges,
|
||||
update: (ranges, tr) => {
|
||||
if (!tr.docChanged) {
|
||||
const rangeEffects = tr.effects
|
||||
.filter((e) => e.is(updateRangesEffect))
|
||||
.reduce((acc, e) => {
|
||||
const operation = e.value as RangeUpdateOperation;
|
||||
acc.set(operation.index, operation);
|
||||
return acc;
|
||||
}, new Map<number, RangeUpdateOperation>());
|
||||
|
||||
if (!tr.docChanged && rangeEffects.size === 0) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Map all positions through the changes
|
||||
// Map all positions through the changes and apply any effect
|
||||
let newRanges = ranges
|
||||
.map((range) => ({
|
||||
from: tr.changes.mapPos(range.from),
|
||||
to: tr.changes.mapPos(range.to, 1),
|
||||
source: range.source,
|
||||
}))
|
||||
.filter((range) => range.from !== range.to);
|
||||
.map((range, index) => {
|
||||
let source = range.source;
|
||||
const effect = rangeEffects.get(index) as RangeChangeSourceOperation;
|
||||
if (effect) {
|
||||
source = effect.newSource;
|
||||
}
|
||||
return {
|
||||
from: tr.changes.mapPos(range.from),
|
||||
to: tr.changes.mapPos(range.to, 1),
|
||||
source,
|
||||
};
|
||||
})
|
||||
.filter((range, index) => {
|
||||
return range.from !== range.to && !rangeEffects.has(index);
|
||||
});
|
||||
|
||||
// Sort ranges by start position (leftmost first)
|
||||
newRanges.sort((a, b) => a.from - b.from);
|
||||
|
|
@ -214,6 +252,213 @@ interface ConflictRange {
|
|||
source: "old" | "new" | "both"; // where the content originated
|
||||
}
|
||||
|
||||
interface ResolutionWidgetProps {
|
||||
onAccept?: () => void;
|
||||
onDiscard?: () => void;
|
||||
onAcceptAbove?: () => void;
|
||||
onAcceptBelow?: () => void;
|
||||
onAcceptBoth?: () => void;
|
||||
onDiscardBoth?: () => void;
|
||||
}
|
||||
|
||||
class ResolutionWidget extends WidgetType {
|
||||
onAccept?: () => void;
|
||||
onDiscard?: () => void;
|
||||
onAcceptAbove?: () => void;
|
||||
onAcceptBelow?: () => void;
|
||||
onAcceptBoth?: () => void;
|
||||
onDiscardBoth?: () => void;
|
||||
|
||||
constructor(props: ResolutionWidgetProps) {
|
||||
super();
|
||||
({
|
||||
onAccept: this.onAccept,
|
||||
onDiscard: this.onDiscard,
|
||||
onAcceptAbove: this.onAcceptAbove,
|
||||
onAcceptBelow: this.onAcceptBelow,
|
||||
onAcceptBoth: this.onAcceptBoth,
|
||||
onDiscardBoth: this.onDiscardBoth,
|
||||
} = props);
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
const root = createRoot(div);
|
||||
root.render(
|
||||
<UnifiedResolutionBar
|
||||
onAccept={this.onAccept}
|
||||
onDiscard={this.onDiscard}
|
||||
onAcceptAbove={this.onAcceptAbove}
|
||||
onAcceptBelow={this.onAcceptBelow}
|
||||
onAcceptBoth={this.onAcceptBoth}
|
||||
onDiscardBoth={this.onDiscardBoth}
|
||||
/>,
|
||||
);
|
||||
return div;
|
||||
}
|
||||
}
|
||||
|
||||
const createBlockDecorations = (
|
||||
rangesStateField: StateField<ConflictRange[]>,
|
||||
getView: () => EditorView,
|
||||
) => {
|
||||
return EditorView.decorations.compute(
|
||||
[rangesStateField],
|
||||
(state: EditorState) => {
|
||||
const ranges = state.field(rangesStateField);
|
||||
let widgets = [];
|
||||
|
||||
ranges.forEach((range: ConflictRange, index: number) => {
|
||||
if (range.source === "both") {
|
||||
return;
|
||||
}
|
||||
const previousRange = ranges.at(index - 1);
|
||||
const nextRange = ranges.at(index + 1);
|
||||
|
||||
if (range.source === "old") {
|
||||
const nextRangeIsNew = nextRange?.source === "new";
|
||||
if (nextRangeIsNew) {
|
||||
const deco = Decoration.widget({
|
||||
widget: new ResolutionWidget({
|
||||
onAcceptAbove: () => {
|
||||
getView().dispatch({
|
||||
changes: {
|
||||
from: range.to,
|
||||
to: nextRange.to,
|
||||
insert: "",
|
||||
},
|
||||
effects: [
|
||||
updateRangesEffect.of({
|
||||
index,
|
||||
newSource: "both",
|
||||
}),
|
||||
updateRangesEffect.of({
|
||||
index: index + 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
onAcceptBelow: () => {
|
||||
getView().dispatch({
|
||||
changes: {
|
||||
from: range.from,
|
||||
to: nextRange?.from || range.to,
|
||||
insert: "",
|
||||
},
|
||||
effects: [
|
||||
updateRangesEffect.of({
|
||||
index,
|
||||
}),
|
||||
updateRangesEffect.of({
|
||||
index: index + 1,
|
||||
newSource: "both",
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
onAcceptBoth: () => {
|
||||
getView().dispatch({
|
||||
effects: [
|
||||
updateRangesEffect.of({
|
||||
index,
|
||||
newSource: "both",
|
||||
}),
|
||||
updateRangesEffect.of({
|
||||
index: index + 1,
|
||||
newSource: "both",
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
onDiscardBoth: () => {
|
||||
getView().dispatch({
|
||||
changes: {
|
||||
from: Math.max(range.from - 1, 0),
|
||||
to: ranges.at(index + 2)?.from || nextRange.to,
|
||||
insert: "",
|
||||
},
|
||||
effects: [
|
||||
updateRangesEffect.of({
|
||||
index,
|
||||
}),
|
||||
updateRangesEffect.of({
|
||||
index: index + 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
side: 1,
|
||||
block: true,
|
||||
});
|
||||
widgets.push(deco.range(range.to));
|
||||
} else {
|
||||
const deco = Decoration.widget({
|
||||
widget: new ResolutionWidget({
|
||||
onAccept: () => {
|
||||
getView().dispatch({
|
||||
effects: updateRangesEffect.of({
|
||||
index: index,
|
||||
newSource: "both",
|
||||
}),
|
||||
});
|
||||
},
|
||||
onDiscard: () => {
|
||||
getView().dispatch({
|
||||
changes: {
|
||||
from: Math.max(range.from - 1, 0),
|
||||
to: nextRange?.from || range.to,
|
||||
insert: "",
|
||||
},
|
||||
effects: updateRangesEffect.of({
|
||||
index: index,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
side: -1,
|
||||
block: true,
|
||||
});
|
||||
widgets.push(deco.range(range.from));
|
||||
}
|
||||
} else if (range.source === "new" && previousRange?.source !== "old") {
|
||||
// We draw this only in case the previous range doesn't come from the old document
|
||||
// since we handle that above
|
||||
const deco = Decoration.widget({
|
||||
widget: new ResolutionWidget({
|
||||
onAccept: () => {
|
||||
getView().dispatch({
|
||||
effects: updateRangesEffect.of({
|
||||
index: index,
|
||||
newSource: "both",
|
||||
}),
|
||||
});
|
||||
},
|
||||
onDiscard: () => {
|
||||
getView().dispatch({
|
||||
changes: {
|
||||
from: Math.max(range.from - 1, 0),
|
||||
to: nextRange?.from || range.to,
|
||||
insert: "",
|
||||
},
|
||||
effects: updateRangesEffect.of({
|
||||
index: index,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
side: -1,
|
||||
block: true,
|
||||
});
|
||||
widgets.push(deco.range(range.from));
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(widgets);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const UnifiedDiffView: React.FC<UnifiedDiffViewProps> = ({
|
||||
initialOldText,
|
||||
initialNewText,
|
||||
|
|
@ -235,6 +480,11 @@ const UnifiedDiffView: React.FC<UnifiedDiffViewProps> = ({
|
|||
return [
|
||||
conflictRangesField,
|
||||
createDecorationsExtension(conflictRangesField),
|
||||
createBlockDecorations(
|
||||
conflictRangesField,
|
||||
|
||||
() => editorViewRef.current!,
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const conflictRanges = update.state.field(conflictRangesField);
|
||||
|
|
@ -270,9 +520,6 @@ const UnifiedDiffView: React.FC<UnifiedDiffViewProps> = ({
|
|||
"&.cm-focused .cm-cursor": {
|
||||
borderLeftColor: "var(--text-normal)",
|
||||
},
|
||||
".cm-changedLine": {
|
||||
backgroundColor: "rgba(var(--color-yellow-rgb), 0.1)",
|
||||
},
|
||||
".cm-addedLine": {
|
||||
backgroundColor: "rgba(var(--color-green-rgb), 0.1)",
|
||||
},
|
||||
|
|
|
|||
86
src/views/conflicts-resolution/unified-resolution-bar.tsx
Normal file
86
src/views/conflicts-resolution/unified-resolution-bar.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as React from "react";
|
||||
|
||||
interface UnifiedResolutionBarProps {
|
||||
onAccept?: () => void;
|
||||
onDiscard?: () => void;
|
||||
onAcceptAbove?: () => void;
|
||||
onAcceptBelow?: () => void;
|
||||
onAcceptBoth?: () => void;
|
||||
onDiscardBoth?: () => void;
|
||||
}
|
||||
|
||||
const UnifiedResolutionBar: React.FC<UnifiedResolutionBarProps> = ({
|
||||
onAccept,
|
||||
onDiscard,
|
||||
onAcceptAbove,
|
||||
onAcceptBelow,
|
||||
onAcceptBoth,
|
||||
onDiscardBoth,
|
||||
}) => {
|
||||
const renderActions = () => {
|
||||
if (onAccept !== undefined && onDiscard !== undefined) {
|
||||
return (
|
||||
<>
|
||||
<ClickableText text="Accept" onClick={() => onAccept?.()} />
|
||||
{" | "}
|
||||
<ClickableText text="Discard" onClick={() => onDiscard?.()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClickableText text="Accept above" onClick={() => onAcceptAbove?.()} />
|
||||
{" | "}
|
||||
<ClickableText text="Accept below" onClick={() => onAcceptBelow?.()} />
|
||||
{" | "}
|
||||
<ClickableText text="Accept both" onClick={() => onAcceptBoth?.()} />
|
||||
{" | "}
|
||||
<ClickableText text="Discard both" onClick={() => onDiscardBoth?.()} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyItems: "left",
|
||||
fontSize: "var(--font-ui-small)",
|
||||
fontFamily: "var(--font-interface)",
|
||||
color: "var(--text-muted)",
|
||||
backgroundColor: "var(--background-secondary)",
|
||||
padding: "var(--size-2-2)",
|
||||
paddingLeft: "var(--size-2-3)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{renderActions()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ClickableTextProps {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ClickableText: React.FC<ClickableTextProps> = ({ text, onClick }) => {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: hovered ? "var(--text-accent)" : "inherit",
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedResolutionBar;
|
||||
Loading…
Reference in a new issue