diff --git a/apps/desktop/src/components/diff/ConflictHunkCard.svelte b/apps/desktop/src/components/diff/ConflictHunkCard.svelte new file mode 100644 index 00000000000..cc16a7b13e7 --- /dev/null +++ b/apps/desktop/src/components/diff/ConflictHunkCard.svelte @@ -0,0 +1,271 @@ + + + + +
{hunk.ours}
+ {hunk.base}
+ {hunk.theirs}
+ with an inline background. Keep its
+ token colours, drop the frame — the card already provides one. */
+.content :global(pre) {
+ margin: 0;
+ padding: 0;
+ background: transparent !important;
+}
+
+.hint {
+ color: var(--text-2);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx
new file mode 100644
index 00000000000..8bd4d6a4c83
--- /dev/null
+++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx
@@ -0,0 +1,234 @@
+import { getButtonClassName } from "#ui/components/Button.tsx";
+import { Checkbox } from "#ui/components/Checkbox.tsx";
+import { classes } from "#ui/components/classes.ts";
+import { FieldTextareaStyles } from "#ui/components/Field.tsx";
+import { Icon } from "#ui/components/Icon.tsx";
+import { projectSlice } from "#ui/projects/state.ts";
+import { useAppDispatch, useAppSelector } from "#ui/store.ts";
+import {
+ attachResolvedLanguages,
+ getFiletypeFromFileName,
+ getHighlighterIfLoaded,
+ getResolvedOrResolveLanguage,
+ type SupportedLanguages,
+} from "@pierre/diffs";
+import { useEffect, useMemo, useState, type FC } from "react";
+import styles from "./ConflictCard.module.css";
+import type { ConflictHunk, HunkResolution } from "@gitbutler/but-sdk";
+
+type Props = {
+ projectId: string;
+ /** The conflicted commit, which the check is scoped to. */
+ commitId: string;
+ path: string;
+ /** 1-based, as the resolve API addresses conflicts. */
+ hunk: number;
+ conflict: ConflictHunk;
+ /** How many conflicts the file has, for the "N of M" title. */
+ total: number;
+ /** The shiki theme the diff renders with, so the sides match the code around them. */
+ codeTheme: string;
+ /**
+ * Whether the diff around this card is showing two columns. The sides are
+ * named for where they are on screen, and only a split has a left and right.
+ */
+ splitView: boolean;
+ /**
+ * True while any card's resolution is in flight. The mutation is owned one
+ * level up so this covers every card: each apply rewrites the commit, so a
+ * second one started meanwhile would address an id that no longer exists.
+ */
+ busy: boolean;
+ onResolve: (path: string, hunk: number, resolution: HunkResolution) => void;
+};
+
+/**
+ * A conflict side as highlighted code.
+ *
+ * Uses the highlighter the diff already loaded — a card only ever renders
+ * inside a rendered diff, so it is there — and falls back to plain text when a
+ * language or theme has not been resolved, which throws rather than degrading.
+ */
+const Code: FC<{ code: string; path: string; theme: string }> = (p) => {
+ // Bumped once a language had to be fetched, to recompute with it attached.
+ const [attachedLanguages, setAttachedLanguages] = useState(0);
+ const lang = getFiletypeFromFileName(p.path);
+
+ const tokens = useMemo(() => {
+ // Read so the dependency is a real one: it is the retry signal, marking
+ // the attempt below stale once a language has been attached.
+ void attachedLanguages;
+ const highlighter = getHighlighterIfLoaded();
+ if (!highlighter) return null;
+ try {
+ return highlighter.codeToTokens(p.code, { lang, theme: p.theme });
+ } catch {
+ // The language is not attached yet; the effect below fetches it.
+ return null;
+ }
+ }, [p.code, p.theme, lang, attachedLanguages]);
+
+ useEffect(() => {
+ const highlighter = getHighlighterIfLoaded();
+ if (tokens !== null || !highlighter || lang === "text" || lang === "ansi") return;
+ // The diff attaches languages as it needs them, and a card can render
+ // before this file's is there. Resolving and attaching is what the
+ // renderer itself does, so it adds to the shared highlighter rather than
+ // displacing what the diff already has.
+ let stale = false;
+ Promise.resolve(
+ getResolvedOrResolveLanguage(lang as Exclude),
+ )
+ .then((resolved) => {
+ if (stale) return;
+ attachResolvedLanguages(resolved, highlighter);
+ setAttachedLanguages((n) => n + 1);
+ })
+ .catch(() => {});
+ return () => {
+ stale = true;
+ };
+ }, [tokens, lang]);
+
+ return (
+
+ {tokens === null
+ ? p.code
+ : tokens.tokens.map((line, lineIndex) => (
+ // Lines and tokens have no identity beyond their position.
+ // oxlint-disable-next-line no-array-index-key
+
+ {line.map((token, tokenIndex) => (
+ // oxlint-disable-next-line no-array-index-key
+
+ {token.content}
+
+ ))}
+ {"\n"}
+
+ ))}
+
+ );
+};
+
+/** One unresolved conflict, rendered inline in the diff where its region starts. */
+export const ConflictCard: FC = (p) => {
+ const dispatch = useAppDispatch();
+ const [draft, setDraft] = useState(null);
+ const conflict = { commitId: p.commitId, path: p.path, hunk: p.hunk };
+ // A primitive, so checking one conflict re-renders one card rather than all.
+ const checked = useAppSelector((state) =>
+ projectSlice.selectors.selectIsConflictChecked(state, p.projectId, conflict),
+ );
+ // Checked conflicts are resolved from the bar, so their own resolutions go
+ // inert — disabled rather than hidden, which would change the card's height
+ // and shift every conflict below it as you work down the list. The checkbox
+ // and Cancel stay live, or checking a card would trap you in it.
+ const disabled = p.busy || checked;
+
+ const apply = (resolution: HunkResolution) => {
+ if (disabled) return;
+ p.onResolve(p.path, p.hunk, resolution);
+ };
+
+ return (
+
+
+
+ dispatch(
+ projectSlice.actions.checkConflict({
+ projectId: p.projectId,
+ conflict,
+ checked: next,
+ }),
+ )
+ }
+ />
+
+
+ {p.total > 1 ? `Unresolved conflict ${p.hunk} of ${p.total}` : "Unresolved conflict"}
+
+
+
+ {/* Only the ancestor. The diff either side of this card is already the
+ base's version against the commit's own, so repeating those two
+ would say twice what is on screen once. */}
+
+ Common ancestor
+ {p.conflict.base === null ? (
+
+ The merge found no common ancestor for this region.
+
+ ) : (
+
+ )}
+
+
+ {draft === null ? (
+
+
+
+
+
+ ) : (
+ <>
+
+ This replaces the whole conflicted region. Leaving it empty deletes the region; never
+ include conflict markers.
+
+ setDraft(event.currentTarget.value)}
+ />
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css b/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css
index bfc55b867a1..3f194846652 100644
--- a/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css
+++ b/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css
@@ -381,3 +381,19 @@
.commitConflictBadge {
flex-shrink: 0;
}
+
+/* Holds the conflict bar above the diff as one child of `.panel`, whose grid
+ sizes exactly two rows — a third child would take the diff's. */
+.diffArea {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr);
+ min-height: 0;
+}
+
+/* Grid items floor at min-content, so without this the diff sizes to its whole
+ length and the row scrolls the minimap away with it instead of the code area
+ scrolling beneath a pinned ruler. */
+.diffArea > * {
+ min-height: 0;
+}
diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx
index 512f7b47dbd..c9cb913cd0d 100644
--- a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx
+++ b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx
@@ -1,11 +1,16 @@
import { ResizeHandle } from "#ui/components/ResizeHandle.tsx";
import { Scroller } from "#ui/components/Scroller.tsx";
import { SuspenseQuery } from "@suspensive/react-query";
-import { useOpenInProgram, useSaveGUISettings } from "#ui/api/mutations.ts";
+import {
+ useOpenInProgram,
+ useResolveCommitConflictHunks,
+ useSaveGUISettings,
+} from "#ui/api/mutations.ts";
import {
branchDiffQueryOptions,
changesInWorktreeQueryOptions,
commentsQueryOptions,
+ commitConflictsQueryOptions,
commitDetailsWithLineStatsQueryOptions,
forgeInfoOptions,
guiSettingsQueryOptions,
@@ -48,7 +53,13 @@ import {
import { useAppDispatch, useAppSelector, useAppStore } from "#ui/store.ts";
import { classes } from "#ui/components/classes.ts";
import { Toggle, ToggleGroup, Toolbar, Tooltip } from "@base-ui/react";
-import type { CommitDetails, TreeChange } from "@gitbutler/but-sdk";
+import type {
+ CommitDetails,
+ ConflictedFile,
+ ManualConflict,
+ ResolutionSpec,
+ TreeChange,
+} from "@gitbutler/but-sdk";
import {
type CodeViewDiffItem,
type CodeView as CodeViewClass,
@@ -124,6 +135,8 @@ import type { DiffLineTarget } from "./diff-line-target.ts";
import { useHunkMenuItems } from "./useHunkMenuItems.ts";
import { ChangeTypeBadge } from "./ChangeTypeBadge.tsx";
import { AnnotationCard } from "#ui/routes/project/$id/workspace/AnnotationCard.tsx";
+import { ConflictBar } from "#ui/routes/project/$id/workspace/ConflictBar.tsx";
+import { ConflictCard } from "#ui/routes/project/$id/workspace/ConflictCard.tsx";
import {
annotationSideToDiffSide,
annotationsByPathForScope,
@@ -150,21 +163,33 @@ export type DiffViewerHandle = CodeViewHandle;
type PanelId = "files-panel" | "diff-panel";
const EMPTY_ANNOTATIONS_BY_PATH: LocalAnnotationsByPath = new Map();
+const EMPTY_CONFLICTS: Array = [];
+const EMPTY_MANUAL: Array = [];
const getCommitFileRowItems = ({
commitDetails,
+ manual = EMPTY_MANUAL,
}: {
commitDetails: CommitDetails;
+ /**
+ * Conflicted files the resolve API cannot address. They have no diff to
+ * show, which is exactly what a conflict row is — so they keep the commit
+ * visibly conflicted while pointing at the files that need edit mode.
+ */
+ manual?: Array;
}): Array => {
- const conflictedPaths = commitDetails.conflictEntries
- ? globalThis.Array.from(
- new Set([
- ...commitDetails.conflictEntries.ancestorEntries,
- ...commitDetails.conflictEntries.ourEntries,
- ...commitDetails.conflictEntries.theirEntries,
- ]),
- ).toSorted((a, b) => a.localeCompare(b))
- : [];
+ const conflictedPaths = globalThis.Array.from(
+ new Set([
+ ...(commitDetails.conflictEntries
+ ? [
+ ...commitDetails.conflictEntries.ancestorEntries,
+ ...commitDetails.conflictEntries.ourEntries,
+ ...commitDetails.conflictEntries.theirEntries,
+ ]
+ : []),
+ ...manual.map((file) => file.path),
+ ]),
+ ).toSorted((a, b) => a.localeCompare(b));
const conflictedPathSet = new Set(conflictedPaths);
return [
@@ -208,7 +233,7 @@ const withAnnotations = (
// Annotations move when their backend anchor drifts, so the version must cover their
// positions and identities, not just their count.
const annoHash = hash(
- annotations.map((a) => `${a.metadata.id}:${a.side}:${a.lineNumber}`).join(),
+ persistedAnnotations.map((a) => `${a.id}:${a.side}:${a.lineNumber}`).join(),
);
const version = item.version;
@@ -222,6 +247,53 @@ const withAnnotations = (
}),
});
+/**
+ * Anchor each unresolved conflict at the line its region starts on. The backend
+ * counts that line in the commit's auto-resolved content, which is exactly what
+ * the diff renders, so the two agree without any mapping.
+ */
+const withConflictAnnotations = (
+ diffView: DiffView,
+ conflicts: Array,
+): DiffView => {
+ if (conflicts.length === 0) return diffView;
+ const byPath = new Map(conflicts.map((file) => [file.path, file]));
+
+ return {
+ ...diffView,
+ items: diffView.items.map((item) => {
+ const file = diffView.fileByItemId.get(item.id);
+ if (!file) throw new Error("Diff view file not found by ID");
+
+ const conflicted = byPath.get(file.operand.path);
+ if (!conflicted || conflicted.hunks.length === 0) return item;
+
+ const annotations: Array> = conflicted.hunks.map(
+ (conflict, index) => ({
+ lineNumber: conflict.line,
+ side: "additions",
+ metadata: { _tag: "conflict", path: conflicted.path, hunk: index + 1 },
+ }),
+ );
+
+ // Resolving rewrites the commit, so the conflicts that remain shift.
+ // The version must cover their positions, not just their count.
+ const annoHash = hash(
+ conflicted.hunks.map((conflict, index) => `${index + 1}:${conflict.line}`).join(),
+ );
+
+ const version = item.version;
+ if (version === undefined) throw new Error("Diff view item missing base version");
+
+ return {
+ ...item,
+ version: combineHashes(version, annoHash),
+ annotations: [...(item.annotations ?? []), ...annotations],
+ };
+ }),
+ };
+};
+
const DiffContents: FC<{
localAnnotationFormId: string;
selectionScopeRef: RefObject;
@@ -230,6 +302,11 @@ const DiffContents: FC<{
projectId: string;
diffView: DiffView;
annotationsByPath: LocalAnnotationsByPath;
+ /** The selected commit's unresolved conflicts, keyed by path. */
+ conflicts: Array;
+ /** Owned by the parent so the batch bar and the cards share one pending state. */
+ onResolveConflict: (specs: Array) => void;
+ resolvingConflict: boolean;
diffBackgrounds?: GUISettings["diffBackground"];
diffOverflow?: GUISettings["diffOverflow"];
diffStyle?: GUISettings["diffStyle"];
@@ -243,6 +320,9 @@ const DiffContents: FC<{
projectId,
diffView: { items, navigationIndex, hunkByKey, fileByItemId },
annotationsByPath,
+ conflicts,
+ onResolveConflict,
+ resolvingConflict,
diffBackgrounds,
diffOverflow,
diffStyle,
@@ -254,6 +334,7 @@ const DiffContents: FC<{
const newFocusableAnnotationIdRef = useRef(null);
const dispatch = useAppDispatch();
const { mutate: createComment } = useCommentCreate();
+ const conflictsByPath = new Map(conflicts.map((file) => [file.path, file]));
const { data: editors } = useQuery(listEditorsQueryOptions);
const { data: settings } = useQuery({
...guiSettingsQueryOptions,
@@ -265,8 +346,18 @@ const DiffContents: FC<{
diffTabSize: cfg.diffTabSize,
lineDiffType: cfg.lineDiffType,
theme: cfg.theme,
+ syntaxHighlighting: cfg.syntaxHighlighting,
}),
});
+ // The concrete shiki theme the diff is already rendering with, so conflict
+ // snippets are highlighted the same way as the code around them.
+ const themeType = settings?.theme ?? defaultSettings.theme;
+ const prefersDark =
+ themeType === "dark" ||
+ (themeType === "system" && globalThis.matchMedia("(prefers-color-scheme: dark)").matches);
+ const codeTheme = prefersDark
+ ? (settings?.syntaxHighlighting?.dark ?? defaultSettings.syntaxHighlighting.dark)
+ : (settings?.syntaxHighlighting?.light ?? defaultSettings.syntaxHighlighting.light);
const { mutate: openInProgram } = useOpenInProgram();
const hunkMenuItems = useHunkMenuItems({ projectId });
const store = useAppStore();
@@ -595,13 +686,42 @@ const DiffContents: FC<{
);
}}
renderAnnotation={(anno, item) => {
- if (!isDiffAnnotation(anno)) throw new Error("Only diff items may be rendered");
+ // Pinned: inference picks one member of the Annotation union and
+ // narrows `metadata` to it, hiding the conflict variant below.
+ if (!isDiffAnnotation(anno)) throw new Error("Only diff items may be rendered");
const file = fileByItemId.get(item.id);
if (!file) return null;
+ if (anno.metadata._tag === "conflict") {
+ const { path, hunk } = anno.metadata;
+ const conflicted = conflictsByPath.get(path);
+ const conflict = conflicted?.hunks[hunk - 1];
+ // Each apply rewrites the commit, so a card can briefly
+ // outlive the conflict it was rendered for.
+ if (!conflict || fileParent._tag !== "Commit") return null;
+
+ return (
+
+ onResolveConflict([{ path, hunk, resolution }])
+ }
+ />
+ );
+ }
+
const annotations = annotationsByPath.get(file.operand.path) ?? [];
- const annotation = annotations.find(({ id }) => id === anno.metadata.id);
+ const annotationId = anno.metadata.id;
+ const annotation = annotations.find(({ id }) => id === annotationId);
if (!annotation) return null;
return (
@@ -934,6 +1054,10 @@ const Diff: FC<{
changes: Array;
filesVisible: boolean;
filesItems: Array;
+ /** The selected commit's unresolved conflicts, if it has any. */
+ conflicts?: Array;
+ /** Its conflicted files that can only be resolved in edit mode. */
+ manualConflicts?: Array;
onActiveFileSelection: (itemId: string, firstHunk: HunkOperand | null) => void;
onPassiveFileSelection: (selection: string) => void;
selection: Operand;
@@ -945,6 +1069,8 @@ const Diff: FC<{
changes: unsortedChanges,
filesVisible,
filesItems,
+ conflicts = EMPTY_CONFLICTS,
+ manualConflicts = EMPTY_MANUAL,
onPassiveFileSelection,
selection,
projectId,
@@ -956,6 +1082,10 @@ const Diff: FC<{
const localAnnotationFormId = useId();
const selectionScopeRef = useRef(null);
const dispatch = useAppDispatch();
+ // One mutation for the batch bar and every card, so `isPending` means "a
+ // resolution is in flight" rather than "this one's is". Each apply rewrites
+ // the commit, and a second started meanwhile would address the id it replaced.
+ const { mutate: resolveConflict, isPending: resolvingConflict } = useResolveCommitConflictHunks();
const changes = useMemo(
() => unsortedChanges.toSorted((a, b) => compareFilePaths(a.path, b.path)),
[unsortedChanges],
@@ -1057,7 +1187,10 @@ const Diff: FC<{
[fileParent, shownFileIndex, changes, treeChangeDiffs],
);
- const diffView = withAnnotations(diffViewSansAnno, annotationsByPath);
+ const diffView = withConflictAnnotations(
+ withAnnotations(diffViewSansAnno, annotationsByPath),
+ conflicts,
+ );
// The diff panel resolves this selection for the viewer; the ruler wants it in
// file line numbers, which is what the hunk's own range already holds.
@@ -1332,37 +1465,62 @@ const Diff: FC<{