Skip to content
Open
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 @@ -24,6 +24,20 @@ test('structural folds within the lens survive, overlapping folds do not', () =>
assert.ok(!gaps.includes(outside));
});

test('an exact document lens (tour step, code peek) clips an added file to its +/-3 window; without it, #502 fills the whole open run', () => {
const diff = {
...plain,
sourceLineAlignment: Array.from({ length: 300 }, (_, i) => [null, i] as const),
contextScopes: { original: [], modified: [[0, 300] as const] },
};
const ranges = [{ side: 'head' as const, file: 'new.ts', fromLine: 80, toLine: 87 }];
// #502 behavior (Diff tab): the added file is one open run, so contextScopes fills it whole.
assert.deepEqual(lensContextGaps(diff, 0, 300, ranges), []);
// exact document lens: withLens strips contextScopes before calling lensContextGaps.
const clipped = lensContextGaps({ ...diff, contextScopes: undefined }, 0, 300, ranges);
assert.deepEqual(clipped.map(gap => [gap.modifiedStart, gap.modifiedCount]), [[1, 76], [91, 210]]);
});

test('viewed folds never hide an unread counterpart, and do not overlap structural folds', async () => {
const { viewedContextGaps } = await import('./reviewLens.js');
const range = (side: 'base' | 'head', fromLine: number, toLine: number) => ({ side, file: 'a.ts', fromLine, toLine });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export class ReviewApiSourceService extends Disposable implements IReviewApiSour
const current = spec.pins ? reviewSourceAnchor(view(), spec.pins) : reviewSourceComparison(view());
const lens: ReviewDiffLens = {
id: "document:" + JSON.stringify([spec.path, spec.ranges, spec.pins]), title: spec.path,
reviewId: current.reviewId, version: current.version,
reviewId: current.reviewId, version: current.version, exact: true,
ranges: spec.ranges.map(range => ({ file: spec.path, side: range.side ?? spec.side, fromLine: range.startLine, toLine: range.endLine })),
};
return { lens, source: makeSource(() => current) };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ export function withLens(instantiation: IInstantiationService, entries: readonly
}) ? { ...gap, collapsed: false } : gap)
};
const section = progress()?.sections?.find(section => section.id === entry.sectionId);
return lens && !lens.wholeFiles ? { ...diff, contextGaps: lensContextGaps(diff, original.getLineCount(), modified.getLineCount(), lensRanges(section ? { ...lens, ranges: section.sources } : lens, entry)) } : diff;
// An exact lens (a tour step, a code peek) is authored to show one chunk;
// diffr's structural scope expansion is right for the Diff tab, not here.
const scopedDiff = lens?.exact ? { ...diff, contextScopes: undefined } : diff;
return lens && !lens.wholeFiles ? { ...diff, contextGaps: lensContextGaps(scopedDiff, original.getLineCount(), modified.getLineCount(), lensRanges(section ? { ...lens, ranges: section.sources } : lens, entry)) } : diff;
},
};
},
Expand Down
2 changes: 2 additions & 0 deletions packages/review-protocol/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ export interface ReviewInlineEditorFactory {
export interface ReviewDiffLens {
/** Filter files while retaining ordinary diff context/folding within them. */
wholeFiles?: boolean;
/** Show only the pinned range, ignoring diffr's structural scope expansion. */
exact?: boolean;
id: string;
title: string;
reviewId: string;
Expand Down
53 changes: 52 additions & 1 deletion packages/review/app/src/CodePeek.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { selectSource, sourceAnchors } from "../../src/lens-selection";
import { CodePeek, CodePeekCard, CodePeekGroup } from "./CodePeek";
import {
CodePeek,
CodePeekCard,
CodePeekGroup,
CodePeekStack,
} from "./CodePeek";
import {
type ReviewSession,
ReviewSessionProvider,
Expand Down Expand Up @@ -216,6 +221,52 @@ describe("CodePeek native editor", () => {
});
});

it("shows same-file chunks in one editor and other files in their own", async () => {
const chunk = (file: string, fromLine: number, toLine: number) =>
selectSource({ side: "head", file, fromLine, toLine });

const container = document.createElement("div");
document.body.append(container);
root = createRoot(container);

await act(async () =>
renderWithSession(
<CodePeekStack
sources={[
chunk("src/a.ts", 1, 3),
chunk("src/b.ts", 5, 5),
chunk("src/a.ts", 20, 22),
]}
heightMode="content"
lenses={testLenses}
/>,
),
);

await vi.waitFor(() => {
expect(created).toMatchObject([
{
path: "src/a.ts",
title: "src/a.ts:1-3, 20-22",
ranges: [
{ startLine: 1, endLine: 3 },
{ startLine: 20, endLine: 22 },
],
},
{ path: "src/b.ts", title: "src/b.ts:5" },
]);
});

await act(async () => {
created[0]?.onDidOpen?.();
});

expect(posted.at(-1)).toMatchObject({
name: "reveal",
args: { path: "src/a.ts", startLine: 1, endLine: 3 },
});
});

it("gives range side peeks a source title and content height policy", async () => {
const input = {
side: "head",
Expand Down
108 changes: 86 additions & 22 deletions packages/review/app/src/CodePeek.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type DiffSelection,
selectionKey,
sourceAnchor,
sourcePinsKey,
} from "../../src/lens-selection";
import type { ReviewComponentProps } from "../../src/review-document-data";
import { type FileLineRange, codePeekSource } from "../../src/source";
Expand Down Expand Up @@ -116,28 +117,82 @@ export function ReviewCodePeek({ anchor }: ReviewComponentProps<"CodePeek">) {
return <CodePeekCard source={anchor.peek} />;
}

interface CodePeekCardOptions {
active?: boolean;
heightMode?: ReviewInlineEditorHeightMode;
onNativeFocus?: () => void;
lenses?: ReviewLensView;
/** Report resolution telemetry. Only the panel the user opened sets this,
* so an authored document with many inline peeks sends one event, not N. */
reportOutcome?: boolean;
}

/** A document peek is an interval of the same alignment used by diff lenses. */
export function CodePeekCard({
source,
...options
}: CodePeekCardOptions & { source: DiffSelection }) {
const sources = useMemo(() => [source], [source]);

return <CodePeekFileCard sources={sources} {...options} />;
}

/** Several chunks, one card per file: chunks in the same file share a card so
* its lens folds the code between them. */
export function CodePeekStack({
sources,
...options
}: CodePeekCardOptions & { sources: readonly DiffSelection[] }) {
const groups = useMemo(() => codePeekFileGroups(sources), [sources]);

return (
<div className="code-peek-stack">
{groups.map((group) => (
<CodePeekFileCard
key={group.map(selectionKey).join("\n")}
sources={group}
{...options}
/>
))}
</div>
);
}

/** Chunks group by file, diff side and pins, in the order each group first
* appears. */
export function codePeekFileGroups(
sources: readonly DiffSelection[],
): DiffSelection[][] {
const groups = new Map<string, DiffSelection[]>();

for (const source of sources) {
const key = JSON.stringify([
source.file,
sourceAnchor(source).side,
source.pins ? sourcePinsKey(source.pins) : null,
]);

groups.set(key, [...(groups.get(key) ?? []), source]);
}

return [...groups.values()];
}

/** Every source names the same file, side and pins; the first one anchors
* the card's jump-to-source. */
function CodePeekFileCard({
sources,
active = false,
heightMode = "capped",
onNativeFocus,
lenses: lensesOverride,
reportOutcome = false,
}: {
source: DiffSelection;
active?: boolean;
heightMode?: ReviewInlineEditorHeightMode;
onNativeFocus?: () => void;
lenses?: ReviewLensView;
/** Report resolution telemetry. Only the panel the user opened sets this,
* so an authored document with many inline peeks sends one event, not N. */
reportOutcome?: boolean;
}) {
}: CodePeekCardOptions & { sources: readonly DiffSelection[] }) {
const session = useReviewSession();
const contextLenses = useReviewLenses();
const lenses = lensesOverride ?? contextLenses;
const resolved = lenses?.resolve([source]) ?? [];
const resolved = lenses?.resolve(sources) ?? [];
const source = sources[0]!;
const anchor = sourceAnchor(source);

const ranges = resolved.map((range) => ({
Expand All @@ -146,12 +201,14 @@ export function CodePeekCard({
endLine: range.toLine,
}));

const key = selectionKey(source);
const key = sources.map(selectionKey).join("\n");

const outcome = peekResolutionOutcome({
resolvedCount: ranges.length,
complete: Boolean(lenses?.progress?.complete),
unavailable: Boolean(lenses?.progress?.unavailableSelections?.[key]),
unavailable: sources.some(
(item) => lenses?.progress?.unavailableSelections?.[selectionKey(item)],
),
error: Boolean(lenses?.error),
});

Expand Down Expand Up @@ -181,15 +238,7 @@ export function CodePeekCard({
<section className="code-peek" data-code-rendering="inline-editor">
<DocumentCodeView
path={source.file}
title={
source.start.side === source.end.side
? codePeekRangeTitle(
source.file,
source.start.line,
source.end.line,
)
: source.file
}
title={codePeekSelectionTitle(sources)}
side={anchor.side}
pins={source.pins}
ranges={ranges}
Expand All @@ -210,6 +259,21 @@ export function CodePeekCard({
);
}

/** One-sided chunks list their line ranges; a chunk that spans both sides
* leaves only the path. */
function codePeekSelectionTitle(sources: readonly DiffSelection[]): string {
const file = sources[0]!.file;

if (sources.some((source) => source.start.side !== source.end.side))
return file;

const lines = sources.map(({ start, end }) =>
start.line === end.line ? `${start.line}` : `${start.line}-${end.line}`,
);

return `${file}:${lines.join(", ")}`;
}

function FileSnippetCard({
source,
active = false,
Expand Down
48 changes: 48 additions & 0 deletions packages/review/app/src/code-peek-groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";

import type { DiffSelection } from "../../src/lens-selection";
import { codePeekFileGroups } from "./CodePeek";

const chunk = (
file: string,
line: number,
extra: Partial<DiffSelection> = {},
): DiffSelection => ({
file,
start: { side: "head", line },
end: { side: "head", line },
...extra,
});

describe("code peek file groups", () => {
it("puts chunks from one file in one card and other files in their own", () => {
const first = chunk("src/a.ts", 10);
const other = chunk("src/b.ts", 3);
const second = chunk("src/a.ts", 40);

expect(codePeekFileGroups([first, other, second])).toEqual([
[first, second],
[other],
]);
});

it("keeps chunks apart when their side or pins differ", () => {
const head = chunk("src/a.ts", 10);

const base = chunk("src/a.ts", 10, {
start: { side: "base", line: 10 },
end: { side: "base", line: 10 },
pins: { repositoryId: "repo", base: "b1", head: "h1" },
});

const pinned = chunk("src/a.ts", 20, {
pins: { repositoryId: "repo", head: "h2" },
});

expect(codePeekFileGroups([head, base, pinned])).toEqual([
[head],
[base],
[pinned],
]);
});
});
33 changes: 26 additions & 7 deletions packages/review/app/src/diagrams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ import { DiagramTourOverlay, useDiagramTourShell } from "./diagram-tour";
import { useMotionPhase } from "./draw-queue-provider";
import { useReviewSession } from "./host/review-session";
import { useReviewPanel } from "./review-panel";
import type { GuidedTour, PeekAnchor } from "./review-panel-model";
import type {
GuidedTour,
PeekAnchor,
ReviewPeekContent,
} from "./review-panel-model";
import { useTourPersist, useTourRestore } from "./review-view-state";
import { captureUiEvent } from "./ui-telemetry";

Expand Down Expand Up @@ -90,7 +94,10 @@ export interface SequenceMessage {
to: SequenceParticipant;
label: string;
style: Step["style"];
/** The first chunk, where a single anchor is needed. */
source?: Step["source"];
/** Every chunk the step shows, in authored order. */
sources?: NonNullable<Step["source"]>[];
code?: Step["code"];
explanation?: string;
}
Expand Down Expand Up @@ -120,7 +127,12 @@ export function sequenceView(block: SequenceDiagramProps): SequenceView {
style: step.style,
};

if (step.source) message.source = step.source;
const sources = step.sources ?? (step.source ? [step.source] : []);

if (sources.length) {
message.source = sources[0];
message.sources = sources;
}

if (step.code) message.code = step.code;

Expand Down Expand Up @@ -159,15 +171,22 @@ export function createSequenceTourEntry(sequence: SequenceView): GuidedTour {
anchor: panelAnchor(message),
label: message.label,
detail: `${message.from.label} -> ${message.to.label}`,
content: message.code
? { kind: "inline-code" as const, ...message.code }
: message.source
? { kind: "source" as const, source: message.source }
: { kind: "explanation" as const, text: message.explanation },
content: sequenceStopContent(message),
})),
};
}

function sequenceStopContent(message: SequenceMessage): ReviewPeekContent {
if (message.code) return { kind: "inline-code", ...message.code };

if (message.sources && message.sources.length > 1)
return { kind: "sources", sources: message.sources };

if (message.source) return { kind: "source", source: message.source };

return { kind: "explanation", text: message.explanation };
}

function participantsForMessages(
messages: SequenceMessage[],
): SequenceParticipant[] {
Expand Down
Loading