Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject<
return {
...options,
...optionsOverride?.get(),
...(options.forceInline ? { renderSideBySide: false } : {}),
scrollBeyondLastLine: false,
hideUnchangedRegions: options.hideUnchangedRegions ?? {
enabled: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,8 @@ export interface IDiffEditorBaseOptions {
* Configuration options for the diff editor.
*/
export interface IDiffEditorOptions extends IEditorOptions, IDiffEditorBaseOptions {
/** Keep an explicitly one-sided item inline even when its multi-diff container uses split layout. @internal */
forceInline?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1204,3 +1204,9 @@ body

.review-diff-group-toggle { display:flex; align-items:center; gap:8px; min-width:0; border:0; padding:0; color:inherit; background:transparent; cursor:pointer; text-align:left; }
.review-diff-group-toggle:focus-visible { outline:1px solid var(--vscode-focusBorder); }

/* Search evidence is distinct from inserted/deleted-line coloring. */
.review-search-highlight {
background-color: rgba(65, 135, 245, 0.30);
outline: 1px solid rgba(65, 135, 245, 0.65);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { IDocumentDiff, IDocumentContextGap } from '../../editor/common/dif
import type { ReviewDiffLens } from './reviewProtocol.js';

/** Project pinned ranges onto the current diff's correspondence, never onto another revision. */
export function lensContextGaps(diff: IDocumentDiff, originalCount: number, modifiedCount: number, ranges: ReviewDiffLens['ranges']): IDocumentContextGap[] {
export function lensContextGaps(diff: IDocumentDiff, originalCount: number, modifiedCount: number, ranges: Extract<ReviewDiffLens['targets'][number], { kind: 'ranges' }>['ranges']): IDocumentContextGap[] {
const rows = alignmentRows(diff, originalCount, modifiedCount);
const visible = rows.map(row => ranges.some(range => {
const line = row[range.side === 'base' ? 0 : 1];
Expand Down Expand Up @@ -48,9 +48,9 @@ function alignmentRows(diff: IDocumentDiff, originalCount: number, modifiedCount
}

/** A paired row folds only when none of its changed lines remain unread. */
export function viewedContextGaps(diff: IDocumentDiff, originalCount: number, modifiedCount: number, viewed: ReviewDiffLens['ranges'], changed: ReviewDiffLens['ranges']): IDocumentContextGap[] {
export function viewedContextGaps(diff: IDocumentDiff, originalCount: number, modifiedCount: number, viewed: Extract<ReviewDiffLens['targets'][number], { kind: 'ranges' }>['ranges'], changed: Extract<ReviewDiffLens['targets'][number], { kind: 'ranges' }>['ranges']): IDocumentContextGap[] {
const rows = alignmentRows(diff, originalCount, modifiedCount);
const contains = (ranges: ReviewDiffLens['ranges'], side: 0 | 1, line: number) => ranges.some(range => range.side === (side === 0 ? 'base' : 'head') && line + 1 >= range.fromLine && line + 1 <= range.toLine);
const contains = (ranges: Extract<ReviewDiffLens['targets'][number], { kind: 'ranges' }>['ranges'], side: 0 | 1, line: number) => ranges.some(range => range.side === (side === 0 ? 'base' : 'head') && line + 1 >= range.fromLine && line + 1 <= range.toLine);
const hidden = rows.map(row => row.some((line, side) => line !== null && contains(viewed, side as 0 | 1, line)) && row.every((line, side) => line === null || !contains(changed, side as 0 | 1, line) || contains(viewed, side as 0 | 1, line)));
const gaps: IDocumentContextGap[] = [];
let left = 1, right = 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { lensContextGaps } from './reviewLens.js';
import type { ReviewDiffFileWire, ReviewDiffLens } from './reviewProtocol.js';

const files: ReviewDiffFileWire[] = [{ path: 'renamed.ts', previousPath: 'old.ts', status: 'renamed', additions: 2, deletions: 1 }];
const lens: ReviewDiffLens = { id: 'lens', title: 'Context', reviewId: 'review', version: 1, ranges: [
const lens: ReviewDiffLens = { id: 'lens', title: 'Context', reviewId: 'review', version: 1, targets: [{ kind: 'ranges', ranges: [
{ file: 'old.ts', side: 'base', fromLine: 2, toLine: 5 },
{ file: 'context.ts', side: 'base', fromLine: 10, toLine: 12 },
{ file: 'context.ts', side: 'head', fromLine: 10, toLine: 12 },
] };
] }] };

test('diagram references add each unchanged file once, preserving rename identity', () => {
const result = lensFiles(files, lens);
Expand All @@ -23,6 +23,6 @@ test('clearing the lens and whole-file glob lenses do not introduce context file
});

test('an identical file exposes the referenced slice with three surrounding lines', () => {
const gaps = lensContextGaps({ changes: [], moves: [], identical: true, quitEarly: false }, 30, 30, lens.ranges.filter(range => range.file === 'context.ts'));
const gaps = lensContextGaps({ changes: [], moves: [], identical: true, quitEarly: false }, 30, 30, lens.targets.flatMap(target => target.kind === 'ranges' ? target.ranges : []).filter(range => range.file === 'context.ts'));
assert.deepEqual(gaps.map(gap => [gap.originalStart, gap.originalCount, gap.modifiedStart, gap.modifiedCount]), [[1, 6, 1, 6], [16, 15, 16, 15]]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function lensFiles(files: readonly ReviewDiffFileWire[], lens?: ReviewDif
if (!lens || lens.wholeFiles) return files;
const known = new Set(files.flatMap(file => [file.path, ...(file.previousPath ? [file.previousPath] : [])]));
const context: ReviewDiffFileWire[] = [];
for (const range of lens.ranges) {
for (const range of lens.targets.flatMap(target => target.kind === "ranges" ? [...target.ranges] : [])) {
if (known.has(range.file)) continue;
known.add(range.file);
context.push({ path: range.file, status: 'unchanged', additions: 0, deletions: 0 });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";

import type { SearchResultData, SourceData } from "./reviewProtocol.js";
import { evidenceRows } from "./reviewSearchEvidence.js";

const source = (text: string): SourceData => ({
text,
regions: [
{
kind: "leaf",
id: 1,
fold_state_id: 1,
alignment_id: 1,
start: { line: 0, column: 0 },
end: { line: 1, column: 0 },
search_highlights: [{ line: 0, start_column: 2, end_column: 6 }],
},
{
kind: "fold",
id: 2,
fold_state_id: 2,
start: { line: 1, column: 0 },
end: { line: 2, column: 0 },
visibility: { collapsed: true },
children: [
{
kind: "leaf",
id: 3,
fold_state_id: 3,
alignment_id: 3,
start: { line: 1, column: 0 },
end: { line: 2, column: 0 },
},
],
},
],
});
function result(): SearchResultData {
return {
display: "rhs",
scope: {
repo: "/unused",
baseWorktree: { commitId: "base", path: "/unused/base" },
headWorktree: { commitId: "head", path: "/unused/head" },
},
file: { rhs: { path: "example.ts", oid: "a".repeat(40), mode: "100644" } },
sources: { rhs: source("é🔎 match\nhidden\n") },
};
}

test("head-only evidence preserves its lines, UTF-8 highlights, and nested folded context", () => {
const evidence = result();
const rows = evidenceRows(evidence);
assert.ok(rows.every((row) => row.baseLine === undefined));
assert.equal(rows[0].headLine, 1);
assert.deepEqual(rows[0].highlights, [{ startColumn: 2, endColumn: 4 }]);
assert.ok(!rows.some((row) => row.content === "hidden"));
const fold = evidence.sources.rhs!.regions[1];
fold.visibility = { collapsed: false };
const expanded = evidenceRows(evidence);
assert.equal(expanded[1].content, "hidden");
assert.equal(expanded[1].headLine, 2);
assert.equal(expanded[1].fold?.id, 2);
});

test("paired changed lines remain separate while unchanged aligned lines merge", () => {
const evidence = result();
evidence.display = "both";
evidence.file = { lhs: evidence.file.rhs!, rhs: evidence.file.rhs! };
evidence.sources = {
lhs: source("é🔎 match\nhidden\n"),
rhs: evidence.sources.rhs!,
};
assert.equal(evidenceRows(evidence)[0].baseLine, 1);
assert.equal(evidenceRows(evidence)[0].headLine, 1);
const leaf = evidence.sources.lhs!.regions[0];
if (leaf.kind !== "leaf") throw new Error("Expected leaf");
leaf.changed = [{ line: 0, start_column: 0, end_column: 2 }];
const rows = evidenceRows(evidence);
assert.equal(rows[0].kind, "deleted");
assert.equal(rows[0].headLine, undefined);
assert.equal(rows[1].baseLine, undefined);
});


test("display hides the counterpart without losing it, and shared sources keep folds", () => {
const single = result();
const shared: SearchResultData = {
scope: single.scope, display: "rhs",
file: {lhs: single.file.rhs!, rhs: single.file.rhs!},
sources: {same: single.sources.rhs!},
};
assert.ok(evidenceRows(shared).every(row => row.baseLine === undefined));
assert.equal(evidenceRows(shared)[1].fold?.collapsed, true);
shared.display = "both";
assert.equal(evidenceRows(shared)[0].baseLine, 1);
assert.equal(evidenceRows(shared)[0].headLine, 1);
assert.equal(evidenceRows(shared)[1].fold?.collapsed, true);
const paired: SearchResultData = {...shared, display: "lhs", sources: {
lhs: source("before\nhidden\n"), rhs: source("after\nhidden\n"),
}};
assert.ok(evidenceRows(paired).every(row => row.headLine === undefined));
assert.equal(evidenceRows(paired)[0].content, "before");
paired.display = "rhs";
assert.equal(evidenceRows(paired)[0].content, "after");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type {
RegionData,
SearchResultData,
SourceData,
ReviewInlineEditorSpec,
} from "./reviewProtocol.js";
import type { ReviewUnifiedDiffRow } from "./reviewUnifiedDiff.js";

/** Rendering selection never removes sources from retained evidence. */
export function displayedSources(result: SearchResultData) {
return {
lhs: result.display !== "rhs" ? result.sources.same ?? result.sources.lhs : undefined,
rhs: result.display !== "lhs" ? result.sources.same ?? result.sources.rhs : undefined,
};
}

export interface EvidenceRow extends ReviewUnifiedDiffRow {
highlights: { startColumn: number; endColumn: number }[];
fold?: { id: number; collapsed: boolean };
}
interface Item {
key: string;
line: number;
text: string;
changed: boolean;
highlights: EvidenceRow["highlights"];
fold?: EvidenceRow["fold"];
}

function items(source: SourceData): Item[] {
const lines = source.text.split("\n").map((line) => line.replace(/\r$/, ""));
const output: Item[] = [];
const column = (text: string, byte: number) =>
new TextDecoder().decode(new TextEncoder().encode(text).slice(0, byte))
.length + 1;
const walk = (regions: RegionData[]) => {
for (const region of regions) {
const first = output.length;
const end = region.end.line + Number(region.end.column > 0);
if (region.visibility?.collapsed) {
output.push({
key: `fold:${region.fold_state_id}`,
line: region.start.line + 1,
text: `… ${region.visibility.label || `${region.start.line + 1}–${end}`} …`,
changed: false,
highlights: [],
fold: { id: region.fold_state_id, collapsed: true },
});
} else if (region.kind === "fold") walk(region.children);
else
for (let line = region.start.line; line < end; line++) {
const text = lines[line] ?? "";
output.push({
key: `line:${region.alignment_id}:${line - region.start.line}`,
line: line + 1,
text,
changed: (region.changed ?? []).some((span) => span.line === line),
highlights: (region.search_highlights ?? [])
.filter((span) => span.line === line)
.map((span) => ({
startColumn: column(text, span.start_column),
endColumn: column(text, span.end_column),
})),
});
}
if (
output[first] &&
!output[first].fold &&
(region.kind === "fold" || region.visibility?.label)
)
output[first].fold = { id: region.fold_state_id, collapsed: false };
}
};
walk(source.regions);
return output;
}

/** Use supplied structural alignment, visibility and highlights; never compute a new diff. */
export function evidenceRows(result: SearchResultData): EvidenceRow[] {
const sources = displayedSources(result);
const lhs = sources.lhs ? items(sources.lhs) : [];
const rhs = sources.rhs ? items(sources.rhs) : [];
const rows: EvidenceRow[] = [];
const append = (left?: Item, right?: Item) => {
if (
left &&
right &&
(left.changed || right.changed || left.text !== right.text) &&
!left.fold?.collapsed &&
!right.fold?.collapsed
) {
append(left);
append(undefined, right);
return;
}
const item = right ?? left!;
rows.push({
lineNumber: rows.length + 1,
content: item.text,
kind: item.changed ? (right ? "added" : "deleted") : "unchanged",
baseLine: left?.line,
headLine: right?.line,
authorSide: right ? "head" : "base",
authorLine: item.line,
highlights: [...(left?.highlights ?? []), ...(right?.highlights ?? [])],
fold: item.fold ?? left?.fold,
});
};
let right = 0;
for (const left of lhs) {
const match = rhs.findIndex(
(item, index) => index >= right && item.key === left.key,
);
if (match < 0) append(left);
else {
while (right < match) append(undefined, rhs[right++]);
append(left, rhs[right++]);
}
}
while (right < rhs.length) append(undefined, rhs[right++]);
return rows;
}

export function evidenceCoordinates(
content: ReviewInlineEditorSpec["content"],
) {
if (content.kind === "source") return content;
const result = content.result;
const sources = displayedSources(result);
const side = sources.rhs ? ("head" as const) : ("base" as const);
const file = side === "head" ? result.file.rhs! : result.file.lhs!;
const source = sources.rhs ?? sources.lhs!;
const highlighted = evidenceRows(result)
.filter(
(row) =>
row.highlights.length &&
(side === "head"
? row.headLine !== undefined
: row.baseLine !== undefined),
)
.map((row) => ({
startLine: (side === "head" ? row.headLine : row.baseLine)!,
endLine: (side === "head" ? row.headLine : row.baseLine)!,
}));
if (highlighted.length) return { path: file.path, side, ranges: highlighted };
return {
path: file.path,
side,
ranges: source.regions.map((region) => ({
startLine: region.start.line + 1,
endLine: Math.max(
region.start.line + 1,
region.end.line + Number(region.end.column > 0),
),
})),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
collapsedRegions,
hiddenLinesOf,
structuralContextGaps,
searchStructuralDiff,
structuralSearchHighlights,
bandDetail,
structuralCountsTooltip,
structuralInitialCounts,
Expand Down Expand Up @@ -325,3 +327,19 @@ test("a fold pairs through fold state, and a docstring fold only with a docstrin
const unpaired: StructuralTextDiff = { ...diff, rhs: text(lines(4), [other]) };
assert.deepEqual(structuralContextGaps(unpaired, (id) => id === 9 || id === 10).map((g) => g.kind).sort(), ["inserted", "removed", "removed"]);
});


test("head-only search evidence retains native pseudocode bands and UTF-16 highlight positions", () => {
const body = fold(4, [leaf(5, 1, 3, { changed: [{ line: 1, start_column: 0, end_column: 6 }] })]);
body.visibility = { collapsed: true, label: "// pseudocode\nreturn cached value" };
const source = text(["😀match", "hidden", "return"], [leaf(1, 0, 1, { search_highlights: [{ line: 0, start_column: 4, end_column: 9 }] }), body]);
const diff = searchStructuralDiff({ display: "rhs", scope: { repo: "/unused", baseWorktree: { commitId: "base", path: "/unused" }, headWorktree: { commitId: "head", path: "/unused" } }, file: { rhs: { path: "x.ts", oid: "a".repeat(40), mode: "100644" } }, sources: { rhs: source } });
assert.ok(structuralRows(diff).every(([left]) => left === null));
assert.deepEqual(structuralSearchHighlights(diff.rhs), [{ startLineNumber: 1, endLineNumber: 1, startColumn: 3, endColumn: 8 }]);
const [band] = structuralContextGaps(diff, id => id === 4);
assert.equal(band.originalCount, 0);
assert.equal(band.modifiedCount, 2);
assert.equal(bandDetail(band.label), "return cached value");
assert.equal(structuralInitialCounts(diff).visible.added, 0);
assert.equal(structuralInitialCounts(diff).textual.added, 1);
});
Loading