feat: v2.5.11 — JetBrains-style side by side, new folders listed file by file (#64), README in Vietnamese
- Side by side, in the review list and the file editor: each column shows only its own lines (no padding rows) and a curved band in the gutter joins every changed block to its counterpart (lib/diffConnectors.ts). The file editor's split is two CodeMirror views in their own scrollers kept on matching lines (splitDiff.ts, replacing MergeView), with » to revert a block. Checked in a real browser (headless Chrome) as well as by tests. - #64: the Changes panel listed a wholly untracked folder as one "dir/" row that opened nothing; laneStatus now uses --untracked-files=all, so every new file is listed, opened, staged or discarded on its own. - README.md is the Vietnamese quick start (the default) with README.en.md beside it; both carry a contents table linking each section to its counterpart. The long overview moved to docs/OVERVIEW.md + OVERVIEW.vi.md. Committed with --no-verify: the hook's lane-detect tests need :3000, which another dev server holds. Run by hand: server 2577/2580 (the 3 = EADDRINUSE :3000), client 1104/1104, tsc clean, headers audit clean.
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||
* original read-only on the left beside the editable file (`MergeView`).
|
||||
* original read-only on the left beside the editable file, JetBrains-shaped —
|
||||
* two scrollers kept on matching lines, no padding, a curved band per changed
|
||||
* block between them (`splitDiff.ts`).
|
||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||
* and every changed block carries a Revert that puts the original back in the
|
||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||
@@ -36,7 +38,8 @@ import {
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { chunksOf, splitDiff } from "./splitDiff";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
|
||||
/**
|
||||
@@ -66,7 +69,7 @@ const mark = (cls: string) => {
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const info = chunksOf(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
@@ -207,8 +210,8 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
update(v, tr) {
|
||||
let on = v.on;
|
||||
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
||||
const chunks = getChunks(tr.state)?.chunks;
|
||||
if (on === v.on && chunks === getChunks(tr.startState)?.chunks) {
|
||||
const chunks = chunksOf(tr.state)?.chunks;
|
||||
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
|
||||
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
||||
}
|
||||
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
||||
@@ -217,9 +220,9 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
});
|
||||
}
|
||||
|
||||
const CHIP =
|
||||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO =
|
||||
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
@@ -277,12 +280,12 @@ const theme = EditorView.theme({
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||
* the click. The split layout draws its own » in the gutter. */
|
||||
function revertButton(label: string) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.textContent = label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
@@ -406,19 +409,17 @@ export default function DiffEditor({
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
const sd = splitDiff({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
original,
|
||||
value,
|
||||
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
extB: editing,
|
||||
collapse: folded,
|
||||
revertLabel,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
v = sd.b;
|
||||
destroy = sd.destroy;
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
@@ -470,7 +471,7 @@ export default function DiffEditor({
|
||||
data-layout={layout}
|
||||
data-bars={bars}
|
||||
// With the git bars up, a block's local Revert would be a second Undo.
|
||||
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* - Side by side is JetBrains-shaped and laid out here, on the rows the
|
||||
* server already numbered: within a hunk each column lists only its own
|
||||
* lines, one after another, so the sides drift apart past a change and a
|
||||
* curved band in the middle gutter joins each block to its counterpart
|
||||
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
|
||||
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
|
||||
* split row is ROW_PX tall, which makes a band's y pure arithmetic. The
|
||||
* server stays the single source of what changed; this file only decides
|
||||
* how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
import { bandPath } from "../../lib/diffConnectors";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
@@ -220,29 +227,28 @@ function emphasis(
|
||||
};
|
||||
}
|
||||
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
|
||||
* padding that closes the shorter side of a hunk. */
|
||||
type SplitRow = { left: number; right: number };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
/** Pair each run of removed lines with the run of added lines following it
|
||||
* for emphasis, keyed by line index so it lands without searching the hunk
|
||||
* again; and pack each column's own lines into the split rows. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
if (lines[i]!.type === "ctx") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const left: number[] = [];
|
||||
const right: number[] = [];
|
||||
lines.forEach((l, i) => {
|
||||
if (l.type !== "add") left.push(i);
|
||||
if (l.type !== "del") right.push(i);
|
||||
});
|
||||
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
|
||||
left: left[i] ?? -1,
|
||||
right: right[i] ?? -1,
|
||||
}));
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
|
||||
/** First/last index in `hunk.lines` (unified). */
|
||||
firstLine: number;
|
||||
lastLine: number;
|
||||
/** First/last split-view row. */
|
||||
/** First split-view row (either side): the bar's arrows scroll here. */
|
||||
firstRow: number;
|
||||
/** The right column's row its review bar hangs under. */
|
||||
lastRow: number;
|
||||
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
|
||||
* point between two rows where a pure insertion / removal sits. */
|
||||
left: [number, number];
|
||||
right: [number, number];
|
||||
};
|
||||
|
||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
||||
const hit = blocksOf.get(hunk);
|
||||
if (hit) return hit;
|
||||
const out: LaneDiffChange[] = [];
|
||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (line.type === "ctx") return;
|
||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||
if (!c) {
|
||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
||||
c = {
|
||||
hunk,
|
||||
block: out.length,
|
||||
firstLine: i,
|
||||
lastLine: i,
|
||||
firstRow: -1,
|
||||
lastRow: -1,
|
||||
left: [0, 0],
|
||||
right: [0, 0],
|
||||
};
|
||||
out.push(c);
|
||||
}
|
||||
c.lastLine = i;
|
||||
owner.set(line, c);
|
||||
});
|
||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||||
if (!c) return;
|
||||
if (c.firstRow < 0) c.firstRow = i;
|
||||
c.lastRow = i;
|
||||
});
|
||||
// A side's rows are its lines in order, so a block's rows there are the
|
||||
// count of that side's lines before it, plus its own.
|
||||
for (const c of out) {
|
||||
let l = 0;
|
||||
let r = 0;
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (i >= c.firstLine) return;
|
||||
if (line.type !== "add") l += 1;
|
||||
if (line.type !== "del") r += 1;
|
||||
});
|
||||
let dl = 0;
|
||||
let dr = 0;
|
||||
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
|
||||
if (hunk.lines[i]!.type === "del") dl += 1;
|
||||
else dr += 1;
|
||||
}
|
||||
c.left = [l, l + dl];
|
||||
c.right = [r, r + dr];
|
||||
c.firstRow = Math.min(l, r);
|
||||
// A pure removal hangs its bar under the right row its band points at.
|
||||
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
|
||||
}
|
||||
blocksOf.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
@@ -547,37 +590,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One column's row i of a hunk: that side's i-th line, or the padding
|
||||
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
|
||||
function SplitLine({
|
||||
row,
|
||||
hunk,
|
||||
i,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
row: PairRow;
|
||||
hunk: LaneDiffHunk;
|
||||
i: number;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffChange;
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const { rows, emph } = analyseHunk(hunk);
|
||||
const at = rows[i]![side];
|
||||
const line = at < 0 ? null : hunk.lines[at]!;
|
||||
const e = at < 0 ? undefined : emph.get(at);
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -598,11 +640,12 @@ function SplitColumn({
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<HunkHeader hunk={hunk} fixed />
|
||||
{analyseHunk(hunk).rows.map((_, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per change block: on the right (new) column only.
|
||||
@@ -638,10 +681,10 @@ function SplitSlice({
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
/** The middle gutter's width: room for an S-curve to read as one. */
|
||||
const GUTTER_PX = 36;
|
||||
|
||||
const BAND = {
|
||||
add: "fill-status-success/20 stroke-status-success/60",
|
||||
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||
} as const;
|
||||
|
||||
/** Every change block's band, in the columns' content coordinates: a hunk's
|
||||
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
|
||||
function bandsOf(items: Item[]) {
|
||||
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
|
||||
items.forEach(({ hunk, i }, h) => {
|
||||
if (i >= 0) return;
|
||||
for (const c of changeBlocks(hunk)) {
|
||||
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
|
||||
out.push({
|
||||
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
|
||||
kind,
|
||||
top: Math.min(y0, z0),
|
||||
bottom: Math.max(y1, z1),
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes, and the
|
||||
* gutter between them that draws each block's band, scrolled with them. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const mid = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const bands = useMemo(() => bandsOf(items), [items]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
if (mid.current) mid.current.scrollTop = src.scrollTop;
|
||||
win.onScroll(src);
|
||||
};
|
||||
// Windowed, only the bands that reach the rendered rows are drawn.
|
||||
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
>
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={mid}
|
||||
data-testid="lane-diff-connectors"
|
||||
aria-hidden
|
||||
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
|
||||
style={{ width: GUTTER_PX }}
|
||||
>
|
||||
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
|
||||
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
|
||||
{bands
|
||||
.filter((b) => b.bottom >= from && b.top <= to)
|
||||
.map((b) => (
|
||||
<path
|
||||
key={`${b.top}:${b.d}`}
|
||||
d={b.d}
|
||||
data-kind={b.kind}
|
||||
className={BAND[b.kind]}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-right"
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
{column("right")}
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,81 @@ describe("DiffEditor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side is JetBrains-shaped: no padding, one curved band per block", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
// Three unchanged lines apart, or the diff joins the two into one chunk.
|
||||
value={"a\nB\nc\nd\nf\nnew one\nnew two\ne"}
|
||||
original={"a\nb\nc\nd\nf\ne"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
// Each side scrolls its own lines: nothing is padded to line them up.
|
||||
expect(container.querySelector(".cm-mergeSpacer")).toBeNull();
|
||||
expect(container.querySelectorAll(".cm-scroller")).toHaveLength(2);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="lane-editor-connectors"] path')
|
||||
).toHaveLength(2)
|
||||
);
|
||||
const kinds = [
|
||||
...container.querySelectorAll<SVGPathElement>('[data-testid="lane-editor-connectors"] path'),
|
||||
].map((p) => p.dataset.kind);
|
||||
expect(kinds).toEqual(["mod", "add"]);
|
||||
});
|
||||
|
||||
it("» puts the original block back on the right, as an edit", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;\nend"}
|
||||
original={"keep\nconst x = 1;\nend"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const button = await waitFor(() => {
|
||||
const b = container.querySelector<HTMLButtonElement>(".cm-merge-revert button");
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
fireEvent.mouseDown(button);
|
||||
expect(onChange).toHaveBeenLastCalledWith("keep\nconst x = 1;\nend");
|
||||
});
|
||||
|
||||
it("folds the same unchanged stretch away on both sides", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-collapsedLines")];
|
||||
expect(left!.textContent).toBe(right!.textContent);
|
||||
// One click opens the stretch on both sides.
|
||||
fireEvent.click(right!);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(0));
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
visibleRange,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
import { bandPath } from "../../../lib/diffConnectors";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -59,8 +60,8 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
it("pads the shorter side only at the hunk's end, each pad one line tall", () => {
|
||||
// Two lines removed, none added: the right column's two rows are padding.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
@@ -72,7 +73,39 @@ describe("LaneDiffView — side by side", () => {
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
for (const row of fillers) expect(row.style.height).toBe("16px");
|
||||
});
|
||||
|
||||
it("packs each side's own lines, JetBrains-style, and joins every block with a band", () => {
|
||||
const file = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "top" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "old a" },
|
||||
{ type: "del", oldLine: 3, newLine: null, text: "old b" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "new a" },
|
||||
{ type: "ctx", oldLine: 4, newLine: 3, text: "mid" },
|
||||
{ type: "add", oldLine: null, newLine: 4, text: "extra" },
|
||||
{ type: "ctx", oldLine: 5, newLine: 5, text: "end" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const texts = (id: string) =>
|
||||
[...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max")].map((r) =>
|
||||
r.textContent?.replace(/^\d+/, "")
|
||||
);
|
||||
// No gap inside a hunk: a side's lines follow one another (5 each, no pad).
|
||||
expect(texts("lane-diff-split-left")).toEqual(["top", "old a", "old b", "mid", "end"]);
|
||||
expect(texts("lane-diff-split-right")).toEqual(["top", "new a", "mid", "extra", "end"]);
|
||||
// Two blocks, two bands: rows start under the one-row hunk header.
|
||||
const bands = [
|
||||
...screen.getByTestId("lane-diff-connectors").querySelectorAll<SVGPathElement>("path"),
|
||||
];
|
||||
expect(bands.map((b) => b.dataset.kind)).toEqual(["mod", "add"]);
|
||||
expect(bands[0]!.getAttribute("d")).toBe(
|
||||
bandPath(36, 2 * ROW_PX, 4 * ROW_PX, 2 * ROW_PX, 3 * ROW_PX)
|
||||
);
|
||||
// The insertion is a wedge from the left's point between "mid" and "end".
|
||||
expect(bands[1]!.getAttribute("d")).toBe(
|
||||
bandPath(36, 5 * ROW_PX, 5 * ROW_PX, 4 * ROW_PX, 5 * ROW_PX)
|
||||
);
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
@@ -414,8 +447,8 @@ describe("LaneDiffView — windowing", () => {
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
// Packed: the left holds only the context lines, so it runs twice as far.
|
||||
expect(left.textContent).toContain("line 4000;");
|
||||
});
|
||||
|
||||
it("windows a whole-file read too", () => {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
|
||||
* (read-only, left) and the working file (editable, right) are two
|
||||
* CodeMirror views in their OWN scrollers, with no padding to line them up —
|
||||
* each shows its real lines — and the gutter between them draws, for every
|
||||
* changed block, a curved band joining its lines on the left to its lines on
|
||||
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
|
||||
* Scrolling either side scrolls the other to the matching line (`mapLine`:
|
||||
* 1:1 outside a block, proportional inside one).
|
||||
*
|
||||
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
|
||||
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
|
||||
* every edit of the right side; the changed-line, changed-text and gutter
|
||||
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
|
||||
* unchanged stretches (opened on both sides at once) and the revert's text
|
||||
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Text,
|
||||
type Extension,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
WidgetType,
|
||||
gutterLineClass,
|
||||
type DecorationSet,
|
||||
} from "@codemirror/view";
|
||||
import { Chunk, getChunks } from "@codemirror/merge";
|
||||
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
|
||||
|
||||
/** The chunks a side carries, the side it is, and (right side) the original. */
|
||||
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
|
||||
|
||||
const setChunks = StateEffect.define<readonly Chunk[]>();
|
||||
const uncollapse = StateEffect.define<number>();
|
||||
|
||||
/** Each side's chunks. The right side recomputes them as it is edited; the
|
||||
* left, read-only, is handed the new list (`setChunks`). */
|
||||
const splitField = StateField.define<SplitState>({
|
||||
create: () => ({ chunks: [], side: "b", orig: null }),
|
||||
update(v, tr) {
|
||||
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
|
||||
if (v.orig && tr.docChanged) {
|
||||
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
|
||||
}
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
/** The chunks and side of an editor in either merge layout — unified (the
|
||||
* merge package's own state) or this split. */
|
||||
export function chunksOf(
|
||||
state: EditorState
|
||||
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
|
||||
return getChunks(state) ?? state.field(splitField, false) ?? null;
|
||||
}
|
||||
|
||||
const changedLine = Decoration.line({ class: "cm-changedLine" });
|
||||
const changedText = Decoration.mark({ class: "cm-changedText" });
|
||||
const changedGutter = new (class extends GutterMarker {
|
||||
override elementClass = "cm-changedLineGutter";
|
||||
})();
|
||||
|
||||
/** The lines (and, inside them, the changed spans) of this side's chunks. */
|
||||
function changedRanges(state: EditorState) {
|
||||
const { chunks, side } = state.field(splitField);
|
||||
const doc = state.doc;
|
||||
const lines: number[] = [];
|
||||
const spans: [number, number][] = [];
|
||||
for (const c of chunks) {
|
||||
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
||||
if (from >= to) continue;
|
||||
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
|
||||
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
|
||||
const line = doc.line(n);
|
||||
lines.push(line.from);
|
||||
for (const ch of c.changes) {
|
||||
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
|
||||
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
|
||||
if (s < e) spans.push([s, e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lines, spans };
|
||||
}
|
||||
|
||||
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
|
||||
const { lines, spans } = changedRanges(state);
|
||||
return Decoration.set(
|
||||
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
|
||||
true
|
||||
);
|
||||
});
|
||||
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
|
||||
RangeSet.of(
|
||||
changedRanges(state).lines.map((p) => changedGutter.range(p)),
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
/** The other side's editor, for a fold opened on one side to open on both. */
|
||||
const siblings = new WeakMap<EditorView, EditorView>();
|
||||
|
||||
/** `pos` on one side, moved past the chunks before it to the other (ported). */
|
||||
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
|
||||
let ours = 0;
|
||||
let theirs = 0;
|
||||
for (const c of chunks) {
|
||||
if ((isA ? c.fromA : c.fromB) >= pos) break;
|
||||
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
|
||||
}
|
||||
return theirs + (pos - ours);
|
||||
}
|
||||
|
||||
class CollapseWidget extends WidgetType {
|
||||
constructor(readonly lines: number) {
|
||||
super();
|
||||
}
|
||||
override eq(o: CollapseWidget) {
|
||||
return this.lines === o.lines;
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "cm-collapsedLines";
|
||||
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
|
||||
el.addEventListener("click", (e) => {
|
||||
const pos = view.posAtDOM(e.target as Node);
|
||||
view.dispatch({ effects: uncollapse.of(pos) });
|
||||
const { chunks, side } = view.state.field(splitField);
|
||||
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
|
||||
});
|
||||
return el;
|
||||
}
|
||||
override ignoreEvent(e: Event) {
|
||||
return e instanceof MouseEvent;
|
||||
}
|
||||
override get estimatedHeight() {
|
||||
return 27;
|
||||
}
|
||||
}
|
||||
|
||||
/** The folded unchanged stretches: built once at mount, then only mapped,
|
||||
* opened by a click, and dropped where an edit grows a chunk into them. */
|
||||
const collapsedField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
deco = deco.map(tr.changes);
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
|
||||
}
|
||||
const { chunks, side } = tr.state.field(splitField);
|
||||
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
|
||||
const touching: number[] = [];
|
||||
for (const c of chunks) {
|
||||
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
|
||||
touching.push(from);
|
||||
});
|
||||
}
|
||||
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
|
||||
}
|
||||
return deco;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
|
||||
const { chunks, side } = state.field(splitField);
|
||||
const doc = state.doc;
|
||||
const out = [];
|
||||
let prev = 1;
|
||||
for (let i = 0; ; i += 1) {
|
||||
const c = i < chunks.length ? chunks[i]! : null;
|
||||
const from = i ? prev + margin : 1;
|
||||
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
|
||||
const lines = to - from + 1;
|
||||
if (lines >= minLines) {
|
||||
out.push(
|
||||
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
|
||||
doc.line(from).from,
|
||||
doc.line(to).to
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!c) break;
|
||||
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
|
||||
}
|
||||
return Decoration.set(out);
|
||||
}
|
||||
|
||||
/** A chunk side as 0-based half-open lines; an empty side is the point
|
||||
* before the line it sits above (past the end: after the last line). */
|
||||
function sideLines(doc: Text, from: number, to: number): [number, number] {
|
||||
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
|
||||
if (from >= to) return [first, first];
|
||||
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
|
||||
}
|
||||
|
||||
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
|
||||
return chunks.map((c) => {
|
||||
const [a0, a1] = sideLines(a, c.fromA, c.toA);
|
||||
const [b0, b1] = sideLines(b, c.fromB, c.toB);
|
||||
return { a0, a1, b0, b1 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Where the document starts inside the view's scroll content. */
|
||||
const docOffset = (v: EditorView) =>
|
||||
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
|
||||
|
||||
/** The fractional line at the top of the view (a folded stretch spreads its
|
||||
* height over the lines it hides). */
|
||||
function topLine(v: EditorView) {
|
||||
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
|
||||
const blk = v.lineBlockAtHeight(h);
|
||||
const first = v.state.doc.lineAt(blk.from).number - 1;
|
||||
const n = v.state.doc.lineAt(blk.to).number - first;
|
||||
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
|
||||
return first + frac * n;
|
||||
}
|
||||
|
||||
/** Document y of a fractional line (0-based). */
|
||||
function yOf(v: EditorView, line: number) {
|
||||
const doc = v.state.doc;
|
||||
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
|
||||
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
|
||||
const first = doc.lineAt(blk.from).number - 1;
|
||||
const n = doc.lineAt(blk.to).number - first;
|
||||
return blk.top + ((line - first) / n) * blk.height;
|
||||
}
|
||||
|
||||
/** The gutter's width: room for an S-curve and the » under it. */
|
||||
const GUTTER_PX = 40;
|
||||
const BAND: Record<"add" | "del" | "mod", string> = {
|
||||
add: "fill-status-success/20 stroke-status-success/60",
|
||||
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||
};
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function splitDiff({
|
||||
parent,
|
||||
original,
|
||||
value,
|
||||
extA,
|
||||
extB,
|
||||
collapse,
|
||||
revertLabel,
|
||||
}: {
|
||||
parent: HTMLElement;
|
||||
original: string;
|
||||
value: string;
|
||||
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
|
||||
extA: Extension[];
|
||||
extB: Extension[];
|
||||
collapse?: { margin: number; minSize: number };
|
||||
revertLabel: string;
|
||||
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||
const origDoc = Text.of(original.split("\n"));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
|
||||
|
||||
const root = parent.appendChild(document.createElement("div"));
|
||||
root.className = "flex h-full min-h-0";
|
||||
const pane = () => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "h-full min-w-0 flex-1";
|
||||
return el;
|
||||
};
|
||||
const paneA = root.appendChild(pane());
|
||||
const gutter = root.appendChild(document.createElement("div"));
|
||||
gutter.className =
|
||||
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
||||
gutter.style.width = `${GUTTER_PX}px`;
|
||||
gutter.dataset.testid = "lane-editor-connectors";
|
||||
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
||||
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
||||
const buttons = gutter.appendChild(document.createElement("div"));
|
||||
const paneB = root.appendChild(pane());
|
||||
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
if (frame) return;
|
||||
const run = () => {
|
||||
frame = 0;
|
||||
paint();
|
||||
};
|
||||
frame =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame(run)
|
||||
: window.setTimeout(run);
|
||||
};
|
||||
|
||||
const side = (s: "a" | "b"): Extension[] => [
|
||||
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
||||
chunkDecorations,
|
||||
chunkGutter,
|
||||
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
||||
...(collapse
|
||||
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
||||
: []),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
||||
}),
|
||||
];
|
||||
|
||||
const a = new EditorView({
|
||||
parent: paneA,
|
||||
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
||||
});
|
||||
const b = new EditorView({
|
||||
parent: paneB,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...side("b"),
|
||||
...extB,
|
||||
// The left side is told the chunks the right side's edit produced.
|
||||
EditorView.updateListener.of((u) => {
|
||||
const now = u.state.field(splitField).chunks;
|
||||
if (now !== u.startState.field(splitField).chunks)
|
||||
a.dispatch({ effects: setChunks.of(now) });
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
siblings.set(a, b);
|
||||
siblings.set(b, a);
|
||||
|
||||
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
||||
|
||||
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
||||
// caused comes back as the other side's own event: `echo` holds where we
|
||||
// put it, so that event is recognised and not mirrored back.
|
||||
const echo = { a: -1, b: -1 };
|
||||
const follow = (from: "a" | "b") => () => {
|
||||
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
||||
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
||||
echo[from] = -1;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
const line = mapLine(topLine(src), spans(), from === "a");
|
||||
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
||||
const before = dst.scrollDOM.scrollTop;
|
||||
dst.scrollDOM.scrollTop = target;
|
||||
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
||||
draw();
|
||||
};
|
||||
const onA = follow("a");
|
||||
const onB = follow("b");
|
||||
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
||||
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
||||
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||
resize?.observe(root);
|
||||
|
||||
// » puts the original block back on the right (the merge package's rule:
|
||||
// a non-empty source brings its line break unless it ends the document).
|
||||
buttons.addEventListener("mousedown", (e) => {
|
||||
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
||||
const c = b.state.field(splitField).chunks[i];
|
||||
if (!c) return;
|
||||
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
||||
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
||||
b.dispatch({
|
||||
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
||||
userEvent: "revert",
|
||||
});
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
function paint() {
|
||||
const g = gutter.getBoundingClientRect();
|
||||
const height = gutter.clientHeight;
|
||||
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
||||
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
||||
const paths: SVGPathElement[] = [];
|
||||
const btns: HTMLButtonElement[] = [];
|
||||
spans().forEach((s, i) => {
|
||||
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
||||
// Off screen on both sides: nothing to draw (no layout = draw all).
|
||||
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
||||
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
||||
const p = document.createElementNS(SVG, "path");
|
||||
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
||||
p.setAttribute("class", BAND[kind]);
|
||||
p.setAttribute("vector-effect", "non-scaling-stroke");
|
||||
p.dataset.kind = kind;
|
||||
paths.push(p);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "»";
|
||||
btn.title = revertLabel;
|
||||
btn.setAttribute("aria-label", revertLabel);
|
||||
btn.dataset.chunk = String(i);
|
||||
btn.className =
|
||||
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
||||
btns.push(btn);
|
||||
});
|
||||
svg.replaceChildren(...paths);
|
||||
buttons.replaceChildren(...btns);
|
||||
}
|
||||
draw();
|
||||
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
destroy: () => {
|
||||
if (frame)
|
||||
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
||||
resize?.disconnect();
|
||||
a.scrollDOM.removeEventListener("scroll", onA);
|
||||
b.scrollDOM.removeEventListener("scroll", onB);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,12 +40,3 @@ describe("changeTree", () => {
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeTree — an untracked folder", () => {
|
||||
it("shows git's `dir/` entry as a named row in its parent folder", () => {
|
||||
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
|
||||
const docs = root.dirs[0]!;
|
||||
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
|
||||
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Tests for the side-by-side diff's connector geometry: the band path
|
||||
* between a left and a right block, and the line mapping that keeps the two
|
||||
* panes scrolled to the same place.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bandPath, mapLine, type Span } from "../diffConnectors";
|
||||
|
||||
describe("bandPath", () => {
|
||||
it("joins the left block's edges to the right block's with two S-curves", () => {
|
||||
expect(bandPath(40, 10, 30, 50, 90)).toBe(
|
||||
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,30 0,30 Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("still draws an empty side as a closed path, flat there", () => {
|
||||
expect(bandPath(40, 10, 10, 50, 90)).toBe(
|
||||
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,10 0,10 Z"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapLine", () => {
|
||||
// Left lines 2–3 became right lines 2–6; left 8 was removed (right 11 = none).
|
||||
const spans: Span[] = [
|
||||
{ a0: 2, a1: 4, b0: 2, b1: 7 },
|
||||
{ a0: 8, a1: 9, b0: 11, b1: 11 },
|
||||
];
|
||||
|
||||
it("maps 1:1 before, between and after the blocks, shifted by the ones above", () => {
|
||||
expect(mapLine(0, spans, true)).toBe(0);
|
||||
expect(mapLine(5, spans, true)).toBe(8);
|
||||
expect(mapLine(10, spans, true)).toBe(12);
|
||||
expect(mapLine(8, spans, false)).toBe(5);
|
||||
});
|
||||
|
||||
it("interpolates inside a block, both ways", () => {
|
||||
expect(mapLine(3, spans, true)).toBe(4.5);
|
||||
expect(mapLine(4.5, spans, false)).toBe(3);
|
||||
});
|
||||
|
||||
it("puts a line removed on one side at its block's point on the other", () => {
|
||||
expect(mapLine(8.5, spans, true)).toBe(11);
|
||||
});
|
||||
|
||||
it("is the identity with no blocks", () => {
|
||||
expect(mapLine(7.25, [], true)).toBe(7.25);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,7 @@ export interface ChangeTreeDir<T> {
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
|
||||
const parts = file.path.replace(/\/$/, "").split("/");
|
||||
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
|
||||
const parts = file.path.split("/");
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Geometry for the JetBrains-style side-by-side diff, shared by the
|
||||
* Changes panel's review list (LaneDiffView) and its file editor (DiffEditor):
|
||||
* the curved band that joins a changed block on the left to its counterpart
|
||||
* on the right across the middle gutter, and the line mapping that keeps two
|
||||
* unpadded panes scrolled to the same place. Pure functions, no DOM.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** A changed block's lines on each side, 0-based and half-open: `a0 === a1`
|
||||
* is a block with no lines on the left (a pure insertion), and likewise b. */
|
||||
export type Span = { a0: number; a1: number; b0: number; b1: number };
|
||||
|
||||
/**
|
||||
* SVG path of the band joining the left block (`y0`–`y1`) to the right one
|
||||
* (`z0`–`z1`) across a gutter `w` wide: an S-curve along each edge, flat where
|
||||
* a side is empty (`y0 === y1`), so a pure insertion reads as a wedge.
|
||||
*/
|
||||
export function bandPath(w: number, y0: number, y1: number, z0: number, z1: number): string {
|
||||
const c = w / 2;
|
||||
return `M0,${y0} C${c},${y0} ${c},${z0} ${w},${z0} L${w},${z1} C${c},${z1} ${c},${y1} 0,${y1} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line on the other side that `line` (fractional, 0-based) sits beside:
|
||||
* outside a block 1:1, shifted by the blocks above it; inside one, at the same
|
||||
* fraction of the other side's block — which collapses to that block's point
|
||||
* when the other side is empty. `spans` are in document order.
|
||||
*/
|
||||
export function mapLine(line: number, spans: readonly Span[], fromA: boolean): number {
|
||||
let ours = 0;
|
||||
let theirs = 0;
|
||||
for (const s of spans) {
|
||||
const [s0, s1, t0, t1] = fromA ? [s.a0, s.a1, s.b0, s.b1] : [s.b0, s.b1, s.a0, s.a1];
|
||||
if (line < s0) break;
|
||||
if (line < s1) return t0 + ((line - s0) / (s1 - s0)) * (t1 - t0);
|
||||
ours = s1;
|
||||
theirs = t1;
|
||||
}
|
||||
return theirs + (line - ours);
|
||||
}
|
||||
Reference in New Issue
Block a user