From fd5541632108fff35eff052fa7c9903dd6d4e02f Mon Sep 17 00:00:00 2001 From: ketan0 Date: Wed, 16 Sep 2026 16:27:21 -0700 Subject: [PATCH 1/5] Integrate structural diffs with current Review APIs and editor Port PR #245 onto main, retaining diffr v3 folding, alignment, streaming counts, and configuration. Stream exact pinned trees through the JSON review API, support absent sides, and use the structural provider in Monaco while preserving the ordinary unified renderer. Agent-Session: 01a0abcb-f97f-7431-a8d0-9e7a2bb0d192 Agent-Session: 01a0ac29-e501-7282-941e-9d999e389f97 Agent-Session: 01a0ac09-05a3-70e3-8f9c-5c52947ccb2f Agent-Session: 01a0ac8d-66c7-77c2-bea1-b1bbba7cd7d8 --- .../parts/ipc/electron-main/ipc.electron.ts | 5 +- .../components/diffEditorDecorations.ts | 11 + .../components/diffEditorEditors.ts | 2 +- .../diffEditorViewZones.ts | 96 +- .../inlineDiffDeletedCodeMargin.ts | 3 +- .../diffEditorViewZones/renderLines.ts | 5 +- .../widget/diffEditor/diffEditorViewModel.ts | 59 +- .../widget/diffEditor/diffEditorWidget.ts | 7 +- .../features/hideUnchangedRegionsFeature.ts | 89 +- .../browser/widget/diffEditor/style.css | 48 + .../editor/browser/widget/diffEditor/utils.ts | 39 +- .../multiDiffEditorViewModel.ts | 4 +- .../multiDiffEditorWidgetImpl.ts | 70 +- .../vs/editor/common/config/editorOptions.ts | 2 + .../common/diff/documentDiffProvider.ts | 43 + .../editor/common/diff/sourceLineAlignment.ts | 44 + .../src/vs/review/browser/media/review.css | 108 + .../browser/parts/canvas/reviewCanvasPart.ts | 2108 ++++++++++------- .../review/browser/reviewChangedFilesTree.ts | 25 +- .../vs/review/common/reviewConfiguration.ts | 7 +- .../common/reviewConfigurationDefaults.ts | 2 + .../common/reviewStructuralDiff.test.ts | 327 +++ .../vs/review/common/reviewStructuralDiff.ts | 399 ++++ .../review/services/reviewApiSourceService.ts | 723 ++++-- .../reviewDesktopConnectionService.ts | 29 + .../review/services/reviewDiffViewService.ts | 358 +-- .../vs/review/services/reviewFilesDiffView.ts | 795 +++++-- .../src/vs/review/services/reviewMultiDiff.ts | 31 +- .../review/services/reviewStructuralDiff.ts | 325 +++ packages/review-protocol/src/contracts.ts | 39 +- packages/review/app/src/api-canvas.tsx | 2 +- .../review/app/src/diffr-config-form.test.ts | 130 + packages/review/app/src/diffr-config-form.ts | 175 ++ packages/review/app/src/settings-page.tsx | 196 ++ packages/review/app/src/styles.css | 24 + packages/review/src/review-api/http.ts | 49 + packages/review/src/review-api/local-data.ts | 33 + packages/review/src/server/desktop-server.ts | 19 + .../review/src/server/diffr-config.test.ts | 92 + packages/review/src/server/diffr-config.ts | 91 + .../server/structural-diff-workspace.test.ts | 142 ++ .../review/src/server/structural-diff.test.ts | 154 ++ packages/review/src/server/structural-diff.ts | 191 ++ packages/review/src/ui-telemetry-events.ts | 1 + 44 files changed, 5465 insertions(+), 1637 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/editor/common/diff/sourceLineAlignment.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/services/reviewStructuralDiff.ts create mode 100644 packages/review/app/src/diffr-config-form.test.ts create mode 100644 packages/review/app/src/diffr-config-form.ts create mode 100644 packages/review/src/server/diffr-config.test.ts create mode 100644 packages/review/src/server/diffr-config.ts create mode 100644 packages/review/src/server/structural-diff-workspace.test.ts create mode 100644 packages/review/src/server/structural-diff.test.ts create mode 100644 packages/review/src/server/structural-diff.ts diff --git a/apps/review-desktop/code-oss/src/vs/base/parts/ipc/electron-main/ipc.electron.ts b/apps/review-desktop/code-oss/src/vs/base/parts/ipc/electron-main/ipc.electron.ts index 11037accc..1d83f9d93 100644 --- a/apps/review-desktop/code-oss/src/vs/base/parts/ipc/electron-main/ipc.electron.ts +++ b/apps/review-desktop/code-oss/src/vs/base/parts/ipc/electron-main/ipc.electron.ts @@ -52,7 +52,10 @@ export class Server extends IPCServer { Server.Clients.delete(id); } - onDidClientReconnect.dispose(); + // Let every disconnect listener run before disposing the emitter. + // Disposing during fire clears its delivery queue, leaving the old + // channel server subscribed after a renderer reload. + queueMicrotask(() => onDidClientReconnect.dispose()); }); const protocol = new ElectronProtocol(webContents, onMessage); diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorDecorations.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorDecorations.ts index 609dd70fc..5894e0189 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorDecorations.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorDecorations.ts @@ -41,6 +41,17 @@ export class DiffEditorDecorations extends Disposable { const originalDecorations: IModelDeltaDecoration[] = []; const modifiedDecorations: IModelDeltaDecoration[] = []; + if (diff.changeHighlights) { + for (const line of diff.changeHighlights.originalLines ?? []) { + originalDecorations.push({ range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }, options: diffLineDeleteDecorationBackground }); + } + for (const line of diff.changeHighlights.modifiedLines ?? []) { + modifiedDecorations.push({ range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 }, options: diffLineAddDecorationBackground }); + } + for (const range of diff.changeHighlights.original) originalDecorations.push({ range, options: diffDeleteDecoration }); + for (const range of diff.changeHighlights.modified) modifiedDecorations.push({ range, options: diffAddDecoration }); + return { originalDecorations, modifiedDecorations }; + } if (!movedTextToCompare) { for (const m of diff.mappings) { if (!m.lineRangeMapping.original.isEmpty) { diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts index f0790e419..b14d063aa 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts @@ -190,7 +190,7 @@ export class DiffEditorEditors extends Disposable { // Clone scrollbar options before changing them clonedOptions.scrollbar = { ...(clonedOptions.scrollbar || {}) }; - clonedOptions.folding = false; + clonedOptions.folding = this._options.editorOptions.get().experimentalDiffFolding === true; clonedOptions.codeLens = this._options.diffCodeLens.get(); clonedOptions.fixedOverflowWidgets = true; diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts index 56d3850c9..3e39b57bd 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { projectSourceAlignment } from "../../../../../common/diff/sourceLineAlignment.js"; import { $, addDisposableListener } from '../../../../../../base/browser/dom.js'; import { ArrayQueue } from '../../../../../../base/common/arrays.js'; import { RunOnceScheduler } from '../../../../../../base/common/async.js'; @@ -19,7 +20,7 @@ import { DiffEditorViewModel, DiffMapping } from '../../diffEditorViewModel.js'; import { DiffEditorWidget } from '../../diffEditorWidget.js'; import { InlineDiffDeletedCodeMargin } from './inlineDiffDeletedCodeMargin.js'; import { LineSource, RenderOptions, renderLines } from './renderLines.js'; -import { IObservableViewZone, animatedObservable, joinCombine } from '../../utils.js'; +import { IObservableViewZone, animatedObservable, bandZoneHeightPx, joinCombine } from '../../utils.js'; import { EditorOption } from '../../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../../common/core/ranges/lineRange.js'; import { Position } from '../../../../../common/core/position.js'; @@ -81,6 +82,8 @@ export class DiffEditorViewZones extends Disposable { this._register(this._editors.original.onDidChangeViewZones((_args) => { if (!this._canIgnoreViewZoneUpdateEvent()) { updateImmediately.schedule(); } })); this._register(this._editors.modified.onDidChangeViewZones((_args) => { if (!this._canIgnoreViewZoneUpdateEvent()) { updateImmediately.schedule(); } })); + this._register(this._editors.original.onDidChangeHiddenAreas(() => updateImmediately.schedule())); + this._register(this._editors.modified.onDidChangeHiddenAreas(() => updateImmediately.schedule())); this._register(this._editors.original.onDidChangeConfiguration((args) => { if (args.hasChanged(EditorOption.wrappingInfo) || args.hasChanged(EditorOption.lineHeight)) { updateImmediately.schedule(); } })); @@ -100,6 +103,24 @@ export class DiffEditorViewZones extends Disposable { state.read(reader); const renderSideBySide = this._options.renderSideBySide.read(reader); const innerHunkAlignment = renderSideBySide; + if (renderSideBySide && diff.sourceLineAlignment) { + const regions = diffModel.unchangedRegions.read(reader); + const compactMode = this._options.compactMode.read(reader); + const bandLineHeight = this._editors.modified.getOption(EditorOption.lineHeight); + // A one-sided band is a zone on its own side only. Its height counts as the height of its first hidden line, + // so the row that starts the fold gets a filler of exactly that height on the other side. + const originalBands = new Map(), modifiedBands = new Map(); + regions.forEach((region, index) => { + if (region.kind === 'unchanged') { return; } + const height = bandZoneHeightPx(regions, index, compactMode, bandLineHeight, reader); + if (height === undefined) { return; } + const [bands, first] = region.kind === 'removed' + ? [originalBands, region.getHiddenOriginalRange(reader).startLineNumber] + : [modifiedBands, region.getHiddenModifiedRange(reader).startLineNumber]; + bands.set(first, (bands.get(first) ?? 0) + height); + }); + return computeSourceAlignment(this._editors.original, this._editors.modified, diff.sourceLineAlignment, this._origViewZonesToIgnore, this._modViewZonesToIgnore, originalBands, modifiedBands); + } return computeRangeAlignment( this._editors.original, this._editors.modified, @@ -184,7 +205,7 @@ export class DiffEditorViewZones extends Disposable { if (i > originalModel.getLineCount()) { return { orig: origViewZones, mod: modViewZones }; } - deletedCodeLineBreaksComputer?.addRequest(i, null); + if (this._editors.original._getViewModel()!.coordinatesConverter.getModelLineViewLineCount(i) > 0) deletedCodeLineBreaksComputer?.addRequest(i, null); } } } @@ -201,13 +222,15 @@ export class DiffEditorViewZones extends Disposable { const mightContainRTL = this._editors.original.getModel()?.mightContainRTL() ?? false; const renderOptions = RenderOptions.fromEditor(this._editors.modified); + const changeHighlights = this._diffModel.read(reader)?.diff.read(reader)?.changeHighlights; for (const a of alignmentsVal) { if (a.diff && !renderSideBySide && (!this._options.useTrueInlineDiffRendering.read(reader) || !allowsTrueInlineDiffRendering(a.diff))) { - if (!a.originalRange.isEmpty) { + if (!a.originalRange.isEmpty && a.originalRange.mapToLineArray(l => this._editors.original._getViewModel()!.coordinatesConverter.getModelLineViewLineCount(l)).some(height => height > 0)) { originalModelTokenizationCompleted.read(reader); // Update view-zones once tokenization completes const deletedCodeDomNode = document.createElement('div'); - deletedCodeDomNode.classList.add('view-lines', 'line-delete', 'line-delete-selectable', 'monaco-mouse-cursor-text'); + deletedCodeDomNode.classList.add('view-lines', 'line-delete-selectable', 'monaco-mouse-cursor-text'); + if (!changeHighlights) deletedCodeDomNode.classList.add('line-delete'); const originalModel = this._editors.original.getModel()!; // `a.originalRange` can be out of bound when the diff has not been updated yet. // In this case, we do an early return. @@ -215,27 +238,50 @@ export class DiffEditorViewZones extends Disposable { if (a.originalRange.endLineNumberExclusive - 1 > originalModel.getLineCount()) { return { orig: origViewZones, mod: modViewZones }; } + const visibleOriginalLines = a.originalRange.mapToLineArray(l => l).filter(l => this._editors.original._getViewModel()!.coordinatesConverter.getModelLineViewLineCount(l) > 0); const source = new LineSource( - a.originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)), - a.originalRange.mapToLineArray(_ => lineBreakData[lineBreakDataIdx++]), + visibleOriginalLines.map(l => originalModel.tokenization.getLineTokens(l)), + visibleOriginalLines.map(_ => lineBreakData[lineBreakDataIdx++]), mightContainNonBasicASCII, mightContainRTL, ); const decorations: InlineDecoration[] = []; - for (const i of a.diff.innerChanges || []) { - decorations.push(new InlineDecoration( - i.originalRange.delta(-(a.diff.original.startLineNumber - 1)), - diffDeleteDecoration.className!, - InlineDecorationType.Regular - )); + if (changeHighlights) { + // Source lines can be hidden by folds. Map paint into the compact + // deleted-code buffer rather than treating it as contiguous source. + const visibleRows = new Map(visibleOriginalLines.map((line, i) => [line, i + 1])); + for (const highlight of changeHighlights.original) { + for (let line = highlight.startLineNumber; line <= highlight.endLineNumber; line++) { + const row = visibleRows.get(line); + if (row === undefined) continue; + const start = line === highlight.startLineNumber ? highlight.startColumn : 1; + const end = line === highlight.endLineNumber ? highlight.endColumn : originalModel.getLineMaxColumn(line); + if (end > start) decorations.push(new InlineDecoration(new Range(row, start, row, end), diffDeleteDecoration.className!, InlineDecorationType.Regular)); + } + } + } else { + for (const i of a.diff.innerChanges || []) { + decorations.push(new InlineDecoration(i.originalRange.delta(-(a.diff.original.startLineNumber - 1)), diffDeleteDecoration.className!, InlineDecorationType.Regular)); + } + } + const result = renderLines(source, renderOptions, decorations, deletedCodeDomNode, false, changeHighlights !== undefined); + if (changeHighlights) { + const novelLines = new Set(changeHighlights.originalLines); + const viewLines = deletedCodeDomNode.querySelectorAll('.view-line'); + let viewRow = 0; + for (let i = 0; i < visibleOriginalLines.length; i++) { + for (let wrappedRow = 0; wrappedRow < result.viewLineCounts[i]; wrappedRow++, viewRow++) { + if (novelLines.has(visibleOriginalLines[i])) viewLines[viewRow].classList.add('line-delete'); + + } + } } - const result = renderLines(source, renderOptions, decorations, deletedCodeDomNode); const marginDomNode = document.createElement('div'); - marginDomNode.className = 'inline-deleted-margin-view-zone'; + marginDomNode.className = changeHighlights ? 'inline-original-margin-view-zone' : 'inline-deleted-margin-view-zone'; applyFontInfo(marginDomNode, renderOptions.fontInfo); - if (this._options.renderIndicators.read(reader)) { + if (!changeHighlights && this._options.renderIndicators.read(reader)) { for (let i = 0; i < result.heightInLines; i++) { const marginElement = document.createElement('div'); marginElement.className = `delete-sign ${ThemeIcon.asClassName(diffRemoveIcon)}`; @@ -257,6 +303,7 @@ export class DiffEditorViewZones extends Disposable { this._editors.original.getModel()!, this._contextMenuService, this._clipboardService, + visibleOriginalLines, ) ); @@ -265,7 +312,7 @@ export class DiffEditorViewZones extends Disposable { // Account for wrapped lines in the (collapsed) original editor (which doesn't wrap lines). if (count > 1) { origViewZones.push({ - afterLineNumber: a.originalRange.startLineNumber + i, + afterLineNumber: visibleOriginalLines[i], domNode: createFakeLinesDiv(), heightInPx: (count - 1) * modLineHeight, showInHiddenAreas: true, @@ -287,7 +334,7 @@ export class DiffEditorViewZones extends Disposable { } const marginDomNode = document.createElement('div'); - marginDomNode.className = 'gutter-delete'; + marginDomNode.className = changeHighlights ? '' : 'gutter-delete'; origViewZones.push({ afterLineNumber: a.originalRange.endLineNumberExclusive - 1, @@ -657,3 +704,18 @@ export function allowsTrueInlineDiffRendering(mapping: DetailedLineRangeMapping) export function rangeIsSingleLine(range: Range): boolean { return range.startLineNumber === range.endLineNumber; } + +/** Project existing correspondence through folding and wrapping, without rematching. */ +/** `originalBands` and `modifiedBands` are band heights by the band's first hidden line, one-based, on the side that shows it. */ +function computeSourceAlignment(original: CodeEditorWidget, modified: CodeEditorWidget, rows: readonly (readonly [number | null, number | null])[], originalZonesToIgnore: ReadonlySet, modifiedZonesToIgnore: ReadonlySet, originalBands: ReadonlyMap, modifiedBands: ReadonlyMap): ILineRangeAlignment[] { + const leftView = original._getViewModel()!.coordinatesConverter; + const rightView = modified._getViewModel()!.coordinatesConverter; + const leftHeight = original.getOption(EditorOption.lineHeight); + const rightHeight = modified.getOption(EditorOption.lineHeight); + const leftExtra = new Map(getAdditionalLineHeights(original, originalZonesToIgnore).map(info => [info.lineNumber, info.heightInPx])); + const rightExtra = new Map(getAdditionalLineHeights(modified, modifiedZonesToIgnore).map(info => [info.lineNumber, info.heightInPx])); + return projectSourceAlignment(rows, + l => (leftView.getModelLineViewLineCount(l + 1) === 0 ? 0 : leftHeight + (leftExtra.get(l + 1) ?? 0)) + (originalBands.get(l + 1) ?? 0), + r => (rightView.getModelLineViewLineCount(r + 1) === 0 ? 0 : rightHeight + (rightExtra.get(r + 1) ?? 0)) + (modifiedBands.get(r + 1) ?? 0), + ).filter(s => s.leftHeight !== s.rightHeight).map(s => ({ originalRange: new LineRange(s.leftStart + 1, s.leftEnd + 1), modifiedRange: new LineRange(s.rightStart + 1, s.rightEnd + 1), originalHeightInPx: s.leftHeight, modifiedHeightInPx: s.rightHeight, diff: undefined })); +} diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts index 225411784..436dbf6a4 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts @@ -48,6 +48,7 @@ export class InlineDiffDeletedCodeMargin extends Disposable { private readonly _originalTextModel: ITextModel, private readonly _contextMenuService: IContextMenuService, private readonly _clipboardService: IClipboardService, + private readonly _originalLineNumbers?: readonly number[], ) { super(); @@ -171,7 +172,7 @@ export class InlineDiffDeletedCodeMargin extends Disposable { for (let i = 0; i < this._renderLinesResult.viewLineCounts.length; i++) { acc += this._renderLinesResult.viewLineCounts[i]; if (lineNumberOffset < acc) { - return i; + return this._originalLineNumbers ? this._originalLineNumbers[i] - this._diff.original.startLineNumber : i; } } } diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts index fc04faf71..a16814f39 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts @@ -20,10 +20,11 @@ import { getColumnOfNodeOffset } from '../../../../viewParts/viewLines/viewLine. const ttPolicy = createTrustedTypesPolicy('diffEditorWidget', { createHTML: value => value }); -export function renderLines(source: LineSource, options: RenderOptions, decorations: InlineDecoration[], domNode: HTMLElement, noExtra = false): RenderLinesResult { +export function renderLines(source: LineSource, options: RenderOptions, decorations: InlineDecoration[], domNode: HTMLElement, noExtra = false, explicitChangeHighlights = false): RenderLinesResult { applyFontInfo(domNode, options.fontInfo); - const hasCharChanges = (decorations.length > 0); + // An explicit empty highlight set means unchanged, not a whole-line deletion. + const hasCharChanges = explicitChangeHighlights || (decorations.length > 0); const sb = new StringBuilder(10000); let maxCharsPerLine = 0; diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts index 9d9dfc2e7..8b6fa8aac 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts @@ -94,7 +94,7 @@ export class DiffEditorViewModel extends Disposable implements IDiffEditorViewMo /** @description collapse touching unchanged ranges */ const lastUnchangedRegions = this._unchangedRegions.read(reader); - if (!lastUnchangedRegions || lastUnchangedRegions.regions.some(r => r.isDragged.read(reader))) { + if (!lastUnchangedRegions || lastUnchangedRegions.regions.some(r => r instanceof SuppliedContextGap) || lastUnchangedRegions.regions.some(r => r.isDragged.read(reader))) { return; } @@ -151,6 +151,31 @@ export class DiffEditorViewModel extends Disposable implements IDiffEditorViewMo })); const updateUnchangedRegions = (result: IDocumentDiff, tx: ITransaction, reader?: IReader) => { + if (result.contextGaps) { + const previous = this._unchangedRegions.get(); + const regions = result.contextGaps.map(gap => { + const region = new SuppliedContextGap(gap); + const old = previous?.regions.find(r => r.originalLineNumber === gap.originalStart && r.modifiedLineNumber === gap.modifiedStart && r.lineCount === region.lineCount && r.label === gap.label); + // The supplier's open/closed state wins; a matching old region keeps a partial reveal + // only while it agrees with that state. + const open = gap.collapsed === false; + const oldOpen = old ? old.visibleLineCountTop.get() + old.visibleLineCountBottom.get() >= old.lineCount : false; + if (old && oldOpen === open) { + region.setState(old.visibleLineCountTop.get(), old.visibleLineCountBottom.get(), tx); + } else if (open) { + region.showAll(tx); + } else if (old && gap.collapsed === true) { + region.collapseAll(tx); + } else if (old) { + region.setState(old.visibleLineCountTop.get(), old.visibleLineCountBottom.get(), tx); + } + return region; + }); + model.original.deltaDecorations(previous?.originalDecorationIds ?? [], []); + model.modified.deltaDecorations(previous?.modifiedDecorationIds ?? [], []); + this._unchangedRegions.set({ regions, originalDecorationIds: [], modifiedDecorationIds: [] }, tx); + return; + } const newUnchangedRegions = UnchangedRegion.fromDiffs( result.changes, model.original.getLineCount(), @@ -380,6 +405,9 @@ export class DiffEditorViewModel extends Disposable implements IDiffEditorViewMo function normalizeDocumentDiff(diff: IDocumentDiff, original: ITextModel, modified: ITextModel): IDocumentDiff { return { + sourceLineAlignment: diff.sourceLineAlignment, + contextGaps: diff.contextGaps, + changeHighlights: diff.changeHighlights, changes: diff.changes.map(c => new DetailedLineRangeMapping( c.original, c.modified, @@ -419,6 +447,8 @@ export class DiffState { result.moves || [], result.identical, result.quitEarly, + result.sourceLineAlignment, + result.changeHighlights, ); } @@ -427,6 +457,8 @@ export class DiffState { public readonly movedTexts: readonly MovedText[], public readonly identical: boolean, public readonly quitEarly: boolean, + public readonly sourceLineAlignment?: IDocumentDiff["sourceLineAlignment"], + public readonly changeHighlights?: IDocumentDiff["changeHighlights"], ) { } } @@ -514,6 +546,12 @@ export class UnchangedRegion { public readonly isDragged = observableValue(this, undefined); + /** A provider-supplied name for the region; undefined means "N hidden lines". */ + public get label(): string | undefined { return undefined; } + public get breadcrumbs(): boolean { return true; } + /** What the hidden lines are; supplied gaps say, computed regions are unchanged context. */ + public get kind(): 'unchanged' | 'inserted' | 'removed' { return 'unchanged'; } + constructor( public readonly originalLineNumber: number, public readonly modifiedLineNumber: number, @@ -642,6 +680,25 @@ export class UnchangedRegion { } } +/** Native expansion controls over the exact omitted ranges supplied by the provider. */ +class SuppliedContextGap extends UnchangedRegion { + constructor(private readonly gap: NonNullable[number]) { + super(gap.originalStart, gap.modifiedStart, Math.max(gap.originalCount, gap.modifiedCount), 0, 0); + } + override get label(): string | undefined { return this.gap.label; } + override get kind(): 'unchanged' | 'inserted' | 'removed' { return this.gap.kind ?? 'unchanged'; } + override get breadcrumbs(): boolean { return this.gap.breadcrumbs ?? true; } + override get originalUnchangedRange(): LineRange { return LineRange.ofLength(this.gap.originalStart, this.gap.originalCount); } + override get modifiedUnchangedRange(): LineRange { return LineRange.ofLength(this.gap.modifiedStart, this.gap.modifiedCount); } + private hidden(start: number, count: number, reader: IReader | undefined): LineRange { + const top = Math.min(count, this.visibleLineCountTop.read(reader)); + const bottom = Math.min(count - top, this.visibleLineCountBottom.read(reader)); + return LineRange.ofLength(start + top, count - top - bottom); + } + override getHiddenOriginalRange(reader: IReader | undefined): LineRange { return this.hidden(this.gap.originalStart, this.gap.originalCount, reader); } + override getHiddenModifiedRange(reader: IReader | undefined): LineRange { return this.hidden(this.gap.modifiedStart, this.gap.modifiedCount, reader); } +} + export const enum RevealPreference { FromCloserSide, FromTop, diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index af46ccb96..5a8ee4ccf 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -40,7 +40,7 @@ import { DiffEditorSash, SashLayout } from './components/diffEditorSash.js'; import { DiffEditorViewZones } from './components/diffEditorViewZones/diffEditorViewZones.js'; import { DelegatingEditor } from './delegatingEditorImpl.js'; import { DiffEditorOptions } from './diffEditorOptions.js'; -import { DiffEditorViewModel, DiffMapping, DiffState } from './diffEditorViewModel.js'; +import { DiffEditorViewModel, DiffMapping, DiffState, UnchangedRegion } from './diffEditorViewModel.js'; import { DiffEditorGutter } from './features/gutterFeature.js'; import { HideUnchangedRegionsFeature } from './features/hideUnchangedRegionsFeature.js'; import { MovedBlocksLinesFeature } from './features/movedBlocksLinesFeature.js'; @@ -87,6 +87,11 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { public get renderSideBySideObservable() { return this._options.renderSideBySide; } public get collapseUnchangedRegions() { return this._options.hideUnchangedRegions.get(); } + /** The hidden regions of the current model, for observers that supply their own. */ + public get unchangedRegions(): IObservable { + return this._diffModel.map((m, reader) => m?.unchangedRegions.read(reader) ?? []); + } + /** * `true` when every hidden-unchanged region of the current diff is fully * revealed (or there are none). Read by `DiffEditorItemTemplate` to drive the diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts index a2ee0b709..34f3b4f9f 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts @@ -13,6 +13,8 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { isDefined } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { diffUnchangedRegionForeground } from '../../../../../platform/theme/common/colors/editorColors.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { LineRange } from '../../../../common/core/ranges/lineRange.js'; import { Position } from '../../../../common/core/position.js'; @@ -25,7 +27,7 @@ import { observableCodeEditor } from '../../../observableCodeEditor.js'; import { DiffEditorEditors } from '../components/diffEditorEditors.js'; import { DiffEditorOptions } from '../diffEditorOptions.js'; import { DiffEditorViewModel, RevealPreference, UnchangedRegion } from '../diffEditorViewModel.js'; -import { IObservableViewZone, PlaceholderViewZone, ViewZoneOverlayWidget, applyObservableDecorations, applyStyle } from '../utils.js'; +import { IObservableViewZone, PlaceholderViewZone, ViewZoneOverlayWidget, applyObservableDecorations, applyStyle, bandDetailText, bandZoneHeightPx } from '../utils.js'; /** * Make sure to add the view zones to the editor! @@ -63,6 +65,7 @@ export class HideUnchangedRegionsFeature extends Disposable { private readonly _diffModel: IObservable, private readonly _options: DiffEditorOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IThemeService private readonly _themeService: IThemeService, ) { super(); @@ -110,18 +113,18 @@ export class HideUnchangedRegionsFeature extends Disposable { const curUnchangedRegions = unchangedRegions.read(reader); for (let i = 0; i < curUnchangedRegions.length; i++) { const r = curUnchangedRegions[i]; - if (r.shouldHideControls(reader)) { - continue; - } - - if (compactMode && (i === 0 || i === curUnchangedRegions.length - 1)) { + const height = bandZoneHeightPx(curUnchangedRegions, i, compactMode, this._editors.modified.getOption(EditorOption.lineHeight), reader); + if (height === undefined) { continue; } + // A region on one side only is a band on that side alone; the diff's alignment leaves room for it on the other. + const onOriginal = r.kind !== 'inserted'; + const onModified = r.kind !== 'removed'; if (compactMode) { - { + if (onOriginal) { const d = derived(this, reader => /** @description hiddenOriginalRangeStart */ r.getHiddenOriginalRange(reader).startLineNumber - 1); - const origVz = new PlaceholderViewZone(d, 12); + const origVz = new PlaceholderViewZone(d, height); origViewZones.push(origVz); reader.store.add(new CompactCollapsedCodeOverlayWidget( this._editors.original, @@ -130,9 +133,9 @@ export class HideUnchangedRegionsFeature extends Disposable { !sideBySide, )); } - { + if (onModified) { const d = derived(this, reader => /** @description hiddenModifiedRangeStart */ r.getHiddenModifiedRange(reader).startLineNumber - 1); - const modViewZone = new PlaceholderViewZone(d, 12); + const modViewZone = new PlaceholderViewZone(d, height); modViewZones.push(modViewZone); reader.store.add(new CompactCollapsedCodeOverlayWidget( this._editors.modified, @@ -141,9 +144,9 @@ export class HideUnchangedRegionsFeature extends Disposable { )); } } else { - { + if (onOriginal) { const d = derived(this, reader => /** @description hiddenOriginalRangeStart */ r.getHiddenOriginalRange(reader).startLineNumber - 1); - const origVz = new PlaceholderViewZone(d, 24); + const origVz = new PlaceholderViewZone(d, height); origViewZones.push(origVz); reader.store.add(new CollapsedCodeOverlayWidget( this._editors.original, @@ -154,11 +157,12 @@ export class HideUnchangedRegionsFeature extends Disposable { modifiedOutlineSource, l => this._diffModel.get()!.ensureModifiedLineIsVisible(l, RevealPreference.FromBottom, undefined), this._options, + this._themeService, )); } - { + if (onModified) { const d = derived(this, reader => /** @description hiddenModifiedRangeStart */ r.getHiddenModifiedRange(reader).startLineNumber - 1); - const modViewZone = new PlaceholderViewZone(d, 24); + const modViewZone = new PlaceholderViewZone(d, height); modViewZones.push(modViewZone); reader.store.add(new CollapsedCodeOverlayWidget( this._editors.modified, @@ -169,6 +173,7 @@ export class HideUnchangedRegionsFeature extends Disposable { modifiedOutlineSource, l => this._diffModel.get()!.ensureModifiedLineIsVisible(l, RevealPreference.FromBottom, undefined), this._options, + this._themeService, )); } } @@ -193,8 +198,10 @@ export class HideUnchangedRegionsFeature extends Disposable { this._register(applyObservableDecorations(this._editors.original, derived(this, reader => { /** @description decorations */ - const curUnchangedRegions = unchangedRegions.read(reader); - const result = curUnchangedRegions.map(r => ({ + // An inserted region belongs to the modified side: no decoration and no control here. + const curUnchangedRegions = unchangedRegions.read(reader).filter(r => r.kind !== 'inserted'); + // The unchanged-code background belongs to unchanged regions; an inserted or removed one keeps its change tint. + const result = curUnchangedRegions.filter(r => r.kind === 'unchanged').map(r => ({ range: r.originalUnchangedRange.toInclusiveRange()!, options: unchangedLinesDecoration, })); @@ -211,8 +218,10 @@ export class HideUnchangedRegionsFeature extends Disposable { this._register(applyObservableDecorations(this._editors.modified, derived(this, reader => { /** @description decorations */ - const curUnchangedRegions = unchangedRegions.read(reader); - const result = curUnchangedRegions.map(r => ({ + // A removed region belongs to the original side: no decoration and no control here. + const curUnchangedRegions = unchangedRegions.read(reader).filter(r => r.kind !== 'removed'); + // The unchanged-code background belongs to unchanged regions; an inserted or removed one keeps its change tint. + const result = curUnchangedRegions.filter(r => r.kind === 'unchanged').map(r => ({ range: r.modifiedUnchangedRange.toInclusiveRange()!, options: unchangedLinesDecoration, })); @@ -300,16 +309,26 @@ class CompactCollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { } } +/** The reveal tooltip names what the band hides. */ +export function showTitle(kind: 'unchanged' | 'inserted' | 'removed'): string { + switch (kind) { + case 'inserted': return localize('showAddedLines', 'Show added lines'); + case 'removed': return localize('showRemovedLines', 'Show removed lines'); + default: return localize('showUnchangedRegion', 'Show Unchanged Region'); + } +} + class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { private readonly _nodes = h('div.diff-hidden-lines', [ h('div.top@top', { title: localize('diff.hiddenLines.top', 'Click or drag to show more above') }), h('div.center@content', { style: { display: 'flex' } }, [ h('div@first', { style: { display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: '0' } }, - [$('a', { title: localize('showUnchangedRegion', 'Show Unchanged Region'), role: 'button', onclick: () => { this._unchangedRegion.showAll(undefined); } }, + [$('a', { title: showTitle(this._unchangedRegion.kind), role: 'button', onclick: () => { this._unchangedRegion.showAll(undefined); } }, ...renderLabelWithIcons('$(unfold)'))] ), h('div@others', { style: { display: 'flex', justifyContent: 'center', alignItems: 'center' } }), ]), + h('div.detail@detail', []), h('div.bottom@bottom', { title: localize('diff.bottom', 'Click or drag to show more below'), role: 'button' }), ]); @@ -322,15 +341,18 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { private readonly _modifiedOutlineSource: IDiffEditorBreadcrumbsSource, private readonly _revealModifiedHiddenLine: (lineNumber: number) => void, private readonly _options: DiffEditorOptions, + private readonly _themeService: IThemeService, ) { const root = h('div.diff-hidden-lines-widget'); super(_editor, _viewZone, root.root); root.root.appendChild(this._nodes.root); + this._nodes.root.classList.add(`kind-${_unchangedRegion.kind}`); if (!this._hide) { this._register(applyStyle(this._nodes.first, { width: observableCodeEditor(this._editor).layoutInfoContentLeft })); } else { reset(this._nodes.first); + this._nodes.root.classList.add('empty-side'); } this._register(autorun(reader => { @@ -446,9 +468,32 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { /** @description update labels */ const children: HTMLElement[] = []; + const label = _unchangedRegion.label; + const detailText = bandDetailText(label); + const lineCount = Math.max(_unchangedRegion.getHiddenModifiedRange(reader).length, _unchangedRegion.getHiddenOriginalRange(reader).length); + if (detailText && !this._hide) { + const theme = this._themeService.getColorTheme(); + const language = this._editor.getModel()?.getLanguageId() ?? 'plaintext'; + const commentStyle = theme.getTokenStyleMetadata('comment', [], language); + const commentColor = commentStyle?.foreground !== undefined ? theme.tokenColorMap[commentStyle.foreground] : undefined; + const color = commentColor ?? theme.getColor(diffUnchangedRegionForeground)?.toString() ?? ''; + const fontSize = Math.max(11, this._editor.getOption(EditorOption.fontSize) - 1); + // One indent level under the header line, in the editor's own space width. + const indent = observableCodeEditor(this._editor).layoutInfoContentLeft.read(reader) + this._editor.getOption(EditorOption.fontInfo).spaceWidth * 4; + const pre = $('pre.diff-hidden-lines-detail', undefined, detailText); + pre.style.color = color; + pre.style.borderLeftColor = color; + pre.style.fontSize = `${fontSize}px`; + pre.style.lineHeight = `${this._editor.getOption(EditorOption.lineHeight)}px`; + pre.style.marginLeft = `${indent}px`; + reset(this._nodes.detail, pre); + } else { + reset(this._nodes.detail); + } if (!this._hide) { - const lineCount = _unchangedRegion.getHiddenModifiedRange(reader).length; - const linesHiddenText = localize('hiddenLines', '{0} hidden lines', lineCount); + const [title] = label ? label.split('\n') : []; + // A band with detail keeps Monaco's own count; the detail is a comment attached to it. + const linesHiddenText = detailText || !label ? localize('hiddenLines', '{0} hidden lines', lineCount) : title; const span = $('span', { title: localize('diff.hiddenLines.expandAll', 'Double click to unfold') }, linesHiddenText); span.addEventListener('dblclick', e => { if (e.button !== 0) { return; } @@ -458,7 +503,7 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { children.push(span); const range = this._unchangedRegion.getHiddenModifiedRange(reader); - const items = this._modifiedOutlineSource.getBreadcrumbItems(range, reader); + const items = this._unchangedRegion.breadcrumbs ? this._modifiedOutlineSource.getBreadcrumbItems(range, reader) : []; if (items.length > 0) { children.push($('span', undefined, '\u00a0\u00a0|\u00a0\u00a0')); diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/style.css b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/style.css index 6e27efbe7..6da711e6d 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/style.css +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/style.css @@ -84,6 +84,53 @@ box-shadow: inset 0 -5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow), inset 0 5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow); } +.monaco-editor .diff-hidden-lines.empty-side .center, +.monaco-editor .diff-hidden-lines.empty-side .top, +.monaco-editor .diff-hidden-lines.empty-side .bottom { + visibility: hidden; +} + +.monaco-editor .diff-hidden-lines .detail:empty { + display: none; +} + +.monaco-editor .diff-hidden-lines .detail { + background: var(--vscode-diffEditor-unchangedRegionBackground); + color: var(--vscode-diffEditor-unchangedRegionForeground); + padding: 4px 0 8px 0; + overflow: hidden; +} + +/* A band over lines that exist on one side only takes that side's change tint, so it reads as added or removed code. */ +.monaco-editor .diff-hidden-lines.kind-inserted .center, +.monaco-editor .diff-hidden-lines.kind-inserted .detail { + background: var(--vscode-diffEditor-insertedLineBackground); +} + +.monaco-editor .diff-hidden-lines.kind-removed .center, +.monaco-editor .diff-hidden-lines.kind-removed .detail { + background: var(--vscode-diffEditor-removedLineBackground); +} + +/* Pseudocode reads as a comment: the theme's comment colour, italic, one step smaller than code, with a thin rule. */ +.monaco-editor .diff-hidden-lines .detail pre.diff-hidden-lines-detail { + margin: 0; + padding-left: 10px; + border-left: 2px solid; + border-left-color: inherit; + font-family: var(--monaco-monospace-font); + font-style: italic; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 16px; +} + +.monaco-editor .diff-hidden-lines .detail pre.diff-hidden-lines-detail { + border-left-color: color-mix(in srgb, currentColor 50%, transparent); +} + + .monaco-editor .diff-hidden-lines .center span.codicon { vertical-align: middle; } @@ -217,6 +264,7 @@ opacity: 1; } +.monaco-editor .inline-original-margin-view-zone, .monaco-editor .inline-deleted-margin-view-zone { text-align: right; } diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/utils.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/utils.ts index 6a2906678..a5390c110 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/utils.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/utils.ts @@ -7,7 +7,7 @@ import { IDimension } from '../../../../base/browser/dom.js'; import { findLast } from '../../../../base/common/arraysFind.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, IObservableWithChange, ISettableObservable, autorun, autorunHandleChanges, autorunOpts, autorunWithStore, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, IObservableWithChange, IReader, ISettableObservable, autorun, autorunHandleChanges, autorunOpts, autorunWithStore, observableValue, transaction } from '../../../../base/common/observable.js'; import { ElementSizeObserver } from '../../config/elementSizeObserver.js'; import { ICodeEditor, IOverlayWidget, IViewZone } from '../../editorBrowser.js'; import { Position } from '../../../common/core/position.js'; @@ -539,3 +539,40 @@ class ClonedRefCounted extends RefCounted { this._base._decreaseRefCount(this._debugOwner); } } + +/** + * The text shown under a band's title. A supplied label may begin with a + * ` pseudocode` marker meant for terminals; it is dropped here. + */ +export function bandDetailText(label: string | undefined): string { + if (!label) { return ''; } + const lines = label.split('\n'); + if (lines.length < 2) { return ''; } + const body = /^(\/\/|#|--|;|%)\s*pseudocode$/.test(lines[0].trim()) ? lines.slice(1) : lines; + return body.join('\n'); +} + +/** The band is 24px for its controls, plus one line of detail text per detail line. */ +export function bandHeightPx(label: string | undefined, lineHeight: number): number { + const detail = bandDetailText(label); + const lines = detail ? detail.split('\n').length : 0; + return 24 + (lines > 0 ? lines * lineHeight + 12 : 0); +} + +/** What sizing a hidden-region band needs to know about its region. */ +export interface IBandRegion { + readonly label: string | undefined; + shouldHideControls(reader: IReader | undefined): boolean; +} + +/** + * The height of the band the region at `index` shows, or undefined when it shows none: a fully revealed + * region has no band, and compact mode drops the first and last. The band zones and the side-by-side + * alignment both size a band here, so a one-sided band gets exactly its height of room on the other side. + */ +export function bandZoneHeightPx(regions: readonly IBandRegion[], index: number, compactMode: boolean, lineHeight: number, reader: IReader | undefined): number | undefined { + const region = regions[index]; + if (region.shouldHideControls(reader)) { return undefined; } + if (compactMode) { return index === 0 || index === regions.length - 1 ? undefined : 12; } + return bandHeightPx(region.label, lineHeight); +} diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index b2e4b669e..26740ac40 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -136,8 +136,8 @@ export class DocumentDiffItemViewModel extends Disposable { public get originalUri(): URI | undefined { return this.documentDiffItem.original?.uri; } public get modifiedUri(): URI | undefined { return this.documentDiffItem.modified?.uri; } - public get originalLabelUri(): URI | undefined { return this.documentDiffItem.labelUris?.original ?? this.originalUri; } - public get modifiedLabelUri(): URI | undefined { return this.documentDiffItem.labelUris?.modified ?? this.modifiedUri; } + public get originalLabelUri(): URI | undefined { return this.documentDiffItem.labelUris ? this.documentDiffItem.labelUris.original : this.originalUri; } + public get modifiedLabelUri(): URI | undefined { return this.documentDiffItem.labelUris ? this.documentDiffItem.labelUris.modified : this.modifiedUri; } public readonly isActive: IObservable = derived(this, reader => this._editorViewModel.activeDiffItem.read(reader) === this); diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index f3d890e96..e161e52b4 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -9,7 +9,7 @@ import { compareBy, numberComparator } from '../../../../base/common/arrays.js'; import { findFirstMax } from '../../../../base/common/arraysFind.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, IReader, ITransaction, autorun, autorunWithStore, derived, disposableObservableValue, globalTransaction, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, IReader, ITransaction, autorun, autorunWithStore, mapObservableArrayCached, derived, disposableObservableValue, globalTransaction, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; import { Scrollable, ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -131,30 +131,20 @@ export class MultiDiffEditorWidgetImpl extends Disposable { })); this.scrollTop = observableFromEvent(this, this._scrollableElement.onScroll, () => /** @description scrollTop */ this._scrollableElement.getScrollPosition().scrollTop); this.scrollLeft = observableFromEvent(this, this._scrollableElement.onScroll, () => /** @description scrollLeft */ this._scrollableElement.getScrollPosition().scrollLeft); - this._viewItemsInfo = derived<{ items: readonly VirtualizedViewItem[]; getItem: (viewModel: DocumentDiffItemViewModel) => VirtualizedViewItem }>(this, - (reader) => { - const vm = this._viewModel.read(reader); - if (!vm) { - return { items: [], getItem: _d => { throw new BugIndicatingError(); } }; - } - const viewModels = vm.items.read(reader); - const map = new Map(); - const items = viewModels.map(d => { - const item = reader.store.add(new VirtualizedViewItem(d, this._objectPool, this.scrollLeft, delta => { - this._scrollableElement.setScrollPosition({ scrollTop: this._scrollableElement.getScrollPosition().scrollTop + delta }); - })); - const data = this._lastDocStates?.[item.getKey()]; - if (data) { - transaction(tx => { - item.setViewState(data, tx); - }); - } - map.set(d, item); - return item; - }); - return { items, getItem: d => map.get(d)! }; - } - ); + const viewModels = derived(this, reader => this._viewModel.read(reader)?.items.read(reader) ?? []); + const virtualItems = mapObservableArrayCached(this, viewModels, (d, store) => { + const item = store.add(new VirtualizedViewItem(d, this._objectPool, this.scrollLeft, delta => { + this._scrollableElement.setScrollPosition({ scrollTop: this._scrollableElement.getScrollPosition().scrollTop + delta }); + })); + const data = this._lastDocStates?.[item.getKey()]; + if (data) transaction(tx => item.setViewState(data, tx)); + return item; + }); + this._viewItemsInfo = derived(this, reader => { + const items = virtualItems.read(reader); + const map = new Map(items.map(item => [item.viewModel, item])); + return { items, getItem: (d: DocumentDiffItemViewModel) => map.get(d)! }; + }); this._viewItems = this._viewItemsInfo.map(this, items => items.items); this._spaceBetweenPx = 0; this.contentHeight = this._viewItems.map(this, (items, reader) => items.reduce((r, i) => r + i.contentHeight.read(reader) + this._spaceBetweenPx, 0)); @@ -229,6 +219,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._scrollableElements.content.style.position = 'relative'; + let previousItems: readonly VirtualizedViewItem[] = []; + let previousHeights: number[] = []; + let previousModel: MultiDiffEditorViewModel | undefined; this._register(autorun((reader) => { /** @description Update scroll dimensions */ const height = this._sizeObserver.height.read(reader); @@ -240,6 +233,29 @@ export class MultiDiffEditorWidgetImpl extends Disposable { let scrollWidth = width; const viewItems = this._viewItems.read(reader); + const model = this._viewModel.read(reader); + let anchoredScrollTop: number | undefined; + // Streaming inserts files before the viewport, then their measured + // heights replace estimates. Anchor both updates to the same existing + // file and pixel offset; preserving only insertions still causes jumps. + if (model === previousModel) { + const oldTop = this._scrollableElement.getScrollPosition().scrollTop + this._scrollStart(); + let before = 0; + for (let i = 0; i < previousItems.length; i++) { + if (oldTop < before + previousHeights[i]) { + const index = viewItems.indexOf(previousItems[i]); + if (index >= 0) { + const newBefore = viewItems.slice(0, index).reduce((sum, item) => sum + item.contentHeight.get() + this._spaceBetweenPx, 0); + anchoredScrollTop = newBefore + oldTop - before - this._scrollStart(); + } + break; + } + before += previousHeights[i]; + } + } + previousItems = viewItems; + previousHeights = viewItems.map(item => item.contentHeight.read(reader) + this._spaceBetweenPx); + previousModel = model; const max = findFirstMax(viewItems, compareBy(i => i.maxScroll.read(reader).maxScroll, numberComparator)); if (max) { const maxScroll = max.maxScroll.read(reader); @@ -258,6 +274,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { scrollWidth, }); + if (anchoredScrollTop !== undefined && anchoredScrollTop !== this._scrollableElement.getScrollPosition().scrollTop) { + this._scrollableElement.setScrollPosition({ scrollTop: anchoredScrollTop }); + } + // A restored scroll offset applied before the model updated these // dimensions would be clamped against a stale (often 0) scrollHeight, so // apply it here once the dimensions are known. diff --git a/apps/review-desktop/code-oss/src/vs/editor/common/config/editorOptions.ts b/apps/review-desktop/code-oss/src/vs/editor/common/config/editorOptions.ts index 9a8e1efff..6a7973ad8 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/common/config/editorOptions.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/common/config/editorOptions.ts @@ -51,6 +51,8 @@ export const enum EditorAutoIndentStrategy { * Configuration options for the editor. */ export interface IEditorOptions { + /** @internal Enables folding in the structural diff experiment only. */ + experimentalDiffFolding?: boolean; /** * This editor is used inside a diff editor. */ diff --git a/apps/review-desktop/code-oss/src/vs/editor/common/diff/documentDiffProvider.ts b/apps/review-desktop/code-oss/src/vs/editor/common/diff/documentDiffProvider.ts index 3e9c82fd5..532dceb85 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/common/diff/documentDiffProvider.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/common/diff/documentDiffProvider.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IRange } from '../core/range.js'; import { Event } from '../../../base/common/event.js'; import { MovedText } from './linesDiffComputer.js'; import { DetailedLineRangeMapping } from './rangeMapping.js'; @@ -53,7 +54,49 @@ export interface IDocumentDiffProviderOptions { * Represents a diff between two text models. * @internal */ +/** + * A hidden region supplied by the diff provider, one-based and per side. A side + * with a zero count is absent there. `label` names the region (a gap, a folded + * body, a summary); a multi-line label is shown in full under its first line. + * @internal + */ +export interface IDocumentContextGap { + readonly originalStart: number; + readonly modifiedStart: number; + readonly originalCount: number; + readonly modifiedCount: number; + readonly label?: string; + /** + * What the hidden lines are: unchanged context, lines that exist only on + * the modified side, or only on the original side. Drives the band's tint. + */ + readonly kind?: 'unchanged' | 'inserted' | 'removed'; + /** + * Whether the band lists the symbols its hidden lines belong to. Default + * `true`; `false` keeps the bare count. + */ + readonly breadcrumbs?: boolean; + /** + * Whether the region starts hidden. `false` supplies a region that is + * fully shown, so the editor keeps its fold control on the region's + * first line. Default `true`. + */ + readonly collapsed?: boolean; +} + export interface IDocumentDiff { + /** Authoritative change paint, independent of replacement ranges used for layout. + * When present (even empty), replaces inferred line and character highlighting. */ + readonly changeHighlights?: { + readonly original: readonly IRange[]; + readonly modified: readonly IRange[]; + /** One-based source lines receiving a lighter whole-line background. */ + readonly originalLines?: readonly number[]; + readonly modifiedLines?: readonly number[]; + }; + /** Optional authoritative zero-based source row correspondence. Null denotes padding. */ + readonly contextGaps?: readonly IDocumentContextGap[]; + readonly sourceLineAlignment?: readonly (readonly [number | null, number | null])[]; /** * If true, both text models are identical (byte-wise). */ diff --git a/apps/review-desktop/code-oss/src/vs/editor/common/diff/sourceLineAlignment.ts b/apps/review-desktop/code-oss/src/vs/editor/common/diff/sourceLineAlignment.ts new file mode 100644 index 000000000..ada0b0a2c --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/editor/common/diff/sourceLineAlignment.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface SourceAlignmentSegment { + leftStart: number; + leftEnd: number; + rightStart: number; + rightEnd: number; + leftHeight: number; + rightHeight: number; +} + +/** + * Heights are zero for folded lines. Visible paired lines remain anchors, and a run of rows folded on + * both sides also bounds a segment: the filler a segment needs sits after its last line, so it must not + * reach past the fold, or it lands beside or below the band that stands in for the folded rows. + */ +export function projectSourceAlignment( + rows: readonly (readonly [number | null, number | null])[], + leftHeight: (line: number) => number, + rightHeight: (line: number) => number, +): SourceAlignmentSegment[] { + const result: SourceAlignmentSegment[] = []; + let left = 0, right = 0, startLeft = 0, startRight = 0, heightLeft = 0, heightRight = 0, previousVisible = true; + const flush = () => { + if (left === startLeft && right === startRight) return; + result.push({ leftStart: startLeft, leftEnd: left, rightStart: startRight, rightEnd: right, leftHeight: heightLeft, rightHeight: heightRight }); + startLeft = left; startRight = right; heightLeft = heightRight = 0; + }; + for (const [l, r] of rows) { + const lh = l === null ? 0 : leftHeight(l); + const rh = r === null ? 0 : rightHeight(r); + const visible = lh > 0 || rh > 0; + if ((l !== null && r !== null && lh > 0 && rh > 0) || visible !== previousVisible) flush(); + previousVisible = visible; + if (l !== null) left = l + 1; + if (r !== null) right = r + 1; + heightLeft += lh; heightRight += rh; + } + flush(); + return result; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css b/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css index 202d607bd..7b1b0aa54 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css +++ b/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css @@ -1091,3 +1091,111 @@ body background: var(--vscode-diffEditor-unchangedRegionBackground); font: inherit; } + +/* Give commenting and folding separate hit targets in the Files view. */ +.review-files-editor .monaco-editor .margin-view-overlays .cldr:is( + .codicon-folding-expanded, + .codicon-folding-collapsed, + .codicon-folding-manual-expanded, + .codicon-folding-manual-collapsed +) { + margin-left: 30px; + width: 20px !important; +} + +.review-files-editor-diffs { position: relative; } +.review-files-editor-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-sideBar-border, transparent); + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.review-files-editor-summary[hidden] { + display: none; +} + +.review-files-editor-summary-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.review-files-editor-summary-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.review-files-editor-summary-bar { + display: inline-flex; + gap: 1px; +} + +.review-files-editor-summary-block { + width: 8px; + height: 8px; + background: var(--vscode-editorWidget-border); +} + +.review-files-editor-summary-block.added { + background: var(--vscode-gitDecoration-addedResourceForeground); +} + +.review-files-editor-summary-block.removed { + background: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.review-multidiff-note { + padding: 0 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.review-multidiff-note[hidden] { + display: none; +} + +.review-structural-label-inline { + color: var(--vscode-editor-foldPlaceholderForeground); + font-style: italic; +} + +.review-structural-label-zone { + background-color: var(--vscode-editor-foldBackground); + color: var(--vscode-editor-foldPlaceholderForeground); +} + +.review-structural-label-zone pre { + margin: 0; + padding-left: 4ch; + font: inherit; + white-space: pre; +} + +.review-structural-stream-status:not([hidden]) { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); +} +.review-structural-stream-status.loading::before { + content: ""; + width: 14px; + height: 14px; + flex-shrink: 0; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: review-structural-loading 1s linear infinite; +} +@keyframes review-structural-loading { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .review-structural-stream-status.loading::before { animation: none; } +} diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index 607c56080..d92865263 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -3,68 +3,96 @@ * Licensed under the MIT License. See LICENSE in the repository root for license information. *--------------------------------------------------------------------------------------------*/ -import { resolveReviewSourceView, type ReviewSourceView, type ReviewSourceSelection } from "../../../common/reviewProtocol.js"; - import { $, getWindow, type Dimension } from "../../../../base/browser/dom.js"; import { createTrustedTypesPolicy } from "../../../../base/browser/trustedTypes.js"; import type { CancellationToken } from "../../../../base/common/cancellation.js"; import { Emitter } from "../../../../base/common/event.js"; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from "../../../../base/common/lifecycle.js"; +import { + Disposable, + DisposableStore, + MutableDisposable, + toDisposable, +} from "../../../../base/common/lifecycle.js"; import { FileAccess } from "../../../../base/common/network.js"; import type { ICursorPositionChangedEvent } from "../../../../editor/common/cursorEvents.js"; import { ICommandService } from "../../../../platform/commands/common/commands.js"; -import { ConfigurationTarget, IConfigurationService } from "../../../../platform/configuration/common/configuration.js"; -import { TextEditorSelectionSource, type IEditorOptions } from "../../../../platform/editor/common/editor.js"; -import { createDecorator, IInstantiationService } from "../../../../platform/instantiation/common/instantiation.js"; +import { + ConfigurationTarget, + IConfigurationService, +} from "../../../../platform/configuration/common/configuration.js"; +import { + TextEditorSelectionSource, + type IEditorOptions, +} from "../../../../platform/editor/common/editor.js"; +import { + createDecorator, + IInstantiationService, +} from "../../../../platform/instantiation/common/instantiation.js"; import { ILogService } from "../../../../platform/log/common/log.js"; import { FocusMode } from "../../../../platform/native/common/native.js"; import { IProductService } from "../../../../platform/product/common/productService.js"; -import { IEditorProgressService, LongRunningOperation } from "../../../../platform/progress/common/progress.js"; -import { IStorageService, StorageScope, StorageTarget } from "../../../../platform/storage/common/storage.js"; +import { + IEditorProgressService, + LongRunningOperation, +} from "../../../../platform/progress/common/progress.js"; +import { + IStorageService, + StorageScope, + StorageTarget, +} from "../../../../platform/storage/common/storage.js"; import { ITelemetryService } from "../../../../platform/telemetry/common/telemetry.js"; import { ColorScheme } from "../../../../platform/theme/common/theme.js"; import { IThemeService } from "../../../../platform/theme/common/themeService.js"; import { Part } from "../../../../workbench/browser/part.js"; import { EditorPane } from "../../../../workbench/browser/parts/editor/editorPane.js"; import type { - IEditorControl, - IEditorOpenContext, - IEditorPaneSelection, - IEditorPaneSelectionChangeEvent, + IEditorControl, + IEditorOpenContext, + IEditorPaneSelection, + IEditorPaneSelectionChangeEvent, } from "../../../../workbench/common/editor.js"; import { EditorPaneSelectionChangeReason } from "../../../../workbench/common/editor.js"; import type { IEditorGroup } from "../../../../workbench/services/editor/common/editorGroupsService.js"; import { IHostService } from "../../../../workbench/services/host/browser/host.js"; -import { IWorkbenchLayoutService, Parts } from "../../../../workbench/services/layout/browser/layoutService.js"; import { - REVIEW_KEYMAP_SETTING, - REVIEW_SOFTWARE_MAP_SETTING, - REVIEW_TELEMETRY_SETTING, + IWorkbenchLayoutService, + Parts, +} from "../../../../workbench/services/layout/browser/layoutService.js"; +import { + REVIEW_KEYMAP_SETTING, + REVIEW_SOFTWARE_MAP_SETTING, + REVIEW_STRUCTURAL_DIFF_SETTING, + REVIEW_TELEMETRY_SETTING, } from "../../../common/reviewConfigurationDefaults.js"; +import { + resolveReviewSourceView, + type ReviewSourceView, + type ReviewSourceSelection, +} from "../../../common/reviewProtocol.js"; import type { - ReviewCanvasBridge, - ReviewCanvasContent, - ReviewCanvasDiagnostic, - ReviewCanvasHandle, - ReviewCanvasHomeSetup, - ReviewCanvasInstallContent, - ReviewCanvasModule, - ReviewCanvasOnboarding, - ReviewCanvasSettingsContent, - ReviewCanvasTutorialBridge, - ReviewCliInstallStatus, - ReviewKeymapChoice, - ReviewRuntimeConfig, - ReviewSurfaceEvent, - ReviewTheme, - TutorialProgressV1, - TutorialStepId, + ReviewCanvasBridge, + ReviewCanvasContent, + ReviewCanvasDiagnostic, + ReviewCanvasHandle, + ReviewCanvasHomeSetup, + ReviewCanvasInstallContent, + ReviewCanvasModule, + ReviewCanvasOnboarding, + ReviewCanvasSettingsContent, + ReviewCanvasTutorialBridge, + ReviewCliInstallStatus, + ReviewKeymapChoice, + ReviewRuntimeConfig, + ReviewSurfaceEvent, + ReviewTheme, + TutorialProgressV1, + TutorialStepId, } from "../../../common/reviewProtocol.js"; import { - parseReviewVerbRequest, - REVIEW_CANVAS_RESUME_EVENT, - REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, - REVIEW_TUTORIAL_STEP_IDS + parseReviewVerbRequest, + REVIEW_CANVAS_RESUME_EVENT, + REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, + REVIEW_TUTORIAL_STEP_IDS, } from "../../../common/reviewProtocol.js"; import { IReviewVerbsService } from "../../../contrib/verbs/reviewVerbs.js"; import { IReviewApiCatalogService } from "../../../services/reviewApiCatalogService.js"; @@ -73,974 +101,1222 @@ import { IReviewCanvasEditorTabsService } from "../../../services/reviewCanvasEd import { IReviewDesktopConnectionService } from "../../../services/reviewDesktopConnectionService.js"; import { ReviewDiffViewService } from "../../../services/reviewDiffViewService.js"; import { - ReviewEmbeddedEditorSelection, - reviewEmbeddedSelectionFromOptions, + ReviewEmbeddedEditorSelection, + reviewEmbeddedSelectionFromOptions, } from "../../../services/reviewEmbeddedNavigation.js"; import { ReviewInlineEditorService } from "../../../services/reviewInlineEditorService.js"; import { IReviewTelemetryService } from "../../../services/reviewTelemetryService.js"; import "../../media/review.css"; -import { applyReviewThemeChoice, currentReviewThemeChoice } from "../../reviewThemeChoice.js"; +import { + applyReviewThemeChoice, + currentReviewThemeChoice, +} from "../../reviewThemeChoice.js"; import { IReviewExplorerPartsService } from "../explorer/reviewExplorerPart.js"; import { ReviewCanvasEditorInput } from "./reviewCanvasEditorInput.js"; interface ReviewCanvasAssetsModule extends ReviewCanvasModule { - readonly clearReviewViewState: (config: ReviewRuntimeConfig) => void; - readonly reviewWasmUrl: string; - readonly reviewStylesheetUrls: readonly string[]; + readonly clearReviewViewState: (config: ReviewRuntimeConfig) => void; + readonly reviewWasmUrl: string; + readonly reviewStylesheetUrls: readonly string[]; } interface ReviewCanvasGlobalThis { - __zod_globalConfig?: { - jitless?: boolean; - }; + __zod_globalConfig?: { + jitless?: boolean; + }; } interface ReviewCanvasLoadLifecycle { - ready(): void; - reportDiagnostic(diagnostic: ReviewCanvasDiagnostic): void; + ready(): void; + reportDiagnostic(diagnostic: ReviewCanvasDiagnostic): void; } -type ReviewCanvasState = "home" | "connecting" | "active" | "completed" | "error"; +type ReviewCanvasState = + | "home" + | "connecting" + | "active" + | "completed" + | "error"; const reviewCanvasPolicy = createTrustedTypesPolicy("reviewCanvas", { - createScriptURL: (value: string) => value, + createScriptURL: (value: string) => value, }); const requestReviewApi: typeof fetch = (url, init) => fetch(url, init); function isTutorialStepId(step: unknown): step is TutorialStepId { - return typeof step === "string" && REVIEW_TUTORIAL_STEP_IDS.includes(step as TutorialStepId); + return ( + typeof step === "string" && + REVIEW_TUTORIAL_STEP_IDS.includes(step as TutorialStepId) + ); } -function embeddedSelectionChangeReason(event: ICursorPositionChangedEvent): EditorPaneSelectionChangeReason { - switch (event.source) { - case TextEditorSelectionSource.PROGRAMMATIC: - return EditorPaneSelectionChangeReason.PROGRAMMATIC; - case TextEditorSelectionSource.NAVIGATION: - return EditorPaneSelectionChangeReason.NAVIGATION; - case TextEditorSelectionSource.JUMP: - return EditorPaneSelectionChangeReason.JUMP; - default: - return EditorPaneSelectionChangeReason.USER; - } +function embeddedSelectionChangeReason( + event: ICursorPositionChangedEvent, +): EditorPaneSelectionChangeReason { + switch (event.source) { + case TextEditorSelectionSource.PROGRAMMATIC: + return EditorPaneSelectionChangeReason.PROGRAMMATIC; + case TextEditorSelectionSource.NAVIGATION: + return EditorPaneSelectionChangeReason.NAVIGATION; + case TextEditorSelectionSource.JUMP: + return EditorPaneSelectionChangeReason.JUMP; + default: + return EditorPaneSelectionChangeReason.USER; + } } export class ReviewCanvasEditorPane extends EditorPane { - static readonly ID = ReviewCanvasEditorInput.EDITOR_ID; + static readonly ID = ReviewCanvasEditorInput.EDITOR_ID; - private readonly canvas = this._register(new MutableDisposable()); - private readonly surfaceEvents = this._register(new Emitter()); - private readonly _onDidChangeSelection = this._register(new Emitter()); - readonly onDidChangeSelection = this._onDidChangeSelection.event; - private readonly embeddedSelectionListeners = this._register(new MutableDisposable()); - private readonly themeEvents = this._register(new Emitter()); - private container: HTMLElement | null = null; - private canvasMount: HTMLElement | null = null; - private targetDocument: Document | null = null; - private apiContent: Extract | undefined; - private loadGeneration = 0; - private openingGeneration: number | undefined; - private readonly refreshProgress: LongRunningOperation; - private renderedInput: ReviewCanvasEditorInput | undefined; - private readyInput: ReviewCanvasEditorInput | undefined; - private assetsPromise: Promise | null = null; - private readonly modelSubscription = this._register(new MutableDisposable()); - private readonly inlineEditors: ReviewInlineEditorService; - private readonly diffViews: ReviewDiffViewService; + private readonly canvas = this._register( + new MutableDisposable(), + ); + private readonly surfaceEvents = this._register( + new Emitter(), + ); + private readonly _onDidChangeSelection = this._register( + new Emitter(), + ); + readonly onDidChangeSelection = this._onDidChangeSelection.event; + private readonly embeddedSelectionListeners = this._register( + new MutableDisposable(), + ); + private readonly themeEvents = this._register(new Emitter()); + private container: HTMLElement | null = null; + private canvasMount: HTMLElement | null = null; + private targetDocument: Document | null = null; + private apiContent: Extract | undefined; + private loadGeneration = 0; + private openingGeneration: number | undefined; + private readonly refreshProgress: LongRunningOperation; + private renderedInput: ReviewCanvasEditorInput | undefined; + private readyInput: ReviewCanvasEditorInput | undefined; + private assetsPromise: Promise | null = null; + private readonly modelSubscription = this._register(new MutableDisposable()); + private readonly inlineEditors: ReviewInlineEditorService; + private readonly diffViews: ReviewDiffViewService; - constructor( - group: IEditorGroup, - @ITelemetryService telemetryService: ITelemetryService, - @IThemeService private readonly reviewThemeService: IThemeService, - @IStorageService private readonly storageService: IStorageService, - @IProductService private readonly productService: IProductService, - @IReviewDesktopConnectionService - private readonly desktopConnection: IReviewDesktopConnectionService, - @IReviewApiSourceService private readonly apiSource: IReviewApiSourceService, - @IReviewApiCatalogService private readonly apiCatalog: IReviewApiCatalogService, - @IReviewVerbsService private readonly verbs: IReviewVerbsService, - @IReviewCanvasEditorTabsService - private readonly tabsService: IReviewCanvasEditorTabsService, - @IReviewExplorerPartsService - private readonly explorerParts: IReviewExplorerPartsService, - @IInstantiationService - reviewInstantiationService: IInstantiationService, - @IHostService private readonly hostService: IHostService, - @IWorkbenchLayoutService - private readonly layoutService: IWorkbenchLayoutService, - @ICommandService private readonly commandService: ICommandService, - @IConfigurationService - private readonly configurationService: IConfigurationService, - @IReviewTelemetryService - private readonly reviewTelemetryService: IReviewTelemetryService, - @ILogService private readonly logService: ILogService, - @IEditorProgressService editorProgressService: IEditorProgressService, - ) { - super(ReviewCanvasEditorPane.ID, group, telemetryService, reviewThemeService, storageService); - this.inlineEditors = this._register(reviewInstantiationService.createInstance(ReviewInlineEditorService)); - this.refreshProgress = this._register(new LongRunningOperation(editorProgressService)); - this.diffViews = this._register( - reviewInstantiationService.createInstance(ReviewDiffViewService, this.inlineEditors), - ); - this._register( - verbs.onDidEmitSurfaceEvent((event) => { - this.surfaceEvents.fire(event); - }), - ); - this._register( - verbs.onDidRequestCanvasFocus(() => { - this.canvas.value?.focus(); - void this.hostService.focus(this.targetDocument?.defaultView ?? window); - }), - ); - this._register( - this.inlineEditors.onDidChangeActiveEditor(() => { - this._onDidChangeControl.fire(); - this.bindEmbeddedSelectionControl(); - }), - ); - this._register( - reviewThemeService.onDidColorThemeChange(() => { - const theme = this.colorScheme(); - this.themeEvents.fire(theme); - this.surfaceEvents.fire({ event: "themeChanged", theme }); - }), - ); - this._register(desktopConnection.onDidFail((error) => void this.renderFailure(error))); - this._register( - configurationService.onDidChangeConfiguration((event) => { - if (!event.affectsConfiguration(REVIEW_SOFTWARE_MAP_SETTING)) return; - if (this.apiContent) { - this.apiContent = { ...this.apiContent, softwareMapEnabled: this.currentSoftwareMapEnabled() }; - this.canvas.value?.update(this.apiContent); - return; - } - }), - ); - } + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService private readonly reviewThemeService: IThemeService, + @IStorageService private readonly storageService: IStorageService, + @IProductService private readonly productService: IProductService, + @IReviewDesktopConnectionService + private readonly desktopConnection: IReviewDesktopConnectionService, + @IReviewApiSourceService + private readonly apiSource: IReviewApiSourceService, + @IReviewApiCatalogService + private readonly apiCatalog: IReviewApiCatalogService, + @IReviewVerbsService private readonly verbs: IReviewVerbsService, + @IReviewCanvasEditorTabsService + private readonly tabsService: IReviewCanvasEditorTabsService, + @IReviewExplorerPartsService + private readonly explorerParts: IReviewExplorerPartsService, + @IInstantiationService + reviewInstantiationService: IInstantiationService, + @IHostService private readonly hostService: IHostService, + @IWorkbenchLayoutService + private readonly layoutService: IWorkbenchLayoutService, + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService + private readonly configurationService: IConfigurationService, + @IReviewTelemetryService + private readonly reviewTelemetryService: IReviewTelemetryService, + @ILogService private readonly logService: ILogService, + @IEditorProgressService editorProgressService: IEditorProgressService, + ) { + super( + ReviewCanvasEditorPane.ID, + group, + telemetryService, + reviewThemeService, + storageService, + ); + this.inlineEditors = this._register( + reviewInstantiationService.createInstance(ReviewInlineEditorService), + ); + this.refreshProgress = this._register( + new LongRunningOperation(editorProgressService), + ); + this.diffViews = this._register( + reviewInstantiationService.createInstance( + ReviewDiffViewService, + this.inlineEditors, + ), + ); + this._register( + verbs.onDidEmitSurfaceEvent((event) => { + this.surfaceEvents.fire(event); + }), + ); + this._register( + verbs.onDidRequestCanvasFocus(() => { + this.canvas.value?.focus(); + void this.hostService.focus(this.targetDocument?.defaultView ?? window); + }), + ); + this._register( + this.inlineEditors.onDidChangeActiveEditor(() => { + this._onDidChangeControl.fire(); + this.bindEmbeddedSelectionControl(); + }), + ); + this._register( + reviewThemeService.onDidColorThemeChange(() => { + const theme = this.colorScheme(); + this.themeEvents.fire(theme); + this.surfaceEvents.fire({ event: "themeChanged", theme }); + }), + ); + this._register( + desktopConnection.onDidFail((error) => void this.renderFailure(error)), + ); + this._register( + configurationService.onDidChangeConfiguration((event) => { + if ( + !event.affectsConfiguration(REVIEW_SOFTWARE_MAP_SETTING) && + !event.affectsConfiguration(REVIEW_STRUCTURAL_DIFF_SETTING) + ) + return; + if (this.apiContent) { + this.apiContent = { + ...this.apiContent, + structuralDiffEnabled: this.currentStructuralDiffEnabled(), + softwareMapEnabled: this.currentSoftwareMapEnabled(), + }; + this.canvas.value?.update(this.apiContent); + return; + } + }), + ); + } - protected override createEditor(parent: HTMLElement): void { - parent.classList.add("review-canvas-part"); - this.targetDocument = parent.ownerDocument; - parent.ownerDocument.title = "Review"; - parent.ownerDocument.body.dataset["reviewCanvasMode"] = "renderer"; - const outer = $(".content.review-canvas-container"); - this.container = $(".review-canvas-host"); - this.container.tabIndex = -1; - this.canvasMount = $(".review-canvas-surface"); - this.container.appendChild(this.canvasMount); - outer.append(this.container); - parent.appendChild(outer); - // Inline peek editors promise fixedOverflowWidgets; their hover and - // definition widgets must be parented outside .review-canvas-root, - // whose container-query containment re-anchors and clips - // position: fixed descendants — but inside .monaco-workbench, where - // the --vscode-* theme variables that style hover widgets are scoped. - // One shared host serves every peek in this pane. - const overflowWidgets = $(".review-overflow-widgets.monaco-editor"); - this.layoutService.getContainer(getWindow(parent)).appendChild(overflowWidgets); - this._register(toDisposable(() => overflowWidgets.remove())); - this.inlineEditors.setOverflowWidgetsDomNode(overflowWidgets); - this.diffViews.setOverflowWidgetsDomNode(overflowWidgets); - this.desktopConnection.attachControl(async (value) => { - const request = parseReviewVerbRequest(value); - if (request.name === "openApiReview") { - const response = await this.verbs.dispatch(request); - return response.ok ? { ok: true, result: { softwareMapEnabled: this.currentSoftwareMapEnabled() } } : response; - } - if (request.name === "focusWindow") { - await this.hostService.focus(this.targetDocument?.defaultView ?? window, { mode: FocusMode.Force }); - return { ok: true }; - } - return this.verbs.dispatch(request); - }); - void this.desktopConnection.initialize().catch((error) => this.renderError(error)); - } + protected override createEditor(parent: HTMLElement): void { + parent.classList.add("review-canvas-part"); + this.targetDocument = parent.ownerDocument; + parent.ownerDocument.title = "Review"; + parent.ownerDocument.body.dataset["reviewCanvasMode"] = "renderer"; + const outer = $(".content.review-canvas-container"); + this.container = $(".review-canvas-host"); + this.container.tabIndex = -1; + this.canvasMount = $(".review-canvas-surface"); + this.container.appendChild(this.canvasMount); + outer.append(this.container); + parent.appendChild(outer); + // Inline peek editors promise fixedOverflowWidgets; their hover and + // definition widgets must be parented outside .review-canvas-root, + // whose container-query containment re-anchors and clips + // position: fixed descendants — but inside .monaco-workbench, where + // the --vscode-* theme variables that style hover widgets are scoped. + // One shared host serves every peek in this pane. + const overflowWidgets = $(".review-overflow-widgets.monaco-editor"); + this.layoutService + .getContainer(getWindow(parent)) + .appendChild(overflowWidgets); + this._register(toDisposable(() => overflowWidgets.remove())); + this.inlineEditors.setOverflowWidgetsDomNode(overflowWidgets); + this.diffViews.setOverflowWidgetsDomNode(overflowWidgets); + this.desktopConnection.attachControl(async (value) => { + const request = parseReviewVerbRequest(value); + if (request.name === "openApiReview") { + const response = await this.verbs.dispatch(request); + return response.ok + ? { + ok: true, + result: { softwareMapEnabled: this.currentSoftwareMapEnabled() }, + } + : response; + } + if (request.name === "focusWindow") { + await this.hostService.focus( + this.targetDocument?.defaultView ?? window, + { mode: FocusMode.Force }, + ); + return { ok: true }; + } + return this.verbs.dispatch(request); + }); + void this.desktopConnection + .initialize() + .catch((error) => this.renderError(error)); + } - override async setInput( - input: ReviewCanvasEditorInput, - options: IEditorOptions | undefined, - context: IEditorOpenContext, - token: CancellationToken, - ): Promise { - const generation = ++this.loadGeneration; - this.refreshProgress.stop(); - this.openingGeneration = generation; - try { - await this.setReviewInput(input, options, context, token, generation); - } finally { - if (this.openingGeneration === generation) { - this.openingGeneration = undefined; - } - } - } + override async setInput( + input: ReviewCanvasEditorInput, + options: IEditorOptions | undefined, + context: IEditorOpenContext, + token: CancellationToken, + ): Promise { + const generation = ++this.loadGeneration; + this.refreshProgress.stop(); + this.openingGeneration = generation; + try { + await this.setReviewInput(input, options, context, token, generation); + } finally { + if (this.openingGeneration === generation) { + this.openingGeneration = undefined; + } + } + } - private async setReviewInput( - input: ReviewCanvasEditorInput, - options: IEditorOptions | undefined, - context: IEditorOpenContext, - token: CancellationToken, - generation: number, - ): Promise { - await super.setInput(input, options, context, token); - this.restoreEmbeddedSelection(options); - try { - await this.desktopConnection.initialize(); - } catch (error) { - if (generation === this.loadGeneration && !token.isCancellationRequested) { - await this.renderError(error, generation); - } - return; - } - if (generation !== this.loadGeneration || token.isCancellationRequested) { - return; - } - if (input.target.kind === "api" && this.readyInput === input && this.renderedInput === input) { - this.canvasMount?.dispatchEvent(new globalThis.Event(REVIEW_CANVAS_RESUME_EVENT)); - return; - } - if (input.target.kind === "api-source") { - // The Source placeholder replaces the mount, so the reuse shortcuts - // above must not treat the previous review as still rendered. - this.renderedInput = input; - this.readyInput = undefined; - this.setCanvasState("home"); - await this.render({ kind: "source" }, generation); - return; - } - if (!(await this.resetCanvasForGeneration(generation))) { - return; - } - this.modelSubscription.clear(); - if (input.target.kind === "api") { - try { - const { reviewId } = input.target; - const [connection, assets] = await Promise.all([this.desktopConnection.getConnection(), this.loadAssets()]); - if (generation !== this.loadGeneration || token.isCancellationRequested) return; - this.renderedInput = input; - this.setCanvasState("active", reviewId); - void this.apiCatalog - .attention(reviewId, "view") - .catch((error) => this.logService.warn("[Review] Could not mark review viewed:", error)); - let sourceSelection: ReviewSourceSelection = { reviewId, kind: "current" }; - let sourceView: ReviewSourceView = resolveReviewSourceView({ reviewId, version: 0, pins: {} }); - const source = this.apiSource.canvas(() => sourceView, this.inlineEditors, this.diffViews); - const closeTutorial = () => void this.group.closeEditor(input); - const updateTutorial = (progress: TutorialProgressV1) => { - this.writeTutorialProgress(progress); - if (!this.apiContent) return; - this.apiContent = { - ...this.apiContent, - tutorial: this.createTutorialBridge(reviewId, progress, updateTutorial, closeTutorial), - }; - this.canvas.value?.update(this.apiContent); - }; - await this.render( - { - setTutorial: (enabled) => { - if ( - !this.apiContent || - generation !== this.loadGeneration || - enabled === Boolean(this.apiContent.tutorial) - ) - return; - this.apiContent = { - ...this.apiContent, - tutorial: enabled - ? this.createTutorialBridge(reviewId, this.readTutorialProgress(), updateTutorial, closeTutorial) - : undefined, - }; - this.canvas.value?.update(this.apiContent); - }, - kind: "api", - reviewId, - softwareMapEnabled: this.currentSoftwareMapEnabled(), - setTitle: (title) => input.setApiTitle(title), - setSourceView: (selection, next) => { sourceSelection = selection; sourceView = next; }, - openSource: (source, range) => this.apiSource.open(source, range), - bridge: { - ...source, - ...this.sharedBridge(generation, () => { - this.readyInput = input; - }), - config: this.reviewRuntimeConfig( - { - ...connection, - reviewId: reviewId, - }, - assets, - ), - request: requestReviewApi, - post: async (request) => { - if (request.name === "openSourceTree") { - await this.tabsService.openApiSource(sourceSelection, input.getName()); - this.explorerParts.show(); - return { ok: true }; - } - if (request.name === "reveal") { - const range = { startLine: request.args.startLine, endLine: request.args.endLine }; - await this.apiSource.open( - { view: sourceView, file: request.args.path, side: request.args.side ?? "head" }, - range, - ); - return { ok: true }; - } - if (request.name === "openDiff") { - await this.apiSource.openDiff(sourceView, request.args.path); - return { ok: true }; - } - return this.verbs.dispatch(request); - }, - }, - }, - generation, - assets, - ); - } catch (error) { - if (generation === this.loadGeneration) await this.renderError(error, generation); - } - return; - } - if (input.target.kind === "home") { - this.renderedInput = input; - this.setCanvasState("home"); - const setup = await this.resolveHomeSetup(); - await this.apiCatalog.initialize(); - let emptyStateVisible = false; - /* The empty-list render suspends on the install fetch below, while + private async setReviewInput( + input: ReviewCanvasEditorInput, + options: IEditorOptions | undefined, + context: IEditorOpenContext, + token: CancellationToken, + generation: number, + ): Promise { + await super.setInput(input, options, context, token); + this.restoreEmbeddedSelection(options); + try { + await this.desktopConnection.initialize(); + } catch (error) { + if ( + generation === this.loadGeneration && + !token.isCancellationRequested + ) { + await this.renderError(error, generation); + } + return; + } + if (generation !== this.loadGeneration || token.isCancellationRequested) { + return; + } + if ( + input.target.kind === "api" && + this.readyInput === input && + this.renderedInput === input + ) { + this.canvasMount?.dispatchEvent( + new globalThis.Event(REVIEW_CANVAS_RESUME_EVENT), + ); + return; + } + if (input.target.kind === "api-source") { + // The Source placeholder replaces the mount, so the reuse shortcuts + // above must not treat the previous review as still rendered. + this.renderedInput = input; + this.readyInput = undefined; + this.setCanvasState("home"); + await this.render({ kind: "source" }, generation); + return; + } + if (!(await this.resetCanvasForGeneration(generation))) { + return; + } + this.modelSubscription.clear(); + if (input.target.kind === "api") { + try { + const { reviewId } = input.target; + const [connection, assets] = await Promise.all([ + this.desktopConnection.getConnection(), + this.loadAssets(), + ]); + if (generation !== this.loadGeneration || token.isCancellationRequested) + return; + this.renderedInput = input; + this.setCanvasState("active", reviewId); + void this.apiCatalog + .attention(reviewId, "view") + .catch((error) => + this.logService.warn( + "[Review] Could not mark review viewed:", + error, + ), + ); + let sourceSelection: ReviewSourceSelection = { + reviewId, + kind: "current", + }; + let sourceView: ReviewSourceView = resolveReviewSourceView({ + reviewId, + version: 0, + pins: {}, + }); + const source = this.apiSource.canvas( + () => sourceView, + this.inlineEditors, + this.diffViews, + ); + const closeTutorial = () => void this.group.closeEditor(input); + const updateTutorial = (progress: TutorialProgressV1) => { + this.writeTutorialProgress(progress); + if (!this.apiContent) return; + this.apiContent = { + ...this.apiContent, + tutorial: this.createTutorialBridge( + reviewId, + progress, + updateTutorial, + closeTutorial, + ), + }; + this.canvas.value?.update(this.apiContent); + }; + await this.render( + { + setTutorial: (enabled) => { + if ( + !this.apiContent || + generation !== this.loadGeneration || + enabled === Boolean(this.apiContent.tutorial) + ) + return; + this.apiContent = { + ...this.apiContent, + tutorial: enabled + ? this.createTutorialBridge( + reviewId, + this.readTutorialProgress(), + updateTutorial, + closeTutorial, + ) + : undefined, + }; + this.canvas.value?.update(this.apiContent); + }, + kind: "api", + reviewId, + structuralDiffEnabled: this.currentStructuralDiffEnabled(), + softwareMapEnabled: this.currentSoftwareMapEnabled(), + setTitle: (title) => input.setApiTitle(title), + setSourceView: (selection, next) => { + sourceSelection = selection; + sourceView = next; + }, + openSource: (source, range) => this.apiSource.open(source, range), + bridge: { + ...source, + ...this.sharedBridge(generation, () => { + this.readyInput = input; + }), + config: this.reviewRuntimeConfig( + { + ...connection, + reviewId: reviewId, + }, + assets, + ), + request: requestReviewApi, + post: async (request) => { + if (request.name === "openSourceTree") { + await this.tabsService.openApiSource( + sourceSelection, + input.getName(), + ); + this.explorerParts.show(); + return { ok: true }; + } + if (request.name === "reveal") { + const range = { + startLine: request.args.startLine, + endLine: request.args.endLine, + }; + await this.apiSource.open( + { + view: sourceView, + file: request.args.path, + side: request.args.side ?? "head", + }, + range, + ); + return { ok: true }; + } + if (request.name === "openDiff") { + await this.apiSource.openDiff(sourceView, request.args.path); + return { ok: true }; + } + return this.verbs.dispatch(request); + }, + }, + }, + generation, + assets, + ); + } catch (error) { + if (generation === this.loadGeneration) + await this.renderError(error, generation); + } + return; + } + if (input.target.kind === "home") { + this.renderedInput = input; + this.setCanvasState("home"); + const setup = await this.resolveHomeSetup(); + await this.apiCatalog.initialize(); + let emptyStateVisible = false; + /* The empty-list render suspends on the install fetch below, while the list render has no await at all. The sequence number keeps a suspended empty render from resuming after a later list render and overwriting it with a stale snapshot. */ - let renderSeq = 0; - const renderHome = async () => { - const seq = ++renderSeq; - const reviews = this.apiCatalog.reviews; - const isEmpty = reviews.length === 0; - // Only the Welcome rail needs install status; the list must - // render without waiting on it. One fetch serves both the - // install card and the onboarding rail. - const install = isEmpty ? await this.resolveInstallContent() : undefined; - if (seq !== renderSeq) return; - if (isEmpty && !emptyStateVisible) { - this.reviewTelemetryService.capture("home_empty_state_viewed"); - } - emptyStateVisible = isEmpty; - const openReview = (uuid: string) => { - this.reviewTelemetryService.capture("review_opened", { - via: "home", - }); - const api = this.apiCatalog.reviews.find((review) => review.reviewId === uuid); - return api ? this.tabsService.openApiReview(uuid, api.title) : Promise.resolve(); - }; - return this.render( - { - kind: "home", - reviews, - openReview: (uuid) => void openReview(uuid), - deleteReview: (uuid) => this.apiCatalog.deleteReview(uuid), - dismissReview: (uuid) => this.apiCatalog.attention(uuid, "dismiss"), - restoreReview: (uuid) => this.apiCatalog.attention(uuid, "restore"), - openSourceTree: (uuid) => { - const api = this.apiCatalog.reviews.find((review) => review.reviewId === uuid); - if (api) { - void this.tabsService.openApiSource({ reviewId: api.reviewId, kind: "current" }, api.title).then(() => this.explorerParts.show()); - return; - } - }, - setup, - // Home shows the Welcome rail while the list is empty. - install, - onboarding: install ? this.resolveOnboarding(install.status) : undefined, - openTutorial: () => this.openTutorial(), - }, - generation, - ); - }; - // Home stays live while it is the rendered input: a deletion or a - // newly published review re-renders the list. render() drops stale - // generations once another input starts loading. - const subscriptions = new DisposableStore(); - subscriptions.add(this.desktopConnection.onDidChangeLists(() => void renderHome())); - subscriptions.add(this.apiCatalog.onDidChange(() => void renderHome())); - this.modelSubscription.value = subscriptions; - await renderHome(); - return; - } - if (input.target.kind === "welcome") { - void this.desktopConnection - .prepareTutorial() - .catch((error) => this.logService.warn("[Review] Tutorial preparation did not complete:", error)); - this.renderedInput = input; - this.setCanvasState("home"); - /* Same stale-resume guard as Home: the install fetch suspends, and + let renderSeq = 0; + const renderHome = async () => { + const seq = ++renderSeq; + const reviews = this.apiCatalog.reviews; + const isEmpty = reviews.length === 0; + // Only the Welcome rail needs install status; the list must + // render without waiting on it. One fetch serves both the + // install card and the onboarding rail. + const install = isEmpty + ? await this.resolveInstallContent() + : undefined; + if (seq !== renderSeq) return; + if (isEmpty && !emptyStateVisible) { + this.reviewTelemetryService.capture("home_empty_state_viewed"); + } + emptyStateVisible = isEmpty; + const openReview = (uuid: string) => { + this.reviewTelemetryService.capture("review_opened", { + via: "home", + }); + const api = this.apiCatalog.reviews.find( + (review) => review.reviewId === uuid, + ); + return api + ? this.tabsService.openApiReview(uuid, api.title) + : Promise.resolve(); + }; + return this.render( + { + kind: "home", + reviews, + openReview: (uuid) => void openReview(uuid), + deleteReview: (uuid) => this.apiCatalog.deleteReview(uuid), + dismissReview: (uuid) => this.apiCatalog.attention(uuid, "dismiss"), + restoreReview: (uuid) => this.apiCatalog.attention(uuid, "restore"), + openSourceTree: (uuid) => { + const api = this.apiCatalog.reviews.find( + (review) => review.reviewId === uuid, + ); + if (api) { + void this.tabsService + .openApiSource( + { reviewId: api.reviewId, kind: "current" }, + api.title, + ) + .then(() => this.explorerParts.show()); + return; + } + }, + setup, + // Home shows the Welcome rail while the list is empty. + install, + onboarding: install + ? this.resolveOnboarding(install.status) + : undefined, + openTutorial: () => this.openTutorial(), + }, + generation, + ); + }; + // Home stays live while it is the rendered input: a deletion or a + // newly published review re-renders the list. render() drops stale + // generations once another input starts loading. + const subscriptions = new DisposableStore(); + subscriptions.add( + this.desktopConnection.onDidChangeLists(() => void renderHome()), + ); + subscriptions.add(this.apiCatalog.onDidChange(() => void renderHome())); + this.modelSubscription.value = subscriptions; + await renderHome(); + return; + } + if (input.target.kind === "welcome") { + void this.desktopConnection + .prepareTutorial() + .catch((error) => + this.logService.warn( + "[Review] Tutorial preparation did not complete:", + error, + ), + ); + this.renderedInput = input; + this.setCanvasState("home"); + /* Same stale-resume guard as Home: the install fetch suspends, and a later list event must win over an earlier suspended render. */ - let renderSeq = 0; - const renderWelcome = async () => { - const seq = ++renderSeq; - const install = await this.resolveInstallContent(); - if (seq !== renderSeq) return; - return this.render( - { - kind: "welcome", - install, - close: () => void this.group.closeEditor(input), - onboarding: install ? this.resolveOnboarding(install.status) : undefined, - openTutorial: () => this.openTutorial(), - }, - generation, - ); - }; - // The last step completes when a review publishes, which can happen - // while this tab sits open. - this.modelSubscription.value = this.desktopConnection.onDidChangeLists(() => void renderWelcome()); - await renderWelcome(); - return; - } - if (input.target.kind === "settings") { - this.renderedInput = input; - this.setCanvasState("home"); - const [settings, install] = await Promise.all([this.resolveSettingsContent(), this.resolveInstallContent()]); - await this.render({ kind: "settings", settings: { ...settings, install } }, generation); - return; - } - } + let renderSeq = 0; + const renderWelcome = async () => { + const seq = ++renderSeq; + const install = await this.resolveInstallContent(); + if (seq !== renderSeq) return; + return this.render( + { + kind: "welcome", + install, + close: () => void this.group.closeEditor(input), + onboarding: install + ? this.resolveOnboarding(install.status) + : undefined, + openTutorial: () => this.openTutorial(), + }, + generation, + ); + }; + // The last step completes when a review publishes, which can happen + // while this tab sits open. + this.modelSubscription.value = this.desktopConnection.onDidChangeLists( + () => void renderWelcome(), + ); + await renderWelcome(); + return; + } + if (input.target.kind === "settings") { + this.renderedInput = input; + this.setCanvasState("home"); + const [settings, install] = await Promise.all([ + this.resolveSettingsContent(), + this.resolveInstallContent(), + ]); + await this.render( + { kind: "settings", settings: { ...settings, install } }, + generation, + ); + return; + } + } + + override async clearInput(): Promise { + this.apiContent = undefined; + this.refreshProgress.stop(); + await super.clearInput(); + } - override async clearInput(): Promise { - this.apiContent = undefined; - this.refreshProgress.stop(); - await super.clearInput(); - } + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); + if (!visible) this.refreshProgress.stop(); + } - protected override setEditorVisible(visible: boolean): void { - super.setEditorVisible(visible); - if (!visible) this.refreshProgress.stop(); - } + override focus(): void { + this.canvas.value?.focus(); + } - override focus(): void { - this.canvas.value?.focus(); - } + showFind(): boolean { + const editor = this.inlineEditors.activeCodeEditor; + let seed: string | undefined; + if (editor?.hasTextFocus()) { + const model = editor.getModel(); + const selection = editor.getSelection(); + if (model && selection && !selection.isEmpty()) { + seed = model.getValueInRange(selection); + } + } + return this.canvas.value?.showFind(seed) ?? false; + } - showFind(): boolean { - const editor = this.inlineEditors.activeCodeEditor; - let seed: string | undefined; - if (editor?.hasTextFocus()) { - const model = editor.getModel(); - const selection = editor.getSelection(); - if (model && selection && !selection.isEmpty()) { - seed = model.getValueInRange(selection); - } - } - return this.canvas.value?.showFind(seed) ?? false; - } + override getControl(): IEditorControl | undefined { + return this.inlineEditors; + } - override getControl(): IEditorControl | undefined { - return this.inlineEditors; - } + override setOptions(options: IEditorOptions | undefined): void { + super.setOptions(options); + this.restoreEmbeddedSelection(options); + } - override setOptions(options: IEditorOptions | undefined): void { - super.setOptions(options); - this.restoreEmbeddedSelection(options); - } + getSelection(): IEditorPaneSelection | undefined { + const editor = this.inlineEditors.selectionCodeEditor; + const position = editor?.getPosition(); + const model = editor?.getModel(); + if (!editor || !position || !model) return undefined; + const domNode = editor.getDomNode(); + const view = domNode?.closest(".review-files-editor") ? "diff" : "review"; + const section = domNode?.closest("[data-review-section]") + ?.dataset["reviewSection"]; + return new ReviewEmbeddedEditorSelection(editor, { + view, + path: model.uri.path.slice(1), + side: + new URLSearchParams(model.uri.query).get("side") === "base" + ? "base" + : "head", + lineNumber: position.lineNumber, + column: position.column, + section, + }); + } - getSelection(): IEditorPaneSelection | undefined { - const editor = this.inlineEditors.selectionCodeEditor; - const position = editor?.getPosition(); - const model = editor?.getModel(); - if (!editor || !position || !model) return undefined; - const domNode = editor.getDomNode(); - const view = domNode?.closest(".review-files-editor") ? "diff" : "review"; - const section = domNode?.closest("[data-review-section]")?.dataset["reviewSection"]; - return new ReviewEmbeddedEditorSelection(editor, { - view, - path: model.uri.path.slice(1), - side: new URLSearchParams(model.uri.query).get("side") === "base" ? "base" : "head", - lineNumber: position.lineNumber, - column: position.column, - section, - }); - } + private bindEmbeddedSelectionControl(): void { + const editor = this.inlineEditors.activeCodeEditor; + if (!editor) { + this.embeddedSelectionListeners.clear(); + return; + } + const store = new DisposableStore(); + store.add( + editor.onDidFocusEditorText(() => { + this._onDidChangeSelection.fire({ + reason: EditorPaneSelectionChangeReason.USER, + }); + }), + ); + store.add( + editor.onDidChangeCursorPosition((event) => + this._onDidChangeSelection.fire({ + reason: embeddedSelectionChangeReason(event), + }), + ), + ); + this.embeddedSelectionListeners.value = store; + } - private bindEmbeddedSelectionControl(): void { - const editor = this.inlineEditors.activeCodeEditor; - if (!editor) { - this.embeddedSelectionListeners.clear(); - return; - } - const store = new DisposableStore(); - store.add( - editor.onDidFocusEditorText(() => { - this._onDidChangeSelection.fire({ - reason: EditorPaneSelectionChangeReason.USER, - }); - }), - ); - store.add( - editor.onDidChangeCursorPosition((event) => - this._onDidChangeSelection.fire({ - reason: embeddedSelectionChangeReason(event), - }), - ), - ); - this.embeddedSelectionListeners.value = store; - } + private restoreEmbeddedSelection(options: IEditorOptions | undefined): void { + const selection = reviewEmbeddedSelectionFromOptions(options); + if (!selection) return; + selection.restoreInCanvas(); + } - private restoreEmbeddedSelection(options: IEditorOptions | undefined): void { - const selection = reviewEmbeddedSelectionFromOptions(options); - if (!selection) return; - selection.restoreInCanvas(); - } + /** + * Retargets the Toggle Inline View command at the in-tab diff. The diff + * editor commands duck-type this method on the active pane, so the Review + * tab answers for its embedded diff and no-ops in the other views. + */ + toggleRenderSideBySide(): void { + this.diffViews.toggleRenderSideBySide(); + } - /** - * Retargets the Toggle Inline View command at the in-tab diff. The diff - * editor commands duck-type this method on the active pane, so the Review - * tab answers for its embedded diff and no-ops in the other views. - */ - toggleRenderSideBySide(): void { - this.diffViews.toggleRenderSideBySide(); - } + override layout(_dimension: Dimension): void { + // The canvas uses normal CSS flow and fills the editor pane. + } - override layout(_dimension: Dimension): void { - // The canvas uses normal CSS flow and fills the editor pane. - } + /** + * Install status for the Agent Setup page. The page must render even when + * the status endpoint fails (an older server, a race during startup), so a + * failure yields no install content rather than an error state. + */ + private async resolveInstallContent(): Promise< + ReviewCanvasInstallContent | undefined + > { + try { + const status = await this.desktopConnection.getCliInstallStatus(); + return { + status, + apply: async (request) => { + await this.desktopConnection.applyCliInstall(request); + return this.desktopConnection.getCliInstallStatus(); + }, + remove: async (request) => { + await this.desktopConnection.removeCliInstall(request); + return this.desktopConnection.getCliInstallStatus(); + }, + decline: async () => { + await this.desktopConnection.declineCliInstall(); + return this.desktopConnection.getCliInstallStatus(); + }, + skip: async () => { + await this.desktopConnection.skipCliInstallPrompts(); + return this.desktopConnection.getCliInstallStatus(); + }, + enablePrompts: async () => { + await this.desktopConnection.resetCliInstallPrompts(); + return this.desktopConnection.getCliInstallStatus(); + }, + }; + } catch { + return undefined; + } + } - /** - * Install status for the Agent Setup page. The page must render even when - * the status endpoint fails (an older server, a race during startup), so a - * failure yields no install content rather than an error state. - */ - private async resolveInstallContent(): Promise { - try { - const status = await this.desktopConnection.getCliInstallStatus(); - return { - status, - apply: async (request) => { - await this.desktopConnection.applyCliInstall(request); - return this.desktopConnection.getCliInstallStatus(); - }, - remove: async (request) => { - await this.desktopConnection.removeCliInstall(request); - return this.desktopConnection.getCliInstallStatus(); - }, - decline: async () => { - await this.desktopConnection.declineCliInstall(); - return this.desktopConnection.getCliInstallStatus(); - }, - skip: async () => { - await this.desktopConnection.skipCliInstallPrompts(); - return this.desktopConnection.getCliInstallStatus(); - }, - enablePrompts: async () => { - await this.desktopConnection.resetCliInstallPrompts(); - return this.desktopConnection.getCliInstallStatus(); - }, - }; - } catch { - return undefined; - } - } + private openTutorial(): void { + // The command opens and focuses the tutorial tab itself, and reports + // its own failures. Welcome stays open behind it: it is a hub the + // reader comes back to, not a one-shot wizard. + void this.commandService.executeCommand("review.openTutorial"); + } - private openTutorial(): void { - // The command opens and focuses the tutorial tab itself, and reports - // its own failures. Welcome stays open behind it: it is a hub the - // reader comes back to, not a one-shot wizard. - void this.commandService.executeCommand("review.openTutorial"); - } + /** + * Settings state and actions for the Settings page. Every value lives in + * workbench configuration, apart from the retention window, which the review + * server owns. Extensions reuse the existing quick pick. + */ + private async resolveSettingsContent(): Promise { + return { + telemetryEnabled: this.currentTelemetryEnabled(), + setTelemetryEnabled: async (enabled) => { + this.reviewTelemetryService.capture("setting_changed", { + setting: "telemetry_enabled", + enabled, + }); + if (!enabled) { + await this.reviewTelemetryService.flush(); + } + await this.configurationService.updateValue( + REVIEW_TELEMETRY_SETTING, + enabled, + ConfigurationTarget.USER, + ); + return this.currentTelemetryEnabled(); + }, + theme: currentReviewThemeChoice( + this.configurationService, + this.reviewThemeService, + ), + setTheme: async (choice) => { + await applyReviewThemeChoice(this.configurationService, choice); + return currentReviewThemeChoice( + this.configurationService, + this.reviewThemeService, + ); + }, + keymap: this.currentKeymap(), + setKeymap: async (choice) => { + this.reviewTelemetryService.capture("setting_changed", { + setting: "keymap", + enabled: true, + }); + await this.commandService.executeCommand("review.setKeymap", choice); + return this.currentKeymap(); + }, + softwareMapEnabled: this.currentSoftwareMapEnabled(), + setSoftwareMapEnabled: async (enabled) => { + this.reviewTelemetryService.capture("setting_changed", { + setting: "software_map_enabled", + enabled, + }); + await this.configurationService.updateValue( + REVIEW_SOFTWARE_MAP_SETTING, + enabled, + ConfigurationTarget.USER, + ); + return this.currentSoftwareMapEnabled(); + }, + structuralDiffEnabled: this.currentStructuralDiffEnabled(), + setStructuralDiffEnabled: async (enabled) => { + await this.configurationService.updateValue( + REVIEW_STRUCTURAL_DIFF_SETTING, + enabled, + ConfigurationTarget.USER, + ); + return this.currentStructuralDiffEnabled(); + }, + diffrConfig: { + read: () => this.desktopConnection.readDiffrConfig(), + set: (key, value) => { + this.reviewTelemetryService.capture("setting_changed", { + setting: "diffr_config", + enabled: true, + }); + return this.desktopConnection.setDiffrConfigValue(key, value); + }, + }, + manageExtensions: () => + void this.commandService.executeCommand("review.manageExtensions"), + }; + } - /** - * Settings state and actions for the Settings page. Every value lives in - * workbench configuration, apart from the retention window, which the review - * server owns. Extensions reuse the existing quick pick. - */ - private async resolveSettingsContent(): Promise { - return { - telemetryEnabled: this.currentTelemetryEnabled(), - setTelemetryEnabled: async (enabled) => { - this.reviewTelemetryService.capture("setting_changed", { - setting: "telemetry_enabled", - enabled, - }); - if (!enabled) { - await this.reviewTelemetryService.flush(); - } - await this.configurationService.updateValue(REVIEW_TELEMETRY_SETTING, enabled, ConfigurationTarget.USER); - return this.currentTelemetryEnabled(); - }, - theme: currentReviewThemeChoice(this.configurationService, this.reviewThemeService), - setTheme: async (choice) => { - await applyReviewThemeChoice(this.configurationService, choice); - return currentReviewThemeChoice(this.configurationService, this.reviewThemeService); - }, - keymap: this.currentKeymap(), - setKeymap: async (choice) => { - this.reviewTelemetryService.capture("setting_changed", { - setting: "keymap", - enabled: true, - }); - await this.commandService.executeCommand("review.setKeymap", choice); - return this.currentKeymap(); - }, - softwareMapEnabled: this.currentSoftwareMapEnabled(), - setSoftwareMapEnabled: async (enabled) => { - this.reviewTelemetryService.capture("setting_changed", { - setting: "software_map_enabled", - enabled, - }); - await this.configurationService.updateValue(REVIEW_SOFTWARE_MAP_SETTING, enabled, ConfigurationTarget.USER); - return this.currentSoftwareMapEnabled(); - }, - manageExtensions: () => void this.commandService.executeCommand("review.manageExtensions"), - }; - } + private currentKeymap(): ReviewKeymapChoice { + return ( + this.configurationService.getValue( + REVIEW_KEYMAP_SETTING, + ) ?? "none" + ); + } - private currentKeymap(): ReviewKeymapChoice { - return this.configurationService.getValue(REVIEW_KEYMAP_SETTING) ?? "none"; - } + private currentStructuralDiffEnabled(): boolean { + return ( + this.configurationService.getValue( + REVIEW_STRUCTURAL_DIFF_SETTING, + ) === true + ); + } - private currentSoftwareMapEnabled(): boolean { - return this.configurationService.getValue(REVIEW_SOFTWARE_MAP_SETTING) === true; - } + private currentSoftwareMapEnabled(): boolean { + return ( + this.configurationService.getValue( + REVIEW_SOFTWARE_MAP_SETTING, + ) === true + ); + } - // The setting ships as true, so only an explicit false means opted out. - private currentTelemetryEnabled(): boolean { - return this.configurationService.getValue(REVIEW_TELEMETRY_SETTING) !== false; - } + // The setting ships as true, so only an explicit false means opted out. + private currentTelemetryEnabled(): boolean { + return ( + this.configurationService.getValue(REVIEW_TELEMETRY_SETTING) !== + false + ); + } - /** - * Install status for the Home setup banner. Home must render even when the - * status endpoint fails, so a failure yields no banner. - */ - private async resolveHomeSetup(): Promise { - try { - return { - status: await this.desktopConnection.getCliInstallStatus(), - open: () => void this.tabsService.openWelcome(true), - }; - } catch { - return undefined; - } - } + /** + * Install status for the Home setup banner. Home must render even when the + * status endpoint fails, so a failure yields no banner. + */ + private async resolveHomeSetup(): Promise { + try { + return { + status: await this.desktopConnection.getCliInstallStatus(), + open: () => void this.tabsService.openWelcome(true), + }; + } catch { + return undefined; + } + } - /** - * Step state for the Welcome rail, derived from the install status the - * caller already fetched, so one render costs one status round-trip. The - * rest is local: stored tutorial progress and the review list. - */ - private resolveOnboarding(status: ReviewCliInstallStatus): ReviewCanvasOnboarding { - const checked = new Set(this.readTutorialProgress().checked); - const steps = REVIEW_TUTORIAL_STEP_IDS.filter((step) => step !== "openMap" || this.currentSoftwareMapEnabled()); - return { - installed: status.agents.some((agent) => agent.installed), - tutorialChecked: steps.filter((step) => checked.has(step)).length, - tutorialTotal: steps.length, - // Drafts are filtered out of this list and the tutorial never - // joins it, so this counts only a real published review. - published: this.apiCatalog.reviews.length > 0, - }; - } + /** + * Step state for the Welcome rail, derived from the install status the + * caller already fetched, so one render costs one status round-trip. The + * rest is local: stored tutorial progress and the review list. + */ + private resolveOnboarding( + status: ReviewCliInstallStatus, + ): ReviewCanvasOnboarding { + const checked = new Set(this.readTutorialProgress().checked); + const steps = REVIEW_TUTORIAL_STEP_IDS.filter( + (step) => step !== "openMap" || this.currentSoftwareMapEnabled(), + ); + return { + installed: status.agents.some((agent) => agent.installed), + tutorialChecked: steps.filter((step) => checked.has(step)).length, + tutorialTotal: steps.length, + // Drafts are filtered out of this list and the tutorial never + // joins it, so this counts only a real published review. + published: this.apiCatalog.reviews.length > 0, + }; + } - private createTutorialBridge( - reviewUuid: string, - progress: TutorialProgressV1, - onChange: (progress: TutorialProgressV1) => void, - close: () => void, - ): ReviewCanvasTutorialBridge { - /* Mutations re-read stored progress instead of using the captured + private createTutorialBridge( + reviewUuid: string, + progress: TutorialProgressV1, + onChange: (progress: TutorialProgressV1) => void, + close: () => void, + ): ReviewCanvasTutorialBridge { + /* Mutations re-read stored progress instead of using the captured snapshot: two events arriving before the re-rendered bridge lands (hover then goto-definition) must not clobber each other. */ - const setStep = (step: TutorialStepId, checked: boolean) => { - if (!REVIEW_TUTORIAL_STEP_IDS.includes(step)) return; - const current = this.readTutorialProgress(); - if (current.checked.includes(step) === checked) return; - const values = new Set(current.checked); - if (checked) values.add(step); - else values.delete(step); - onChange({ ...current, checked: [...values] }); - }; - return { - content: { reviewUuid, progress, keymap: this.currentKeymap() }, - setStep, - dismiss: () => onChange({ ...this.readTutorialProgress(), dismissed: true }), - reopen: () => onChange({ ...this.readTutorialProgress(), dismissed: false }), - selectKeymap: async (keymap) => { - if (keymap !== "none" && keymap !== "vim" && keymap !== "emacs") { - throw new Error("Unsupported tutorial keymap choice."); - } - setStep("chooseKeymap", true); - try { - /* The keymap command may reload the window before its promise can + const setStep = (step: TutorialStepId, checked: boolean) => { + if (!REVIEW_TUTORIAL_STEP_IDS.includes(step)) return; + const current = this.readTutorialProgress(); + if (current.checked.includes(step) === checked) return; + const values = new Set(current.checked); + if (checked) values.add(step); + else values.delete(step); + onChange({ ...current, checked: [...values] }); + }; + return { + content: { reviewUuid, progress, keymap: this.currentKeymap() }, + setStep, + dismiss: () => + onChange({ ...this.readTutorialProgress(), dismissed: true }), + reopen: () => + onChange({ ...this.readTutorialProgress(), dismissed: false }), + selectKeymap: async (keymap) => { + if (keymap !== "none" && keymap !== "vim" && keymap !== "emacs") { + throw new Error("Unsupported tutorial keymap choice."); + } + setStep("chooseKeymap", true); + try { + /* The keymap command may reload the window before its promise can settle. Persist the completed step first so the restored tutorial advances from the choice the user already made. */ - await this.commandService.executeCommand("review.setKeymap", keymap); - } catch (error) { - setStep("chooseKeymap", false); - throw error; - } - }, - close, - }; - } + await this.commandService.executeCommand("review.setKeymap", keymap); + } catch (error) { + setStep("chooseKeymap", false); + throw error; + } + }, + close, + }; + } - private readTutorialProgress(): TutorialProgressV1 { - const empty: TutorialProgressV1 = { - version: 1, - checked: [], - dismissed: false, - }; - const raw = this.storageService.get(REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, StorageScope.APPLICATION); - if (!raw) return empty; - try { - const value = JSON.parse(raw) as { - version?: unknown; - checked?: unknown; - dismissed?: unknown; - steps?: unknown; - }; - if ( - value.version !== 1 || - !Array.isArray(value.steps) || - !Array.isArray(value.checked) || - !value.checked.every(isTutorialStepId) || - typeof value.dismissed !== "boolean" - ) { - throw new Error("Invalid tutorial progress."); - } - const checked = new Set(value.checked); - const known = value.steps.filter(isTutorialStepId); - if (known.length > 0 && known.every((step) => checked.has(step))) { - for (const step of REVIEW_TUTORIAL_STEP_IDS) { - checked.add(step); - } - } - return { - version: 1, - checked: [...checked], - dismissed: value.dismissed, - }; - } catch { - this.writeTutorialProgress(empty); - return empty; - } - } + private readTutorialProgress(): TutorialProgressV1 { + const empty: TutorialProgressV1 = { + version: 1, + checked: [], + dismissed: false, + }; + const raw = this.storageService.get( + REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, + StorageScope.APPLICATION, + ); + if (!raw) return empty; + try { + const value = JSON.parse(raw) as { + version?: unknown; + checked?: unknown; + dismissed?: unknown; + steps?: unknown; + }; + if ( + value.version !== 1 || + !Array.isArray(value.steps) || + !Array.isArray(value.checked) || + !value.checked.every(isTutorialStepId) || + typeof value.dismissed !== "boolean" + ) { + throw new Error("Invalid tutorial progress."); + } + const checked = new Set(value.checked); + const known = value.steps.filter(isTutorialStepId); + if (known.length > 0 && known.every((step) => checked.has(step))) { + for (const step of REVIEW_TUTORIAL_STEP_IDS) { + checked.add(step); + } + } + return { + version: 1, + checked: [...checked], + dismissed: value.dismissed, + }; + } catch { + this.writeTutorialProgress(empty); + return empty; + } + } - private writeTutorialProgress(progress: TutorialProgressV1): void { - this.storageService.store( - REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, - // `steps` marks which step ids existed at write time, so a later - // release can tell a finished tour from one its new steps reopened. - JSON.stringify({ ...progress, steps: REVIEW_TUTORIAL_STEP_IDS }), - StorageScope.APPLICATION, - StorageTarget.MACHINE, - ); - } + private writeTutorialProgress(progress: TutorialProgressV1): void { + this.storageService.store( + REVIEW_TUTORIAL_PROGRESS_STORAGE_KEY, + // `steps` marks which step ids existed at write time, so a later + // release can tell a finished tour from one its new steps reopened. + JSON.stringify({ ...progress, steps: REVIEW_TUTORIAL_STEP_IDS }), + StorageScope.APPLICATION, + StorageTarget.MACHINE, + ); + } - private async renderFailure(error: Error): Promise { - this.refreshProgress.stop(); - const generation = ++this.loadGeneration; - if (await this.resetCanvasForGeneration(generation)) { - await this.renderError(error, generation); - } - } + private async renderFailure(error: Error): Promise { + this.refreshProgress.stop(); + const generation = ++this.loadGeneration; + if (await this.resetCanvasForGeneration(generation)) { + await this.renderError(error, generation); + } + } - private async renderError(error: unknown, generation?: number): Promise { - const activeGeneration = generation ?? ++this.loadGeneration; - this.setCanvasState("error"); - await this.render( - { - kind: "error", - message: error instanceof Error ? error.message : String(error), - }, - activeGeneration, - ); - } + private async renderError( + error: unknown, + generation?: number, + ): Promise { + const activeGeneration = generation ?? ++this.loadGeneration; + this.setCanvasState("error"); + await this.render( + { + kind: "error", + message: error instanceof Error ? error.message : String(error), + }, + activeGeneration, + ); + } - private async render( - content: ReviewCanvasContent, - generation: number, - loadedAssets?: ReviewCanvasAssetsModule, - ): Promise { - if (!this.canvasMount) return; - const assets = loadedAssets ?? (await this.loadAssets()); - if (generation !== this.loadGeneration) return; - this.apiContent = content.kind === "api" ? content : undefined; - if (this.canvas.value) { - this.canvas.value.update(content); - } else { - this.canvas.value = assets.mountReviewCanvas(this.canvasMount, content); - } - } + private async render( + content: ReviewCanvasContent, + generation: number, + loadedAssets?: ReviewCanvasAssetsModule, + ): Promise { + if (!this.canvasMount) return; + const assets = loadedAssets ?? (await this.loadAssets()); + if (generation !== this.loadGeneration) return; + this.apiContent = content.kind === "api" ? content : undefined; + if (this.canvas.value) { + this.canvas.value.update(content); + } else { + this.canvas.value = assets.mountReviewCanvas(this.canvasMount, content); + } + } - private loadAssets(): Promise { - this.assetsPromise ??= this.importAssets().catch((error) => { - this.assetsPromise = null; - throw error; - }); - return this.assetsPromise; - } + private loadAssets(): Promise { + this.assetsPromise ??= this.importAssets().catch((error) => { + this.assetsPromise = null; + throw error; + }); + return this.assetsPromise; + } - private async importAssets(): Promise { - // Zod 4 probes the Function constructor unless its CSP-safe mode is set - // before the canvas module graph is evaluated. Chromium reports the caught - // probe as a Trusted Types violation, so configure the bundled authoring - // runtime before importing it instead of weakening the workbench policy. - const canvasGlobal = globalThis as ReviewCanvasGlobalThis; - canvasGlobal.__zod_globalConfig ??= {}; - canvasGlobal.__zod_globalConfig.jitless = true; + private async importAssets(): Promise { + // Zod 4 probes the Function constructor unless its CSP-safe mode is set + // before the canvas module graph is evaluated. Chromium reports the caught + // probe as a Trusted Types violation, so configure the bundled authoring + // runtime before importing it instead of weakening the workbench policy. + const canvasGlobal = globalThis as ReviewCanvasGlobalThis; + canvasGlobal.__zod_globalConfig ??= {}; + canvasGlobal.__zod_globalConfig.jitless = true; - const url = FileAccess.asBrowserUri("vs/review/canvas/canvas-loader.js").toString(true); - const trustedUrl = reviewCanvasPolicy?.createScriptURL(url) ?? (url as string); - const assets = (await import( - /* webpackIgnore: true */ trustedUrl as unknown as string - )) as ReviewCanvasAssetsModule; - if (typeof assets.mountReviewCanvas !== "function") { - throw new Error("Review canvas bundle has no mount function."); - } - await Promise.all(assets.reviewStylesheetUrls.map((stylesheet) => loadStylesheet(document, stylesheet))); - return assets; - } + const url = FileAccess.asBrowserUri( + "vs/review/canvas/canvas-loader.js", + ).toString(true); + const trustedUrl = + reviewCanvasPolicy?.createScriptURL(url) ?? (url as string); + const assets = (await import( + /* webpackIgnore: true */ trustedUrl as unknown as string + )) as ReviewCanvasAssetsModule; + if (typeof assets.mountReviewCanvas !== "function") { + throw new Error("Review canvas bundle has no mount function."); + } + await Promise.all( + assets.reviewStylesheetUrls.map((stylesheet) => + loadStylesheet(document, stylesheet), + ), + ); + return assets; + } - /** The bridge members every canvas shares; `onReady` runs once the mount reports ready. */ - private sharedBridge( - generation: number, - onReady: () => void, - lifecycle?: ReviewCanvasLoadLifecycle, - ): Pick< - ReviewCanvasBridge, - | "appSessionId" - | "subscribe" - | "currentTheme" - | "onDidChangeTheme" - | "currentDiffLayout" - | "setDiffLayout" - | "onDidChangeDiffLayout" - | "ready" - | "reportDiagnostic" - > { - return { - appSessionId: this.reviewTelemetryService.appSessionId, - subscribe: (listener) => this.surfaceEvents.event(listener), - currentTheme: () => this.colorScheme(), - onDidChangeTheme: (listener) => this.themeEvents.event(listener), - currentDiffLayout: () => this.diffViews.diffLayout.get(), - setDiffLayout: (layout) => this.diffViews.diffLayout.set(layout), - onDidChangeDiffLayout: (listener) => this.diffViews.diffLayout.onDidChange(listener), - ready: () => { - if (generation !== this.loadGeneration || !this.targetDocument) return; - this.targetDocument.body.dataset["reviewCanvasReady"] = "true"; - onReady(); - }, - reportDiagnostic: (diagnostic) => { - if (generation === this.loadGeneration && diagnostic.level === "error") { - delete this.targetDocument?.body.dataset["reviewCanvasReady"]; - } - const method = diagnostic.level === "error" ? console.error : console.warn; - method(`[Review canvas ${diagnostic.source}] ${diagnostic.message}`, diagnostic.stack ?? ""); - lifecycle?.reportDiagnostic(diagnostic); - }, - }; - } + /** The bridge members every canvas shares; `onReady` runs once the mount reports ready. */ + private sharedBridge( + generation: number, + onReady: () => void, + lifecycle?: ReviewCanvasLoadLifecycle, + ): Pick< + ReviewCanvasBridge, + | "appSessionId" + | "subscribe" + | "currentTheme" + | "onDidChangeTheme" + | "currentDiffLayout" + | "setDiffLayout" + | "onDidChangeDiffLayout" + | "ready" + | "reportDiagnostic" + > { + return { + appSessionId: this.reviewTelemetryService.appSessionId, + subscribe: (listener) => this.surfaceEvents.event(listener), + currentTheme: () => this.colorScheme(), + onDidChangeTheme: (listener) => this.themeEvents.event(listener), + currentDiffLayout: () => this.diffViews.diffLayout.get(), + setDiffLayout: (layout) => this.diffViews.diffLayout.set(layout), + onDidChangeDiffLayout: (listener) => + this.diffViews.diffLayout.onDidChange(listener), + ready: () => { + if (generation !== this.loadGeneration || !this.targetDocument) return; + this.targetDocument.body.dataset["reviewCanvasReady"] = "true"; + onReady(); + }, + reportDiagnostic: (diagnostic) => { + if ( + generation === this.loadGeneration && + diagnostic.level === "error" + ) { + delete this.targetDocument?.body.dataset["reviewCanvasReady"]; + } + const method = + diagnostic.level === "error" ? console.error : console.warn; + method( + `[Review canvas ${diagnostic.source}] ${diagnostic.message}`, + diagnostic.stack ?? "", + ); + lifecycle?.reportDiagnostic(diagnostic); + }, + }; + } - private reviewRuntimeConfig( - connection: Pick, - assets: ReviewCanvasAssetsModule, - ): ReviewRuntimeConfig { - return { - ...connection, - wasmUrl: assets.reviewWasmUrl, - appVersion: this.productService.reviewVersion ?? this.productService.version, - theme: this.colorScheme(), - host: "desktop", - }; - } + private reviewRuntimeConfig( + connection: Pick, + assets: ReviewCanvasAssetsModule, + ): ReviewRuntimeConfig { + return { + ...connection, + wasmUrl: assets.reviewWasmUrl, + appVersion: + this.productService.reviewVersion ?? this.productService.version, + theme: this.colorScheme(), + host: "desktop", + }; + } - private async resetCanvasForGeneration(generation: number): Promise { - if (generation !== this.loadGeneration) { - return false; - } - this.readyInput = undefined; - this.inlineEditors.reset(); - this.diffViews.reset(); - return generation === this.loadGeneration; - } + private async resetCanvasForGeneration(generation: number): Promise { + if (generation !== this.loadGeneration) { + return false; + } + this.readyInput = undefined; + this.inlineEditors.reset(); + this.diffViews.reset(); + return generation === this.loadGeneration; + } - private setCanvasState(state: ReviewCanvasState, reviewId?: string): void { - if (!this.targetDocument) return; - this.targetDocument.body.dataset["reviewCanvasState"] = state; - if (state === "active" && reviewId) { - this.targetDocument.body.dataset["reviewId"] = reviewId; - delete this.targetDocument.body.dataset["reviewCanvasReady"]; - } else { - delete this.targetDocument.body.dataset["reviewId"]; - delete this.targetDocument.body.dataset["reviewCanvasReady"]; - } - } + private setCanvasState(state: ReviewCanvasState, reviewId?: string): void { + if (!this.targetDocument) return; + this.targetDocument.body.dataset["reviewCanvasState"] = state; + if (state === "active" && reviewId) { + this.targetDocument.body.dataset["reviewId"] = reviewId; + delete this.targetDocument.body.dataset["reviewCanvasReady"]; + } else { + delete this.targetDocument.body.dataset["reviewId"]; + delete this.targetDocument.body.dataset["reviewCanvasReady"]; + } + } - private colorScheme(): ReviewTheme { - const type = this.reviewThemeService.getColorTheme().type; - return type === ColorScheme.LIGHT || type === ColorScheme.HIGH_CONTRAST_LIGHT ? "light" : "dark"; - } + private colorScheme(): ReviewTheme { + const type = this.reviewThemeService.getColorTheme().type; + return type === ColorScheme.LIGHT || + type === ColorScheme.HIGH_CONTRAST_LIGHT + ? "light" + : "dark"; + } } class ReviewCanvasPlaceholderPart extends Part { - override readonly minimumWidth = 0; - override readonly maximumWidth = Number.POSITIVE_INFINITY; - override readonly minimumHeight = 0; - override readonly maximumHeight = Number.POSITIVE_INFINITY; + override readonly minimumWidth = 0; + override readonly maximumWidth = Number.POSITIVE_INFINITY; + override readonly minimumHeight = 0; + override readonly maximumHeight = Number.POSITIVE_INFINITY; - constructor( - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - ) { - super( - Parts.REVIEW_CANVAS_PART, - { hasTitle: false, borderWidth: () => 0 }, - themeService, - storageService, - layoutService, - ); - } + constructor( + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + ) { + super( + Parts.REVIEW_CANVAS_PART, + { hasTitle: false, borderWidth: () => 0 }, + themeService, + storageService, + layoutService, + ); + } - override create(parent: HTMLElement): void { - this.element = parent; - super.create(parent); - } + override create(parent: HTMLElement): void { + this.element = parent; + super.create(parent); + } - protected override createContentArea(parent: HTMLElement): HTMLElement { - return parent; - } + protected override createContentArea(parent: HTMLElement): HTMLElement { + return parent; + } - toJSON(): object { - return { type: Parts.REVIEW_CANVAS_PART }; - } + toJSON(): object { + return { type: Parts.REVIEW_CANVAS_PART }; + } } -export const IReviewCanvasPartsService = createDecorator("reviewCanvasPartsService"); +export const IReviewCanvasPartsService = + createDecorator("reviewCanvasPartsService"); export interface IReviewCanvasPartsService { - readonly _serviceBrand: undefined; + readonly _serviceBrand: undefined; } -export class ReviewCanvasParts extends Disposable implements IReviewCanvasPartsService { - declare readonly _serviceBrand: undefined; +export class ReviewCanvasParts + extends Disposable + implements IReviewCanvasPartsService +{ + declare readonly _serviceBrand: undefined; - constructor(@IInstantiationService instantiationService: IInstantiationService) { - super(); - this._register(instantiationService.createInstance(ReviewCanvasPlaceholderPart)); - } + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register( + instantiationService.createInstance(ReviewCanvasPlaceholderPart), + ); + } } function loadStylesheet(document: Document, url: string): Promise { - const existing = [...document.querySelectorAll('link[data-review-canvas-stylesheet="true"]')].find( - (link) => link.href === url, - ); - if (existing) return Promise.resolve(); - return new Promise((resolve, reject) => { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = url; - link.dataset["reviewCanvasStylesheet"] = "true"; - link.addEventListener("load", () => resolve(), { once: true }); - link.addEventListener("error", () => reject(new Error(`Review canvas stylesheet failed: ${url}`)), { once: true }); - document.head.appendChild(link); - }); + const existing = [ + ...document.querySelectorAll( + 'link[data-review-canvas-stylesheet="true"]', + ), + ].find((link) => link.href === url); + if (existing) return Promise.resolve(); + return new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.dataset["reviewCanvasStylesheet"] = "true"; + link.addEventListener("load", () => resolve(), { once: true }); + link.addEventListener( + "error", + () => reject(new Error(`Review canvas stylesheet failed: ${url}`)), + { once: true }, + ); + document.head.appendChild(link); + }); } diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/reviewChangedFilesTree.ts b/apps/review-desktop/code-oss/src/vs/review/browser/reviewChangedFilesTree.ts index 27f1920ba..73dc6e091 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/reviewChangedFilesTree.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/reviewChangedFilesTree.ts @@ -128,6 +128,8 @@ class ChangedFilesTreeRenderer static readonly TEMPLATE_ID = "review.changedFiles.entry"; readonly templateId = ChangedFilesTreeRenderer.TEMPLATE_ID; + constructor(private readonly states: Map) {} + renderTemplate(container: HTMLElement): ChangedFilesTreeTemplate { const row = append(container, $(".review-changed-files-row")); const icon = append(row, $("span.review-changed-files-icon")); @@ -166,6 +168,10 @@ class ChangedFilesTreeRenderer template.icon.className = isFile ? `review-changed-files-icon review-changed-files-icon-${element.file.status} ${ThemeIcon.asClassName(fileStatusIcon(element.file.status))}` : "review-changed-files-icon"; + const state = isFile ? this.states.get(element.file.path) : undefined; + if (state) template.icon.className = `review-changed-files-icon codicon codicon-${state.status === "loading" ? "loading codicon-modifier-spin" : "error"}`; + template.row.title = state?.message ?? (state?.status === "loading" ? "Loading diff…" : ""); + template.row.setAttribute("aria-busy", String(state?.status === "loading")); template.label.textContent = isFile ? element.name : elements.map((item) => item.name).join("/"); @@ -186,6 +192,7 @@ export class ReviewChangedFilesTree extends Disposable { >; private readonly fileElements = new Map(); private files: readonly ReviewDiffFileWire[] = []; + private readonly states = new Map(); private activePath: string | undefined; private syncingActiveFile = false; @@ -199,7 +206,7 @@ export class ReviewChangedFilesTree extends Disposable { container, $(".review-changed-files-tree"), ); - const renderer = new ChangedFilesTreeRenderer(); + const renderer = new ChangedFilesTreeRenderer(this.states); this.tree = this._register( instantiationService.createInstance( WorkbenchCompressibleObjectTree, @@ -254,9 +261,17 @@ export class ReviewChangedFilesTree extends Disposable { this.refresh(selectedPath); } - setActiveFile(path: string | undefined): void { + setFileState(path: string, status: "loading" | "error" | undefined, message?: string): void { + if (status) this.states.set(path, { status, message }); + else this.states.delete(path); + const element = this.fileElements.get(path); + if (element) this.tree.rerender(element); + } + + setActiveFile(path: string | undefined, reveal = true): void { + if (!reveal && this.activePath === path) return; this.activePath = path; - this.syncSelection(path); + this.syncSelection(path, reveal); } layout(height: number, width: number): void { @@ -271,13 +286,13 @@ export class ReviewChangedFilesTree extends Disposable { this.syncSelection(this.activePath ?? selectedPath); } - private syncSelection(path: string | undefined): void { + private syncSelection(path: string | undefined, reveal = true): void { const element = path ? this.fileElements.get(path) : undefined; this.syncingActiveFile = true; try { this.tree.setSelection(element ? [element] : []); this.tree.setFocus(element ? [element] : []); - if (element) this.tree.reveal(element); + if (element && reveal) this.tree.reveal(element); } finally { this.syncingActiveFile = false; } diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts index cf815ac3a..bd324eeea 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfiguration.ts @@ -20,7 +20,7 @@ import { localize } from '../../nls.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { ConfigurationScope, Extensions, type IConfigurationRegistry } from '../../platform/configuration/common/configurationRegistry.js'; -import { REVIEW_KEYMAPS, REVIEW_KEYMAP_SETTING, REVIEW_SOFTWARE_MAP_SETTING, REVIEW_TELEMETRY_SETTING, curatedExtensionConfigurationDefaults, reviewConfigurationDefaults } from './reviewConfigurationDefaults.js'; +import { REVIEW_KEYMAPS, REVIEW_KEYMAP_SETTING, REVIEW_SOFTWARE_MAP_SETTING, REVIEW_STRUCTURAL_DIFF_SETTING, REVIEW_TELEMETRY_SETTING, curatedExtensionConfigurationDefaults, reviewConfigurationDefaults } from './reviewConfigurationDefaults.js'; const configurationRegistry = Registry.as(Extensions.Configuration); @@ -41,6 +41,11 @@ configurationRegistry.registerConfiguration({ default: true, description: localize('review.telemetry.enabled', "Send anonymous Review usage data."), }, + [REVIEW_STRUCTURAL_DIFF_SETTING]: { + type: 'boolean', + default: false, + description: localize('review.experimental.structuralDiff.enabled', "Replace the standard diff view with structural diffs from diffr. Requires the diffr CLI."), + }, [REVIEW_SOFTWARE_MAP_SETTING]: { type: 'boolean', default: false, diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts index 088aaef16..ce8e7bf9e 100644 --- a/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewConfigurationDefaults.ts @@ -24,12 +24,14 @@ export const REVIEW_KEYMAP_SETTING = 'review.keymap'; export const REVIEW_TELEMETRY_SETTING = 'review.telemetry.enabled'; +export const REVIEW_STRUCTURAL_DIFF_SETTING = 'review.experimental.structuralDiff.enabled'; export const REVIEW_SOFTWARE_MAP_SETTING = 'review.experimental.softwareMap.enabled'; export const REVIEW_KEYMAPS = ['none', 'vim', 'emacs'] as const; export type ReviewKeymap = typeof REVIEW_KEYMAPS[number]; export const reviewConfigurationDefaults = { [REVIEW_SOFTWARE_MAP_SETTING]: false, + [REVIEW_STRUCTURAL_DIFF_SETTING]: false, [REVIEW_TELEMETRY_SETTING]: true, 'telemetry.telemetryLevel': 'off', 'telemetry.enableTelemetry': false, diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.test.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.test.ts new file mode 100644 index 000000000..016d601c9 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.test.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { projectSourceAlignment } from "../../editor/common/diff/sourceLineAlignment.js"; +import { + collapsedRegions, + hiddenLinesOf, + structuralContextGaps, + bandDetail, + structuralCountsTooltip, + structuralInitialCounts, + structuralHighlights, + structuralRows, + utf16Column, + type StructuralFold, + type StructuralLeaf, + type StructuralRegion, + type StructuralTextDiff, +} from "./reviewStructuralDiff.js"; + +/** + * A leaf whose `id`, `alignment_id` and `fold_state_id` are all `id`. Tests pair two leaves by giving + * them one number on both sides, which repeats the `id` across sides as diffr never does; nothing here + * keys identity across sides, and the tests that tell the ids apart set them through `extra`. + */ +function leaf(id: number, start: number, end: number, extra: Partial = {}): StructuralLeaf { + return { id, alignment_id: id, fold_state_id: id, kind: "leaf", start: { line: start, column: 0 }, end: { line: end, column: 0 }, ...extra }; +} +function fold(id: number, children: StructuralRegion[], tags: string[] = ["context:body"]): StructuralFold { + const first = children[0], last = children[children.length - 1]; + return { id, fold_state_id: id, kind: "fold", start: first.start, end: last.end, tags, children }; +} +function text(lines: string[], regions: StructuralRegion[]) { + return { text: lines.join("\n") + "\n", regions }; +} +const stats = { textual: { added: 0, removed: 0 }, visible: { added: 0, removed: 0 } }; + +test("paired collapse removes hidden height without leaving padding for hidden anchors", () => { + const rows: [number | null, number | null][] = [ + [0, 0], + [1, 1], + [null, 2], + [2, 3], + [3, 4], + ]; + const segments = projectSourceAlignment( + rows, + (l) => (l === 1 || l === 2 ? 0 : 20), + (r) => (r >= 1 && r <= 3 ? 0 : 20), + ); + assert.deepEqual( + segments.map((s) => [s.leftEnd, s.rightEnd, s.rightHeight - s.leftHeight]), + [ + [1, 1, 0], + [3, 4, 0], + [4, 5, 0], + ], + ); +}); + +test("a one-sided band's height is filled on the other side at the row that starts the fold, not past it", () => { + // Right-only row 1, then a right fold (rows 2..4) whose band is 50px and whose middle row pairs with left 1, then right-only row 5. + const rows: [number | null, number | null][] = [ + [0, 0], + [null, 1], + [null, 2], + [1, 3], + [null, 4], + [null, 5], + [2, 6], + ]; + const segments = projectSourceAlignment( + rows, + (l) => (l === 1 ? 0 : 20), + (r) => (r === 2 ? 50 : r === 3 || r === 4 ? 0 : 20), + ); + // Fillers sit after a segment's last left line: after left 0 for row 1 and the band, then again after left 0 (left 1 is folded) for row 5. + assert.deepEqual( + segments.map((s) => [s.leftEnd, s.rightEnd, s.rightHeight - s.leftHeight]), + [ + [1, 3, 70], + [2, 5, 0], + [2, 6, 20], + [3, 7, 0], + ], + ); +}); + +test("leaves zip by alignment_id into rows: paired line for line, unpaired one-sided, trailing empty lines paired", () => { + // As diffr numbers them: every region has its own id; paired leaves share alignment and fold state. + const rhsLeaf = (id: number, alignment: number, start: number, end: number) => + leaf(id, start, end, { alignment_id: alignment, fold_state_id: alignment === 9 ? id : alignment - 1 }); + const diff: StructuralTextDiff = { + type: "text", + stats, + lhs: text(["a", "b", "c", "d"], [leaf(0, 0, 1, { alignment_id: 1 }), leaf(1, 1, 2, { alignment_id: 2 }), leaf(2, 2, 4, { alignment_id: 3 })]), + rhs: text(["a", "x", "b", "c", "d"], [rhsLeaf(3, 1, 0, 1), rhsLeaf(4, 9, 1, 2), rhsLeaf(5, 2, 2, 3), rhsLeaf(6, 3, 3, 5)]), + }; + assert.deepEqual(structuralRows(diff), [ + [0, 0], + [null, 1], + [1, 2], + [2, 3], + [3, 4], + [4, 5], + ]); +}); + +test("one-sided files and nested folds still tile", () => { + const added: StructuralTextDiff = { + type: "text", + stats, + rhs: text(["fn f() {", " 1", "}"], [fold(1, [leaf(2, 0, 1), leaf(3, 1, 2), leaf(4, 2, 3)])]), + }; + assert.deepEqual(structuralRows(added), [ + [null, 0], + [null, 1], + [null, 2], + [null, 3], + ]); +}); + + +test("change paint comes from changed spans: lines with a span tint, spans paint", () => { + const diff: StructuralTextDiff = { + type: "text", + stats, + lhs: text(["a", "b"], [leaf(1, 0, 1), leaf(2, 1, 2, { changed: [{ line: 1, start_column: 0, end_column: 1 }] })]), + rhs: text(["a", "b + é"], [leaf(1, 0, 1), leaf(2, 1, 2, { changed: [{ line: 1, start_column: 2, end_column: 6 }] })]), + }; + const paint = structuralHighlights(diff); + assert.deepEqual(paint.originalLines, [2]); + assert.deepEqual(paint.modifiedLines, [2]); + assert.deepEqual(paint.modified, [{ startLineNumber: 2, startColumn: 3, endLineNumber: 2, endColumn: 6 }]); +}); + + + +test("Tree-sitter byte offsets convert to Monaco UTF-16 columns", () => { + assert.equal(utf16Column("a😀éz", 7), 5); +}); + +test("header counts are the wire's and never change with fold state", () => { + const span = (line: number) => ({ line, start_column: 0, end_column: 1 }); + const body = fold(3, [leaf(4, 2, 3), leaf(5, 3, 5, { changed: [span(3), span(4)] }), leaf(6, 5, 6)]); + const diff: StructuralTextDiff = { + type: "text", + stats: { textual: { added: 3, removed: 1 }, visible: { added: 1, removed: 3 } }, + lhs: text(["a", "b", "c", "d", "e", "f"], [leaf(1, 0, 1, { changed: [span(0)] }), leaf(2, 1, 2), body]), + rhs: text(["a", "x", "c", "d", "e", "f"], [leaf(1, 0, 1, { changed: [span(0)] }), leaf(2, 1, 2), body]), + }; + assert.deepEqual(structuralInitialCounts(diff).visible, { added: 1, removed: 3 }); + const counts = structuralInitialCounts(diff); + assert.equal(structuralCountsTooltip(counts), "visible +1 −3\ntextual +3 −1"); + assert.equal( + structuralCountsTooltip({ ...counts, fallback: { code: "unsupported_language", message: "no grammar" } }), + "visible +1 −3\ntextual +3 −1\nline diff: unsupported_language", + ); +}); + +test("a collapsed region hides every line it covers, fold or leaf; nested collapses are subsumed", () => { + const collapsed = { collapsed: true, label: "x" }; + const inner = fold(3, [leaf(4, 3, 4), leaf(5, 4, 6)]); + inner.visibility = collapsed; + const body = fold(2, [leaf(6, 2, 3), inner, leaf(7, 6, 8)]); + body.visibility = collapsed; + assert.deepEqual(hiddenLinesOf(body), { start: 2, end: 8 }); + assert.deepEqual(hiddenLinesOf(leaf(1, 0, 2, { visibility: collapsed })), { start: 0, end: 2 }); + assert.deepEqual(collapsedRegions([leaf(1, 0, 2, { visibility: collapsed }), body], (id) => id === 1 || id === 2 || id === 3).map((r) => r.fold_state_id), [1, 2]); + assert.deepEqual(collapsedRegions([leaf(1, 0, 2), body], (id) => id === 3).map((r) => r.fold_state_id), [3]); +}); + +test("every collapsed region becomes a labelled band: paired across sides, or one-sided at the aligned line", () => { + const pseudocode = "// pseudocode\nif not key: return None\nreturn call(key)"; + const gapL = leaf(1, 0, 40, { visibility: { collapsed: true, label: "40 unchanged lines" } }); + // The paired gap has an id of its own and pairs with the left one by alignment. + const gapR = leaf(11, 0, 40, { alignment_id: 1, fold_state_id: 1, visibility: { collapsed: true, label: "40 unchanged lines" } }); + // A body fold covers the body alone; the signature line is the leaf before it. + const removed = fold(2, [leaf(4, 41, 60)], ["deleted-bodies:function"]); + removed.visibility = { collapsed: true, label: "19 lines removed" }; + const added = fold(5, [leaf(7, 41, 70)], ["deleted-bodies:function"]); + added.visibility = { collapsed: true, label: pseudocode }; + const tailL = leaf(8, 60, 62), tailR = leaf(8, 70, 72); + const lines = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`); + const diff: StructuralTextDiff = { + type: "text", + stats, + lhs: text(lines(62), [gapL, leaf(3, 40, 41), removed, tailL]), + rhs: text(lines(72), [gapR, leaf(6, 40, 41), added, tailR]), + }; + const gaps = structuralContextGaps(diff, (id) => id === 1 || id === 2 || id === 5); + assert.deepEqual(gaps, [ + { originalStart: 1, originalCount: 40, modifiedStart: 1, modifiedCount: 40, label: "40 unchanged lines", kind: "unchanged", collapsed: true, foldStateId: 1, breadcrumbs: true }, + // The removed body hides lines 42..60 on the left; its rows precede the added body's, so it anchors before them on the right. + { originalStart: 42, originalCount: 19, modifiedStart: 41, modifiedCount: 0, label: "19 lines removed", kind: "removed", collapsed: true, foldStateId: 2, breadcrumbs: true }, + { originalStart: 61, originalCount: 0, modifiedStart: 42, modifiedCount: 29, label: pseudocode, kind: "inserted", collapsed: true, foldStateId: 5, breadcrumbs: true }, + ]); + // A region the reader revealed stays a band, marked open, so the editor keeps a fold control on it. + const revealed = structuralContextGaps(diff, (id) => id === 2 || id === 5, (id) => (id === 1 ? false : id === 2 || id === 5 ? true : undefined)); + assert.deepEqual(revealed.map((g) => [g.foldStateId, g.collapsed]), [[1, false], [2, true], [5, true]]); + // A region the state never knew is not a band at all, and neither is one that never starts collapsed. + assert.deepEqual(structuralContextGaps(diff, (id) => id === 2 || id === 5).map((g) => g.foldStateId), [2, 5]); + assert.deepEqual(structuralContextGaps(diff, () => false, (id) => (id === 8 ? false : id === 2 ? true : undefined)).map((g) => g.foldStateId), [2]); + // A collapsed region without a label is named by its line count. + const unlabeled = leaf(9, 0, 3, { visibility: { collapsed: true, label: "" } }); + const small: StructuralTextDiff = { type: "text", stats, lhs: text(lines(3), [unlabeled]), rhs: text(lines(3), [unlabeled]) }; + assert.equal(structuralContextGaps(small, () => true)[0].label, "3 hidden lines"); +}); + +test("a band's detail drops diffr's pseudocode marker line and keeps one-line labels empty", () => { + assert.equal(bandDetail("// pseudocode\nx = 1\n y = 2"), "x = 1\n y = 2"); + assert.equal(bandDetail("# pseudocode\nreturn x"), "return x"); + assert.equal(bandDetail("first\nsecond"), "first\nsecond"); + assert.equal(bandDetail("19 lines removed"), ""); +}); + +test("a one-sided band also hides the opposite lines the zip aligned with it", () => { + // The lhs fold is unpaired (no rhs region shares its fold state), but its body leaf (3) pairs with an rhs leaf: the band spans both. + const body = fold(2, [leaf(4, 1, 6)], ["deleted-bodies:function"]); + body.visibility = { collapsed: true, label: "5 lines removed" }; + const rhsHead = leaf(3, 0, 1), rhsBody = leaf(4, 1, 6); + const lines = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`); + const diff: StructuralTextDiff = { type: "text", stats, lhs: text(lines(6), [leaf(3, 0, 1), body]), rhs: text(lines(6), [rhsHead, rhsBody]) }; + const [gap] = structuralContextGaps(diff, (id) => id === 2); + assert.deepEqual(gap, { + originalStart: 2, originalCount: 5, modifiedStart: 2, modifiedCount: 5, + label: "5 lines removed", kind: "removed", collapsed: true, foldStateId: 2, breadcrumbs: true, + }); +}); + +test("a one-sided band starts after the row before its fold and hides only the opposite lines inside its rows", () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`); + // Right: a function whose body has an inserted run, a middle leaf paired with the left, and another inserted run. + const added = fold(20, [leaf(22, 3, 5), leaf(12, 5, 7), leaf(23, 7, 9)], ["deleted-bodies:function"]); + added.visibility = { collapsed: true, label: "// pseudocode\nreturn rows" }; + const inserted: StructuralTextDiff = { + type: "text", + stats, + lhs: text(lines(6), [leaf(10, 0, 2), leaf(12, 2, 4), leaf(14, 4, 6)]), + rhs: text(lines(11), [leaf(10, 0, 2), leaf(21, 2, 3), added, leaf(14, 9, 11)]), + }; + assert.deepEqual(structuralRows(inserted).slice(0, 10), [ + [0, 0], [1, 1], [null, 2], [null, 3], [null, 4], [2, 5], [3, 6], [null, 7], [null, 8], [4, 9], + ]); + assert.deepEqual(structuralContextGaps(inserted, (id) => id === 20), [{ + originalStart: 3, originalCount: 2, modifiedStart: 4, modifiedCount: 6, + label: "// pseudocode\nreturn rows", kind: "inserted", collapsed: true, foldStateId: 20, breadcrumbs: true, + }]); + // The mirror: a left-only function whose rows are all filler on the right, followed by a right-only line. + const removed = fold(20, [leaf(22, 3, 6)], ["deleted-bodies:function"]); + removed.visibility = { collapsed: true, label: "3 lines removed" }; + const deleted: StructuralTextDiff = { + type: "text", + stats, + lhs: text(lines(8), [leaf(10, 0, 2), leaf(21, 2, 3), removed, leaf(14, 6, 8)]), + rhs: text(lines(5), [leaf(10, 0, 2), leaf(30, 2, 3), leaf(14, 3, 5)]), + }; + assert.deepEqual(structuralRows(deleted).slice(0, 8), [[0, 0], [1, 1], [2, null], [3, null], [4, null], [5, null], [null, 2], [6, 3]]); + assert.deepEqual(structuralContextGaps(deleted, (id) => id === 20), [{ + originalStart: 4, originalCount: 3, modifiedStart: 3, modifiedCount: 0, + label: "3 lines removed", kind: "removed", collapsed: true, foldStateId: 20, breadcrumbs: true, + }]); +}); + +test("alignment and fold state are separate: the zip follows one, collapse follows the other", () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`); + // A docstring (id 1) linked to its function under fold state 7 on the rhs. The matched + // functions have their own ids (2 and 5), share fold state 7, and their leaves pair by alignment. + const doc = { ...leaf(1, 0, 2, { visibility: { collapsed: true, label: "" } }), fold_state_id: 7 }; + const fnR = { ...fold(5, [leaf(4, 3, 6)]), fold_state_id: 7 }; + fnR.visibility = { collapsed: true, label: "3 hidden lines" }; + const fnL = { ...fold(2, [leaf(4, 1, 4)]), fold_state_id: 7 }; + fnL.visibility = { collapsed: true, label: "3 hidden lines" }; + const diff: StructuralTextDiff = { type: "text", stats, lhs: text(lines(4), [leaf(3, 0, 1), fnL]), rhs: text(lines(6), [doc, leaf(3, 2, 3), fnR]) }; + // Rows pair the functions' leaves by alignment_id; the docstring is rhs-only. + assert.deepEqual(structuralRows(diff).slice(0, 3), [[null, 0], [null, 1], [0, 2]]); + // One toggle, fold state 7, hides both the docstring and the paired function. + const gaps = structuralContextGaps(diff, (id) => id === 7); + assert.deepEqual(gaps.map((g) => [g.kind, g.foldStateId]).sort(), [["inserted", 7], ["unchanged", 7]]); + assert.deepEqual(gaps.find((g) => g.kind === "unchanged"), { + originalStart: 2, originalCount: 3, modifiedStart: 4, modifiedCount: 3, + label: "3 hidden lines", kind: "unchanged", collapsed: true, foldStateId: 7, breadcrumbs: true, + }); + // With fold state 7 open, neither the docstring nor the function is a band. + assert.deepEqual(structuralContextGaps(diff, () => false), []); +}); + +test("a docstring bundled with its function shares one fold state and shows a bare count", () => { + const collapsed = { collapsed: true, label: "" }; + const doc = leaf(30, 0, 2, { tags: ["summarize:docstring"], visibility: collapsed }); + doc.fold_state_id = 40; + const body = fold(40, [leaf(42, 3, 6)], ["deleted-bodies:function"]); + body.visibility = { collapsed: true, label: "// pseudocode\nreturn x" }; + const lines = ["/// a", "/// b", "fn f() {", "x", "y", "}"]; + const diff: StructuralTextDiff = { type: "text", stats, lhs: text([], []), rhs: text(lines, [doc, leaf(41, 2, 3), body]) }; + const open = new Set(); + const state = (id: number) => (id === 40 ? !open.has(id) : undefined); + const gaps = structuralContextGaps(diff, (id) => state(id) === true, state); + assert.deepEqual(gaps.map((g) => [g.foldStateId, g.breadcrumbs, g.collapsed]), [[40, false, true], [40, true, true]]); + open.add(40); + assert.deepEqual(structuralContextGaps(diff, (id) => state(id) === true, state).map((g) => g.collapsed), [false, false]); +}); + +test("a fold pairs through fold state, and a docstring fold only with a docstring fold", () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`); + const collapsed = { collapsed: true, label: "" }; + // lhs: a docstring fold (0-3) linked to the function body (3-7). rhs: only the body (0-4), matched with the lhs body. + const docL: StructuralRegion = { ...fold(1, [leaf(2, 0, 3)], ["deleted-bodies:docstring"]), fold_state_id: 9, visibility: collapsed }; + const bodyL: StructuralRegion = { ...fold(4, [leaf(6, 4, 7)], ["deleted-bodies:function"]), fold_state_id: 9, visibility: { collapsed: true, label: "3 lines" } }; + const bodyR: StructuralRegion = { ...fold(7, [leaf(6, 1, 4)], ["deleted-bodies:function"]), fold_state_id: 9, visibility: { collapsed: true, label: "3 lines" } }; + const diff: StructuralTextDiff = { type: "text", stats, lhs: text(lines(7), [docL, leaf(5, 3, 4), bodyL]), rhs: text(lines(4), [leaf(5, 0, 1), bodyR]) }; + const gaps = structuralContextGaps(diff, (id) => id === 9); + // A whole-node docstring fold covers every line it holds; the body fold starts below + // the signature leaf beside it. + assert.deepEqual(gaps.map((g) => [g.kind, g.originalStart, g.originalCount, g.modifiedStart, g.modifiedCount, g.breadcrumbs]), [ + ["removed", 1, 3, 1, 0, false], + ["unchanged", 5, 3, 2, 3, true], + ]); + // Folds that share an id but not a fold state are not a pair. + const other: StructuralRegion = { ...bodyR, id: 4, fold_state_id: 10 }; + 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"]); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.ts new file mode 100644 index 000000000..ce92a6445 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewStructuralDiff.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * diffr's v3 wire as the native experiment reads it. Lines are 0-based and + * split on `\n` only; columns are byte offsets into the wire text; ranges + * are half-open. Sides are `lhs` (base) and `rhs` (head), and a pairing + * carries whichever sides exist. + */ +export const STRUCTURAL_WIRE_VERSION = 3; + +export interface StructuralPairing { + lhs?: T; + rhs?: T; +} +export interface StructuralProblem { + code: string; + message: string; +} +export interface StructuralPos { + line: number; + column: number; +} +export interface StructuralSpan { + line: number; + start_column: number; + end_column: number; +} +export interface StructuralVisibility { + collapsed?: boolean; + label?: string; +} +/** + * One range on one side. `id` names the region, unique within the file + * across both sides. Only a leaf has an `alignment_id`: it pairs the leaf + * with the leaf on the other side whose rows line up with it and keys the row + * zip. `fold_state_id` groups what opens and closes together, on either side, + * and keys collapse state: a fold's counterpart is the region on the other + * side sharing it. Leaves tile the file in order; a fold's range is the hull + * of its children. Tags are `:`. + */ +export type StructuralRegion = StructuralLeaf | StructuralFold; +interface StructuralRegionBase { + id: number; + fold_state_id: number; + start: StructuralPos; + end: StructuralPos; + tags?: string[]; + visibility?: StructuralVisibility; +} +export interface StructuralLeaf extends StructuralRegionBase { + kind: "leaf"; + alignment_id: number; + changed?: StructuralSpan[]; +} +export interface StructuralFold extends StructuralRegionBase { + kind: "fold"; + children: StructuralRegion[]; +} +export interface StructuralSyntaxSpan extends StructuralSpan { + capture: string; +} +export interface StructuralSource { + text: string; + syntax?: StructuralSyntaxSpan[]; + regions?: StructuralRegion[]; +} +export interface StructuralLineCounts { + added: number; + removed: number; +} +export interface StructuralStats { + textual: StructuralLineCounts; + /** Changed lines on screen under the wire's initial fold state. Always present. */ + visible: StructuralLineCounts; + /** Present when the AST match did not run and this is a line diff. */ + fallback?: StructuralProblem; +} +export type StructuralTextDiff = { type: "text"; stats: StructuralStats } & StructuralPairing; +export type StructuralBinaryDiff = { type: "binary" } & StructuralPairing<{ size: number }>; +export type StructuralDiff = StructuralTextDiff | StructuralBinaryDiff; +export interface StructuralFileRef { + path: string; + oid: string; + mode: string; +} +export interface StructuralFileChange { + file: StructuralPairing; + status: "added" | "deleted" | "modified" | "renamed" | "copied" | "type_changed"; + /** Linguist and git-attribute tags such as `generated`, `test`, `docs`, `vendored`; sorted. */ + tags?: string[]; +} +export type StructuralEvent = + | { type: "start"; version: number; files: StructuralFileChange[] } + | { + type: "file"; + file: StructuralPairing; + /** How the whole file starts out, set by a plugin: collapsed with a reason label. Absent means open. */ + visibility?: StructuralVisibility; + diff?: StructuralDiff; + error?: StructuralProblem; + } + | { type: "complete"; succeeded: number; failed: number; aborted?: StructuralProblem }; + +/** Review keys a file by its head path, or its base path for a deletion. */ +export function structuralFilePath(file: StructuralPairing): string { + return file.rhs?.path ?? file.lhs!.path; +} + +/** The 0-based, half-open line span a region touches. An end at column 0 does not touch its end line. */ +export function regionLines(region: StructuralRegion): { start: number; end: number } { + return { start: region.start.line, end: region.end.column === 0 ? region.end.line : region.end.line + 1 }; +} + +export function structuralLeaves(regions: readonly StructuralRegion[] | undefined): StructuralLeaf[] { + const leaves: StructuralLeaf[] = []; + const walk = (region: StructuralRegion) => { + if (region.kind === "leaf") leaves.push(region); + else for (const child of region.children) walk(child); + }; + for (const region of regions ?? []) walk(region); + return leaves; +} + +/** Monaco keeps a final empty line after a newline; the wire need not mention it. */ +function monacoLineCount(source: StructuralSource | undefined): number { + return source ? source.text.split("\n").length : 0; +} + +/** + * Zips the two sides' leaves by `alignment_id` into the full row table. Paired leaves + * yield rows line for line, in the same order on both sides; an unpaired leaf + * yields one-sided rows. Rows past the last leaf pair the trailing empty + * lines Monaco keeps. + */ +export function structuralRows(diff: StructuralTextDiff): [number | null, number | null][] { + const lhsLeaves = structuralLeaves(diff.lhs?.regions); + const rhsLeaves = structuralLeaves(diff.rhs?.regions); + const leftCount = monacoLineCount(diff.lhs); + const rightCount = monacoLineCount(diff.rhs); + const rows: [number | null, number | null][] = []; + let left = 0, + right = 0; + const push = (l: number | null, r: number | null) => { + rows.push([l, r]); + if (l !== null) left = l + 1; + if (r !== null) right = r + 1; + }; + const oneSided = (leaf: StructuralLeaf, side: 0 | 1) => { + const lines = regionLines(leaf); + for (let line = lines.start; line < lines.end; line++) push(side === 0 ? line : null, side === 1 ? line : null); + }; + const rhsIndex = new Map(rhsLeaves.map((leaf, index) => [leaf.alignment_id, index] as const)); + let cursor = 0; + for (const leaf of lhsLeaves) { + const partner = rhsIndex.get(leaf.alignment_id); + if (partner === undefined) { + oneSided(leaf, 0); + continue; + } + while (cursor < partner) oneSided(rhsLeaves[cursor++], 1); + const a = regionLines(leaf); + const b = regionLines(rhsLeaves[partner]); + for (let offset = 0; offset < a.end - a.start; offset++) push(a.start + offset, b.start + offset); + cursor = partner + 1; + } + while (cursor < rhsLeaves.length) oneSided(rhsLeaves[cursor++], 1); + while (left < leftCount || right < rightCount) { + rows.push([left < leftCount ? left++ : null, right < rightCount ? right++ : null]); + } + return rows; +} + +export function utf16Column(text: string, byteColumn: number): number { + let bytes = 0, + units = 0; + for (const character of text) { + if (bytes >= byteColumn) break; + bytes += new TextEncoder().encode(character).length; + units += character.length; + } + return units + 1; +} + +/** + * Change paint. A line with any `changed` span in its leaf gets the light + * whole-line tint; the spans themselves get the darker token tint. Text + * inequality alone never paints. + */ +export function structuralHighlights(diff: StructuralTextDiff) { + function side(source: StructuralSource | undefined) { + if (!source) return { spans: [], lines: [] }; + const lines = source.text.replace(/\r\n/g, "\n").split("\n"); + const changedLines = new Set(); + const spans = []; + for (const leaf of structuralLeaves(source.regions)) { + for (const span of leaf.changed ?? []) { + changedLines.add(span.line + 1); + spans.push({ + startLineNumber: span.line + 1, + startColumn: utf16Column(lines[span.line], span.start_column), + endLineNumber: span.line + 1, + endColumn: utf16Column(lines[span.line], span.end_column), + }); + } + } + return { spans, lines: [...changedLines].sort((a, b) => a - b) }; + } + const original = side(diff.lhs); + const modified = side(diff.rhs); + return { + original: original.spans, + modified: modified.spans, + originalLines: original.lines, + modifiedLines: modified.lines, + }; +} + +/** Tags are `:`; several plugins capture docstrings, each under its own prefix. */ +const isDocstring = (region: StructuralRegion) => region.tags?.some((tag) => tag.slice(tag.lastIndexOf(":") + 1) === "docstring") === true; + +/** A hidden band on one or both sides, one-based like Monaco's diff editor. */ +export interface StructuralGap { + originalStart: number; + modifiedStart: number; + originalCount: number; + modifiedCount: number; + label: string; + /** What the band hides: unchanged context, or lines that exist on one side only. */ + kind: "unchanged" | "inserted" | "removed"; + /** False for a region the reader revealed: it stays a band the editor can fold again. */ + collapsed: boolean; + /** The fold-state id of the region(s) this band hides; toggling the band toggles it. */ + foldStateId: number; + /** False for a bundled docstring: its band shows the bare count, no symbol names. */ + breadcrumbs: boolean; +} + +/** + * The text a band shows under its title. diffr prepends a ` pseudocode` + * line to a summary for terminals; the app has its own caption, so that line + * is dropped here. A one-line label has no detail. + */ +export function bandDetail(label: string): string { + const lines = label.split("\n"); + if (lines.length < 2) return ""; + const body = /^(\/\/|#|--|;|%)\s*pseudocode$/.test(lines[0].trim()) ? lines.slice(1) : lines; + return body.join("\n"); +} + +/** + * The lines a collapsed region hides on its side, zero-based half-open: every + * line it covers, fold or leaf. A fold covers its body alone — the line that + * opens the construct is the last line of the leaf before it — so a band + * hides exactly the fold's range, and the signature above it stays a row. + */ +export function hiddenLinesOf(region: StructuralRegion): { start: number; end: number } { + return regionLines(region); +} + +/** Collapsed regions of one side, outermost first; a collapsed descendant of a collapsed region is subsumed. `isCollapsed` answers for a fold-state id. */ +export function collapsedRegions( + regions: readonly StructuralRegion[] | undefined, + isCollapsed: (foldStateId: number) => boolean, +): StructuralRegion[] { + return knownRegions(regions, (id) => (isCollapsed(id) ? true : undefined)).map((r) => r.region); +} + +/** + * Regions of one side the state knows about, outermost first: collapsed ones + * and ones a reader revealed. A collapsed region subsumes its descendants; a + * revealed one still lists them, since a child may be collapsed on its own. + */ +export function knownRegions( + regions: readonly StructuralRegion[] | undefined, + state: (foldStateId: number) => boolean | undefined, +): { region: StructuralRegion; collapsed: boolean }[] { + const result: { region: StructuralRegion; collapsed: boolean }[] = []; + const walk = (region: StructuralRegion) => { + const known = state(region.fold_state_id); + const hides = hiddenLinesOf(region).end > hiddenLinesOf(region).start; + if (known === true) { + if (hides) result.push({ region, collapsed: true }); + return; + } + // Open, but a band by the wire's default: a reader revealed it, and the editor can fold it again. + if (known === false && hides && region.visibility?.collapsed === true) result.push({ region, collapsed: false }); + if (region.kind === "fold") for (const child of region.children) walk(child); + }; + for (const region of regions ?? []) walk(region); + return result; +} + +/** + * Every collapsed region as a diff-editor band. A region collapsed on both + * sides becomes one band: a leaf with the leaf sharing its `alignment_id`, a + * fold with the fold sharing its `fold_state_id` (a docstring only with a + * docstring, since a docstring shares its body's fold state). A region on one side only + * becomes a band on that side; the other side's range starts right after the + * row that precedes the region and covers only the opposite lines the zip put + * inside the region's rows, none when those rows are filler. + */ +export function structuralContextGaps( + diff: StructuralTextDiff, + isCollapsed: (foldStateId: number) => boolean, + state: (foldStateId: number) => boolean | undefined = (id) => (isCollapsed(id) ? true : undefined), +): StructuralGap[] { + const rows = structuralRows(diff); + const rowOfLeft = new Map(), rowOfRight = new Map(); + rows.forEach(([l, r], index) => { + if (l !== null) rowOfLeft.set(l, index); + if (r !== null) rowOfRight.set(r, index); + }); + // One-based start and count of the opposite range for lines hidden on `side`. Rows are monotone, so + // the opposite lines inside the region's rows follow the opposite line of the row before it. + const oppositeSpan = (side: 0 | 1, hidden: { start: number; end: number }): { start: number; count: number } => { + const other = side === 0 ? 1 : 0; + const rowOf = side === 0 ? rowOfLeft : rowOfRight; + const first = rowOf.get(hidden.start)!, last = rowOf.get(hidden.end - 1)!; + let before = -1; + for (let index = first - 1; index >= 0 && before === -1; index--) before = rows[index][other] ?? -1; + let count = 0; + for (let index = first; index <= last; index++) if (rows[index][other] !== null) count++; + return { start: before + 2, count }; + }; + const lhs = knownRegions(diff.lhs?.regions, state); + const rhs = knownRegions(diff.rhs?.regions, state); + // Leaves pair by alignment; folds pair by fold state, the only identity they share across sides. + const usedRhs = new Set(); + const pairs = (left: StructuralRegion, right: StructuralRegion) => { + if (left.kind === "leaf") return right.kind === "leaf" && right.alignment_id === left.alignment_id; + return right.kind === "fold" && right.fold_state_id === left.fold_state_id && isDocstring(right) === isDocstring(left); + }; + const counterpart = (left: StructuralRegion) => + rhs.find(({ region: right }) => !usedRhs.has(right) && pairs(left, right)); + const gaps: StructuralGap[] = []; + for (const { region: left, collapsed } of lhs) { + const hidden = hiddenLinesOf(left); + const partner = counterpart(left); + if (partner) { + usedRhs.add(partner.region); + const right = hiddenLinesOf(partner.region); + gaps.push({ + originalStart: hidden.start + 1, originalCount: hidden.end - hidden.start, + modifiedStart: right.start + 1, modifiedCount: right.end - right.start, + label: partner.region.visibility?.label || left.visibility?.label || "", + kind: "unchanged", + collapsed: collapsed && partner.collapsed, + foldStateId: left.fold_state_id, + breadcrumbs: !isDocstring(left) && !isDocstring(partner.region), + }); + continue; + } + const opposite = oppositeSpan(0, hidden); + gaps.push({ + originalStart: hidden.start + 1, originalCount: hidden.end - hidden.start, + modifiedStart: opposite.start, modifiedCount: opposite.count, + label: left.visibility?.label || "", kind: "removed", collapsed, foldStateId: left.fold_state_id, + breadcrumbs: !isDocstring(left), + }); + } + for (const { region: right, collapsed } of rhs) { + if (usedRhs.has(right)) continue; + const hidden = hiddenLinesOf(right); + const opposite = oppositeSpan(1, hidden); + gaps.push({ + originalStart: opposite.start, originalCount: opposite.count, + modifiedStart: hidden.start + 1, modifiedCount: hidden.end - hidden.start, + label: right.visibility?.label || "", kind: "inserted", collapsed, foldStateId: right.fold_state_id, + breadcrumbs: !isDocstring(right), + }); + } + gaps.sort((a, b) => (a.modifiedStart - b.modifiedStart) || (a.originalStart - b.originalStart)); + for (const gap of gaps) if (!gap.label) { + const count = Math.max(gap.originalCount, gap.modifiedCount); + gap.label = `${count} hidden line${count === 1 ? "" : "s"}`; + } + return gaps; +} + +export interface StructuralFileCounts { + visible: StructuralLineCounts; + textual: StructuralLineCounts; + fallback?: StructuralProblem; +} + +/** The file's counts, straight from the wire. Folding never changes them. */ +export function structuralInitialCounts(diff: StructuralTextDiff): StructuralFileCounts { + return { visible: diff.stats.visible, textual: diff.stats.textual, fallback: diff.stats.fallback }; +} + +export function structuralCountsTooltip(counts: StructuralFileCounts): string { + const row = (label: string, value: StructuralLineCounts) => `${label} +${value.added} −${value.removed}`; + const rows = [row("visible", counts.visible), row("textual", counts.textual)]; + if (counts.fallback) rows.push(`line diff: ${counts.fallback.code}`); + return rows.join("\n"); +} diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts index 1cd6634db..af29bac72 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts @@ -9,288 +9,493 @@ import { ILanguageService } from "../../editor/common/languages/language.js"; import type { ITextModel } from "../../editor/common/model.js"; import { IModelService } from "../../editor/common/services/model.js"; import { ITextModelService } from "../../editor/common/services/resolverService.js"; -import { IFileService, type IFileStat } from "../../platform/files/common/files.js"; +import { + IFileService, + type IFileStat, +} from "../../platform/files/common/files.js"; import { createDecorator } from "../../platform/instantiation/common/instantiation.js"; import { IEditorService } from "../../workbench/services/editor/common/editorService.js"; -import { reviewPeekWindows, reviewPeekDiffWindows, reviewPeekLineMappings } from "../common/reviewPeek.js"; +import { + reviewPeekWindows, + reviewPeekDiffWindows, + reviewPeekLineMappings, +} from "../common/reviewPeek.js"; import type { - ReviewDiffSide, - ReviewInlineEditorRange, - ReviewDiffFileWire, - ReviewInlineEditorFactory, - ReviewDiffViewFactory, - ReviewSourceEntry, - ReviewApiSourceLocation, + ReviewDiffSide, + ReviewInlineEditorRange, + ReviewDiffFileWire, + ReviewInlineEditorFactory, + ReviewDiffViewFactory, + ReviewSourceEntry, + ReviewApiSourceLocation, +} from "../common/reviewProtocol.js"; +import { + resolveReviewSourceView, + reviewSourceComparison, + reviewSourceQuery, + type ReviewSourceView, } from "../common/reviewProtocol.js"; -import { resolveReviewSourceView, reviewSourceComparison, reviewSourceQuery, type ReviewSourceView } from "../common/reviewProtocol.js"; -import { apiSourceUri, sourceLocation, sourceTreeUri, sourceTreeSelection, REVIEW_API_TREE_SCHEME, REVIEW_API_SOURCE_SCHEME } from "../common/reviewSourceView.js"; import { REVIEW_LANGUAGE_SOURCE_SCHEME } from "../common/reviewReadonlySource.js"; +import { + apiSourceUri, + sourceLocation, + sourceTreeUri, + sourceTreeSelection, + REVIEW_API_TREE_SCHEME, + REVIEW_API_SOURCE_SCHEME, +} from "../common/reviewSourceView.js"; import { IReviewCanvasEditorTabsService } from "./reviewCanvasEditorTabsService.js"; -import type { ReviewCodeModelReference, ReviewCodeDiffTarget } from "./reviewCodeResourceService.js"; -import { IReviewDesktopConnectionService, reviewResponseError } from "./reviewDesktopConnectionService.js"; -import type { ReviewDiffViewService, ReviewDiffViewSource } from "./reviewDiffViewService.js"; -import type { ReviewInlineEditorService, ReviewInlineSource } from "./reviewInlineEditorService.js"; +import type { + ReviewCodeModelReference, + ReviewCodeDiffTarget, +} from "./reviewCodeResourceService.js"; +import { + IReviewDesktopConnectionService, + reviewResponseError, +} from "./reviewDesktopConnectionService.js"; +import type { + ReviewDiffViewService, + ReviewDiffViewSource, +} from "./reviewDiffViewService.js"; +import type { + ReviewInlineEditorService, + ReviewInlineSource, +} from "./reviewInlineEditorService.js"; -export { apiSourceUri, REVIEW_API_SOURCE_SCHEME } from "../common/reviewSourceView.js"; +export { + apiSourceUri, + REVIEW_API_SOURCE_SCHEME, +} from "../common/reviewSourceView.js"; export type ApiSourceTarget = ReviewApiSourceLocation; -export const IReviewApiSourceService = createDecorator("reviewApiSourceService"); +export const IReviewApiSourceService = createDecorator( + "reviewApiSourceService", +); export interface IReviewApiSourceService { - readonly _serviceBrand: undefined; - open(target: ApiSourceTarget, range?: ReviewInlineEditorRange): Promise; - children(resource: URI): Promise; - openDiff(view: ReviewSourceView, path: string): Promise; - canvas( - view: () => ReviewSourceView, - inline: ReviewInlineEditorService, - diff: ReviewDiffViewService, - ): { - inlineEditors: ReviewInlineEditorFactory; - diffView: ReviewDiffViewFactory; - }; + readonly _serviceBrand: undefined; + open(target: ApiSourceTarget, range?: ReviewInlineEditorRange): Promise; + children(resource: URI): Promise; + openDiff(view: ReviewSourceView, path: string): Promise; + canvas( + view: () => ReviewSourceView, + inline: ReviewInlineEditorService, + diff: ReviewDiffViewService, + ): { + inlineEditors: ReviewInlineEditorFactory; + diffView: ReviewDiffViewFactory; + }; } /** Resolve native or retained resources once; all review surfaces share this path. */ -export class ReviewApiSourceService extends Disposable implements IReviewApiSourceService { - declare readonly _serviceBrand: undefined; +export class ReviewApiSourceService + extends Disposable + implements IReviewApiSourceService +{ + declare readonly _serviceBrand: undefined; - constructor( - @IReviewDesktopConnectionService private readonly session: IReviewDesktopConnectionService, - @ITextModelService private readonly models: ITextModelService, - @IModelService modelService: IModelService, - @ILanguageService languages: ILanguageService, - @IEditorService private readonly editors: IEditorService, - @IReviewCanvasEditorTabsService private readonly tabs: IReviewCanvasEditorTabsService, - @IFileService private readonly files: IFileService, - ) { - super(); - this._register( - models.registerTextModelContentProvider(REVIEW_API_SOURCE_SCHEME, { - provideTextContent: async (resource) => { - const existing = modelService.getModel(resource); - if (existing) return existing; - const query = new URLSearchParams(resource.query); - const target = sourceLocation(resource); - const body = query.has("empty") - ? { text: "" } - : await this.read<{ text: string; localPath?: string }>(target.view.reviewId, "/file", { ...reviewSourceQuery(target.view), side: target.side, file: target.file }); - const model = ( - modelService.getModel(resource) ?? - modelService.createModel( - body.text, - languages.createByFilepathOrFirstLine(resource, body.text.split("\n", 1)[0]), - resource, - ) - ); - if (body.localPath) { - this.followDisk(model, URI.file(body.localPath), async () => (await this.read<{ text: string }>(target.view.reviewId, "/file", { ...reviewSourceQuery(target.view), side: target.side, file: target.file })).text); - } - return model; - }, - }), - ); - this._register(models.registerTextModelContentProvider(REVIEW_LANGUAGE_SOURCE_SCHEME, { - provideTextContent: async resource => { - const existing = modelService.getModel(resource); - if (existing) return existing; - const local = URI.file(resource.path); - const read = async () => (await this.files.readFile(local)).value.toString(); - const model = modelService.createModel(await read(), languages.createByFilepathOrFirstLine(resource), resource); - this.followDisk(model, local, read); - return model; - }, - })); - } + constructor( + @IReviewDesktopConnectionService + private readonly session: IReviewDesktopConnectionService, + @ITextModelService private readonly models: ITextModelService, + @IModelService modelService: IModelService, + @ILanguageService languages: ILanguageService, + @IEditorService private readonly editors: IEditorService, + @IReviewCanvasEditorTabsService + private readonly tabs: IReviewCanvasEditorTabsService, + @IFileService private readonly files: IFileService, + ) { + super(); + this._register( + models.registerTextModelContentProvider(REVIEW_API_SOURCE_SCHEME, { + provideTextContent: async (resource) => { + const existing = modelService.getModel(resource); + if (existing) return existing; + const query = new URLSearchParams(resource.query); + const target = sourceLocation(resource); + const body = query.has("empty") + ? { text: "" } + : await this.read<{ text: string; localPath?: string }>( + target.view.reviewId, + "/file", + { + ...reviewSourceQuery(target.view), + side: target.side, + file: target.file, + }, + ); + const model = + modelService.getModel(resource) ?? + modelService.createModel( + body.text, + languages.createByFilepathOrFirstLine( + resource, + body.text.split("\n", 1)[0], + ), + resource, + ); + if (body.localPath) { + this.followDisk( + model, + URI.file(body.localPath), + async () => + ( + await this.read<{ text: string }>( + target.view.reviewId, + "/file", + { + ...reviewSourceQuery(target.view), + side: target.side, + file: target.file, + }, + ) + ).text, + ); + } + return model; + }, + }), + ); + this._register( + models.registerTextModelContentProvider(REVIEW_LANGUAGE_SOURCE_SCHEME, { + provideTextContent: async (resource) => { + const existing = modelService.getModel(resource); + if (existing) return existing; + const local = URI.file(resource.path); + const read = async () => + (await this.files.readFile(local)).value.toString(); + const model = modelService.createModel( + await read(), + languages.createByFilepathOrFirstLine(resource), + resource, + ); + this.followDisk(model, local, read); + return model; + }, + }), + ); + } - private followDisk(model: ITextModel, local: URI, read: () => Promise): void { - const owned = this._register(new DisposableStore()); - let revision = 0; - owned.add(this.files.watch(local)); - owned.add(this.files.onDidFilesChange(event => { - if (!event.affects(local)) return; - const request = ++revision; - void read().then(text => { - if (!model.isDisposed() && request === revision && model.getValue() !== text) model.setValue(text); - }).catch(() => { /* A missing file cannot supply fresh source. */ }); - })); - owned.add(model.onWillDispose(() => { this._store.delete(owned); owned.dispose(); })); - } + private followDisk( + model: ITextModel, + local: URI, + read: () => Promise, + ): void { + const owned = this._register(new DisposableStore()); + let revision = 0; + owned.add(this.files.watch(local)); + owned.add( + this.files.onDidFilesChange((event) => { + if (!event.affects(local)) return; + const request = ++revision; + void read() + .then((text) => { + if ( + !model.isDisposed() && + request === revision && + model.getValue() !== text + ) + model.setValue(text); + }) + .catch(() => { + /* A missing file cannot supply fresh source. */ + }); + }), + ); + owned.add( + model.onWillDispose(() => { + this._store.delete(owned); + owned.dispose(); + }), + ); + } - private async read( - reviewId: string, - route: string, - query: Record, - ): Promise { - const { serverUrl, token } = await this.session.getConnection(); - const params = new URLSearchParams( - Object.entries(query) - .filter(([key, value]) => key !== "reviewId" && value !== undefined) - .map(([key, value]) => [key, String(value)]), - ); - const response = await fetch(`${serverUrl}/reviews-api/${encodeURIComponent(reviewId)}${route}?${params}`, { - headers: { "x-review-token": token }, - signal: AbortSignal.timeout(30_000), - }); - if (!response.ok) throw await reviewResponseError(response, `Could not read pinned source (${response.status}).`); - return response.json(); - } + private async read( + reviewId: string, + route: string, + query: Record, + ): Promise { + const { serverUrl, token } = await this.session.getConnection(); + const params = new URLSearchParams( + Object.entries(query) + .filter(([key, value]) => key !== "reviewId" && value !== undefined) + .map(([key, value]) => [key, String(value)]), + ); + const response = await fetch( + `${serverUrl}/reviews-api/${encodeURIComponent(reviewId)}${route}?${params}`, + { + headers: { "x-review-token": token }, + signal: AbortSignal.timeout(30_000), + }, + ); + if (!response.ok) + throw await reviewResponseError( + response, + `Could not read pinned source (${response.status}).`, + ); + return response.json(); + } - private async sourceResource(target: ApiSourceTarget, empty = false): Promise { - return apiSourceUri(target, empty); - } + private async sourceResource( + target: ApiSourceTarget, + empty = false, + ): Promise { + return apiSourceUri(target, empty); + } - async open(target: ApiSourceTarget, range?: ReviewInlineEditorRange): Promise { - const pane = await this.editors.openEditor({ - resource: await this.sourceResource(target), - options: { - pinned: true, - ...(range - ? { - selection: { - startLineNumber: range.startLine, - startColumn: 1, - endLineNumber: range.endLine, - endColumn: Number.MAX_SAFE_INTEGER, - }, - } - : {}), - }, - }); - if (pane?.input) this.tabs.registerReviewEditor(target.view.reviewId, pane.input); - } + async open( + target: ApiSourceTarget, + range?: ReviewInlineEditorRange, + ): Promise { + const pane = await this.editors.openEditor({ + resource: await this.sourceResource(target), + options: { + pinned: true, + ...(range + ? { + selection: { + startLineNumber: range.startLine, + startColumn: 1, + endLineNumber: range.endLine, + endColumn: Number.MAX_SAFE_INTEGER, + }, + } + : {}), + }, + }); + if (pane?.input) + this.tabs.registerReviewEditor(target.view.reviewId, pane.input); + } - async openDiff(view: ReviewSourceView, path: string): Promise { - const files = await this.read(view.reviewId, "/diff", reviewSourceQuery(view)); - const file = files.find((file) => file.path === path); - if (!file) throw new Error(`File is not changed in this review version: ${path}`); - const target = { view, file: path }; - const pane = await this.editors.openEditor({ - original: { - resource: await this.sourceResource({ ...target, file: file.previousPath ?? path, side: "base" }, file.status === "added"), - }, - modified: { resource: await this.sourceResource({ ...target, side: "head" }, file.status === "deleted") }, - options: { pinned: true }, - }); - if (pane?.input) this.tabs.registerReviewEditor(view.reviewId, pane.input); - } + async openDiff(view: ReviewSourceView, path: string): Promise { + const files = await this.read( + view.reviewId, + "/diff", + reviewSourceQuery(view), + ); + const file = files.find((file) => file.path === path); + if (!file) + throw new Error(`File is not changed in this review version: ${path}`); + const target = { view, file: path }; + const pane = await this.editors.openEditor({ + original: { + resource: await this.sourceResource( + { ...target, file: file.previousPath ?? path, side: "base" }, + file.status === "added", + ), + }, + modified: { + resource: await this.sourceResource( + { ...target, side: "head" }, + file.status === "deleted", + ), + }, + options: { pinned: true }, + }); + if (pane?.input) this.tabs.registerReviewEditor(view.reviewId, pane.input); + } - async children(resource: URI): Promise { - const selection = resource.scheme === REVIEW_API_TREE_SCHEME ? sourceTreeSelection(resource) : undefined; - let target: ApiSourceTarget; - if (selection) { - const snapshot = await this.read[0]>(selection.reviewId, "", { - full: "true", version: selection.kind === "version" ? selection.version : undefined, - }); - target = { view: resolveReviewSourceView(snapshot), side: "head", file: resource.path.slice(1) }; - } else { - target = sourceLocation(resource); - } + async children(resource: URI): Promise { + const selection = + resource.scheme === REVIEW_API_TREE_SCHEME + ? sourceTreeSelection(resource) + : undefined; + let target: ApiSourceTarget; + if (selection) { + const snapshot = await this.read< + Parameters[0] + >(selection.reviewId, "", { + full: "true", + version: selection.kind === "version" ? selection.version : undefined, + }); + target = { + view: resolveReviewSourceView(snapshot), + side: "head", + file: resource.path.slice(1), + }; + } else { + target = sourceLocation(resource); + } - const entries = await this.read(target.view.reviewId, "/tree", { - ...reviewSourceQuery(target.view), side: target.side, path: target.file, - }); - return entries.map((entry) => ({ - resource: selection && entry.kind === "directory" ? sourceTreeUri(selection, entry.path) : apiSourceUri({ ...target, file: entry.path }), - name: entry.path.split("/").at(-1)!, - isFile: entry.kind === "file", - isDirectory: entry.kind === "directory", - isSymbolicLink: false, - readonly: true, - children: undefined, - })); - } + const entries = await this.read( + target.view.reviewId, + "/tree", + { + ...reviewSourceQuery(target.view), + side: target.side, + path: target.file, + }, + ); + return entries.map((entry) => ({ + resource: + selection && entry.kind === "directory" + ? sourceTreeUri(selection, entry.path) + : apiSourceUri({ ...target, file: entry.path }), + name: entry.path.split("/").at(-1)!, + isFile: entry.kind === "file", + isDirectory: entry.kind === "directory", + isSymbolicLink: false, + readonly: true, + children: undefined, + })); + } - private async snippet( - target: ApiSourceTarget, - ranges: readonly ReviewInlineEditorRange[], - ): Promise { - const resource = await this.sourceResource(target); - const reference = await this.models.createModelReference(resource); - try { - const model = reference.object.textEditorModel; - return { - model, - target: { resource }, - windows: reviewPeekWindows(model.getLineCount(), ranges, "content"), - dispose: () => reference.dispose(), - }; - } catch (error) { - reference.dispose(); - throw error; - } - } + private async snippet( + target: ApiSourceTarget, + ranges: readonly ReviewInlineEditorRange[], + ): Promise { + const resource = await this.sourceResource(target); + const reference = await this.models.createModelReference(resource); + try { + const model = reference.object.textEditorModel; + return { + model, + target: { resource }, + windows: reviewPeekWindows(model.getLineCount(), ranges, "content"), + dispose: () => reference.dispose(), + }; + } catch (error) { + reference.dispose(); + throw error; + } + } - private async peekDiff( - target: ApiSourceTarget, - ranges: readonly ReviewInlineEditorRange[], - files: Promise, - ): Promise { - const file = (await files).find( - (file) => (target.side === "base" ? (file.previousPath ?? file.path) : file.path) === target.file, - ); - if (!file) return undefined; - // Mappings come from the patch, as in the session path; the editors own the models. - const patch = - file.patch ?? - (await this.read(target.view.reviewId, "/diff", { - ...reviewSourceQuery(target.view), - file: file.path, - })); - const mappings = reviewPeekLineMappings(patch); - return { - original: await this.sourceResource( - { ...target, side: "base", file: file.previousPath ?? file.path }, - file.status === "added", - ), - modified: await this.sourceResource({ ...target, side: "head", file: file.path }, file.status === "deleted"), - diffFile: file, - mappings, - windows: (leftCount, rightCount) => reviewPeekDiffWindows(leftCount, rightCount, ranges, target.side, mappings), - }; - } + private async peekDiff( + target: ApiSourceTarget, + ranges: readonly ReviewInlineEditorRange[], + files: Promise, + ): Promise { + const file = (await files).find( + (file) => + (target.side === "base" + ? (file.previousPath ?? file.path) + : file.path) === target.file, + ); + if (!file) return undefined; + // Mappings come from the patch, as in the session path; the editors own the models. + const patch = + file.patch ?? + (await this.read(target.view.reviewId, "/diff", { + ...reviewSourceQuery(target.view), + file: file.path, + })); + const mappings = reviewPeekLineMappings(patch); + return { + original: await this.sourceResource( + { ...target, side: "base", file: file.previousPath ?? file.path }, + file.status === "added", + ), + modified: await this.sourceResource( + { ...target, side: "head", file: file.path }, + file.status === "deleted", + ), + diffFile: file, + mappings, + windows: (leftCount, rightCount) => + reviewPeekDiffWindows( + leftCount, + rightCount, + ranges, + target.side, + mappings, + ), + }; + } - canvas(view: () => ReviewSourceView, inline: ReviewInlineEditorService, diff: ReviewDiffViewService) { - const lists = new Map>(); - const files = (current: ReviewSourceView) => { - const key = JSON.stringify(reviewSourceQuery(current)); - let list = lists.get(key); - if (!list) { - list = this.read(current.reviewId, "/diff", reviewSourceQuery(current)); - list.catch(() => lists.delete(key)); - lists.set(key, list); - } - return list; - }; - const source = (file: string, side: ReviewDiffSide, ranges: readonly ReviewInlineEditorRange[]): ReviewInlineSource => { - const target = { view: view(), file, side }; - return { snippet: () => this.snippet(target, ranges), diff: () => this.peekDiff(target, ranges, files(target.view)) }; - }; - const diffSource: ReviewDiffViewSource = { - files: scope => files(reviewSourceComparison(view(), scope?.commit)), - load: async scope => { - // Capture the comparison once; live checkout bytes may change during the load. - const current = reviewSourceComparison(view(), scope?.commit); - const entries = await files(current); - return { - sourceUri: URI.from({ scheme: "review-api-diff", authority: current.reviewId, path: `/${current.version}/${current.generation ?? ""}`, query: current.commit ? `commit=${encodeURIComponent(current.commit)}` : undefined }), - entries: await Promise.all(entries.map(async file => { - const original = file.status === "added" ? undefined : await this.sourceResource({ view: current, side: "base", file: file.previousPath ?? file.path }); - const modified = file.status === "deleted" ? undefined : await this.sourceResource({ view: current, side: "head", file: file.path }); - return { file, original, modified, goToFileResource: (modified ?? original)! }; - })), - }; - }, - }; - return { - inlineEditors: { - create: (spec) => inline.create(spec, source(spec.path, spec.side, spec.ranges)), - find: (spec, query) => inline.find(spec, query, source(spec.path, spec.side, spec.ranges)), - } satisfies ReviewInlineEditorFactory, - diffView: { - create: (spec) => diff.create(spec, diffSource), - files: diffSource.files, - } satisfies ReviewDiffViewFactory, - }; - } + canvas( + view: () => ReviewSourceView, + inline: ReviewInlineEditorService, + diff: ReviewDiffViewService, + ) { + const lists = new Map>(); + const files = (current: ReviewSourceView) => { + const key = JSON.stringify(reviewSourceQuery(current)); + let list = lists.get(key); + if (!list) { + list = this.read( + current.reviewId, + "/diff", + reviewSourceQuery(current), + ); + list.catch(() => lists.delete(key)); + lists.set(key, list); + } + return list; + }; + const source = ( + file: string, + side: ReviewDiffSide, + ranges: readonly ReviewInlineEditorRange[], + ): ReviewInlineSource => { + const target = { view: view(), file, side }; + return { + snippet: () => this.snippet(target, ranges), + diff: () => this.peekDiff(target, ranges, files(target.view)), + }; + }; + const diffSource: ReviewDiffViewSource = { + files: (scope) => files(reviewSourceComparison(view(), scope?.commit)), + load: async (scope) => { + // Capture the comparison once; live checkout bytes may change during the load. + const current = reviewSourceComparison(view(), scope?.commit); + const entries = await files(current); + return { + structuralDiff: async (signal: AbortSignal) => { + const { serverUrl, token } = await this.session.getConnection(); + const query = new URLSearchParams(Object.entries(reviewSourceQuery(current)).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)])); + return fetch( + `${serverUrl}/reviews-api/${encodeURIComponent(current.reviewId)}/structural-diff?${query}`, + { headers: { "x-review-token": token }, signal }, + ); + }, + sourceUri: URI.from({ + scheme: "review-api-diff", + authority: current.reviewId, + path: `/${current.version}/${current.generation ?? ""}`, + query: current.commit + ? `commit=${encodeURIComponent(current.commit)}` + : undefined, + }), + entries: await Promise.all( + entries.map(async (file) => { + const original = + file.status === "added" + ? undefined + : await this.sourceResource({ + view: current, + side: "base", + file: file.previousPath ?? file.path, + }); + const modified = + file.status === "deleted" + ? undefined + : await this.sourceResource({ + view: current, + side: "head", + file: file.path, + }); + return { + file, + original, + modified, + goToFileResource: (modified ?? original)!, + }; + }), + ), + }; + }, + }; + return { + inlineEditors: { + create: (spec) => + inline.create(spec, source(spec.path, spec.side, spec.ranges)), + find: (spec, query) => + inline.find(spec, query, source(spec.path, spec.side, spec.ranges)), + } satisfies ReviewInlineEditorFactory, + diffView: { + create: (spec) => diff.create(spec, diffSource), + files: diffSource.files, + } satisfies ReviewDiffViewFactory, + }; + } } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts index 52876ad34..79cb5ec7c 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts @@ -16,6 +16,8 @@ type ReviewDesktopConnection, import { consumeReviewEventStream } from "../common/reviewEventStream.js"; import { type JsonValue, +type ReviewDiffrConfig, +parseReviewDiffrConfig, parseReviewCliInstallApplyResponse, parseReviewCliInstallStatus, parseReviewDesktopVerbFrame, @@ -48,6 +50,8 @@ export interface IReviewDesktopConnectionService { readonly onDidChangeConnection: Event; initialize(): Promise; getConnection(): Promise; + readDiffrConfig(): Promise; + setDiffrConfigValue(key: string, value: JsonValue): Promise; getTutorialStatus(): Promise<{ version: 1; reviewUuid: string | null }>; prepareTutorial(): Promise; openTutorial(): Promise; @@ -149,6 +153,31 @@ export class ReviewDesktopConnectionService extends Disposable implements IRevie * than a workbench setting because the reaper runs inside the review server. * `null` means never reap. */ + async readDiffrConfig(): Promise { + await this.initialize(); + const response = await fetch(`${this.serverUrl}/diffr-config`, { + headers: this.authHeaders(), + signal: AbortSignal.timeout(30_000), + }); + await this.requireOk(response, "diffr configuration"); + return parseReviewDiffrConfig(await response.json()); + } + + async setDiffrConfigValue(key: string, value: JsonValue): Promise { + await this.initialize(); + const response = await fetch(`${this.serverUrl}/diffr-config`, { + method: "PUT", + headers: { + ...this.authHeaders(), + "content-type": "application/json", + }, + body: JSON.stringify({ key, value }), + signal: AbortSignal.timeout(30_000), + }); + await this.requireOk(response, "diffr configuration"); + return parseReviewDiffrConfig(await response.json()); + } + async getTutorialStatus(): Promise<{ version: 1; reviewUuid: string | null }> { await this.initialize(); const response = await fetch(`${this.serverUrl}/tutorial/status`, { diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts index 2a1a1c1f0..5f5aad7df 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts @@ -8,21 +8,34 @@ import { Disposable, DisposableStore } from "../../base/common/lifecycle.js"; import type { URI } from "../../base/common/uri.js"; import type { ICodeEditor } from "../../editor/browser/editorBrowser.js"; import type { IMultiDiffEditorViewState } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js"; +import { IConfigurationService } from "../../platform/configuration/common/configuration.js"; import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; +import { REVIEW_STRUCTURAL_DIFF_SETTING } from "../common/reviewConfigurationDefaults.js"; import type { - ReviewCommitScope, - ReviewDiffFileWire, - ReviewDiffViewHandle, - ReviewDiffViewSpec, + ReviewCommitScope, + ReviewDiffFileWire, + ReviewDiffViewHandle, + ReviewDiffViewSpec, } from "../common/reviewProtocol.js"; import { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; import { markReviewEmbeddedEditor } from "./reviewEmbeddedNavigation.js"; -import { ReviewFilesDiffView, ReviewFilesEditorInput, type ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; +import { + ReviewFilesDiffView, + ReviewFilesEditorInput, + type ReviewFilesEditorEntry, +} from "./reviewFilesDiffView.js"; import type { ReviewInlineEditorService } from "./reviewInlineEditorService.js"; +import { prepareStructuralReview } from "./reviewStructuralDiff.js"; export interface ReviewDiffViewSource { - load(scope?: ReviewCommitScope): Promise<{ sourceUri: URI; entries: readonly ReviewFilesEditorEntry[] }>; - files(scope?: ReviewCommitScope): Promise; + load( + scope?: ReviewCommitScope, + ): Promise<{ + sourceUri: URI; + entries: readonly ReviewFilesEditorEntry[]; + structuralDiff?: (signal: AbortSignal) => Promise; + }>; + files(scope?: ReviewCommitScope): Promise; } /** @@ -31,151 +44,216 @@ export interface ReviewDiffViewSource { * follow that pane's lifetime. */ export class ReviewDiffViewService extends Disposable { - private overflowWidgetsDomNode: HTMLElement | undefined; - private readonly handles = new Set(); - readonly diffLayout: ReviewDiffLayoutSetting; - /** - * Scroll and expansion state per session document. The Diff view is a - * conditionally rendered React sibling: a toggle away disposes the widget, - * so the state must survive outside it. - */ - private readonly viewStates = new Map(); + private overflowWidgetsDomNode: HTMLElement | undefined; + private readonly handles = new Set(); + readonly diffLayout: ReviewDiffLayoutSetting; + /** + * Scroll and expansion state per session document. The Diff view is a + * conditionally rendered React sibling: a toggle away disposes the widget, + * so the state must survive outside it. + */ + private readonly viewStates = new Map(); - constructor( - private readonly inlineEditors: ReviewInlineEditorService, - @IInstantiationService - private readonly instantiationService: IInstantiationService, - ) { - super(); - this.diffLayout = this._register(instantiationService.createInstance(ReviewDiffLayoutSetting)); - } + constructor( + private readonly inlineEditors: ReviewInlineEditorService, + @IInstantiationService + private readonly instantiationService: IInstantiationService, + ) { + super(); + this.diffLayout = this._register( + instantiationService.createInstance(ReviewDiffLayoutSetting), + ); + } - setOverflowWidgetsDomNode(node: HTMLElement): void { - this.overflowWidgetsDomNode = node; - } + setOverflowWidgetsDomNode(node: HTMLElement): void { + this.overflowWidgetsDomNode = node; + } - create(spec: ReviewDiffViewSpec, source: ReviewDiffViewSource): ReviewDiffViewHandle { - const handle = new DiffViewHandle( - spec, - this.instantiationService, - this.inlineEditors, - this.overflowWidgetsDomNode, - this.diffLayout, - this.viewStates, - () => this.handles.delete(handle), - source, - ); - this.handles.add(handle); - return handle; - } + create( + spec: ReviewDiffViewSpec, + source: ReviewDiffViewSource, + ): ReviewDiffViewHandle { + const handle = new DiffViewHandle( + spec, + this.instantiationService, + this.inlineEditors, + this.overflowWidgetsDomNode, + this.diffLayout, + this.viewStates, + () => this.handles.delete(handle), + source, + ); + this.handles.add(handle); + return handle; + } - reset(): void { - for (const handle of [...this.handles]) handle.dispose(); - this.handles.clear(); - this.viewStates.clear(); - } + reset(): void { + for (const handle of [...this.handles]) handle.dispose(); + this.handles.clear(); + this.viewStates.clear(); + } - toggleRenderSideBySide(): void { - void this.diffLayout.toggle(); - } + toggleRenderSideBySide(): void { + void this.diffLayout.toggle(); + } } class DiffViewHandle extends Disposable implements ReviewDiffViewHandle { - private readonly _onDidError = this._register(new Emitter()); - readonly onDidError = this._onDidError.event; - private readonly activeControlStore = this._register(new DisposableStore()); - private view: ReviewFilesDiffView | undefined; - private viewStateKey: string | undefined; - private adoptedEditors: readonly ICodeEditor[] = []; - private disposed = false; + private readonly _onDidError = this._register(new Emitter()); + readonly onDidError = this._onDidError.event; + private readonly activeControlStore = this._register(new DisposableStore()); + private view: ReviewFilesDiffView | undefined; + private viewStateKey: string | undefined; + private adoptedEditors: readonly ICodeEditor[] = []; + private disposed = false; - constructor( - private readonly spec: ReviewDiffViewSpec, - private readonly instantiationService: IInstantiationService, - private readonly inlineEditors: ReviewInlineEditorService, - private readonly overflowWidgetsDomNode: HTMLElement | undefined, - private readonly diffLayout: ReviewDiffLayoutSetting, - private readonly viewStates: Map, - private readonly onDispose: () => void, - private readonly source: ReviewDiffViewSource, - ) { - super(); - void this.initialize(); - } + constructor( + private readonly spec: ReviewDiffViewSpec, + private readonly instantiationService: IInstantiationService, + private readonly inlineEditors: ReviewInlineEditorService, + private readonly overflowWidgetsDomNode: HTMLElement | undefined, + private readonly diffLayout: ReviewDiffLayoutSetting, + private readonly viewStates: Map, + private readonly onDispose: () => void, + private readonly source: ReviewDiffViewSource, + ) { + super(); + void this.initialize(); + } - focus(): void { - this.view?.focus(); - } + focus(): void { + this.view?.focus(); + } - override dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.captureViewState(); - // A view toggle disposes these editors. The composite must not keep one - // of them as its active editor afterwards. - for (const editor of this.adoptedEditors) { - this.inlineEditors.clearExternalActiveEditor(editor); - } - this.adoptedEditors = []; - this.view = undefined; - super.dispose(); - this.onDispose(); - } + override dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.captureViewState(); + // A view toggle disposes these editors. The composite must not keep one + // of them as its active editor afterwards. + for (const editor of this.adoptedEditors) { + this.inlineEditors.clearExternalActiveEditor(editor); + } + this.adoptedEditors = []; + this.view = undefined; + super.dispose(); + this.onDispose(); + } - private async initialize(): Promise { - try { - const { sourceUri, entries } = await this.source.load(this.spec.scope); - this.viewStateKey = sourceUri.toString(); - if (this.disposed) return; - const store = this._register(new DisposableStore()); - // The input owns the text-model references its view model resolves, so - // this handle disposes it alongside the view. - const input = store.add(this.instantiationService.createInstance(ReviewFilesEditorInput, sourceUri, entries)); - const view = store.add( - this.instantiationService.createInstance( - ReviewFilesDiffView, - this.spec.container, - this.overflowWidgetsDomNode, - this.diffLayout, - ), - ); - this.view = view; - store.add(view.onDidChangeActiveControl(() => this.bindActiveControl(view))); - await view.setInput(input, this.viewStates.get(this.viewStateKey)); - if (this.disposed) return; - this.bindActiveControl(view); - } catch (error) { - if (this.disposed) return; - this._onDidError.fire(error instanceof Error ? error.message : String(error)); - } - } + private async initialize(): Promise { + try { + const data = await this.source.load(this.spec.scope); + const { sourceUri, entries } = data; + const structuralEnabled = this.instantiationService.invokeFunction( + (a) => + a + .get(IConfigurationService) + .getValue(REVIEW_STRUCTURAL_DIFF_SETTING) === true, + ); + this.viewStateKey = `${sourceUri.toString()}:${structuralEnabled}`; + if (this.disposed) return; + const store = this._register(new DisposableStore()); + const structural = structuralEnabled + ? await prepareStructuralReview( + this.instantiationService, + entries, + store, + data.structuralDiff, + ) + : { + instantiation: this.instantiationService, + entries, + enabled: false, + load: undefined, + onDidChangeCounts: undefined, + }; + if (this.disposed) return; + // The input owns the text-model references its view model resolves, so + // this handle disposes it alongside the view. + const input = store.add( + structural.instantiation.createInstance( + ReviewFilesEditorInput, + sourceUri, + structural.entries, + structural.enabled, + ), + ); + const view = store.add( + structural.instantiation.createInstance( + ReviewFilesDiffView, + this.spec.container, + this.overflowWidgetsDomNode, + this.diffLayout, + ), + ); + this.view = view; + if (structural.enabled) view.startLoading(structural.entries); + store.add( + view.onDidChangeActiveControl(() => this.bindActiveControl(view)), + ); + // A saved whole-list offset cannot be restored into a partial streamed list. + await view.setInput( + input, + structural.enabled ? undefined : this.viewStates.get(this.viewStateKey), + ); + if (this.disposed) return; + this.bindActiveControl(view); + if (structural.load) { + store.add( + structural.onDidChangeCounts(({ path, counts }) => { + if (!this.disposed) view.fileCounts(path, counts); + }), + ); + void structural + .load((path, outcome) => { + if (this.disposed) return; + if (outcome.hidden !== undefined) + view.hideFile(path, outcome.hidden); + view.fileLoaded(path, outcome.error, outcome.stats); + }) + .catch((error) => { + if (!this.disposed) + view.loadingFailed( + error instanceof Error ? error.message : String(error), + ); + }); + } + } catch (error) { + if (this.disposed) return; + this._onDidError.fire( + error instanceof Error ? error.message : String(error), + ); + } + } - /** - * Joins the embedded diff's inner editors to the canvas composite. Find and - * the editor context keys act on the composite's active editor, so a focused - * inner editor has to become that editor. - */ - private bindActiveControl(view: ReviewFilesDiffView): void { - this.activeControlStore.clear(); - const diffEditor = view.getActiveControl(); - if (!diffEditor) return; - const unified = view.getActiveUnifiedControl(); - const editors: readonly ICodeEditor[] = unified - ? [unified] - : [diffEditor.getOriginalEditor(), diffEditor.getModifiedEditor()]; - this.adoptedEditors = editors; - for (const editor of editors) { - this.activeControlStore.add(markReviewEmbeddedEditor(editor)); - this.activeControlStore.add( - editor.onDidFocusEditorText(() => this.inlineEditors.setExternalActiveEditor(editor)), - ); - } - } + /** + * Joins the embedded diff's inner editors to the canvas composite. Find and + * the editor context keys act on the composite's active editor, so a focused + * inner editor has to become that editor. + */ + private bindActiveControl(view: ReviewFilesDiffView): void { + this.activeControlStore.clear(); + const diffEditor = view.getActiveControl(); + if (!diffEditor) return; + const unified = view.getActiveUnifiedControl(); + const editors: readonly ICodeEditor[] = unified + ? [unified] + : [diffEditor.getOriginalEditor(), diffEditor.getModifiedEditor()]; + this.adoptedEditors = editors; + for (const editor of editors) { + this.activeControlStore.add(markReviewEmbeddedEditor(editor)); + this.activeControlStore.add( + editor.onDidFocusEditorText(() => + this.inlineEditors.setExternalActiveEditor(editor), + ), + ); + } + } - private captureViewState(): void { - const key = this.viewStateKey; - const state = this.view?.getViewState(); - if (!key || !state) return; - this.viewStates.set(key, state); - } + private captureViewState(): void { + const key = this.viewStateKey; + const state = this.view?.getViewState(); + if (!key || !state) return; + this.viewStates.set(key, state); + } } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts index bcd2c6267..9add1cf2d 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts @@ -5,13 +5,20 @@ import "../browser/media/review.css"; import { $, append, Dimension } from "../../base/browser/dom.js"; -import { Orientation, SplitView } from "../../base/browser/ui/splitview/splitview.js"; +import { + Orientation, + SplitView, +} from "../../base/browser/ui/splitview/splitview.js"; import { Emitter, Event } from "../../base/common/event.js"; import { Disposable, toDisposable } from "../../base/common/lifecycle.js"; +import { autorun } from "../../base/common/observable.js"; import { isEqual } from "../../base/common/resources.js"; import { URI } from "../../base/common/uri.js"; import { ElementSizeObserver } from "../../editor/browser/config/elementSizeObserver.js"; -import type { ICodeEditor, IDiffEditor } from "../../editor/browser/editorBrowser.js"; +import type { + ICodeEditor, + IDiffEditor, +} from "../../editor/browser/editorBrowser.js"; import { MultiDiffEditorViewModel } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js"; import { MultiDiffEditorWidget } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; import type { IMultiDiffEditorViewState } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js"; @@ -21,84 +28,138 @@ import { ITextResourceConfigurationService } from "../../editor/common/services/ import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; import { MultiDiffEditorInput } from "../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js"; import { - IMultiDiffSourceResolverService, - MultiDiffEditorItem, + IMultiDiffSourceResolverService, + MultiDiffEditorItem, } from "../../workbench/contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js"; import { IEditorGroupsService } from "../../workbench/services/editor/common/editorGroupsService.js"; import { IEditorService } from "../../workbench/services/editor/common/editorService.js"; import { ITextFileService } from "../../workbench/services/textfile/common/textfiles.js"; import { ReviewChangedFilesTree } from "../browser/reviewChangedFilesTree.js"; -import { type ReviewCommitScope, type ReviewDiffFileWire } from "../common/reviewProtocol.js"; +import { + type ReviewCommitScope, + type ReviewDiffFileWire, +} from "../common/reviewProtocol.js"; +import { + structuralCountsTooltip, + type StructuralFileCounts, +} from "../common/reviewStructuralDiff.js"; import type { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; -import { reviewMultiDiffLabelUris, ReviewMultiDiffUIElementFactory } from "./reviewMultiDiff.js"; +import { + reviewMultiDiffLabelUris, + ReviewMultiDiffUIElementFactory, +} from "./reviewMultiDiff.js"; const FILE_TREE_MINIMUM_WIDTH = 180; const DIFF_MINIMUM_WIDTH = 320; const FILE_TREE_COLLAPSE_WIDTH = FILE_TREE_MINIMUM_WIDTH + DIFF_MINIMUM_WIDTH; const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { - hideUnchangedRegions: { enabled: true }, - originalEditable: false, - readOnly: true, - glyphMargin: false, - lineNumbersMinChars: 3, + hideUnchangedRegions: { enabled: true }, + originalEditable: false, + readOnly: true, + glyphMargin: false, + lineNumbersMinChars: 3, } satisfies IDiffEditorOptions; export interface ReviewFilesEditorEntry { - readonly file: ReviewDiffFileWire; - readonly original: URI | undefined; - readonly modified: URI | undefined; - readonly goToFileResource: URI; + readonly file: ReviewDiffFileWire; + readonly original: URI | undefined; + readonly modified: URI | undefined; + readonly goToFileResource: URI; } export class ReviewFilesEditorInput extends MultiDiffEditorInput { - readonly scope: ReviewCommitScope | undefined; - static override readonly ID = "workbench.input.devfast.reviewFiles"; - static readonly EDITOR_ID = "workbench.editor.devfast.reviewFiles"; - - constructor( - source: URI, - readonly entries: readonly ReviewFilesEditorEntry[], - @ITextModelService textModelService: ITextModelService, - @ITextResourceConfigurationService - textResourceConfigurationService: ITextResourceConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, - @IMultiDiffSourceResolverService - multiDiffSourceResolverService: IMultiDiffSourceResolverService, - @ITextFileService textFileService: ITextFileService, - ) { - super( - source, - "Files", - entries.map( - (entry) => - new MultiDiffEditorItem( - entry.original, - entry.modified, - entry.goToFileResource, - undefined, - undefined, - reviewMultiDiffLabelUris(entry.file), - REVIEW_FILES_DIFF_EDITOR_OPTIONS, - ), - ), - true, - textModelService, - textResourceConfigurationService, - instantiationService, - multiDiffSourceResolverService, - textFileService, - ); - const commit = new URLSearchParams(source.query).get("commit"); - this.scope = commit ? { commit } : undefined; - } - - override get typeId(): string { - return ReviewFilesEditorInput.ID; - } - - override get editorId(): string { - return ReviewFilesEditorInput.EDITOR_ID; - } + readonly scope: ReviewCommitScope | undefined; + static override readonly ID = "workbench.input.devfast.reviewFiles"; + static readonly EDITOR_ID = "workbench.editor.devfast.reviewFiles"; + + private readonly updateResources: (paths: ReadonlySet) => void; + + setReadyFiles(paths: ReadonlySet): void { + this.updateResources(paths); + } + + constructor( + source: URI, + readonly entries: readonly ReviewFilesEditorEntry[], + readonly structural: boolean = false, + @ITextModelService textModelService: ITextModelService, + @ITextResourceConfigurationService + textResourceConfigurationService: ITextResourceConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IMultiDiffSourceResolverService + multiDiffSourceResolverService: IMultiDiffSourceResolverService, + @ITextFileService textFileService: ITextFileService, + ) { + const items = entries.map( + (entry) => + new MultiDiffEditorItem( + entry.original, + entry.modified, + entry.goToFileResource, + undefined, + undefined, + reviewMultiDiffLabelUris(entry.file), + // Every hidden line comes from the one fold model: diffr's regions, never the diff editor's own unchanged-region hiding. + // Every collapsed region diffr sends is a hidden-region band; the editor's own folding stays off. + structural + ? { + ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, + hideUnchangedRegions: { + enabled: true, + minimumLineCount: 1, + contextLineCount: 0, + }, + folding: false, + glyphMargin: true, + experimental: { useTrueInlineView: false }, + } + : REVIEW_FILES_DIFF_EDITOR_OPTIONS, + ), + ); + const changes = new Emitter(); + let current: readonly MultiDiffEditorItem[] = []; + const streamSource = { + resources: { + get value() { + return current; + }, + onDidChange: changes.event, + }, + }; + super( + source, + "Files", + structural ? undefined : items, + true, + textModelService, + textResourceConfigurationService, + instantiationService, + structural + ? { + _serviceBrand: undefined, + registerResolver: (resolver) => + multiDiffSourceResolverService.registerResolver(resolver), + resolve: async () => streamSource, + } + : multiDiffSourceResolverService, + textFileService, + ); + const commit = new URLSearchParams(source.query).get("commit"); + this.scope = commit ? { commit } : undefined; + this._register(changes); + this.updateResources = (paths) => { + current = items.filter((_, index) => paths.has(entries[index].file.path)); + changes.fire(); + }; + } + + override get typeId(): string { + return ReviewFilesEditorInput.ID; + } + + override get editorId(): string { + return ReviewFilesEditorInput.EDITOR_ID; + } } /** @@ -109,179 +170,441 @@ export class ReviewFilesEditorInput extends MultiDiffEditorInput { * layout call. */ export class ReviewFilesDiffView extends Disposable { - private readonly _onDidChangeActiveControl = this._register(new Emitter()); - readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; - - private readonly root: HTMLElement; - private readonly splitView: SplitView; - private readonly changedFilesTree: ReviewChangedFilesTree; - private readonly widget: MultiDiffEditorWidget; - private viewModel: MultiDiffEditorViewModel | undefined; - private input: ReviewFilesEditorInput | undefined; - - constructor( - private readonly container: HTMLElement, - overflowWidgetsDomNode: HTMLElement | undefined, - layout: ReviewDiffLayoutSetting, - @IInstantiationService - private readonly reviewInstantiationService: IInstantiationService, - @IEditorService private readonly editorService: IEditorService, - @IEditorGroupsService - private readonly editorGroupService: IEditorGroupsService, - ) { - super(); - this.root = append(container, $(".review-files-editor")); - const fileTree = append(this.root, $(".review-files-editor-tree")); - const diffContainer = append(this.root, $(".review-files-editor-diffs")); - - const factory = this.reviewInstantiationService.createInstance( - ReviewMultiDiffUIElementFactory, - () => - this.input - ? this.input.entries.map((entry) => ({ - original: entry.original, - modified: entry.modified, - additions: entry.file.additions, - deletions: entry.file.deletions, - onDidOpen: () => { - void this.editorService.openEditor( - { - resource: entry.goToFileResource, - options: { pinned: true, revealIfVisible: true }, - }, - this.editorGroupService.mainPart.activeGroup, - ); - }, - })) - : [], - "auto", - // Hover and definition widgets must escape the canvas root, whose - // container-query containment clips position: fixed descendants. - overflowWidgetsDomNode, - false, - undefined, - ); - this.widget = this._register( - this.reviewInstantiationService.createInstance(MultiDiffEditorWidget, diffContainer, factory, undefined), - ); - // The widget's own switch, not the per-item option refresh: it pins the - // width heuristic off, so the chosen layout is what renders at any width. - const applyLayout = () => this.widget.setRenderSideBySide(layout.get() === "split"); - this._register(layout.onDidChange(applyLayout)); - applyLayout(); - this._register(this.widget.onDidChangeActiveControl(() => this._onDidChangeActiveControl.fire())); - this._register(this.widget.onDidChangeActiveUnifiedControl(() => this._onDidChangeActiveControl.fire())); - this._register(this.widget.onDidChangeActiveItem(() => this.syncFileSelectionFromWidget())); - this.changedFilesTree = this._register( - this.reviewInstantiationService.createInstance(ReviewChangedFilesTree, fileTree), - ); - this._register( - this.changedFilesTree.onDidOpenFile((file) => { - const element = this.input?.entries.find((entry) => entry.file.path === file.path); - if (!element) return; - this.reveal({ - original: element.original, - modified: element.modified, - }); - }), - ); - this.splitView = this._register( - new SplitView(this.root, { - orientation: Orientation.HORIZONTAL, - proportionalLayout: true, - }), - ); - this.splitView.addView( - { - element: fileTree, - layout: (width, _offset, height) => { - fileTree.style.width = `${width}px`; - this.changedFilesTree.layout(height ?? 0, width); - }, - maximumSize: 380, - minimumSize: FILE_TREE_MINIMUM_WIDTH, - onDidChange: Event.None, - }, - 260, - ); - this.splitView.addView( - { - element: diffContainer, - layout: (width, _offset, height) => { - diffContainer.style.width = `${width}px`; - this.widget.layout(new Dimension(width, height ?? 0)); - }, - maximumSize: Number.POSITIVE_INFINITY, - minimumSize: DIFF_MINIMUM_WIDTH, - onDidChange: Event.None, - }, - 740, - ); - - const sizeObserver = this._register(new ElementSizeObserver(this.container, undefined)); - this._register(sizeObserver.onDidChange(() => this.layout())); - sizeObserver.startObserving(); - this.layout(); - // Registered last so it runs last: the widgets above tear their own DOM - // down, and they must do that while the tree is still attached. - this._register(toDisposable(() => this.root.remove())); - } - - async setInput(input: ReviewFilesEditorInput, viewState: IMultiDiffEditorViewState | undefined): Promise { - this.input = input; - const viewModel = await input.getViewModel(); - if (this._store.isDisposed) return; - this.viewModel = viewModel; - // The canvas mounts this view without a user gesture, so the widget's - // first-change navigation must never take keyboard focus. - this.widget.setViewModel(viewModel, { preserveFocus: true, viewState }); - this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); - this.syncFileSelectionFromWidget(); - } - - getViewState(): IMultiDiffEditorViewState | undefined { - return this.viewModel ? this.widget.getViewState() : undefined; - } - - getActiveUnifiedControl(): ICodeEditor | undefined { - return this.widget.getActiveUnifiedControl(); - } - - getActiveControl(): IDiffEditor | undefined { - return this.widget.getActiveControl(); - } - - focus(): void { - (this.widget.getActiveUnifiedControl() ?? this.widget.getActiveControl())?.focus(); - } - - layout(): void { - const width = this.container.clientWidth; - const height = this.container.clientHeight; - if (width <= 0 || height <= 0) return; - - const fileTreeVisible = width >= FILE_TREE_COLLAPSE_WIDTH; - if (this.splitView.isViewVisible(0) !== fileTreeVisible) { - this.splitView.setViewVisible(0, fileTreeVisible); - } - - this.splitView.layout(width, height); - } - private reveal(resource: { original: URI | undefined; modified: URI | undefined }): void { - this.widget.reveal(resource, { highlight: true }); - } - - private syncFileSelectionFromWidget(): void { - const resource = this.widget.getActiveItem(); - const input = this.input; - if (!resource || !input) return; - const index = input.entries.findIndex( - (entry) => sameResource(entry.original, resource.original) && sameResource(entry.modified, resource.modified), - ); - if (index === -1) return; - this.changedFilesTree.setActiveFile(input.entries[index].file.path); - } + private readonly _onDidChangeActiveControl = this._register( + new Emitter(), + ); + readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; + + private readonly root: HTMLElement; + private readonly splitView: SplitView; + private readonly changedFilesTree: ReviewChangedFilesTree; + private readonly widget: MultiDiffEditorWidget; + private viewModel: MultiDiffEditorViewModel | undefined; + private input: ReviewFilesEditorInput | undefined; + private readonly readyFiles = new Set(); + private readonly fileStates = new Map(); + /** Counts from the structural stream: visible lines replace the git counts once a file lands. */ + private readonly streamStats = new Map< + string, + { counts: StructuralFileCounts; title: string } + >(); + private readonly headerFactory: ReviewMultiDiffUIElementFactory; + private readonly summary: HTMLElement; + private readonly summaryBlocks: HTMLElement[] = []; + /** Files the stream says start hidden, with the reason shown in their header. */ + private readonly hiddenFiles = new Map(); + private readonly hiddenApplied = new Set(); + private pendingPath: string | undefined; + private readonly streamStatus: HTMLElement; + + constructor( + private readonly container: HTMLElement, + overflowWidgetsDomNode: HTMLElement | undefined, + layout: ReviewDiffLayoutSetting, + @IInstantiationService + private readonly reviewInstantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService + private readonly editorGroupService: IEditorGroupsService, + ) { + super(); + this.root = append(container, $(".review-files-editor")); + const fileTree = append(this.root, $(".review-files-editor-tree")); + const diffContainer = append(this.root, $(".review-files-editor-diffs")); + + const factory = this.reviewInstantiationService.createInstance( + ReviewMultiDiffUIElementFactory, + () => + this.input + ? this.input.entries.map((entry) => ({ + original: entry.original, + modified: entry.modified, + additions: + this.streamStats.get(entry.file.path)?.counts.visible.added ?? + entry.file.additions, + deletions: + this.streamStats.get(entry.file.path)?.counts.visible.removed ?? + entry.file.deletions, + countsTitle: this.streamStats.get(entry.file.path)?.title, + note: this.hiddenFiles.get(entry.file.path), + onDidOpen: () => { + void this.editorService.openEditor( + { + resource: entry.goToFileResource, + options: { pinned: true, revealIfVisible: true }, + }, + this.editorGroupService.mainPart.activeGroup, + ); + }, + })) + : [], + "auto", + // Hover and definition widgets must escape the canvas root, whose + // container-query containment clips position: fixed descendants. + overflowWidgetsDomNode, + false, + undefined, + ); + this.headerFactory = factory; + this.widget = this._register( + this.reviewInstantiationService.createInstance( + MultiDiffEditorWidget, + diffContainer, + factory, + undefined, + ), + ); + // The widget's own switch, not the per-item option refresh: it pins the + // width heuristic off, so the chosen layout is what renders at any width. + const applyLayout = () => + this.widget.setRenderSideBySide(layout.get() === "split"); + this._register(layout.onDidChange(applyLayout)); + applyLayout(); + this.streamStatus = append( + diffContainer, + $(".review-structural-stream-status"), + ); + this.streamStatus.setAttribute("role", "status"); + this.streamStatus.hidden = true; + this._register( + this.widget.onDidChangeActiveControl(() => + this._onDidChangeActiveControl.fire(), + ), + ); + this._register( + this.widget.onDidChangeActiveUnifiedControl(() => + this._onDidChangeActiveControl.fire(), + ), + ); + this._register( + this.widget.onDidChangeActiveItem(() => + this.syncFileSelectionFromWidget(), + ), + ); + this.summary = append(fileTree, $(".review-files-editor-summary")); + this.summary.hidden = true; + this.changedFilesTree = this._register( + this.reviewInstantiationService.createInstance( + ReviewChangedFilesTree, + fileTree, + ), + ); + this._register( + this.changedFilesTree.onDidOpenFile((file) => { + const element = this.input?.entries.find( + (entry) => entry.file.path === file.path, + ); + if (!element) return; + if (this.fileStates.has(file.path)) { + this.pendingPath = file.path; + this.showStreamStatus(); + return; + } + this.pendingPath = undefined; + this.showStreamStatus(); + this.reveal({ + original: element.original, + modified: element.modified, + }); + }), + ); + this.splitView = this._register( + new SplitView(this.root, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }), + ); + this.splitView.addView( + { + element: fileTree, + layout: (width, _offset, height) => { + fileTree.style.width = `${width}px`; + this.changedFilesTree.layout( + (height ?? 0) - + (this.summary.hidden ? 0 : this.summary.offsetHeight), + width, + ); + }, + maximumSize: 380, + minimumSize: FILE_TREE_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 260, + ); + this.splitView.addView( + { + element: diffContainer, + layout: (width, _offset, height) => { + diffContainer.style.width = `${width}px`; + this.widget.layout(new Dimension(width, height ?? 0)); + }, + maximumSize: Number.POSITIVE_INFINITY, + minimumSize: DIFF_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 740, + ); + + const sizeObserver = this._register( + new ElementSizeObserver(this.container, undefined), + ); + this._register(sizeObserver.onDidChange(() => this.layout())); + sizeObserver.startObserving(); + this.layout(); + // Registered last so it runs last: the widgets above tear their own DOM + // down, and they must do that while the tree is still attached. + this._register(toDisposable(() => this.root.remove())); + } + + async setInput( + input: ReviewFilesEditorInput, + viewState: IMultiDiffEditorViewState | undefined, + ): Promise { + this.input = input; + this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); + const viewModel = await input.getViewModel(); + if (this._store.isDisposed) return; + this.viewModel = viewModel; + // The canvas mounts this view without a user gesture, so the widget's + // first-change navigation must never take keyboard focus. + this.widget.setViewModel(viewModel, { preserveFocus: true, viewState }); + this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); + this.syncFileSelectionFromWidget(); + this._register( + autorun((reader) => { + const items = viewModel.items.read(reader); + this.applyHiddenFiles(items); + const entry = this.input?.entries.find( + (e) => e.file.path === this.pendingPath, + ); + if (!entry || !this.readyFiles.has(entry.file.path)) return; + if ( + !items.some( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ) + ) + return; + this.pendingPath = undefined; + this.showStreamStatus(); + queueMicrotask(() => { + if (!this._store.isDisposed) this.reveal(entry); + }); + }), + ); + } + + startLoading(entries: readonly ReviewFilesEditorEntry[]): void { + this.changedFilesTree.setFiles(entries.map((e) => e.file)); + for (const entry of entries) { + this.fileStates.set(entry.file.path, "Loading diff…"); + this.changedFilesTree.setFileState(entry.file.path, "loading"); + } + this.showStreamStatus(); + } + + fileLoaded( + path: string, + error?: string, + stats?: { added: number; removed: number }, + ): void { + if (error) { + this.fileStates.set(path, error); + this.changedFilesTree.setFileState(path, "error", error); + } else { + this.fileStates.delete(path); + this.readyFiles.add(path); + this.changedFilesTree.setFileState(path, undefined); + this.input?.setReadyFiles(this.readyFiles); + } + this.showStreamStatus(); + } + + /** Visible counts from the stream, first on arrival and again after every fold toggle. */ + fileCounts(path: string, counts: StructuralFileCounts): void { + this.streamStats.set(path, { + counts, + title: structuralCountsTooltip(counts), + }); + this.changedFilesTree.setFiles(this.filesWithStreamStats()); + this.headerFactory.refreshHeaders(); + this.renderSummary(); + } + + /** The review-level total: the same visible counts summed over files, with GitHub's five-block bar. */ + private renderSummary(): void { + const entries = this.input?.entries ?? []; + const total = { + visible: { added: 0, removed: 0 }, + textual: { added: 0, removed: 0 }, + }; + let lineDiffs = 0; + for (const entry of entries) { + const stats = this.streamStats.get(entry.file.path); + if (!stats) continue; + total.visible.added += stats.counts.visible.added; + total.visible.removed += stats.counts.visible.removed; + total.textual.added += stats.counts.textual.added; + total.textual.removed += stats.counts.textual.removed; + if (stats.counts.fallback) lineDiffs++; + } + if (this.summaryBlocks.length === 0) { + const files = $("span.review-files-editor-summary-files"); + const added = $("span.review-files-editor-summary-added"); + const removed = $("span.review-files-editor-summary-removed"); + const bar = $("span.review-files-editor-summary-bar"); + for (let i = 0; i < 5; i++) + this.summaryBlocks.push( + append(bar, $("span.review-files-editor-summary-block")), + ); + this.summary.append(files, added, removed, bar); + } + const [files, added, removed] = this.summary + .children as unknown as HTMLElement[]; + files.textContent = + entries.length === 1 ? "1 file" : `${entries.length} files`; + added.textContent = `+${total.visible.added}`; + removed.textContent = `−${total.visible.removed}`; + const sum = total.visible.added + total.visible.removed; + const greens = sum === 0 ? 0 : Math.round((total.visible.added / sum) * 5); + const reds = sum === 0 ? 0 : Math.round((total.visible.removed / sum) * 5); + this.summaryBlocks.forEach((block, index) => { + block.className = `review-files-editor-summary-block ${index < greens ? "added" : index < greens + reds ? "removed" : "neutral"}`; + }); + this.summary.title = [ + `visible +${total.visible.added} −${total.visible.removed}`, + `textual +${total.textual.added} −${total.textual.removed}`, + ...(lineDiffs + ? [`line diff: ${lineDiffs === 1 ? "1 file" : `${lineDiffs} files`}`] + : []), + ].join("\n"); + const wasHidden = this.summary.hidden; + this.summary.hidden = false; + if (wasHidden) this.layout(); + } + + /** A file the stream says starts collapsed, and why: its record's `visibility`. */ + hideFile(path: string, label: string): void { + this.hiddenFiles.set(path, label); + this.headerFactory.refreshHeaders(); + if (this.viewModel) this.applyHiddenFiles(this.viewModel.items.get()); + } + + /** GitHub's shape for a hidden file: the header stays, the body waits for a click. */ + private applyHiddenFiles( + items: readonly { + originalUri: URI | undefined; + modifiedUri: URI | undefined; + collapsed: { set(value: boolean, tx: undefined): void }; + }[], + ): void { + for (const entry of this.input?.entries ?? []) { + if ( + !this.hiddenFiles.has(entry.file.path) || + this.hiddenApplied.has(entry.file.path) + ) + continue; + const item = items.find( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ); + if (!item) continue; + item.collapsed.set(true, undefined); + this.hiddenApplied.add(entry.file.path); + } + } + + private filesWithStreamStats(): ReviewDiffFileWire[] { + return (this.input?.entries ?? []).map((entry) => { + const stats = this.streamStats.get(entry.file.path); + return stats + ? { + ...entry.file, + additions: stats.counts.visible.added, + deletions: stats.counts.visible.removed, + } + : entry.file; + }); + } + + loadingFailed(message: string): void { + for (const [path, state] of this.fileStates) + if (state === "Loading diff…") this.fileLoaded(path, message); + this.showStreamStatus(); + } + + private showStreamStatus(): void { + const message = this.pendingPath + ? this.fileStates.get(this.pendingPath) + : undefined; + this.streamStatus.hidden = + (!message && this.readyFiles.size > 0) || this.fileStates.size === 0; + this.streamStatus.textContent = message + ? `${this.pendingPath}: ${message}` + : this.readyFiles.size === 0 + ? ([...this.fileStates.values()][0] ?? "") + : ""; + this.streamStatus.classList.toggle( + "loading", + [...this.fileStates.values()].some((s) => s === "Loading diff…"), + ); + } + + getViewState(): IMultiDiffEditorViewState | undefined { + return this.viewModel ? this.widget.getViewState() : undefined; + } + + getActiveUnifiedControl(): ICodeEditor | undefined { + return this.widget.getActiveUnifiedControl(); + } + + getActiveControl(): IDiffEditor | undefined { + return this.widget.getActiveControl(); + } + + focus(): void { + ( + this.widget.getActiveUnifiedControl() ?? this.widget.getActiveControl() + )?.focus(); + } + + layout(): void { + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width <= 0 || height <= 0) return; + + const fileTreeVisible = width >= FILE_TREE_COLLAPSE_WIDTH; + if (this.splitView.isViewVisible(0) !== fileTreeVisible) { + this.splitView.setViewVisible(0, fileTreeVisible); + } + + this.splitView.layout(width, height); + } + private reveal(resource: { + original: URI | undefined; + modified: URI | undefined; + }): void { + this.widget.reveal(resource, { highlight: true }); + } + + private syncFileSelectionFromWidget(): void { + const resource = this.widget.getActiveItem(); + const input = this.input; + if (!resource || !input || this.pendingPath) return; + const index = input.entries.findIndex( + (entry) => + sameResource(entry.original, resource.original) && + sameResource(entry.modified, resource.modified), + ); + if (index === -1) return; + // Passive editor updates must not move a sidebar the reader scrolled independently. + this.changedFilesTree.setActiveFile(input.entries[index].file.path, false); + } } function sameResource(left: URI | undefined, right: URI | undefined): boolean { - return left === undefined ? right === undefined : !!right && isEqual(left, right); + return left === undefined + ? right === undefined + : !!right && isEqual(left, right); } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts index 491021039..dab62c1df 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts @@ -23,6 +23,10 @@ export interface ReviewMultiDiffHeaderEntry { readonly modified: URI | undefined; readonly additions?: number; readonly deletions?: number; + /** Why the file starts collapsed, e.g. "Generated file · hidden by default". */ + readonly note?: string; + /** Hover text for the counts: visible, structural and textual rows. */ + readonly countsTitle?: string; readonly onDidOpen?: () => void; } @@ -35,6 +39,13 @@ export class ReviewMultiDiffUIElementFactory return !this.hideResourceHeader; } + private readonly headers = new Set<() => void>(); + + /** Re-reads every live header's entry, for counts that change without the items changing. */ + refreshHeaders(): void { + for (const refresh of this.headers) refresh(); + } + constructor( private readonly entries: () => readonly ReviewMultiDiffHeaderEntry[], readonly horizontalScrollbar: "auto" | "hidden", @@ -97,6 +108,9 @@ export class ReviewMultiDiffUIElementFactory counts.className = "review-multidiff-counts"; counts.append(additions, deletions); element.append(counts); + const note = ownerDocument.createElement("span"); + note.className = "review-multidiff-note"; + element.append(note); const openContainer = ownerDocument.createElement("span"); openContainer.className = "review-multidiff-open-container"; @@ -109,10 +123,13 @@ export class ReviewMultiDiffUIElementFactory open.label = `$(${Codicon.goToFile.id}) Open file`; open.element.classList.add("review-multidiff-open"); let current: ReviewMultiDiffHeaderEntry | undefined; + let lastUris: Parameters[0]; const openListener = open.onDidClick(() => current?.onDidOpen?.()); + const refresh = () => setUris(lastUris); + this.headers.add(refresh); - return { - setUris: (uris) => { + const setUris: IResourceHeaderMetadata["setUris"] = (uris) => { + lastUris = uris; current = uris ? this.entries().find( (entry) => @@ -133,9 +150,15 @@ export class ReviewMultiDiffUIElementFactory `${current.additions} lines added, ${current.deletions} lines removed`, ); } + counts.title = current.countsTitle ?? ""; + note.hidden = !current.note; + note.textContent = current.note ?? ""; openContainer.hidden = !current.onDidOpen; - }, - dispose() { + }; + return { + setUris, + dispose: () => { + this.headers.delete(refresh); openListener.dispose(); open.dispose(); element.replaceChildren(); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewStructuralDiff.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewStructuralDiff.ts new file mode 100644 index 000000000..853e37d23 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewStructuralDiff.ts @@ -0,0 +1,325 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IModelService } from "../../editor/common/services/model.js"; +import { ITextModelService } from "../../editor/common/services/resolverService.js"; +import { ILanguageService } from "../../editor/common/languages/language.js"; +import { URI } from "../../base/common/uri.js"; +import { Emitter, Event } from "../../base/common/event.js"; +import { DisposableStore, toDisposable } from "../../base/common/lifecycle.js"; +import { CancellationError } from "../../base/common/errors.js"; +import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; +import { ServiceCollection } from "../../platform/instantiation/common/serviceCollection.js"; +import { IDiffProviderFactoryService } from "../../editor/browser/widget/diffEditor/diffProviderFactoryService.js"; +import { ICodeEditorService } from "../../editor/browser/services/codeEditorService.js"; +import type { IDiffEditor } from "../../editor/browser/editorBrowser.js"; +import { LineRange } from "../../editor/common/core/ranges/lineRange.js"; +import { DetailedLineRangeMapping } from "../../editor/common/diff/rangeMapping.js"; +import { autorun, type IObservable } from "../../base/common/observable.js"; +import type { UnchangedRegion } from "../../editor/browser/widget/diffEditor/diffEditorViewModel.js"; +import { + structuralContextGaps, + structuralFilePath, + structuralInitialCounts, + structuralRows, + structuralHighlights, + STRUCTURAL_WIRE_VERSION, + type StructuralEvent, + type StructuralFileCounts, + type StructuralLineCounts, + type StructuralRegion, + type StructuralSource, + type StructuralTextDiff, +} from "../common/reviewStructuralDiff.js"; +import type { ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; + +/** What the stream says about one file once its result arrives. */ +export interface StructuralFileOutcome { + error?: string; + stats?: StructuralLineCounts; + /** The reason label when a plugin hid the whole file. */ + hidden?: string; +} + +/** Owned by one Files view, including provider registrations and fold listeners. */ +export async function prepareStructuralReview( + instantiation: IInstantiationService, + entries: readonly ReviewFilesEditorEntry[], + lifetime: DisposableStore, + request: (signal: AbortSignal) => Promise, +): Promise<{ + instantiation: IInstantiationService; + enabled: boolean; + entries: readonly ReviewFilesEditorEntry[]; + load( + onFile: (path: string, outcome: StructuralFileOutcome) => void, + ): Promise; + /** Fires when a file's visible counts change: on arrival and on every fold toggle. */ + onDidChangeCounts: Event<{ path: string; counts: StructuralFileCounts }>; +}> { + const files = new Map(); + const binary = new Set(); + /** Collapse state by `${path}:${fold_state_id}`, seeded from the wire's initial visibility. A fold-state id spans sides. */ + const collapsed = new Map(); + const collapseKey = (path: string, foldStateId: number) => `${path}:${foldStateId}`; + const countsChanged = lifetime.add(new Emitter<{ path: string; counts: StructuralFileCounts }>()); + // Counts are the wire's, read once when a file arrives; folding never changes them. + const emitCounts = (path: string) => { + const diff = files.get(path); + if (!diff) return; + countsChanged.fire({ path, counts: structuralInitialCounts(diff) }); + }; + // Use pinned checkout resources so native language providers see real project files. + // Revision-only resources retain their virtual snapshot identity. + const modelService = instantiation.invokeFunction((a) => a.get(IModelService)); + const resolver = instantiation.invokeFunction((a) => a.get(ITextModelService)); + const languages = instantiation.invokeFunction((a) => a.get(ILanguageService)); + lifetime.add(resolver.registerTextModelContentProvider("review-structural-empty", { + provideTextContent: async uri => modelService.getModel(uri) ?? modelService.createModel("", null, uri), + })); + const resolvedEntries = entries.map(entry => ({ + ...entry, + original: entry.original ?? URI.from({ scheme: "review-structural-empty", path: "/base/" + entry.file.path, query: entry.modified!.toString() }), + modified: entry.modified ?? URI.from({ scheme: "review-structural-empty", path: "/head/" + entry.file.path, query: entry.original!.toString() }), + })); + const pairs = new Map(resolvedEntries.map(e => [e.original!.toString() + "\n" + e.modified!.toString(), e.file.path])); + async function accept(event: Extract): Promise<{ path: string; stats?: StructuralLineCounts }> { + const path = structuralFilePath(event.file); + const entry = resolvedEntries.find(e => e.file.path === path); + if (!entry) throw new Error(`diffr returned an unexpected file: ${path}`); + if (event.error) throw new Error(event.error.message); + const diff = event.diff!; + if (diff.type === "text") { + const sides: [typeof entry.original, StructuralSource | undefined][] = [ + [entry.original, diff.lhs], + [entry.modified, diff.rhs], + ]; + for (const [uri, source] of sides) { + const text = source?.text ?? ""; + if (uri.scheme !== "file" && !modelService.getModel(uri)) + modelService.createModel(text, languages.createByFilepathOrFirstLine(uri), uri); + const reference = lifetime.add(await resolver.createModelReference(uri)); + if (reference.object.textEditorModel.getValue().replace(/\r\n/g, "\n") !== text.replace(/\r\n/g, "\n")) + throw new Error("Pinned checkout differs from diffr's source snapshot; reload the review."); + } + } + if (lifetime.isDisposed) throw new CancellationError(); + if (diff.type === "text") { + structuralInitialCounts(diff); + files.set(path, diff); + for (const source of [diff.lhs, diff.rhs]) { + const seed = (region: StructuralRegion) => { + const key = collapseKey(path, region.fold_state_id); + if (!collapsed.has(key)) collapsed.set(key, region.visibility?.collapsed === true); + if (region.kind === "fold") region.children.forEach(seed); + }; + (source?.regions ?? []).forEach(seed); + } + } else binary.add(path); + emitCounts(path); + return { path, stats: diff.type === "text" ? diff.stats.textual : undefined }; + } + async function load( + onFile: (path: string, outcome: StructuralFileOutcome) => void, + ): Promise { + const abort = new AbortController(); + lifetime.add(toDisposable(() => abort.abort())); + const response = await request(abort.signal); + if (!response.ok) throw new Error((await response.json()).error ?? "Structural diff request failed."); + if (!response.body || !response.headers.get("content-type")?.includes("ndjson")) + throw new Error("The Review host must be updated to stream structural diffs."); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = "", started = false, complete = false; + const seen = new Set(); + async function line(text: string) { + if (!text.trim()) return; + const event = JSON.parse(text) as StructuralEvent | { type: "error"; message: string }; + if (!started) { + if (event.type === "error") throw new Error(event.message); + if (event.type !== "start" || event.version !== STRUCTURAL_WIRE_VERSION) throw new Error("Unsupported diffr stream protocol."); + started = true; + } else if (event.type === "file") { + const path = structuralFilePath(event.file); + seen.add(path); + try { + const accepted = await accept(event); + onFile(accepted.path, { + stats: accepted.stats, + hidden: event.visibility?.collapsed ? event.visibility.label || "Hidden by default" : undefined, + }); + } catch (error) { + if (lifetime.isDisposed) throw error; + onFile(path, { error: error instanceof Error ? error.message : String(error) }); + } + } else if (event.type === "complete") { + complete = true; + if (event.aborted) throw new Error(`diffr stopped early: ${event.aborted.message}`); + } else throw new Error((event as { message?: string }).message ?? `Unexpected diffr event: ${event.type}`); + } + try { + while (true) { + const chunk = await reader.read(); + buffer += decoder.decode(chunk.value, { stream: !chunk.done }); + let end: number; + while ((end = buffer.indexOf("\n")) >= 0) { + const text = buffer.slice(0, end); buffer = buffer.slice(end + 1); + await line(text); + } + if (chunk.done) break; + } + await line(buffer); + if (!complete) throw new Error("diffr stream ended before completion."); + for (const entry of entries) if (!seen.has(entry.file.path)) + onFile(entry.file.path, { error: "diffr did not supply a result for this file." }); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + abort.abort(); + } + } + /** Fires when collapse state changes, so every diff editor recomputes its bands. */ + const providerChanged = lifetime.add(new Emitter()); + const factory: IDiffProviderFactoryService = { + _serviceBrand: undefined, + createDiffProvider() { + return { + onDidChange: providerChanged.event, + async computeDiff(original, modified, _options, token) { + if (token.isCancellationRequested) throw new CancellationError(); + const path = pairs.get(original.uri.toString() + "\n" + modified.uri.toString()); + if (path !== undefined && binary.has(path)) { + return { changes: [], moves: [], identical: false, quitEarly: false, changeHighlights: { original: [], modified: [] } }; + } + const diff = path === undefined ? undefined : files.get(path); + if (!diff) throw new Error("diffr did not supply a result for this file."); + const left = (diff.lhs?.text ?? "").replace(/\r\n/g, "\n"); + const right = (diff.rhs?.text ?? "").replace(/\r\n/g, "\n"); + if ( + original.getLinesContent().join("\n") !== left || + modified.getLinesContent().join("\n") !== right + ) { + throw new Error( + "diffr sources differ from Review's editor snapshots; reload the review.", + ); + } + const rows = structuralRows(diff); + // Changed-ness comes from the wire: a one-sided row, or a paired row whose line carries a changed span. + const highlights = structuralHighlights(diff); + const changedLeft = new Set(highlights.originalLines), changedRight = new Set(highlights.modifiedLines); + const changes: DetailedLineRangeMapping[] = []; + let l = 0, + r = 0; + let start: [number, number] | undefined; + const flush = () => { + if (start) + changes.push( + new DetailedLineRangeMapping( + new LineRange(start[0] + 1, l + 1), + new LineRange(start[1] + 1, r + 1), + undefined, + ), + ); + start = undefined; + }; + for (const [a, b] of rows) { + const changed = a === null || b === null || changedLeft.has(a + 1) || changedRight.has(b + 1); + if (changed) start ??= [l, r]; + else flush(); + if (a !== null) l = a + 1; + if (b !== null) r = b + 1; + } + flush(); + return { + changes, + moves: [], + identical: left === right, + quitEarly: false, + sourceLineAlignment: rows, + // Every collapsed region is a hidden-region band, labelled by the wire. + contextGaps: structuralContextGaps( + diff, + (id) => collapsed.get(collapseKey(path!, id)) === true, + (id) => collapsed.get(collapseKey(path!, id)), + ), + changeHighlights: highlights, + }; + }, + }; + }, + }; + const child = lifetime.add( + instantiation.createChild(new ServiceCollection([IDiffProviderFactoryService, factory])), + ); + attachStructuralEditors(instantiation, resolvedEntries, files, lifetime, { + get: (path, id) => collapsed.get(collapseKey(path, id)), + set: (path, id, value) => { + if (collapsed.get(collapseKey(path, id)) === value) return; + collapsed.set(collapseKey(path, id), value); + providerChanged.fire(); + }, + }); + return { instantiation: child, enabled: true, entries: resolvedEntries, load, onDidChangeCounts: countsChanged.event }; +} + +/** + * Keeps each structural diff editor's bands in step with the collapse state: + * a band a reader reveals (its arrows, or double-click) marks its fold state + * open, which covers both sides by construction, and the visible counts follow. + */ +function attachStructuralEditors( + instantiation: IInstantiationService, + entries: readonly ReviewFilesEditorEntry[], + files: Map, + lifetime: DisposableStore, + collapsed: CollapseState, +): void { + const editors = instantiation.invokeFunction((a) => a.get(ICodeEditorService)); + const pairs = new Map(entries.map((e) => [e.original!.toString() + "\n" + e.modified!.toString(), e.file.path])); + function watch(editor: IDiffEditor) { + const store = lifetime.add(new DisposableStore()); + store.add(editor.onDidDispose(() => store.dispose())); + const widget = editor as unknown as { unchangedRegions?: IObservable }; + if (!widget.unchangedRegions) return; + let revealed = new Set(); + store.add( + autorun((reader) => { + const model = editor.getModel(); + const path = model && pairs.get(model.original.uri.toString() + "\n" + model.modified.uri.toString()); + const regions = widget.unchangedRegions!.read(reader); + if (!path || !files.has(path)) return; + const gaps = structuralContextGaps(files.get(path)!, (id) => collapsed.get(path, id) === true, (id) => collapsed.get(path, id)); + const next = new Set(); + const gapOf = (region: UnchangedRegion) => + gaps.find((g) => g.originalStart === region.originalLineNumber && g.modifiedStart === region.modifiedLineNumber && g.label === region.label); + for (const region of regions) { + const fullyShown = region.visibleLineCountTop.read(reader) + region.visibleLineCountBottom.read(reader) >= region.lineCount; + if (fullyShown) { + next.add(region); + if (revealed.has(region)) continue; + const gap = gapOf(region); + if (!gap) continue; + collapsed.set(path, gap.foldStateId, false); + } else if (revealed.has(region)) { + // Monaco's own fold control closed a region we had marked open. + const gap = gapOf(region); + if (!gap) continue; + collapsed.set(path, gap.foldStateId, true); + } + } + revealed = next; + }), + ); + } + + lifetime.add(editors.onDiffEditorAdd(watch)); + for (const editor of editors.listDiffEditors()) watch(editor); +} + +/** Collapse state per file, keyed by fold-state id. */ +interface CollapseState { + get(path: string, foldStateId: number): boolean | undefined; + set(path: string, foldStateId: number, value: boolean): void; +} diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index d8ac02479..c55f5fbf3 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -1,4 +1,4 @@ -import { type JsonValue, isJsonObject } from "@dev.fast/json"; +import { type JsonObject, type JsonValue, isJsonObject } from "@dev.fast/json"; import { ReviewAgentTraceEventSchema, ReviewAgentTraceSessionSchema, @@ -348,6 +348,35 @@ export const DEFAULT_DISMISSED_RETENTION_DAYS = 30; * setter resolves with the value that actually landed, so a row re-renders from * the authoritative result instead of an optimistic one. */ +/** + * diffr's configuration as its CLI reports it: `schema` is the JSON Schema + * from `diffr config schema`, whose properties carry `description` and + * `default`; `values` is the resolved configuration from `diffr config show`. + */ +export interface ReviewDiffrConfig { + schema: JsonObject; + values: JsonObject; +} + +export function parseReviewDiffrConfig(value: JsonValue): ReviewDiffrConfig { + if ( + !isJsonObject(value) || + !isJsonObject(value.schema) || + !isJsonObject(value.values) + ) { + throw new Error("diffr configuration response is malformed."); + } + + return { schema: value.schema, values: value.values }; +} + +export interface ReviewDiffrConfigActions { + read(): Promise; + // Writes one dotted key with `diffr config set` and resolves with the fresh + // configuration, so the form re-renders from what actually landed. + set(key: string, value: JsonValue): Promise; +} + export interface ReviewCanvasSettingsContent { // Backed by the `review.telemetry.enabled` workbench setting, which the // review server and the CLI both read. @@ -364,6 +393,13 @@ export interface ReviewCanvasSettingsContent { // in the server preferences file. `null` turns reaping off. softwareMapEnabled: boolean; setSoftwareMapEnabled(enabled: boolean): Promise; + structuralDiffEnabled: boolean; + setStructuralDiffEnabled(enabled: boolean): Promise; + // diffr's own configuration, read and written through its CLI on the host + // so the diffr TUI and Review edit one file. Reads run lazily: the page asks + // when the Structural diff section renders, and a missing executable + // surfaces as the read's error rather than breaking the whole page. + diffrConfig: ReviewDiffrConfigActions; manageExtensions(): void; // Agent installs are managed here too, so they stay reachable once Home // has reviews and no longer shows the Welcome rail. Absent when the @@ -438,6 +474,7 @@ export type ReviewCanvasContent = kind: "api"; tutorial?: ReviewCanvasTutorialBridge; setTutorial?(enabled: boolean): void; + structuralDiffEnabled?: boolean; softwareMapEnabled?: boolean; reviewId: string; version?: number; diff --git a/packages/review/app/src/api-canvas.tsx b/packages/review/app/src/api-canvas.tsx index 563c06699..c2bc83992 100644 --- a/packages/review/app/src/api-canvas.tsx +++ b/packages/review/app/src/api-canvas.tsx @@ -196,7 +196,7 @@ export function ApiCanvas({ inlineEditors: { ...content.bridge.inlineEditors }, diffView: { ...content.bridge.diffView }, }), - [content.bridge, sourceVersion], + [content.bridge, sourceVersion, content.structuralDiffEnabled], ); const baseSession = useMemo(() => { diff --git a/packages/review/app/src/diffr-config-form.test.ts b/packages/review/app/src/diffr-config-form.test.ts new file mode 100644 index 000000000..163c24f41 --- /dev/null +++ b/packages/review/app/src/diffr-config-form.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + diffrConfigDefaultText, + diffrConfigFields, + diffrConfigInputValue, +} from "./diffr-config-form"; + +const schema = { + type: "object", + properties: { + summarize: { + type: "object", + properties: { + provider: { + type: "string", + enum: ["gemini"], + default: "gemini", + description: "Model provider used for fold summaries.", + }, + api_key: { + type: ["string", "null"], + description: "API key for the provider.", + }, + }, + }, + folds: { $ref: "#/$defs/Folds" }, + languages: { + type: "object", + additionalProperties: { type: "string" }, + description: "Per-language query overrides.", + }, + }, + $defs: { + Folds: { + type: "object", + properties: { + min_lines: { + type: "integer", + default: 12, + description: "Bodies shorter than this are never summarized.", + }, + collapse_deleted: { type: "boolean", default: true }, + }, + }, + }, +}; + +describe("diffrConfigFields", () => { + it("flattens nested objects and $refs into dotted keys with current values", () => { + const fields = diffrConfigFields(schema, { + summarize: { provider: "gemini" }, + folds: { min_lines: 20, collapse_deleted: false }, + }); + + expect(fields.map((field) => [field.key, field.kind, field.value])).toEqual( + [ + ["summarize.provider", "enum", "gemini"], + ["summarize.api_key", "string", undefined], + ["folds.min_lines", "number", 20], + ["folds.collapse_deleted", "boolean", false], + ], + ); + expect(fields[0].choices).toEqual(["gemini"]); + expect(fields[2].description).toBe( + "Bodies shorter than this are never summarized.", + ); + }); + + it("marks credential keys as secret and hides their defaults", () => { + const fields = diffrConfigFields(schema, {}); + const key = fields.find((field) => field.key === "summarize.api_key"); + expect(key?.secret).toBe(true); + expect(diffrConfigDefaultText(fields[2])).toBe("12"); + expect(diffrConfigDefaultText(key!)).toBe(""); + }); + + it("refuses to guess at an unknown definition", () => { + expect(() => + diffrConfigFields({ properties: { x: { $ref: "#/$defs/Missing" } } }, {}), + ).toThrow("unknown definition"); + }); +}); + +describe("diffrConfigInputValue", () => { + it("parses numbers and rejects blanks", () => { + const field = diffrConfigFields(schema, {})[2]; + expect(diffrConfigInputValue(field, "8")).toBe(8); + expect(() => diffrConfigInputValue(field, "")).toThrow("must be a number"); + }); +}); + +describe("current diffr plugin schema", () => { + it("uses setting titles and groups and omits fields marked non-editable", () => { + const fields = diffrConfigFields( + { + properties: { + plugins: { + properties: { + "deleted-bodies": { + properties: { + min_lines: { + type: "integer", + title: "Shortest body to collapse", + "x-group": "Deleted function bodies", + default: 12, + }, + }, + }, + summarize: { + properties: { + system_prompt: { type: "string", "x-settings": false }, + }, + }, + }, + }, + }, + }, + { plugins: { "deleted-bodies": { min_lines: 20 } } }, + ); + + expect(fields).toHaveLength(1); + expect(fields[0]).toMatchObject({ + key: "plugins.deleted-bodies.min_lines", + label: "Shortest body to collapse", + group: "Deleted function bodies", + value: 20, + }); + }); +}); diff --git a/packages/review/app/src/diffr-config-form.ts b/packages/review/app/src/diffr-config-form.ts new file mode 100644 index 000000000..910282c1c --- /dev/null +++ b/packages/review/app/src/diffr-config-form.ts @@ -0,0 +1,175 @@ +import { + type JsonObject, + type JsonValue, + isJsonArray, + isJsonObject, + isStringValue, + jsonProperty, + jsonString, +} from "@dev.fast/json"; + +/** + * One editable diffr setting, flattened from the JSON Schema `diffr config + * schema` prints. Nested objects become dotted keys, which is what `diffr + * config set` takes. + */ +export interface DiffrConfigField { + key: string; + label: string; + group?: string; + kind: "boolean" | "number" | "string" | "enum"; + description: string; + default: JsonValue | undefined; + choices: string[]; + value: JsonValue | undefined; + /** Rendered as a password input and never echoed anywhere else. */ + secret: boolean; +} + +const SECRET_KEY = /(^|[._])(api_key|token|secret|password)($|[._])/; + +function schemaType(schema: JsonObject): string | undefined { + const type = jsonProperty(schema, "type"); + + if (isStringValue(type)) return type; + + // schemars writes nullable fields as ["string", "null"]; the first entry names the editor. + if (isJsonArray(type)) { + return jsonString( + type.find((entry) => isStringValue(entry) && entry !== "null"), + ); + } + + return undefined; +} + +function valueAt( + values: JsonObject, + path: readonly string[], +): JsonValue | undefined { + let current: JsonValue | undefined = values; + + for (const segment of path) { + if (!isJsonObject(current)) return undefined; + current = jsonProperty(current, segment); + } + + return current; +} + +/** + * Flattens the schema into fields in declaration order, resolving each + * field's current value. Keys the schema cannot describe as one of the four + * editable kinds are left out rather than rendered as free text, since a + * wrong write would be diffr's error to report, not the form's. + */ +export function diffrConfigFields( + schema: JsonObject, + values: JsonObject, +): DiffrConfigField[] { + const fields: DiffrConfigField[] = []; + + const definitions = + jsonProperty(schema, "$defs") ?? jsonProperty(schema, "definitions"); + + function resolve(node: JsonObject): JsonObject { + const reference = jsonString(jsonProperty(node, "$ref")); + + if (reference === undefined) return node; + const name = reference.replace(/^#\/(\$defs|definitions)\//, ""); + + const target = isJsonObject(definitions) + ? jsonProperty(definitions, name) + : undefined; + + if (!isJsonObject(target)) + throw new Error( + `diffr schema references an unknown definition: ${reference}`, + ); + + return target; + } + + function walk(node: JsonObject, path: readonly string[]) { + const resolved = { ...resolve(node), ...node }; + + if (resolved["x-settings"] === false) return; + const properties = jsonProperty(resolved, "properties"); + + if (isJsonObject(properties)) { + for (const [name, child] of Object.entries(properties)) { + if (isJsonObject(child)) walk(child, [...path, name]); + } + + return; + } + + if (path.length === 0) return; + const key = path.join("."); + const description = jsonString(jsonProperty(resolved, "description")) ?? ""; + const choices = jsonProperty(resolved, "enum"); + + const enumChoices = isJsonArray(choices) + ? choices.filter(isStringValue) + : []; + + const type = schemaType(resolved); + let kind: DiffrConfigField["kind"] | undefined; + + if ( + isJsonArray(choices) && + enumChoices.length === choices.length && + choices.length > 0 + ) + kind = "enum"; + else if (type === "boolean") kind = "boolean"; + else if (type === "integer" || type === "number") kind = "number"; + else if (type === "string") kind = "string"; + + if (!kind) return; + fields.push({ + key, + label: jsonString(jsonProperty(resolved, "title")) ?? key, + group: jsonString(jsonProperty(resolved, "x-group")), + kind, + description, + default: jsonProperty(resolved, "default"), + choices: kind === "enum" ? enumChoices : [], + value: valueAt(values, path), + secret: SECRET_KEY.test(key), + }); + } + + walk(schema, []); + + return fields; +} + +/** The text shown beside a field for its shipped default. */ +export function diffrConfigDefaultText(field: DiffrConfigField): string { + if (field.default === undefined || field.default === null) return ""; + + if (field.secret) return ""; + + return isStringValue(field.default) + ? field.default + : JSON.stringify(field.default); +} + +/** Parses what the user typed into the value `diffr config set` should receive. */ +export function diffrConfigInputValue( + field: DiffrConfigField, + text: string, +): JsonValue { + if (field.kind === "number") { + const value = Number(text); + + if (text.trim() === "" || !Number.isFinite(value)) { + throw new Error(`${field.key} must be a number.`); + } + + return value; + } + + return text; +} diff --git a/packages/review/app/src/settings-page.tsx b/packages/review/app/src/settings-page.tsx index 33f8dec17..94354f4db 100644 --- a/packages/review/app/src/settings-page.tsx +++ b/packages/review/app/src/settings-page.tsx @@ -1,12 +1,21 @@ import type { ReviewCanvasSettingsContent, ReviewCliInstallStatus, + ReviewDiffrConfig, + ReviewDiffrConfigActions, ReviewKeymapChoice, ReviewThemeChoice, } from "@dev.fast/review-protocol"; +import { isStringValue } from "@dev.fast/review-protocol"; import { type ReactNode, useEffect, useState } from "react"; import { AgentSetupCard } from "./agent-setup-card"; +import { + type DiffrConfigField, + diffrConfigDefaultText, + diffrConfigFields, + diffrConfigInputValue, +} from "./diffr-config-form"; import { TraceCaptureSection } from "./trace-capture-section"; const THEME_LABELS: Record = { @@ -45,6 +54,10 @@ export function SettingsPage({ settings.softwareMapEnabled, ); + const [structuralDiffEnabled, setStructuralDiffEnabled] = useState( + settings.structuralDiffEnabled, + ); + const [installStatus, setInstallStatus] = useState< ReviewCliInstallStatus | undefined >(settings.install?.status); @@ -171,6 +184,31 @@ export function SettingsPage({
+ + + + {structuralDiffEnabled ? ( + + ) : null} (null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + useEffect(() => { + let cancelled = false; + setConfig(null); + setError(null); + actions.read().then( + (loaded) => { + if (!cancelled) setConfig(loaded); + }, + (cause: unknown) => { + if (!cancelled) + setError(cause instanceof Error ? cause.message : String(cause)); + }, + ); + + return () => { + cancelled = true; + }; + }, [actions]); + + const write = async (field: DiffrConfigField, text: string) => { + setBusy(field.key); + setError(null); + + try { + setConfig( + await actions.set(field.key, diffrConfigInputValue(field, text)), + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(null); + } + }; + + const fields = config ? diffrConfigFields(config.schema, config.values) : []; + + return ( +
+

+ These settings belong to diffr and are shared with its terminal UI. +

+ {config === null && error === null ? ( +

Reading diffr settings…

+ ) : null} + {fields.map((field) => ( + + void write(field, text)} + /> + + ))} + {error ?

{error}

: null} +
+ ); +} + +function DiffrConfigControl({ + field, + disabled, + onCommit, +}: { + field: DiffrConfigField; + disabled: boolean; + onCommit: (text: string) => void; +}) { + const current = + field.value === undefined || field.value === null + ? "" + : isStringValue(field.value) + ? field.value + : JSON.stringify(field.value); + + const [draft, setDraft] = useState(current); + useEffect(() => setDraft(current), [current]); + + if (field.kind === "boolean") { + return ( + + ); + } + + if (field.kind === "enum") { + return ( + + ); + } + + return ( + setDraft(event.target.value)} + onBlur={() => { + if (draft !== current) onCommit(draft); + }} + onKeyDown={(event) => { + if (event.key === "Enter" && draft !== current) onCommit(draft); + }} + /> + ); +} + function Section({ label, children }: { label: string; children: ReactNode }) { return (
diff --git a/packages/review/app/src/styles.css b/packages/review/app/src/styles.css index fb3cba193..3f503b6ca 100644 --- a/packages/review/app/src/styles.css +++ b/packages/review/app/src/styles.css @@ -6826,6 +6826,30 @@ button.review-stack-row:disabled .review-stack-relation { cursor: pointer; } +.review-settings-input { + min-width: 200px; + padding: 4px 8px; + border: 1px solid var(--review-home-rule-soft); + border-radius: 5px; + color: inherit; + background: var(--transparent); + font: inherit; +} + +.review-settings-input:disabled { + opacity: 0.5; +} + +.review-settings-diffr { + margin-top: 8px; + padding-left: 12px; + border-left: 2px solid var(--review-home-rule-soft); +} + +.review-settings-diffr-lede { + margin: 0 0 8px; +} + .review-settings-unavailable { color: var(--review-home-meta); font-size: 12px; diff --git a/packages/review/src/review-api/http.ts b/packages/review/src/review-api/http.ts index 55a362baa..ab292cb61 100644 --- a/packages/review/src/review-api/http.ts +++ b/packages/review/src/review-api/http.ts @@ -1,3 +1,4 @@ +import type { JsonObject } from "@dev.fast/json"; import { Hono, type MiddlewareHandler } from "hono"; import { z } from "zod"; @@ -471,6 +472,54 @@ export function createReviewApi( return context.json({ ...file, ...local }); }); + app.get("/:id/structural-diff", async (context) => { + const input = readQuerySchemas.diff.parse(context.req.query()); + const id = context.req.param("id"); + + const { pins } = await data.resolveSource( + readReview(id, input.version), + input.commit, + ); + + const abort = new AbortController(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: JsonObject) => { + if (!abort.signal.aborted) + controller.enqueue(encoder.encode(JSON.stringify(event) + "\n")); + }; + + try { + await data.structuralChanges( + id, + pins, + AbortSignal.any([context.req.raw.signal, abort.signal]), + send, + input.file, + ); + } catch (error) { + send({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + } finally { + if (!abort.signal.aborted) controller.close(); + } + }, + cancel() { + abort.abort(); + }, + }); + + return new Response(stream, { + headers: { + "content-type": "application/x-ndjson", + "cache-control": "no-store", + }, + }); + }); app.get("/:id/diff", async (context) => { const input = readQuerySchemas.diff.parse(context.req.query()); diff --git a/packages/review/src/review-api/local-data.ts b/packages/review/src/review-api/local-data.ts index 08fd76931..693f7e4ac 100644 --- a/packages/review/src/review-api/local-data.ts +++ b/packages/review/src/review-api/local-data.ts @@ -1,6 +1,7 @@ import { type FSWatcher, existsSync, watch } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; +import type { JsonObject } from "@dev.fast/json"; import { type BlobBatchReader, type LocalVcs, @@ -25,6 +26,8 @@ import type { import { z } from "zod"; import { textIncludesQuote } from "../evidence.js"; +import { ensureReviewPinnedCheckout } from "../review-head-checkout.js"; +import { structuralDiff } from "../server/structural-diff.js"; import { resolveSoftwareMapDiffCounts } from "../software-map-diff-counts.js"; import { type NormalizedSoftwareModel, @@ -204,6 +207,36 @@ export class LocalReviewData { ); } + async structuralChanges( + reviewId: string, + pins: Pins, + signal: AbortSignal, + onEvent: (event: JsonObject) => void, + file?: string, + ): Promise { + if (file !== undefined) checkRelativePath(file); + + const rootPath = await ensureReviewPinnedCheckout({ + rootPath: this.store.repositoryPath(pins.repositoryId), + ref: pins.head, + reviewUuid: reviewId, + }); + + if (!rootPath) + throw new ReviewInputError( + "Cannot prepare the pinned repository for structural diffing.", + ); + await structuralDiff({ + rootPath, + baseRef: pins.base, + headRef: pins.head, + exactTrees: true, + paths: file === undefined ? undefined : [file], + signal, + onEvent, + }); + } + private closed = false; private readonly worktrees = new Map< string, diff --git a/packages/review/src/server/desktop-server.ts b/packages/review/src/server/desktop-server.ts index 60ce45a28..23c5d9987 100644 --- a/packages/review/src/server/desktop-server.ts +++ b/packages/review/src/server/desktop-server.ts @@ -39,6 +39,7 @@ import type { ReviewStore } from "../review-api/store.js"; import { reviewDesktopDiscoveryPath } from "../review-home-paths"; import { ReviewTelemetry } from "../review-telemetry"; import type { SharedReviewStore } from "../sharing/import.js"; +import { readDiffrConfig, setDiffrConfigValue } from "./diffr-config"; import { GlobalReviewDesktopVerbRelay, type ReviewDesktopVerbRelay, @@ -258,6 +259,24 @@ export function createGlobalReviewServer( return globalJson(200, { ok: true }); }); + app.get("/diffr-config", async () => + globalJson(200, await readDiffrConfig()), + ); + app.put("/diffr-config", async (context) => { + const body = await readBoundedRequestJson(context.req.raw); + + const key = isJsonObject(body) + ? jsonString(jsonProperty(body, "key")) + : undefined; + + const value = isJsonObject(body) ? jsonProperty(body, "value") : undefined; + + if (key === undefined || value === undefined) { + throw new ReviewServerError("key and value are required.", 400); + } + + return globalJson(200, await setDiffrConfigValue(key, value)); + }); app.get("/install/status", async () => globalJson( 200, diff --git a/packages/review/src/server/diffr-config.test.ts b/packages/review/src/server/diffr-config.test.ts new file mode 100644 index 000000000..cd3d83ec2 --- /dev/null +++ b/packages/review/src/server/diffr-config.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { readDiffrConfig, setDiffrConfigValue } from "./diffr-config"; + +const roots: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +/** A stand-in diffr that records `config set` calls and answers schema/show. */ +async function fakeDiffr() { + const root = await mkdtemp(path.join(tmpdir(), "review-diffr-config-")); + roots.push(root); + const log = path.join(root, "calls.log"); + const file = path.join(root, "diffr"); + await writeFile( + file, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(args) + "\\n"); +if (args[1] === "schema") { + console.log(JSON.stringify({ type: "object", properties: { summarize: { type: "object", properties: { provider: { type: "string", enum: ["gemini"], default: "gemini", description: "Model provider." } } } } })); +} else if (args[1] === "show") { + console.log(JSON.stringify({ summarize: { provider: "gemini" } })); +} else if (args[1] === "set") { + if (args[2] === "bad.key") { console.error("unknown key"); process.exit(2); } +} else { + process.exit(3); +} +`, + { mode: 0o755 }, + ); + vi.stubEnv("REVIEW_DIFFR_BINARY", file); + + return { root, log }; +} + +test("reads the schema and resolved values through the CLI", async () => { + await fakeDiffr(); + const config = await readDiffrConfig(); + expect(config.schema).toMatchObject({ type: "object" }); + expect(config.values).toEqual({ summarize: { provider: "gemini" } }); +}); + +test("writes one key as text and returns the fresh configuration", async () => { + const { log } = await fakeDiffr(); + const config = await setDiffrConfigValue("folds.min_lines", 12); + expect(config.values).toEqual({ summarize: { provider: "gemini" } }); + + const calls = (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + expect(calls[0]).toEqual(["config", "set", "folds.min_lines", "12"]); +}); + +test("surfaces diffr's own error text", async () => { + await fakeDiffr(); + await expect(setDiffrConfigValue("bad.key", true)).rejects.toThrow( + "unknown key", + ); +}); + +test("rejects keys that are not dotted identifiers before running anything", async () => { + const { log } = await fakeDiffr(); + await expect(setDiffrConfigValue("--flag", "x")).rejects.toThrow( + "Invalid diffr config key", + ); + await expect(readFile(log, "utf8")).rejects.toThrow("ENOENT"); +}); + +test("explains a missing executable", async () => { + vi.stubEnv("REVIEW_DIFFR_BINARY", "/nonexistent/diffr"); + await expect(readDiffrConfig()).rejects.toThrow("Cannot find diffr"); +}); + +test("accepts current diffr plugin names containing hyphens", async () => { + await fakeDiffr(); + await expect( + setDiffrConfigValue("plugins.deleted-bodies.min_lines", 20), + ).resolves.toMatchObject({ values: { summarize: { provider: "gemini" } } }); +}); diff --git a/packages/review/src/server/diffr-config.ts b/packages/review/src/server/diffr-config.ts new file mode 100644 index 000000000..159828688 --- /dev/null +++ b/packages/review/src/server/diffr-config.ts @@ -0,0 +1,91 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { + type JsonObject, + type JsonValue, + isJsonObject, + isStringValue, + parseJsonText, +} from "@dev.fast/json"; + +import { diffrExecutable, diffrMissingError } from "./structural-diff"; + +const execFileAsync = promisify(execFile); + +/** + * diffr's own configuration, read and written through its CLI so the TUI and + * Review share one source of truth. `schema` is the JSON Schema `diffr config + * schema` prints (every key carries `description` and `default`); `values` is + * the resolved configuration from `diffr config show --json`. + */ +export interface DiffrConfig { + schema: JsonObject; + values: JsonObject; +} + +async function diffr( + args: readonly string[], + rootPath?: string, +): Promise { + try { + const { stdout } = await execFileAsync(diffrExecutable(), args, { + cwd: rootPath, + maxBuffer: 16 * 1024 * 1024, + signal: AbortSignal.timeout(30_000), + }); + + return stdout; + } catch (error) { + // SAFETY: execFile rejects with the spawn or exit error it produced, which + // carries `code` and, after a nonzero exit, the captured `stderr`. + const failure = error as NodeJS.ErrnoException & { stderr?: string }; + + if (failure.code === "ENOENT") throw diffrMissingError(); + throw new Error( + `diffr ${args.join(" ")} failed: ${failure.stderr?.trim() || failure.message}`, + ); + } +} + +function jsonObjectOutput(text: string, what: string): JsonObject { + const value = parseJsonText(text); + + if (!isJsonObject(value)) + throw new Error(`diffr ${what} did not print a JSON object.`); + + return value; +} + +export async function readDiffrConfig(rootPath?: string): Promise { + const [schema, values] = await Promise.all([ + diffr(["config", "schema"], rootPath), + diffr(["config", "show", "--json"], rootPath), + ]); + + return { + schema: jsonObjectOutput(schema, "config schema"), + values: jsonObjectOutput(values, "config show"), + }; +} + +/** The text form `diffr config set` accepts: scalars verbatim, anything else as JSON. */ +export function diffrConfigValueText(value: JsonValue): string { + return isStringValue(value) ? value : JSON.stringify(value); +} + +export async function setDiffrConfigValue( + key: string, + value: JsonValue, + rootPath?: string, +): Promise { + if ( + !/^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)*$/.test(key) + ) { + throw new Error(`Invalid diffr config key: ${key}`); + } + + await diffr(["config", "set", key, diffrConfigValueText(value)], rootPath); + + return readDiffrConfig(rootPath); +} diff --git a/packages/review/src/server/structural-diff-workspace.test.ts b/packages/review/src/server/structural-diff-workspace.test.ts new file mode 100644 index 000000000..fea731217 --- /dev/null +++ b/packages/review/src/server/structural-diff-workspace.test.ts @@ -0,0 +1,142 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { createReviewApi as createJsonReviewApi } from "../review-api/http.js"; +import { openLocalReviewStore } from "../review-api/local-data.js"; + +const roots: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +const hasJj = spawnSync("jj", ["--version"]).status === 0; + +test.skipIf(!hasJj)( + "structural requests resolve pinned revisions from a jj workspace inside another Git repository", + async () => { + const root = await mkdtemp( + path.join(tmpdir(), "review-structural-workspace-"), + ); + + roots.push(root); + + const run = (cwd: string, command: string, args: string[]) => + execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + const git = (cwd: string, args: string[]) => run(cwd, "git", args); + const source = path.join(root, "source"); + const outer = path.join(root, "outer"); + await mkdir(source); + await mkdir(outer); + git(source, ["init", "-b", "main"]); + git(source, ["config", "user.name", "Test"]); + git(source, ["config", "user.email", "test@example.com"]); + await writeFile(path.join(source, "file name.ts"), "base\n"); + git(source, ["add", "."]); + git(source, ["commit", "-m", "base"]); + const base = git(source, ["rev-parse", "HEAD"]); + await writeFile(path.join(source, "file name.ts"), "head\n"); + git(source, ["commit", "-am", "head"]); + const head = git(source, ["rev-parse", "HEAD"]); + git(source, ["checkout", "-b", "base-side", base]); + await writeFile(path.join(source, "file name.ts"), "divergent base\n"); + git(source, ["commit", "-am", "divergent base"]); + const divergentBase = git(source, ["rev-parse", "HEAD"]); + git(source, ["checkout", "main"]); + git(outer, ["init", "-b", "unrelated"]); + run(source, "jj", ["git", "init", "--colocate"]); + const workspace = path.join(outer, "workspace"); + run(source, "jj", ["workspace", "add", workspace, "-r", head]); + // A Git-only consumer discovers the enclosing repo from the jj workspace. + expect(git(workspace, ["rev-parse", "--show-toplevel"])).toBe( + await realpath(outer), + ); + + // Exercise the same Git-only discovery and revision reads as diffr without + // requiring a Rust binary in the JS unit-test environment. + const executable = path.join(root, "diffr"); + await writeFile( + executable, + `#!${process.execPath} +const {execFileSync} = require('node:child_process'); +const args = process.argv.slice(2); +const repo = args[args.indexOf('--repo') + 1]; +const revisions = args.slice(args.indexOf('ndjson') + 1, args.indexOf('--')); +const range = revisions.length === 1 ? revisions[0].split('...') : revisions; +const file = args[args.indexOf('--') + 1]; +const read = rev => execFileSync('git', ['-C', repo, 'show', rev + ':' + file], {encoding:'utf8'}); +console.log(JSON.stringify({type:'start',version:3,files:[]})); +console.log(JSON.stringify({type:'file',file:{rhs:{path:file}},before:read(range[0]),after:read(range[1])})); +console.log(JSON.stringify({type:'complete',succeeded:1,failed:0})); +`, + { mode: 0o755 }, + ); + vi.stubEnv("REVIEW_DIFFR_BINARY", executable); + const local = openLocalReviewStore(path.join(root, "reviews.db")); + + try { + const repository = await local.data.register(workspace); + + const pins = await local.data.resolvePins( + repository.id, + divergentBase, + head, + ); + + const { reviewId } = await local.store.execute({ + commandId: randomUUID(), + operation: { type: "create", title: "Structural", pins }, + }); + + const app = createJsonReviewApi(local.store, local.data); + + for (const commit of [undefined, head]) { + const query = new URLSearchParams({ + file: "file name.ts", + version: String(local.store.read(reviewId).version), + }); + + if (commit) query.set("commit", commit); + + const response = await app.request( + `/${reviewId}/structural-diff?${query}`, + ); + + expect(response.status).toBe(200); + + const events = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + expect(events.find((event) => event.type === "error")).toBeUndefined(); + expect(events.find((event) => event.type === "file")).toMatchObject({ + before: commit ? "base\n" : "divergent base\n", + after: "head\n", + }); + } + + const invalid = await app.request( + `/${reviewId}/structural-diff?version=999`, + ); + + expect(invalid.status).toBe(404); + } finally { + await local.data.close(); + await local.store.close(); + } + }, +); diff --git a/packages/review/src/server/structural-diff.test.ts b/packages/review/src/server/structural-diff.test.ts new file mode 100644 index 000000000..0568c716e --- /dev/null +++ b/packages/review/src/server/structural-diff.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { structuralDiff } from "./structural-diff"; + +const roots: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function executable(script: string) { + const root = await mkdtemp(path.join(tmpdir(), "review-diffr-test-")); + roots.push(root); + const file = path.join(root, "diffr"); + await writeFile(file, `#!${process.execPath}\n${script}`, { mode: 0o755 }); + vi.stubEnv("REVIEW_DIFFR_BINARY", file); + + return root; +} + +const START = + '{"type":"start","version":3,"lhs":{"type":"revision","rev":"base"},"rhs":{"type":"revision","rev":"head"},"files":[]}'; + +const FILE = + '{"lhs":{"path":"a.ts","oid":"1","mode":"100644"},"rhs":{"path":"a.ts","oid":"2","mode":"100644"}}'; + +test("reads chunked NDJSON and sends Review's merge-base comparison as one argument", async () => { + const root = await executable(` + process.stdout.write('${START}\\n{"type":"fi'); + setTimeout(() => { + process.stdout.write('le","file":${FILE},"args":' + JSON.stringify(process.argv.slice(2)) + '}\\n'); + process.stdout.write('{"type":"complete","succeeded":1,"failed":0}\\n'); + }, 5); + `); + + const result = await structuralDiff({ + rootPath: root, + baseRef: "base", + headRef: "head", + paths: ["space name.ts"], + }); + + expect(result.events[1]).toMatchObject({ + type: "file", + args: [ + "--repo", + root, + "--format", + "ndjson", + "base...head", + "--", + "space name.ts", + ], + }); +}); + +test("rejects an older wire version", async () => { + const root = await executable( + `console.log('{"type":"start","version":1}\\n{"type":"complete","succeeded":0,"failed":0}');`, + ); + + await expect( + structuralDiff({ rootPath: root, baseRef: "base" }), + ).rejects.toThrow("Unsupported diffr stream protocol"); +}); + +test("rejects a successful process that truncates its stream", async () => { + const root = await executable(`console.log('${START}');`); + await expect( + structuralDiff({ rootPath: root, baseRef: "base" }), + ).rejects.toThrow("before completion"); +}); + +test("propagates per-file errors instead of silently showing a different diff", async () => { + const root = await executable( + `console.log('${START}\\n{"type":"file","file":${FILE},"error":{"code":"parse_error","message":"parse failed"}}');`, + ); + + await expect( + structuralDiff({ rootPath: root, baseRef: "base" }), + ).rejects.toThrow("parse failed"); +}); + +test("an aborted run keeps the files it emitted and reports the abort", async () => { + const root = await executable(` + console.log('${START}'); + console.log('{"type":"file","file":${FILE},"diff":{"type":"binary","lhs":{"size":1},"rhs":{"size":2}}}'); + console.log('{"type":"complete","succeeded":1,"failed":0,"aborted":{"code":"hook_failed","message":"summarizer down"}}'); + process.exit(2); + `); + + await expect( + structuralDiff({ rootPath: root, baseRef: "base" }), + ).rejects.toThrow("summarizer down"); + const forwarded: unknown[] = []; + await structuralDiff({ + rootPath: root, + baseRef: "base", + onEvent: (event) => forwarded.push(event), + }); + expect(forwarded.map((event) => (event as { type: string }).type)).toEqual([ + "start", + "file", + "complete", + ]); + expect(forwarded[2]).toMatchObject({ aborted: { code: "hook_failed" } }); +}); + +test("a streamed run with a failed file resolves despite exit 2", async () => { + const root = await executable(` + console.log('${START}'); + console.log('{"type":"file","file":${FILE},"error":{"code":"read_failed","message":"binary"}}'); + console.log('{"type":"complete","succeeded":0,"failed":1}'); + process.exit(2); + `); + + const forwarded: unknown[] = []; + await structuralDiff({ + rootPath: root, + baseRef: "base", + onEvent: (event) => forwarded.push(event), + }); + expect(forwarded).toHaveLength(3); +}); + +test("exit 2 without failed files is still an error", async () => { + const root = await executable(` + console.log('${START}'); + console.log('{"type":"complete","succeeded":0,"failed":0}'); + process.exit(2); + `); + + await expect( + structuralDiff({ rootPath: root, baseRef: "base", onEvent: () => {} }), + ).rejects.toThrow("exited with 2"); +}); + +test("cancellation terminates the subprocess", async () => { + const root = await executable(`setInterval(() => {}, 1000);`); + await expect( + structuralDiff({ + rootPath: root, + baseRef: "base", + signal: AbortSignal.timeout(50), + }), + ).rejects.toThrow(/abort|exited/i); +}); diff --git a/packages/review/src/server/structural-diff.ts b/packages/review/src/server/structural-diff.ts new file mode 100644 index 000000000..c10f3fbdb --- /dev/null +++ b/packages/review/src/server/structural-diff.ts @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; + +import { + type JsonObject, + type JsonValue, + isJsonObject, + isStringValue, + jsonNumber, + parseJsonText, +} from "@dev.fast/json"; + +/** The diffr wire version this host reads. Changes within it are additive. */ +export const STRUCTURAL_DIFF_WIRE_VERSION = 3; + +/** The one error shape diffr uses for file failures, aborts and fallbacks. */ +export interface StructuralProblem { + code: string; + message: string; +} + +export function structuralProblem( + value: JsonValue | undefined, +): StructuralProblem { + if ( + !isJsonObject(value) || + !isStringValue(value.code) || + !isStringValue(value.message) + ) { + throw new Error("diffr sent a malformed error record."); + } + + return { code: value.code, message: value.message }; +} + +export function diffrExecutable(): string { + return process.env.REVIEW_DIFFR_BINARY || "diffr"; +} + +export function diffrMissingError(): Error { + return new Error( + "Cannot find diffr. Install it on the Review host PATH or set REVIEW_DIFFR_BINARY to its executable, then restart Review.", + ); +} + +/** + * Runs one diffr comparison and reads its v3 NDJSON stream: a `start` + * header, one `file` record per changed file in completion order, and a + * `complete` footer. The frontend setting opts in; the host selects the + * executable, never the request. + * + * With `onEvent`, records are forwarded as they arrive, including per-file + * errors and a `complete` that carries `aborted`. Without it, records are + * collected and any per-file error or abort rejects the whole comparison. + */ +export async function structuralDiff(input: { + rootPath: string; + baseRef?: string; + headRef?: string; + paths?: readonly string[]; + /** Compare the exact pinned trees instead of merge-base to head. */ + exactTrees?: boolean; + signal?: AbortSignal; + onEvent?: (event: JsonObject) => void; +}): Promise<{ enabled: boolean; events: JsonObject[] }> { + if (!input.baseRef) + throw new Error("Structural review requires a base revision."); + const args = ["--repo", input.rootPath, "--format", "ndjson"]; + + // Match Review's merge-base-to-head comparison, including commit scopes. + if (input.exactTrees && input.headRef) + args.push(input.baseRef, input.headRef); + else + args.push( + input.headRef ? `${input.baseRef}...${input.headRef}` : input.baseRef, + ); + args.push("--", ...(input.paths ?? [])); + + const signal = AbortSignal.any([ + AbortSignal.timeout(120_000), + ...(input.signal ? [input.signal] : []), + ]); + + // The host inherits its own environment and runs from the repository, so + // diffr reads the user's config and keys exactly as it would from a shell. + console.info( + `[Review] structural diff: ${diffrExecutable()} ${args.join(" ")}`, + ); + + const child = spawn(diffrExecutable(), args, { + cwd: input.rootPath, + stdio: ["ignore", "pipe", "pipe"], + signal, + }); + + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr = (stderr + chunk).slice(-16_384); + }); + + const exited = new Promise((resolve, reject) => { + child.once("error", (error: NodeJS.ErrnoException) => { + reject(error.code === "ENOENT" ? diffrMissingError() : error); + }); + child.once("close", (code) => resolve(code)); + }); + + const exitError = (code: number | null) => + new Error(`diffr exited with ${code}: ${stderr}`); + + // Observe process errors immediately, including before stdout closes. + void exited.catch(() => {}); + const events: JsonObject[] = []; + let bytes = 0; + let started = false; + let completed = false; + let aborted: StructuralProblem | undefined; + let failed = 0; + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + + try { + for await (const line of lines) { + bytes += Buffer.byteLength(line); + + if (bytes > 64 * 1024 * 1024) + throw new Error("Structural diff exceeded 64 MiB."); + + if (!line.trim()) continue; + const event = parseJsonText(line); + + if (!isJsonObject(event) || !isStringValue(event.type)) + throw new Error("diffr sent a record without a type."); + + if (completed) throw new Error("diffr emitted data after completion."); + + if (!started) { + if ( + event.type !== "start" || + event.version !== STRUCTURAL_DIFF_WIRE_VERSION + ) { + throw new Error("Unsupported diffr stream protocol."); + } + + started = true; + } else if (event.type === "complete") { + completed = true; + + failed = jsonNumber(event.failed) ?? 0; + + if (event.aborted !== undefined) + aborted = structuralProblem(event.aborted); + } else if (event.type === "file") { + if (event.error !== undefined && !input.onEvent) { + throw new Error(`diffr: ${structuralProblem(event.error).message}`); + } + } else { + throw new Error(`Unexpected diffr event: ${event.type}`); + } + + if (input.onEvent) input.onEvent(event); + else events.push(event); + } + + if (!completed) { + const code = await exited; + + if (code !== 0) throw exitError(code); + throw new Error("diffr stream ended before completion."); + } + + if (aborted) { + // Files already emitted stay valid; the run itself failed. + if (!input.onEvent) throw new Error(`diffr aborted: ${aborted.message}`); + + return { enabled: true, events }; + } + + // diffr exits 2 when any file failed; those files already carry their error records. + const code = await exited; + + if (code !== 0 && !(code === 2 && failed > 0)) throw exitError(code); + + return { enabled: true, events }; + } finally { + lines.close(); + + if (child.exitCode === null) child.kill(); + await exited.catch(() => {}); + } +} diff --git a/packages/review/src/ui-telemetry-events.ts b/packages/review/src/ui-telemetry-events.ts index 30c77a7e0..8477e0c3d 100644 --- a/packages/review/src/ui-telemetry-events.ts +++ b/packages/review/src/ui-telemetry-events.ts @@ -125,6 +125,7 @@ export const SETTING_NAME = [ "keymap", "dismissed_retention_days", "software_map_enabled", + "diffr_config", ] as const; export const REVIEW_OPENED_VIA = ["home", "cli", "other"] as const; From 64e81f126283d492d39c22f4ef4b885b6dccc421 Mon Sep 17 00:00:00 2001 From: ketan0 Date: Wed, 16 Sep 2026 16:38:51 -0700 Subject: [PATCH 2/5] Use Monaco for every diff layout and restore code selection actions Agent-Session: 01a0abcb-f97f-7431-a8d0-9e7a2bb0d192 Agent-Session: 01a0ac29-e501-7282-941e-9d999e389f97 Agent-Session: 01a0ac09-05a3-70e3-8f9c-5c52947ccb2f Agent-Session: 01a0ac8d-66c7-77c2-bea1-b1bbba7cd7d8 --- .../widget/diffEditor/diffEditorWidget.ts | 1 - .../multiDiffEditor/diffEditorItemTemplate.ts | 86 +- .../multiDiffEditor/multiDiffEditorWidget.ts | 6 +- .../multiDiffEditorWidgetImpl.ts | 19 +- .../workbenchUIElementFactory.ts | 11 - .../src/vs/review/browser/media/review.css | 10 - .../browser/parts/canvas/reviewCanvasPart.ts | 5 + .../contrib/verbs/reviewApiSelection.test.ts | 29 + .../contrib/verbs/reviewApiSelection.ts | 25 + .../vs/review/contrib/verbs/reviewVerbs.ts | 22 +- .../review/services/reviewApiSourceService.ts | 21 + .../review/services/reviewDiffViewService.ts | 403 +++---- .../vs/review/services/reviewFilesDiffView.ts | 1046 ++++++++--------- .../src/vs/review/services/reviewMultiDiff.ts | 1 - .../services/reviewUnifiedEditor.test.ts | 33 - .../vs/review/services/reviewUnifiedEditor.ts | 11 - packages/review-protocol/src/contracts.ts | 7 + packages/review/app/src/agent-selection.tsx | 13 +- packages/review/app/src/api-canvas.test.tsx | 35 +- packages/review/src/agent-selection.ts | 6 +- .../review/src/review-api/authoring-tools.ts | 8 +- packages/review/src/review-api/http.ts | 40 +- .../review/src/review-api/local-data.test.ts | 72 ++ packages/review/src/server/desktop-server.ts | 7 +- 24 files changed, 941 insertions(+), 976 deletions(-) create mode 100644 apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.test.ts create mode 100644 apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.ts delete mode 100644 apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.test.ts diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index 5a8ee4ccf..82c13f18d 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -84,7 +84,6 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { private readonly _gutter: IObservable; - public get renderSideBySideObservable() { return this._options.renderSideBySide; } public get collapseUnchangedRegions() { return this._options.hideUnchangedRegions.get(); } /** The hidden regions of the current model, for observers that supply their own. */ diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index ea9ad2b3f..5987ff974 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ICodeEditor } from '../../editorBrowser.js'; import { h } from '../../../../base/browser/dom.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derived, globalTransaction, IObservable, observableValue } from '../../../../base/common/observable.js'; @@ -16,7 +15,7 @@ import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import { MultiDiffEditorResourceHeader } from './multiDiffEditorResourceHeader.js'; import { DocumentDiffItemViewModel } from './multiDiffEditorViewModel.js'; import { IObjectData, IPooledObject } from './objectPool.js'; -import { IUnifiedDiffEditor, IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; +import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; export class TemplateData implements IObjectData { constructor( @@ -32,13 +31,6 @@ export class TemplateData implements IObjectData { export class DiffEditorItemTemplate extends Disposable implements IPooledObject { private readonly _viewModel; - private _unifiedHandle: IUnifiedDiffEditor | undefined; - public readonly unifiedEditor = observableValue(this, undefined); - private readonly _unifiedStore = this._register(new DisposableStore()); - public get selectionEditor(): ICodeEditor { return this.unifiedEditor.get() ?? this.editor.getModifiedEditor(); } - public focus(): void { (this.unifiedEditor.get() ?? this.editor).focus(); } - - private readonly _collapsed; private readonly _editorContentHeight; @@ -95,7 +87,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._elements = h('div.multiDiffEntry', [ h('div.editorParent@editorParent', [ h('div.editorContainer@editor'), - h('div.editorContainer@unified'), ]) ]) as Record; this.editor = this._register(this._instantiationService.createInstance(DiffEditorWidget, this._elements.editor, { @@ -104,10 +95,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< }, this._workbenchUIElementFactory.codeEditorWidgetOptions ?? {})); this.isModifedFocused = observableCodeEditor(this.editor.getModifiedEditor()).isFocused; this.isOriginalFocused = observableCodeEditor(this.editor.getOriginalEditor()).isFocused; - this.isFocused = derived(this, reader => { - const unified = this.unifiedEditor.read(reader); - return unified ? observableCodeEditor(unified).isFocused.read(reader) : this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader); - }); + this.isFocused = derived(this, reader => this.isModifedFocused.read(reader) || this.isOriginalFocused.read(reader)); this._dataStore = this._register(new DisposableStore()); this._resourceHeader = this._register(this._instantiationService.createInstance( MultiDiffEditorResourceHeader, @@ -123,9 +111,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._register(autorun(reader => { const collapsed = this._collapsed.read(reader); - const unified = this.unifiedEditor.read(reader); - this._elements.editor.style.display = collapsed || unified ? 'none' : 'block'; - this._elements.unified.style.display = collapsed || !unified ? 'none' : 'block'; + this._elements.editor.style.display = collapsed ? 'none' : 'block'; })); this._register(this.editor.getModifiedEditor().onDidLayoutChange(e => { @@ -139,7 +125,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< })); this._register(this.editor.onDidContentSizeChange(e => { - if (this.unifiedEditor.get()) { return; } globalTransaction(tx => { this._editorContentHeight.set(e.contentHeight, tx); this._modifiedContentWidth.set(this.editor.getModifiedEditor().getContentWidth(), tx); @@ -148,7 +133,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< })); this._register(this.editor.getOriginalEditor().onDidScrollChange(e => { - if (this._isSettingScrollTop || this.unifiedEditor.get()) { + if (this._isSettingScrollTop) { return; } @@ -175,8 +160,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< } public setScrollLeft(left: number): void { - const unified = this.unifiedEditor.get(); - if (unified) { unified.setScrollLeft(left); return; } if (this._modifiedContentWidth.get() - this._modifiedWidth.get() > this._originalContentWidth.get() - this._originalWidth.get()) { this.editor.getModifiedEditor().setScrollLeft(left); } else { @@ -190,9 +173,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< public setData(data: TemplateData | undefined): void { this._data = data; - this.unifiedEditor.set(undefined, undefined); - this._unifiedStore.clear(); - this._unifiedHandle = undefined; const optionsOverride = this._optionsOverride; function updateOptions(options: IDiffEditorOptions): IDiffEditorOptions { return { @@ -240,9 +220,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this.editor.setDiffModel(data.viewModel.diffEditorViewModelRef, tx); this.editor.updateOptions(updateOptions(value.options ?? {})); }); - this._dataStore.add(autorun(reader => { - this.updateUnifiedEditor(!this.editor.renderSideBySideObservable.read(reader)); - })); if (value.onOptionsDidChange) { this._dataStore.add(value.onOptionsDidChange(() => { this.editor.updateOptions(updateOptions(value.options ?? {})); @@ -267,57 +244,6 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< } } - public goToUnifiedDiff(direction: 'next' | 'previous' | 'first' | 'last'): boolean { - const handle = this._unifiedHandle; - if (!handle) { return false; } - const line = handle.editor.getPosition()?.lineNumber ?? 1; - const ranges = handle.changedRanges; - const target = direction === 'first' ? ranges[0] : direction === 'last' ? ranges.at(-1) - : direction === 'next' ? ranges.find(range => range.startLineNumber > line) - : ranges.findLast(range => range.startLineNumber < line); - if (!target) { return false; } - handle.editor.setPosition({ lineNumber: target.startLineNumber, column: 1 }); - handle.editor.revealLineInCenter(target.startLineNumber); - return true; - } - - private updateUnifiedEditor(inline: boolean): void { - const data = this._data; - const create = this._workbenchUIElementFactory.createUnifiedEditor; - const useUnified = !!create && inline; - if (useUnified === !!this.unifiedEditor.get() || !data) { return; } - this.unifiedEditor.set(undefined, undefined); - this._unifiedStore.clear(); - this._unifiedHandle = undefined; - if (!useUnified) { - this._editorContentHeight.set(this.editor.getContentHeight(), undefined); - return; - } - const handle = create!.call(this._workbenchUIElementFactory, this._elements.unified, data.viewModel.originalUri, data.viewModel.modifiedUri, this._instantiationService); - if (!handle) { return; } - this._unifiedHandle = handle; - this._unifiedStore.add(handle); - const editor = handle.editor; - this.unifiedEditor.set(editor, undefined); - const updateSize = () => { - globalTransaction(tx => { - this._editorContentHeight.set(editor.getContentHeight(), tx); - this._modifiedContentWidth.set(editor.getContentWidth(), tx); - this._modifiedWidth.set(editor.getLayoutInfo().contentWidth, tx); - this._originalContentWidth.set(0, tx); - this._originalWidth.set(0, tx); - }); - }; - this._unifiedStore.add(editor.onDidContentSizeChange(updateSize)); - this._unifiedStore.add(editor.onDidLayoutChange(updateSize)); - this._unifiedStore.add(editor.onDidScrollChange(e => { - if (!this._isSettingScrollTop && e.scrollTopChanged) { - data.deltaScrollVertical(e.scrollTop - this._lastScrollTop); - } - })); - updateSize(); - } - private readonly _headerHeight; private _lastScrollTop; @@ -340,14 +266,12 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< width: width - 2 * 8 - 2 * 1, height: verticalRange.length - this._outerEditorHeight, }; - // Keep the native editor's responsive layout decision current while unified is visible. this.editor.layout(dimension); - this.unifiedEditor.get()?.layout(dimension); }); try { this._isSettingScrollTop = true; this._lastScrollTop = editorScroll; - (this.unifiedEditor.get() ?? this.editor.getOriginalEditor()).setScrollTop(editorScroll); + this.editor.getOriginalEditor().setScrollTop(editorScroll); } finally { this._isSettingScrollTop = false; } diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index e30101667..619616ac7 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -14,7 +14,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { Range } from '../../../common/core/range.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; -import { ICodeEditor } from '../../editorBrowser.js'; +import type { ICodeEditor } from '../../editorBrowser.js'; import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; @@ -105,10 +105,6 @@ export class MultiDiffEditorWidget extends Disposable { this._renderSideBySide.set(!(this._renderSideBySide.get() ?? true), undefined); } - private readonly _activeUnifiedControl = derived(this, reader => this._widgetImpl.read(reader).activeUnifiedControl.read(reader)); - public getActiveUnifiedControl(): ICodeEditor | undefined { return this._activeUnifiedControl.get(); } - public readonly onDidChangeActiveUnifiedControl = Event.fromObservableLight(this._activeUnifiedControl); - private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); private readonly _scrollTop = derived(this, (reader) => this._widgetImpl.read(reader).scrollTop.read(reader)); private readonly _contentHeight = derived(this, (reader) => this._widgetImpl.read(reader).contentHeight.read(reader)); diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index e161e52b4..6b5d81324 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -59,7 +59,6 @@ export class MultiDiffEditorWidgetImpl extends Disposable { public readonly contentHeight; public readonly activeControl; - public readonly activeUnifiedControl; private readonly _contextKeyService; private readonly _instantiationService; @@ -154,10 +153,6 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const viewItem = this._viewItemsInfo.read(reader).getItem(activeDiffItem); return viewItem.template.read(reader)?.editor; }); - this.activeUnifiedControl = derived(this, reader => { - const item = this._viewModel.read(reader)?.activeDiffItem.read(reader); - return item ? this._viewItemsInfo.read(reader).getItem(item).template.read(reader)?.unifiedEditor.read(reader) : undefined; - }); this._contextKeyService = this._register(this._parentContextKeyService.createScoped(this._element)); this._instantiationService = this._register(this._parentInstantiationService.createChild( new ServiceCollection([IContextKeyService, this._contextKeyService]) @@ -542,7 +537,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { viewModel.activeDiffItem.setCache(target, undefined); if (!this._preserveFocusOnLoad) { - this._viewItemsInfo.get().getItem(target).template.get()?.focus(); + this._viewItemsInfo.get().getItem(target).template.get()?.editor.focus(); } return true; } @@ -601,9 +596,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { currentItem.viewModel.collapsed.set(false, undefined); } - const template = currentItem.template.get(); - if (template?.goToUnifiedDiff(direction)) { return; } - const editor = template?.unifiedEditor.get() ? undefined : template?.editor; + const editor = currentItem.template.get()?.editor; if (editor?.getDiffComputationResult()?.changes2?.length) { const pos = editor.getModifiedEditor().getPosition()?.lineNumber || 1; const changes = editor.getDiffComputationResult()!.changes2!; @@ -628,8 +621,6 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this.reveal({ original: item.viewModel.originalUri, modified: item.viewModel.modifiedUri }); - const unified = item.template.get()?.unifiedEditor.get(); - if (unified) { item.template.get()?.goToUnifiedDiff(position); if (focusEditor) { unified.focus(); } return; } const editor = item.template.get()?.editor; if (editor?.getDiffComputationResult()?.changes2?.length) { if (position === 'first') { @@ -795,7 +786,7 @@ class VirtualizedViewItem extends Disposable { const ref = this._templateRef.get(); if (ref) { if (selections) { - ref.object.selectionEditor.setSelections(selections); + ref.object.editor.getModifiedEditor().setSelections(selections); } } } @@ -805,7 +796,7 @@ class VirtualizedViewItem extends Disposable { if (!ref) { return; } this.viewModel.lastTemplateData.set({ contentHeight: ref.object.contentHeight.get(), - selections: ref.object.selectionEditor.getSelections() ?? undefined, + selections: ref.object.editor.getModifiedEditor().getSelections() ?? undefined, }, tx); } @@ -833,7 +824,7 @@ class VirtualizedViewItem extends Disposable { const selections = this.viewModel.lastTemplateData.get().selections; if (selections) { - ref.object.selectionEditor.setSelections(selections); + ref.object.editor.getModifiedEditor().setSelections(selections); } } ref.object.render(verticalSpace, width, offset, viewPort); diff --git a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index c00ad9648..9b74747d1 100644 --- a/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/apps/review-desktop/code-oss/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -3,9 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import type { IRange } from '../../../common/core/range.js'; -import type { ICodeEditor } from '../../editorBrowser.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import type { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -18,9 +15,6 @@ import type { IDiffCodeEditorWidgetOptions } from '../diffEditor/diffEditorWidge * This would make monaco-editor consumption much more difficult though. */ export interface IWorkbenchUIElementFactory { - /** Optional selectable row editor for a read-only unified diff. */ - createUnifiedEditor?(container: HTMLElement, original: URI | undefined, modified: URI | undefined, instantiationService: IInstantiationService): IUnifiedDiffEditor | undefined; - createResourceLabel?(element: HTMLElement): IResourceLabel; createResourceHeaderMetadata?(element: HTMLElement): IResourceHeaderMetadata; @@ -69,8 +63,3 @@ export interface IResourceLabel extends IDisposable { export interface IResourceLabelOptions { strikethrough?: boolean; } - -export interface IUnifiedDiffEditor extends IDisposable { - readonly editor: ICodeEditor; - readonly changedRanges: readonly IRange[]; -} diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css b/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css index 7b1b0aa54..a2ef5cd08 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css +++ b/apps/review-desktop/code-oss/src/vs/review/browser/media/review.css @@ -1082,16 +1082,6 @@ body ); } -.review-unified-expand-context { - width: 100%; - border: 0; - text-align: left; - cursor: pointer; - color: var(--vscode-descriptionForeground); - background: var(--vscode-diffEditor-unchangedRegionBackground); - font: inherit; -} - /* Give commenting and folding separate hit targets in the Files view. */ .review-files-editor .monaco-editor .margin-view-overlays .cldr:is( .codicon-folding-expanded, diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index d92865263..b08908e7d 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -248,6 +248,11 @@ export class ReviewCanvasEditorPane extends EditorPane { ); this._register( verbs.onDidEmitSurfaceEvent((event) => { + if ( + event.event === "editorSelectionChanged" && + event.reviewId !== this.apiContent?.reviewId + ) + return; this.surfaceEvents.fire(event); }), ); diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.test.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.test.ts new file mode 100644 index 000000000..a9389d892 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { URI } from "../../../base/common/uri.js"; +import { Selection } from "../../../editor/common/core/selection.js"; +import { apiSourceUri } from "../../services/reviewApiSourceService.js"; +import { apiSelectionEvent } from "./reviewApiSelection.js"; + +test("API diff selections retain side, version and commit without a legacy session", () => { + for (const side of ["base", "head"] as const) { + const resource = apiSourceUri({ view: { reviewId: "review-a", version: 7, commit: "selected-commit" }, side, file: "src/[route].ts" }); + for (const selection of [new Selection(2, 1, 4, 1), new Selection(4, 1, 2, 1)]) { + assert.deepEqual(apiSelectionEvent(resource, selection, { x: 70, y: 140 }), { + event: "editorSelectionChanged", reviewId: "review-a", anchor: { x: 70, y: 140 }, + path: "src/[route].ts", sideContext: side, isEmpty: false, + range: { fromLine: 2, toLine: 3 }, + apiSource: { reviewId: "review-a", version: 7, commit: "selected-commit" }, + }); + } + assert.equal(apiSelectionEvent(resource, new Selection(2, 1, 2, 1))?.isEmpty, true); + } +}); + +test("empty diff sides and unrelated resources cannot produce source-copy actions", () => { + const empty = apiSourceUri({ view: { reviewId: "review-a", version: 0 }, side: "base", file: "new.ts" }, true); + const selection = new Selection(1, 1, 2, 1); + assert.equal(apiSelectionEvent(empty, selection), undefined); + assert.equal(apiSelectionEvent(URI.file("/tmp/file.ts"), selection), undefined); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.ts new file mode 100644 index 000000000..f104c6e5b --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewApiSelection.ts @@ -0,0 +1,25 @@ +import type { URI } from "../../../base/common/uri.js"; +import type { Selection } from "../../../editor/common/core/selection.js"; +import type { ReviewSurfaceEvent } from "../../common/reviewProtocol.js"; +import { apiSourceTarget } from "../../services/reviewApiSourceService.js"; + +/** API-backed editors carry their pins in the URI and have no legacy session. */ +export function apiSelectionEvent( + resource: URI, + selection: Selection, + anchor?: { x: number; y: number }, +): Extract | undefined { + const source = apiSourceTarget(resource); + if (!source || new URLSearchParams(resource.query).has("empty")) return undefined; + const start = selection.getStartPosition(); + const end = selection.getEndPosition(); + return { + event: "editorSelectionChanged", reviewId: source.view.reviewId, anchor, + path: source.file, sideContext: source.side, isEmpty: selection.isEmpty(), + range: { + fromLine: start.lineNumber, + toLine: Math.max(start.lineNumber, end.lineNumber - (end.column === 1 && end.lineNumber > start.lineNumber ? 1 : 0)), + }, + apiSource: { reviewId: source.view.reviewId, version: source.view.version, commit: source.view.commit }, + }; +} diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewVerbs.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewVerbs.ts index bbe36c880..b81df065a 100644 --- a/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewVerbs.ts +++ b/apps/review-desktop/code-oss/src/vs/review/contrib/verbs/reviewVerbs.ts @@ -20,9 +20,9 @@ import { type ReviewView, } from "../../common/reviewProtocol.js"; import { IReviewApiCatalogService } from "../../services/reviewApiCatalogService.js"; -import { REVIEW_API_SOURCE_SCHEME } from "../../services/reviewApiSourceService.js"; import { IReviewCanvasEditorTabsService } from "../../services/reviewCanvasEditorTabsService.js"; import { IReviewCodeResourceService } from "../../services/reviewCodeResourceService.js"; +import { apiSelectionEvent } from "./reviewApiSelection.js"; export const IReviewVerbsService = createDecorator("reviewVerbsService"); @@ -79,10 +79,6 @@ export class ReviewVerbsService extends Disposable implements IReviewVerbsServic const model = editor.getModel(); const selection = editor.getSelection(); if (!model || !selection) return; - const unified = this.codeResources.unifiedResource(model.uri); - const apiSide = - model.uri.scheme === REVIEW_API_SOURCE_SCHEME ? new URLSearchParams(model.uri.query).get("side") : null; - if (!unified && apiSide !== "base" && apiSide !== "head") return; const start = selection.getStartPosition(); const end = selection.getEndPosition(); const fromLine = start.lineNumber; @@ -90,6 +86,12 @@ export class ReviewVerbsService extends Disposable implements IReviewVerbsServic const rect = editor.getDomNode()?.getBoundingClientRect(); const position = editor.getScrolledVisiblePosition(selection.getPosition()); const anchor = rect && position ? { x: rect.left + position.left, y: rect.top + position.top } : undefined; + const apiSelection = apiSelectionEvent(model.uri, selection, anchor); + if (apiSelection) { + this._onDidEmitSurfaceEvent.fire(apiSelection); + return; + } + const unified = this.codeResources.unifiedResource(model.uri); if (unified) { const rows = unified.rows.slice(fromLine - 1, toLine); if (!rows.length) return; @@ -117,16 +119,6 @@ export class ReviewVerbsService extends Disposable implements IReviewVerbsServic rows: rows.map((row) => ({ kind: row.kind, text: row.content })), }, }); - } else if (apiSide === "base" || apiSide === "head") { - this._onDidEmitSurfaceEvent.fire({ - event: "editorSelectionChanged", - reviewId: model.uri.authority, - anchor, - path: model.uri.path.slice(1), - sideContext: apiSide, - isEmpty: selection.isEmpty(), - range: { fromLine, toLine }, - }); } } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts index af29bac72..d019a32f3 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewApiSourceService.ts @@ -69,6 +69,27 @@ export { export type ApiSourceTarget = ReviewApiSourceLocation; +/** Recover the immutable source identity even when no legacy session is active. */ +export function apiSourceTarget(resource: URI): ApiSourceTarget | undefined { + if (resource.scheme !== REVIEW_API_SOURCE_SCHEME) return undefined; + const query = new URLSearchParams(resource.query); + const version = Number(query.get("version")); + const side = query.get("side"); + if ( + !resource.authority || + !query.has("version") || + !Number.isInteger(version) || + version < 0 || + (side !== "base" && side !== "head") + ) + return undefined; + return { + view: { reviewId: resource.authority, version, commit: query.get("commit") ?? undefined, generation: query.get("generation") ?? undefined }, + side, + file: resource.path.slice(1), + }; +} + export const IReviewApiSourceService = createDecorator( "reviewApiSourceService", ); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts index 5f5aad7df..6ca61c255 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDiffViewService.ts @@ -12,30 +12,23 @@ import { IConfigurationService } from "../../platform/configuration/common/confi import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; import { REVIEW_STRUCTURAL_DIFF_SETTING } from "../common/reviewConfigurationDefaults.js"; import type { - ReviewCommitScope, - ReviewDiffFileWire, - ReviewDiffViewHandle, - ReviewDiffViewSpec, + ReviewCommitScope, + ReviewDiffFileWire, + ReviewDiffViewHandle, + ReviewDiffViewSpec, } from "../common/reviewProtocol.js"; import { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; import { markReviewEmbeddedEditor } from "./reviewEmbeddedNavigation.js"; -import { - ReviewFilesDiffView, - ReviewFilesEditorInput, - type ReviewFilesEditorEntry, -} from "./reviewFilesDiffView.js"; +import { ReviewFilesDiffView, ReviewFilesEditorInput, type ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; import type { ReviewInlineEditorService } from "./reviewInlineEditorService.js"; + import { prepareStructuralReview } from "./reviewStructuralDiff.js"; export interface ReviewDiffViewSource { - load( - scope?: ReviewCommitScope, - ): Promise<{ - sourceUri: URI; - entries: readonly ReviewFilesEditorEntry[]; - structuralDiff?: (signal: AbortSignal) => Promise; - }>; - files(scope?: ReviewCommitScope): Promise; + load(scope?: ReviewCommitScope): Promise<{ sourceUri: URI; entries: readonly ReviewFilesEditorEntry[] ; + structuralDiff?: (signal: AbortSignal) => Promise; + }>; + files(scope?: ReviewCommitScope): Promise; } /** @@ -44,216 +37,196 @@ export interface ReviewDiffViewSource { * follow that pane's lifetime. */ export class ReviewDiffViewService extends Disposable { - private overflowWidgetsDomNode: HTMLElement | undefined; - private readonly handles = new Set(); - readonly diffLayout: ReviewDiffLayoutSetting; - /** - * Scroll and expansion state per session document. The Diff view is a - * conditionally rendered React sibling: a toggle away disposes the widget, - * so the state must survive outside it. - */ - private readonly viewStates = new Map(); + private overflowWidgetsDomNode: HTMLElement | undefined; + private readonly handles = new Set(); + readonly diffLayout: ReviewDiffLayoutSetting; + /** + * Scroll and expansion state per session document. The Diff view is a + * conditionally rendered React sibling: a toggle away disposes the widget, + * so the state must survive outside it. + */ + private readonly viewStates = new Map(); - constructor( - private readonly inlineEditors: ReviewInlineEditorService, - @IInstantiationService - private readonly instantiationService: IInstantiationService, - ) { - super(); - this.diffLayout = this._register( - instantiationService.createInstance(ReviewDiffLayoutSetting), - ); - } + constructor( + private readonly inlineEditors: ReviewInlineEditorService, + @IInstantiationService + private readonly instantiationService: IInstantiationService, + ) { + super(); + this.diffLayout = this._register(instantiationService.createInstance(ReviewDiffLayoutSetting)); + } - setOverflowWidgetsDomNode(node: HTMLElement): void { - this.overflowWidgetsDomNode = node; - } + setOverflowWidgetsDomNode(node: HTMLElement): void { + this.overflowWidgetsDomNode = node; + } - create( - spec: ReviewDiffViewSpec, - source: ReviewDiffViewSource, - ): ReviewDiffViewHandle { - const handle = new DiffViewHandle( - spec, - this.instantiationService, - this.inlineEditors, - this.overflowWidgetsDomNode, - this.diffLayout, - this.viewStates, - () => this.handles.delete(handle), - source, - ); - this.handles.add(handle); - return handle; - } + create(spec: ReviewDiffViewSpec, source: ReviewDiffViewSource): ReviewDiffViewHandle { + const handle = new DiffViewHandle( + spec, + this.instantiationService, + this.inlineEditors, + this.overflowWidgetsDomNode, + this.diffLayout, + this.viewStates, + () => this.handles.delete(handle), + source, + ); + this.handles.add(handle); + return handle; + } - reset(): void { - for (const handle of [...this.handles]) handle.dispose(); - this.handles.clear(); - this.viewStates.clear(); - } + reset(): void { + for (const handle of [...this.handles]) handle.dispose(); + this.handles.clear(); + this.viewStates.clear(); + } - toggleRenderSideBySide(): void { - void this.diffLayout.toggle(); - } + toggleRenderSideBySide(): void { + void this.diffLayout.toggle(); + } } class DiffViewHandle extends Disposable implements ReviewDiffViewHandle { - private readonly _onDidError = this._register(new Emitter()); - readonly onDidError = this._onDidError.event; - private readonly activeControlStore = this._register(new DisposableStore()); - private view: ReviewFilesDiffView | undefined; - private viewStateKey: string | undefined; - private adoptedEditors: readonly ICodeEditor[] = []; - private disposed = false; + private readonly _onDidError = this._register(new Emitter()); + readonly onDidError = this._onDidError.event; + private readonly activeControlStore = this._register(new DisposableStore()); + private view: ReviewFilesDiffView | undefined; + private viewStateKey: string | undefined; + private adoptedEditors: readonly ICodeEditor[] = []; + private disposed = false; - constructor( - private readonly spec: ReviewDiffViewSpec, - private readonly instantiationService: IInstantiationService, - private readonly inlineEditors: ReviewInlineEditorService, - private readonly overflowWidgetsDomNode: HTMLElement | undefined, - private readonly diffLayout: ReviewDiffLayoutSetting, - private readonly viewStates: Map, - private readonly onDispose: () => void, - private readonly source: ReviewDiffViewSource, - ) { - super(); - void this.initialize(); - } + constructor( + private readonly spec: ReviewDiffViewSpec, + private readonly instantiationService: IInstantiationService, + private readonly inlineEditors: ReviewInlineEditorService, + private readonly overflowWidgetsDomNode: HTMLElement | undefined, + private readonly diffLayout: ReviewDiffLayoutSetting, + private readonly viewStates: Map, + private readonly onDispose: () => void, + private readonly source: ReviewDiffViewSource, + ) { + super(); + void this.initialize(); + } - focus(): void { - this.view?.focus(); - } + focus(): void { + this.view?.focus(); + } - override dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.captureViewState(); - // A view toggle disposes these editors. The composite must not keep one - // of them as its active editor afterwards. - for (const editor of this.adoptedEditors) { - this.inlineEditors.clearExternalActiveEditor(editor); - } - this.adoptedEditors = []; - this.view = undefined; - super.dispose(); - this.onDispose(); - } + override dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.captureViewState(); + // A view toggle disposes these editors. The composite must not keep one + // of them as its active editor afterwards. + for (const editor of this.adoptedEditors) { + this.inlineEditors.clearExternalActiveEditor(editor); + } + this.adoptedEditors = []; + this.view = undefined; + super.dispose(); + this.onDispose(); + } - private async initialize(): Promise { - try { - const data = await this.source.load(this.spec.scope); - const { sourceUri, entries } = data; - const structuralEnabled = this.instantiationService.invokeFunction( - (a) => - a - .get(IConfigurationService) - .getValue(REVIEW_STRUCTURAL_DIFF_SETTING) === true, - ); - this.viewStateKey = `${sourceUri.toString()}:${structuralEnabled}`; - if (this.disposed) return; - const store = this._register(new DisposableStore()); - const structural = structuralEnabled - ? await prepareStructuralReview( - this.instantiationService, - entries, - store, - data.structuralDiff, - ) - : { - instantiation: this.instantiationService, - entries, - enabled: false, - load: undefined, - onDidChangeCounts: undefined, - }; - if (this.disposed) return; - // The input owns the text-model references its view model resolves, so - // this handle disposes it alongside the view. - const input = store.add( - structural.instantiation.createInstance( - ReviewFilesEditorInput, - sourceUri, - structural.entries, - structural.enabled, - ), - ); - const view = store.add( - structural.instantiation.createInstance( - ReviewFilesDiffView, - this.spec.container, - this.overflowWidgetsDomNode, - this.diffLayout, - ), - ); - this.view = view; - if (structural.enabled) view.startLoading(structural.entries); - store.add( - view.onDidChangeActiveControl(() => this.bindActiveControl(view)), - ); - // A saved whole-list offset cannot be restored into a partial streamed list. - await view.setInput( - input, - structural.enabled ? undefined : this.viewStates.get(this.viewStateKey), - ); - if (this.disposed) return; - this.bindActiveControl(view); - if (structural.load) { - store.add( - structural.onDidChangeCounts(({ path, counts }) => { - if (!this.disposed) view.fileCounts(path, counts); - }), - ); - void structural - .load((path, outcome) => { - if (this.disposed) return; - if (outcome.hidden !== undefined) - view.hideFile(path, outcome.hidden); - view.fileLoaded(path, outcome.error, outcome.stats); - }) - .catch((error) => { - if (!this.disposed) - view.loadingFailed( - error instanceof Error ? error.message : String(error), - ); - }); - } - } catch (error) { - if (this.disposed) return; - this._onDidError.fire( - error instanceof Error ? error.message : String(error), - ); - } - } + private async initialize(): Promise { + try { + const data = await this.source.load(this.spec.scope); + const { sourceUri, entries } = data; + const structuralEnabled = this.instantiationService.invokeFunction( + (a) => + a + .get(IConfigurationService) + .getValue(REVIEW_STRUCTURAL_DIFF_SETTING) === true, + ); + this.viewStateKey = `${sourceUri.toString()}:${structuralEnabled}`; + if (this.disposed) return; + if (structuralEnabled && !data.structuralDiff) throw new Error("Structural diffs are unavailable for this source."); + const store = this._register(new DisposableStore()); + const structural = structuralEnabled + ? await prepareStructuralReview( + this.instantiationService, + entries, + store, + data.structuralDiff!, + ) + : { + instantiation: this.instantiationService, + entries, + enabled: false, + load: undefined, + onDidChangeCounts: undefined, + }; + if (this.disposed) return; + // The input owns the text-model references its view model resolves, so + // this handle disposes it alongside the view. + const input = store.add(structural.instantiation.createInstance(ReviewFilesEditorInput, sourceUri, structural.entries, + structural.enabled)); + const view = store.add( + structural.instantiation.createInstance( + ReviewFilesDiffView, + this.spec.container, + this.overflowWidgetsDomNode, + this.diffLayout, + ), + ); + this.view = view; + if (structural.enabled) view.startLoading(structural.entries); + store.add(view.onDidChangeActiveControl(() => this.bindActiveControl(view))); + // A saved whole-list offset cannot be restored into a partial streamed list. + await view.setInput(input, structural.enabled ? undefined : this.viewStates.get(this.viewStateKey), + ); + if (this.disposed) return; + this.bindActiveControl(view); + if (structural.load) { + store.add( + structural.onDidChangeCounts(({ path, counts }) => { + if (!this.disposed) view.fileCounts(path, counts); + }), + ); + void structural + .load((path, outcome) => { + if (this.disposed) return; + if (outcome.hidden !== undefined) + view.hideFile(path, outcome.hidden); + view.fileLoaded(path, outcome.error, outcome.stats); + }) + .catch((error) => { + if (!this.disposed) + view.loadingFailed( + error instanceof Error ? error.message : String(error), + ); + }); + } + } catch (error) { + if (this.disposed) return; + this._onDidError.fire(error instanceof Error ? error.message : String(error)); + } + } - /** - * Joins the embedded diff's inner editors to the canvas composite. Find and - * the editor context keys act on the composite's active editor, so a focused - * inner editor has to become that editor. - */ - private bindActiveControl(view: ReviewFilesDiffView): void { - this.activeControlStore.clear(); - const diffEditor = view.getActiveControl(); - if (!diffEditor) return; - const unified = view.getActiveUnifiedControl(); - const editors: readonly ICodeEditor[] = unified - ? [unified] - : [diffEditor.getOriginalEditor(), diffEditor.getModifiedEditor()]; - this.adoptedEditors = editors; - for (const editor of editors) { - this.activeControlStore.add(markReviewEmbeddedEditor(editor)); - this.activeControlStore.add( - editor.onDidFocusEditorText(() => - this.inlineEditors.setExternalActiveEditor(editor), - ), - ); - } - } + /** + * Joins the embedded diff's inner editors to the canvas composite. Find and + * the editor context keys act on the composite's active editor, so a focused + * inner editor has to become that editor. + */ + private bindActiveControl(view: ReviewFilesDiffView): void { + this.activeControlStore.clear(); + const diffEditor = view.getActiveControl(); + if (!diffEditor) return; + const editors: readonly ICodeEditor[] = [diffEditor.getOriginalEditor(), diffEditor.getModifiedEditor(), + ]; + this.adoptedEditors = editors; + for (const editor of editors) { + this.activeControlStore.add(markReviewEmbeddedEditor(editor)); + this.activeControlStore.add( + editor.onDidFocusEditorText(() => this.inlineEditors.setExternalActiveEditor(editor)), + ); + } + } - private captureViewState(): void { - const key = this.viewStateKey; - const state = this.view?.getViewState(); - if (!key || !state) return; - this.viewStates.set(key, state); - } + private captureViewState(): void { + const key = this.viewStateKey; + const state = this.view?.getViewState(); + if (!key || !state) return; + this.viewStates.set(key, state); + } } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts index 9add1cf2d..5369fbf05 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewFilesDiffView.ts @@ -5,20 +5,14 @@ import "../browser/media/review.css"; import { $, append, Dimension } from "../../base/browser/dom.js"; -import { - Orientation, - SplitView, -} from "../../base/browser/ui/splitview/splitview.js"; +import { Orientation, SplitView } from "../../base/browser/ui/splitview/splitview.js"; import { Emitter, Event } from "../../base/common/event.js"; import { Disposable, toDisposable } from "../../base/common/lifecycle.js"; import { autorun } from "../../base/common/observable.js"; import { isEqual } from "../../base/common/resources.js"; import { URI } from "../../base/common/uri.js"; import { ElementSizeObserver } from "../../editor/browser/config/elementSizeObserver.js"; -import type { - ICodeEditor, - IDiffEditor, -} from "../../editor/browser/editorBrowser.js"; +import type { IDiffEditor } from "../../editor/browser/editorBrowser.js"; import { MultiDiffEditorViewModel } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js"; import { MultiDiffEditorWidget } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; import type { IMultiDiffEditorViewState } from "../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js"; @@ -28,138 +22,128 @@ import { ITextResourceConfigurationService } from "../../editor/common/services/ import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; import { MultiDiffEditorInput } from "../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js"; import { - IMultiDiffSourceResolverService, - MultiDiffEditorItem, + IMultiDiffSourceResolverService, + MultiDiffEditorItem, } from "../../workbench/contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js"; import { IEditorGroupsService } from "../../workbench/services/editor/common/editorGroupsService.js"; import { IEditorService } from "../../workbench/services/editor/common/editorService.js"; import { ITextFileService } from "../../workbench/services/textfile/common/textfiles.js"; import { ReviewChangedFilesTree } from "../browser/reviewChangedFilesTree.js"; +import { type ReviewDiffFileWire } from "../common/reviewProtocol.js"; import { - type ReviewCommitScope, - type ReviewDiffFileWire, -} from "../common/reviewProtocol.js"; -import { - structuralCountsTooltip, - type StructuralFileCounts, + structuralCountsTooltip, + type StructuralFileCounts, } from "../common/reviewStructuralDiff.js"; import type { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; -import { - reviewMultiDiffLabelUris, - ReviewMultiDiffUIElementFactory, -} from "./reviewMultiDiff.js"; +import { reviewMultiDiffLabelUris, ReviewMultiDiffUIElementFactory } from "./reviewMultiDiff.js"; const FILE_TREE_MINIMUM_WIDTH = 180; const DIFF_MINIMUM_WIDTH = 320; const FILE_TREE_COLLAPSE_WIDTH = FILE_TREE_MINIMUM_WIDTH + DIFF_MINIMUM_WIDTH; const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { - hideUnchangedRegions: { enabled: true }, - originalEditable: false, - readOnly: true, - glyphMargin: false, - lineNumbersMinChars: 3, + hideUnchangedRegions: { enabled: true }, + originalEditable: false, + readOnly: true, + glyphMargin: false, + lineNumbersMinChars: 3, } satisfies IDiffEditorOptions; export interface ReviewFilesEditorEntry { - readonly file: ReviewDiffFileWire; - readonly original: URI | undefined; - readonly modified: URI | undefined; - readonly goToFileResource: URI; + readonly file: ReviewDiffFileWire; + readonly original: URI | undefined; + readonly modified: URI | undefined; + readonly goToFileResource: URI; } export class ReviewFilesEditorInput extends MultiDiffEditorInput { - readonly scope: ReviewCommitScope | undefined; - static override readonly ID = "workbench.input.devfast.reviewFiles"; - static readonly EDITOR_ID = "workbench.editor.devfast.reviewFiles"; - - private readonly updateResources: (paths: ReadonlySet) => void; - - setReadyFiles(paths: ReadonlySet): void { - this.updateResources(paths); - } - - constructor( - source: URI, - readonly entries: readonly ReviewFilesEditorEntry[], - readonly structural: boolean = false, - @ITextModelService textModelService: ITextModelService, - @ITextResourceConfigurationService - textResourceConfigurationService: ITextResourceConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, - @IMultiDiffSourceResolverService - multiDiffSourceResolverService: IMultiDiffSourceResolverService, - @ITextFileService textFileService: ITextFileService, - ) { - const items = entries.map( - (entry) => - new MultiDiffEditorItem( - entry.original, - entry.modified, - entry.goToFileResource, - undefined, - undefined, - reviewMultiDiffLabelUris(entry.file), - // Every hidden line comes from the one fold model: diffr's regions, never the diff editor's own unchanged-region hiding. - // Every collapsed region diffr sends is a hidden-region band; the editor's own folding stays off. - structural - ? { - ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, - hideUnchangedRegions: { - enabled: true, - minimumLineCount: 1, - contextLineCount: 0, - }, - folding: false, - glyphMargin: true, - experimental: { useTrueInlineView: false }, - } - : REVIEW_FILES_DIFF_EDITOR_OPTIONS, - ), - ); - const changes = new Emitter(); - let current: readonly MultiDiffEditorItem[] = []; - const streamSource = { - resources: { - get value() { - return current; - }, - onDidChange: changes.event, - }, - }; - super( - source, - "Files", - structural ? undefined : items, - true, - textModelService, - textResourceConfigurationService, - instantiationService, - structural - ? { - _serviceBrand: undefined, - registerResolver: (resolver) => - multiDiffSourceResolverService.registerResolver(resolver), - resolve: async () => streamSource, - } - : multiDiffSourceResolverService, - textFileService, - ); - const commit = new URLSearchParams(source.query).get("commit"); - this.scope = commit ? { commit } : undefined; - this._register(changes); - this.updateResources = (paths) => { - current = items.filter((_, index) => paths.has(entries[index].file.path)); - changes.fire(); - }; - } - - override get typeId(): string { - return ReviewFilesEditorInput.ID; - } - - override get editorId(): string { - return ReviewFilesEditorInput.EDITOR_ID; - } + static override readonly ID = "workbench.input.devfast.reviewFiles"; + static readonly EDITOR_ID = "workbench.editor.devfast.reviewFiles"; + + private readonly updateResources: (paths: ReadonlySet) => void; + + setReadyFiles(paths: ReadonlySet): void { + this.updateResources(paths); + } + + constructor( + source: URI, + readonly entries: readonly ReviewFilesEditorEntry[], + readonly structural: boolean = false, + @ITextModelService textModelService: ITextModelService, + @ITextResourceConfigurationService + textResourceConfigurationService: ITextResourceConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IMultiDiffSourceResolverService + multiDiffSourceResolverService: IMultiDiffSourceResolverService, + @ITextFileService textFileService: ITextFileService, + ) { + const items = entries.map( + (entry) => + new MultiDiffEditorItem( + entry.original, + entry.modified, + entry.goToFileResource, + undefined, + undefined, + reviewMultiDiffLabelUris(entry.file), + // Every hidden line comes from the one fold model: diffr's regions, never the diff editor's own unchanged-region hiding. + // Every collapsed region diffr sends is a hidden-region band; the editor's own folding stays off. + structural + ? { + ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, + hideUnchangedRegions: { + enabled: true, + minimumLineCount: 1, + contextLineCount: 0, + }, + folding: false, + glyphMargin: true, + experimental: { useTrueInlineView: false }, + } + : REVIEW_FILES_DIFF_EDITOR_OPTIONS, + ), + ); + const changes = new Emitter(); + let current: readonly MultiDiffEditorItem[] = []; + const streamSource = { + resources: { + get value() { + return current; + }, + onDidChange: changes.event, + }, + }; + super( + source, + "Files", + structural ? undefined : items, + true, + textModelService, + textResourceConfigurationService, + instantiationService, + structural + ? { + _serviceBrand: undefined, + registerResolver: (resolver) => + multiDiffSourceResolverService.registerResolver(resolver), + resolve: async () => streamSource, + } + : multiDiffSourceResolverService, + textFileService, + ); + this._register(changes); + this.updateResources = (paths) => { current = items.filter((_, index) => paths.has(entries[index].file.path)); + changes.fire(); + } ; + } + + override get typeId(): string { + return ReviewFilesEditorInput.ID; + } + + override get editorId(): string { + return ReviewFilesEditorInput.EDITOR_ID; + } } /** @@ -170,441 +154,391 @@ export class ReviewFilesEditorInput extends MultiDiffEditorInput { * layout call. */ export class ReviewFilesDiffView extends Disposable { - private readonly _onDidChangeActiveControl = this._register( - new Emitter(), - ); - readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; - - private readonly root: HTMLElement; - private readonly splitView: SplitView; - private readonly changedFilesTree: ReviewChangedFilesTree; - private readonly widget: MultiDiffEditorWidget; - private viewModel: MultiDiffEditorViewModel | undefined; - private input: ReviewFilesEditorInput | undefined; - private readonly readyFiles = new Set(); - private readonly fileStates = new Map(); - /** Counts from the structural stream: visible lines replace the git counts once a file lands. */ - private readonly streamStats = new Map< - string, - { counts: StructuralFileCounts; title: string } - >(); - private readonly headerFactory: ReviewMultiDiffUIElementFactory; - private readonly summary: HTMLElement; - private readonly summaryBlocks: HTMLElement[] = []; - /** Files the stream says start hidden, with the reason shown in their header. */ - private readonly hiddenFiles = new Map(); - private readonly hiddenApplied = new Set(); - private pendingPath: string | undefined; - private readonly streamStatus: HTMLElement; - - constructor( - private readonly container: HTMLElement, - overflowWidgetsDomNode: HTMLElement | undefined, - layout: ReviewDiffLayoutSetting, - @IInstantiationService - private readonly reviewInstantiationService: IInstantiationService, - @IEditorService private readonly editorService: IEditorService, - @IEditorGroupsService - private readonly editorGroupService: IEditorGroupsService, - ) { - super(); - this.root = append(container, $(".review-files-editor")); - const fileTree = append(this.root, $(".review-files-editor-tree")); - const diffContainer = append(this.root, $(".review-files-editor-diffs")); - - const factory = this.reviewInstantiationService.createInstance( - ReviewMultiDiffUIElementFactory, - () => - this.input - ? this.input.entries.map((entry) => ({ - original: entry.original, - modified: entry.modified, - additions: - this.streamStats.get(entry.file.path)?.counts.visible.added ?? - entry.file.additions, - deletions: - this.streamStats.get(entry.file.path)?.counts.visible.removed ?? - entry.file.deletions, - countsTitle: this.streamStats.get(entry.file.path)?.title, - note: this.hiddenFiles.get(entry.file.path), - onDidOpen: () => { - void this.editorService.openEditor( - { - resource: entry.goToFileResource, - options: { pinned: true, revealIfVisible: true }, - }, - this.editorGroupService.mainPart.activeGroup, - ); - }, - })) - : [], - "auto", - // Hover and definition widgets must escape the canvas root, whose - // container-query containment clips position: fixed descendants. - overflowWidgetsDomNode, - false, - undefined, - ); - this.headerFactory = factory; - this.widget = this._register( - this.reviewInstantiationService.createInstance( - MultiDiffEditorWidget, - diffContainer, - factory, - undefined, - ), - ); - // The widget's own switch, not the per-item option refresh: it pins the - // width heuristic off, so the chosen layout is what renders at any width. - const applyLayout = () => - this.widget.setRenderSideBySide(layout.get() === "split"); - this._register(layout.onDidChange(applyLayout)); - applyLayout(); - this.streamStatus = append( - diffContainer, - $(".review-structural-stream-status"), - ); - this.streamStatus.setAttribute("role", "status"); - this.streamStatus.hidden = true; - this._register( - this.widget.onDidChangeActiveControl(() => - this._onDidChangeActiveControl.fire(), - ), - ); - this._register( - this.widget.onDidChangeActiveUnifiedControl(() => - this._onDidChangeActiveControl.fire(), - ), - ); - this._register( - this.widget.onDidChangeActiveItem(() => - this.syncFileSelectionFromWidget(), - ), - ); - this.summary = append(fileTree, $(".review-files-editor-summary")); - this.summary.hidden = true; - this.changedFilesTree = this._register( - this.reviewInstantiationService.createInstance( - ReviewChangedFilesTree, - fileTree, - ), - ); - this._register( - this.changedFilesTree.onDidOpenFile((file) => { - const element = this.input?.entries.find( - (entry) => entry.file.path === file.path, - ); - if (!element) return; - if (this.fileStates.has(file.path)) { - this.pendingPath = file.path; - this.showStreamStatus(); - return; - } - this.pendingPath = undefined; - this.showStreamStatus(); - this.reveal({ - original: element.original, - modified: element.modified, - }); - }), - ); - this.splitView = this._register( - new SplitView(this.root, { - orientation: Orientation.HORIZONTAL, - proportionalLayout: true, - }), - ); - this.splitView.addView( - { - element: fileTree, - layout: (width, _offset, height) => { - fileTree.style.width = `${width}px`; - this.changedFilesTree.layout( - (height ?? 0) - - (this.summary.hidden ? 0 : this.summary.offsetHeight), - width, - ); - }, - maximumSize: 380, - minimumSize: FILE_TREE_MINIMUM_WIDTH, - onDidChange: Event.None, - }, - 260, - ); - this.splitView.addView( - { - element: diffContainer, - layout: (width, _offset, height) => { - diffContainer.style.width = `${width}px`; - this.widget.layout(new Dimension(width, height ?? 0)); - }, - maximumSize: Number.POSITIVE_INFINITY, - minimumSize: DIFF_MINIMUM_WIDTH, - onDidChange: Event.None, - }, - 740, - ); - - const sizeObserver = this._register( - new ElementSizeObserver(this.container, undefined), - ); - this._register(sizeObserver.onDidChange(() => this.layout())); - sizeObserver.startObserving(); - this.layout(); - // Registered last so it runs last: the widgets above tear their own DOM - // down, and they must do that while the tree is still attached. - this._register(toDisposable(() => this.root.remove())); - } - - async setInput( - input: ReviewFilesEditorInput, - viewState: IMultiDiffEditorViewState | undefined, - ): Promise { - this.input = input; - this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); - const viewModel = await input.getViewModel(); - if (this._store.isDisposed) return; - this.viewModel = viewModel; - // The canvas mounts this view without a user gesture, so the widget's - // first-change navigation must never take keyboard focus. - this.widget.setViewModel(viewModel, { preserveFocus: true, viewState }); - this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); - this.syncFileSelectionFromWidget(); - this._register( - autorun((reader) => { - const items = viewModel.items.read(reader); - this.applyHiddenFiles(items); - const entry = this.input?.entries.find( - (e) => e.file.path === this.pendingPath, - ); - if (!entry || !this.readyFiles.has(entry.file.path)) return; - if ( - !items.some( - (item) => - sameResource(item.originalUri, entry.original) && - sameResource(item.modifiedUri, entry.modified), - ) - ) - return; - this.pendingPath = undefined; - this.showStreamStatus(); - queueMicrotask(() => { - if (!this._store.isDisposed) this.reveal(entry); - }); - }), - ); - } - - startLoading(entries: readonly ReviewFilesEditorEntry[]): void { - this.changedFilesTree.setFiles(entries.map((e) => e.file)); - for (const entry of entries) { - this.fileStates.set(entry.file.path, "Loading diff…"); - this.changedFilesTree.setFileState(entry.file.path, "loading"); - } - this.showStreamStatus(); - } - - fileLoaded( - path: string, - error?: string, - stats?: { added: number; removed: number }, - ): void { - if (error) { - this.fileStates.set(path, error); - this.changedFilesTree.setFileState(path, "error", error); - } else { - this.fileStates.delete(path); - this.readyFiles.add(path); - this.changedFilesTree.setFileState(path, undefined); - this.input?.setReadyFiles(this.readyFiles); - } - this.showStreamStatus(); - } - - /** Visible counts from the stream, first on arrival and again after every fold toggle. */ - fileCounts(path: string, counts: StructuralFileCounts): void { - this.streamStats.set(path, { - counts, - title: structuralCountsTooltip(counts), - }); - this.changedFilesTree.setFiles(this.filesWithStreamStats()); - this.headerFactory.refreshHeaders(); - this.renderSummary(); - } - - /** The review-level total: the same visible counts summed over files, with GitHub's five-block bar. */ - private renderSummary(): void { - const entries = this.input?.entries ?? []; - const total = { - visible: { added: 0, removed: 0 }, - textual: { added: 0, removed: 0 }, - }; - let lineDiffs = 0; - for (const entry of entries) { - const stats = this.streamStats.get(entry.file.path); - if (!stats) continue; - total.visible.added += stats.counts.visible.added; - total.visible.removed += stats.counts.visible.removed; - total.textual.added += stats.counts.textual.added; - total.textual.removed += stats.counts.textual.removed; - if (stats.counts.fallback) lineDiffs++; - } - if (this.summaryBlocks.length === 0) { - const files = $("span.review-files-editor-summary-files"); - const added = $("span.review-files-editor-summary-added"); - const removed = $("span.review-files-editor-summary-removed"); - const bar = $("span.review-files-editor-summary-bar"); - for (let i = 0; i < 5; i++) - this.summaryBlocks.push( - append(bar, $("span.review-files-editor-summary-block")), - ); - this.summary.append(files, added, removed, bar); - } - const [files, added, removed] = this.summary - .children as unknown as HTMLElement[]; - files.textContent = - entries.length === 1 ? "1 file" : `${entries.length} files`; - added.textContent = `+${total.visible.added}`; - removed.textContent = `−${total.visible.removed}`; - const sum = total.visible.added + total.visible.removed; - const greens = sum === 0 ? 0 : Math.round((total.visible.added / sum) * 5); - const reds = sum === 0 ? 0 : Math.round((total.visible.removed / sum) * 5); - this.summaryBlocks.forEach((block, index) => { - block.className = `review-files-editor-summary-block ${index < greens ? "added" : index < greens + reds ? "removed" : "neutral"}`; - }); - this.summary.title = [ - `visible +${total.visible.added} −${total.visible.removed}`, - `textual +${total.textual.added} −${total.textual.removed}`, - ...(lineDiffs - ? [`line diff: ${lineDiffs === 1 ? "1 file" : `${lineDiffs} files`}`] - : []), - ].join("\n"); - const wasHidden = this.summary.hidden; - this.summary.hidden = false; - if (wasHidden) this.layout(); - } - - /** A file the stream says starts collapsed, and why: its record's `visibility`. */ - hideFile(path: string, label: string): void { - this.hiddenFiles.set(path, label); - this.headerFactory.refreshHeaders(); - if (this.viewModel) this.applyHiddenFiles(this.viewModel.items.get()); - } - - /** GitHub's shape for a hidden file: the header stays, the body waits for a click. */ - private applyHiddenFiles( - items: readonly { - originalUri: URI | undefined; - modifiedUri: URI | undefined; - collapsed: { set(value: boolean, tx: undefined): void }; - }[], - ): void { - for (const entry of this.input?.entries ?? []) { - if ( - !this.hiddenFiles.has(entry.file.path) || - this.hiddenApplied.has(entry.file.path) - ) - continue; - const item = items.find( - (item) => - sameResource(item.originalUri, entry.original) && - sameResource(item.modifiedUri, entry.modified), - ); - if (!item) continue; - item.collapsed.set(true, undefined); - this.hiddenApplied.add(entry.file.path); - } - } - - private filesWithStreamStats(): ReviewDiffFileWire[] { - return (this.input?.entries ?? []).map((entry) => { - const stats = this.streamStats.get(entry.file.path); - return stats - ? { - ...entry.file, - additions: stats.counts.visible.added, - deletions: stats.counts.visible.removed, - } - : entry.file; - }); - } - - loadingFailed(message: string): void { - for (const [path, state] of this.fileStates) - if (state === "Loading diff…") this.fileLoaded(path, message); - this.showStreamStatus(); - } - - private showStreamStatus(): void { - const message = this.pendingPath - ? this.fileStates.get(this.pendingPath) - : undefined; - this.streamStatus.hidden = - (!message && this.readyFiles.size > 0) || this.fileStates.size === 0; - this.streamStatus.textContent = message - ? `${this.pendingPath}: ${message}` - : this.readyFiles.size === 0 - ? ([...this.fileStates.values()][0] ?? "") - : ""; - this.streamStatus.classList.toggle( - "loading", - [...this.fileStates.values()].some((s) => s === "Loading diff…"), - ); - } - - getViewState(): IMultiDiffEditorViewState | undefined { - return this.viewModel ? this.widget.getViewState() : undefined; - } - - getActiveUnifiedControl(): ICodeEditor | undefined { - return this.widget.getActiveUnifiedControl(); - } - - getActiveControl(): IDiffEditor | undefined { - return this.widget.getActiveControl(); - } - - focus(): void { - ( - this.widget.getActiveUnifiedControl() ?? this.widget.getActiveControl() - )?.focus(); - } - - layout(): void { - const width = this.container.clientWidth; - const height = this.container.clientHeight; - if (width <= 0 || height <= 0) return; - - const fileTreeVisible = width >= FILE_TREE_COLLAPSE_WIDTH; - if (this.splitView.isViewVisible(0) !== fileTreeVisible) { - this.splitView.setViewVisible(0, fileTreeVisible); - } - - this.splitView.layout(width, height); - } - private reveal(resource: { - original: URI | undefined; - modified: URI | undefined; - }): void { - this.widget.reveal(resource, { highlight: true }); - } - - private syncFileSelectionFromWidget(): void { - const resource = this.widget.getActiveItem(); - const input = this.input; - if (!resource || !input || this.pendingPath) return; - const index = input.entries.findIndex( - (entry) => - sameResource(entry.original, resource.original) && - sameResource(entry.modified, resource.modified), - ); - if (index === -1) return; - // Passive editor updates must not move a sidebar the reader scrolled independently. - this.changedFilesTree.setActiveFile(input.entries[index].file.path, false); - } + private readonly _onDidChangeActiveControl = this._register(new Emitter()); + readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; + + private readonly root: HTMLElement; + private readonly splitView: SplitView; + private readonly changedFilesTree: ReviewChangedFilesTree; + private readonly widget: MultiDiffEditorWidget; + private viewModel: MultiDiffEditorViewModel | undefined; + private input: ReviewFilesEditorInput | undefined; + private readonly readyFiles = new Set(); + private readonly fileStates = new Map(); + /** Counts from the structural stream: visible lines replace the git counts once a file lands. */ + private readonly streamStats = new Map< + string, + { counts: StructuralFileCounts; title: string } + >(); + private readonly headerFactory: ReviewMultiDiffUIElementFactory; + private readonly summary: HTMLElement; + private readonly summaryBlocks: HTMLElement[] = []; + /** Files the stream says start hidden, with the reason shown in their header. */ + private readonly hiddenFiles = new Map(); + private readonly hiddenApplied = new Set(); + private pendingPath: string | undefined; + private readonly streamStatus: HTMLElement; + + constructor( + private readonly container: HTMLElement, + overflowWidgetsDomNode: HTMLElement | undefined, + layout: ReviewDiffLayoutSetting, + @IInstantiationService + private readonly reviewInstantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService + private readonly editorGroupService: IEditorGroupsService, + ) { + super(); + this.root = append(container, $(".review-files-editor")); + const fileTree = append(this.root, $(".review-files-editor-tree")); + const diffContainer = append(this.root, $(".review-files-editor-diffs")); + + const factory = this.reviewInstantiationService.createInstance( + ReviewMultiDiffUIElementFactory, + () => + this.input + ? this.input.entries.map((entry) => ({ + original: entry.original, + modified: entry.modified, + additions: this.streamStats.get(entry.file.path)?.counts.visible.added ?? + entry.file.additions, + deletions: this.streamStats.get(entry.file.path)?.counts.visible.removed ?? + entry.file.deletions, + countsTitle: this.streamStats.get(entry.file.path)?.title, + note: this.hiddenFiles.get(entry.file.path), + onDidOpen: () => { + void this.editorService.openEditor( + { + resource: entry.goToFileResource, + options: { pinned: true, revealIfVisible: true }, + }, + this.editorGroupService.mainPart.activeGroup, + ); + }, + })) + : [], + "auto", + // Hover and definition widgets must escape the canvas root, whose + // container-query containment clips position: fixed descendants. + overflowWidgetsDomNode, + false, + undefined, + ); + this.headerFactory = factory; + this.widget = this._register( + this.reviewInstantiationService.createInstance(MultiDiffEditorWidget, diffContainer, factory, undefined), + ); + // The widget's own switch, not the per-item option refresh: it pins the + // width heuristic off, so the chosen layout is what renders at any width. + const applyLayout = () => this.widget.setRenderSideBySide(layout.get() === "split"); + this._register(layout.onDidChange(applyLayout)); + applyLayout(); + this.streamStatus = append(diffContainer, + $(".review-structural-stream-status")); + this.streamStatus.setAttribute("role", "status"); + this.streamStatus.hidden = true; + this._register(this.widget.onDidChangeActiveControl(() => this._onDidChangeActiveControl.fire())); + this._register(this.widget.onDidChangeActiveItem(() => this.syncFileSelectionFromWidget())); + this.summary = append(fileTree, $(".review-files-editor-summary")); + this.summary.hidden = true; + this.changedFilesTree = this._register( + this.reviewInstantiationService.createInstance(ReviewChangedFilesTree, fileTree), + ); + this._register( + this.changedFilesTree.onDidOpenFile((file) => { + const element = this.input?.entries.find((entry) => entry.file.path === file.path); + if (!element) return; + if (this.fileStates.has(file.path)) { + this.pendingPath = file.path; + this.showStreamStatus(); + return; + } + this.pendingPath = undefined; + this.showStreamStatus(); + this.reveal({ + original: element.original, + modified: element.modified, + }); + }), + ); + this.splitView = this._register( + new SplitView(this.root, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }), + ); + this.splitView.addView( + { + element: fileTree, + layout: (width, _offset, height) => { + fileTree.style.width = `${width}px`; + this.changedFilesTree.layout((height ?? 0) - + (this.summary.hidden ? 0 : this.summary.offsetHeight), width, + ); + }, + maximumSize: 380, + minimumSize: FILE_TREE_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 260, + ); + this.splitView.addView( + { + element: diffContainer, + layout: (width, _offset, height) => { + diffContainer.style.width = `${width}px`; + this.widget.layout(new Dimension(width, height ?? 0)); + }, + maximumSize: Number.POSITIVE_INFINITY, + minimumSize: DIFF_MINIMUM_WIDTH, + onDidChange: Event.None, + }, + 740, + ); + + const sizeObserver = this._register(new ElementSizeObserver(this.container, undefined)); + this._register(sizeObserver.onDidChange(() => this.layout())); + sizeObserver.startObserving(); + this.layout(); + // Registered last so it runs last: the widgets above tear their own DOM + // down, and they must do that while the tree is still attached. + this._register(toDisposable(() => this.root.remove())); + } + + async setInput(input: ReviewFilesEditorInput, viewState: IMultiDiffEditorViewState | undefined): Promise { + this.input = input; + this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); + const viewModel = await input.getViewModel(); + if (this._store.isDisposed) return; + this.viewModel = viewModel; + // The canvas mounts this view without a user gesture, so the widget's + // first-change navigation must never take keyboard focus. + this.widget.setViewModel(viewModel, { preserveFocus: true, viewState }); + this.changedFilesTree.setFiles(input.entries.map((entry) => entry.file)); + this.syncFileSelectionFromWidget(); + this._register( + autorun((reader) => { + const items = viewModel.items.read(reader); + this.applyHiddenFiles(items); + const entry = this.input?.entries.find( + (e) => e.file.path === this.pendingPath, + ); + if (!entry || !this.readyFiles.has(entry.file.path)) return; + if ( + !items.some( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ) + ) + return; + this.pendingPath = undefined; + this.showStreamStatus(); + queueMicrotask(() => { + if (!this._store.isDisposed) this.reveal(entry); + }); + }), + ); + } + + startLoading(entries: readonly ReviewFilesEditorEntry[]): void { + this.changedFilesTree.setFiles(entries.map((e) => e.file)); + for (const entry of entries) { + this.fileStates.set(entry.file.path, "Loading diff…"); + this.changedFilesTree.setFileState(entry.file.path, "loading"); + } + this.showStreamStatus(); + } + + fileLoaded( + path: string, + error?: string, + stats?: { added: number; removed: number }, + ): void { + if (error) { + this.fileStates.set(path, error); + this.changedFilesTree.setFileState(path, "error", error); + } else { + this.fileStates.delete(path); + this.readyFiles.add(path); + this.changedFilesTree.setFileState(path, undefined); + this.input?.setReadyFiles(this.readyFiles); + } + this.showStreamStatus(); + } + + /** Visible counts from the stream, first on arrival and again after every fold toggle. */ + fileCounts(path: string, counts: StructuralFileCounts): void { + this.streamStats.set(path, { + counts, + title: structuralCountsTooltip(counts), + }); + this.changedFilesTree.setFiles(this.filesWithStreamStats()); + this.headerFactory.refreshHeaders(); + this.renderSummary(); + } + + /** The review-level total: the same visible counts summed over files, with GitHub's five-block bar. */ + private renderSummary(): void { + const entries = this.input?.entries ?? []; + const total = { + visible: { added: 0, removed: 0 }, + textual: { added: 0, removed: 0 }, + }; + let lineDiffs = 0; + for (const entry of entries) { + const stats = this.streamStats.get(entry.file.path); + if (!stats) continue; + total.visible.added += stats.counts.visible.added; + total.visible.removed += stats.counts.visible.removed; + total.textual.added += stats.counts.textual.added; + total.textual.removed += stats.counts.textual.removed; + if (stats.counts.fallback) lineDiffs++; + } + if (this.summaryBlocks.length === 0) { + const files = $("span.review-files-editor-summary-files"); + const added = $("span.review-files-editor-summary-added"); + const removed = $("span.review-files-editor-summary-removed"); + const bar = $("span.review-files-editor-summary-bar"); + for (let i = 0; i < 5; i++) + this.summaryBlocks.push( + append(bar, $("span.review-files-editor-summary-block")), + ); + this.summary.append(files, added, removed, bar); + } + const [files, added, removed] = this.summary + .children as unknown as HTMLElement[]; + files.textContent = + entries.length === 1 ? "1 file" : `${entries.length} files`; + added.textContent = `+${total.visible.added}`; + removed.textContent = `−${total.visible.removed}`; + const sum = total.visible.added + total.visible.removed; + const greens = sum === 0 ? 0 : Math.round((total.visible.added / sum) * 5); + const reds = sum === 0 ? 0 : Math.round((total.visible.removed / sum) * 5); + this.summaryBlocks.forEach((block, index) => { + block.className = `review-files-editor-summary-block ${index < greens ? "added" : index < greens + reds ? "removed" : "neutral"}`; + }); + this.summary.title = [ + `visible +${total.visible.added} −${total.visible.removed}`, + `textual +${total.textual.added} −${total.textual.removed}`, + ...(lineDiffs + ? [`line diff: ${lineDiffs === 1 ? "1 file" : `${lineDiffs} files`}`] + : []), + ].join("\n"); + const wasHidden = this.summary.hidden; + this.summary.hidden = false; + if (wasHidden) this.layout(); + } + + /** A file the stream says starts collapsed, and why: its record's `visibility`. */ + hideFile(path: string, label: string): void { + this.hiddenFiles.set(path, label); + this.headerFactory.refreshHeaders(); + if (this.viewModel) this.applyHiddenFiles(this.viewModel.items.get()); + } + + /** GitHub's shape for a hidden file: the header stays, the body waits for a click. */ + private applyHiddenFiles( + items: readonly { + originalUri: URI | undefined; + modifiedUri: URI | undefined; + collapsed: { set(value: boolean, tx: undefined): void }; + }[], + ): void { + for (const entry of this.input?.entries ?? []) { + if ( + !this.hiddenFiles.has(entry.file.path) || + this.hiddenApplied.has(entry.file.path) + ) + continue; + const item = items.find( + (item) => + sameResource(item.originalUri, entry.original) && + sameResource(item.modifiedUri, entry.modified), + ); + if (!item) continue; + item.collapsed.set(true, undefined); + this.hiddenApplied.add(entry.file.path); + } + } + + private filesWithStreamStats(): ReviewDiffFileWire[] { + return (this.input?.entries ?? []).map((entry) => { + const stats = this.streamStats.get(entry.file.path); + return stats + ? { + ...entry.file, + additions: stats.counts.visible.added, + deletions: stats.counts.visible.removed, + } + : entry.file; + }); + } + + loadingFailed(message: string): void { + for (const [path, state] of this.fileStates) + if (state === "Loading diff…") this.fileLoaded(path, message); + this.showStreamStatus(); + } + + private showStreamStatus(): void { + const message = this.pendingPath + ? this.fileStates.get(this.pendingPath) + : undefined; + this.streamStatus.hidden = + (!message && this.readyFiles.size > 0) || this.fileStates.size === 0; + this.streamStatus.textContent = message + ? `${this.pendingPath}: ${message}` + : this.readyFiles.size === 0 + ? ([...this.fileStates.values()][0] ?? "") + : ""; + this.streamStatus.classList.toggle( + "loading", + [...this.fileStates.values()].some((s) => s === "Loading diff…"), + ); + } + + getViewState(): IMultiDiffEditorViewState | undefined { + return this.viewModel ? this.widget.getViewState() : undefined; + } + + getActiveControl(): IDiffEditor | undefined { + return this.widget.getActiveControl(); + } + + focus(): void { + this.widget.getActiveControl()?.focus(); + } + + layout(): void { + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (width <= 0 || height <= 0) return; + + const fileTreeVisible = width >= FILE_TREE_COLLAPSE_WIDTH; + if (this.splitView.isViewVisible(0) !== fileTreeVisible) { + this.splitView.setViewVisible(0, fileTreeVisible); + } + + this.splitView.layout(width, height); + } + private reveal(resource: { original: URI | undefined; modified: URI | undefined }): void { + this.widget.reveal(resource, { highlight: true }); + } + + private syncFileSelectionFromWidget(): void { + const resource = this.widget.getActiveItem(); + const input = this.input; + if (!resource || !input|| this.pendingPath) return; + const index = input.entries.findIndex( + (entry) => sameResource(entry.original, resource.original) && sameResource(entry.modified, resource.modified), + ); + if (index === -1) return; + // Passive editor updates must not move a sidebar the reader scrolled independently. + this.changedFilesTree.setActiveFile(input.entries[index].file.path, false); + } } function sameResource(left: URI | undefined, right: URI | undefined): boolean { - return left === undefined - ? right === undefined - : !!right && isEqual(left, right); + return left === undefined ? right === undefined : !!right && isEqual(left, right); } diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts index dab62c1df..a3ee721b3 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewMultiDiff.ts @@ -33,7 +33,6 @@ export interface ReviewMultiDiffHeaderEntry { export class ReviewMultiDiffUIElementFactory implements IWorkbenchUIElementFactory { - createUnifiedEditor?: IWorkbenchUIElementFactory["createUnifiedEditor"]; get headerClickToCollapse(): boolean { return !this.hideResourceHeader; diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.test.ts deleted file mode 100644 index 9432211dd..000000000 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { errorHandler, setUnexpectedErrorHandler } from "../../base/common/errors.js"; -import { FileOperationError, FileOperationResult } from "../../platform/files/common/files.js"; -import { reportReviewUnifiedEditorError } from "./reviewUnifiedEditor.js"; - -test("missing source stays local while unexpected unified editor failures remain reported", () => { - const previous = errorHandler.getUnexpectedErrorHandler(); - const reported: unknown[] = []; - let unavailable = 0; - setUnexpectedErrorHandler((error) => reported.push(error)); - try { - reportReviewUnifiedEditorError( - new FileOperationError("Pinned file missing", FileOperationResult.FILE_NOT_FOUND), - () => { unavailable++; }, - ); - assert.equal(unavailable, 1); - assert.deepEqual(reported, []); - - const unexpected = [ - new FileOperationError("Access denied", FileOperationResult.FILE_PERMISSION_DENIED), - new Error("Unable to resolve nonexistent file"), - ]; - for (const error of unexpected) { - reportReviewUnifiedEditorError(error, () => { unavailable++; }); - } - assert.equal(unavailable, 1); - assert.deepEqual(reported, unexpected); - } finally { - setUnexpectedErrorHandler(previous); - } -}); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.ts index f648f6110..7ab9e50c1 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewUnifiedEditor.ts @@ -1,18 +1,7 @@ import { Range } from "../../editor/common/core/range.js"; -import { onUnexpectedError } from "../../base/common/errors.js"; -import { FileOperationError, FileOperationResult } from "../../platform/files/common/files.js"; import type { IModelDeltaDecoration } from "../../editor/common/model.js"; import type { ReviewUnifiedDiffRow } from "../common/reviewUnifiedDiff.js"; -/** Missing published source is a local unavailable state, not a product fault. */ -export function reportReviewUnifiedEditorError(error: unknown, showUnavailable: () => void): void { - if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { - showUnavailable(); - return; - } - onUnexpectedError(error); -} - export function reviewUnifiedDiffDecorations(rows: readonly ReviewUnifiedDiffRow[]): IModelDeltaDecoration[] { return rows.flatMap((row) => { if (row.kind === "unchanged") return []; diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index c55f5fbf3..d461d033c 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -1145,10 +1145,17 @@ export const ReviewSelectedDiffSchema = z.strictObject({ ), }); +export const ReviewApiSelectionSourceSchema = z.strictObject({ + reviewId: requiredString, + version: z.number().int().nonnegative(), + commit: requiredString.optional(), +}); + export const ReviewSurfaceEventSchema = z.discriminatedUnion("event", [ z.strictObject({ event: z.literal("editorSelectionChanged"), reviewId: requiredString, + apiSource: ReviewApiSelectionSourceSchema.optional(), anchor: z.object({ x: z.number(), y: z.number() }).optional(), path: requiredString, range: ReviewRangeSchema, diff --git a/packages/review/app/src/agent-selection.tsx b/packages/review/app/src/agent-selection.tsx index 9b1b24d51..e84078dec 100644 --- a/packages/review/app/src/agent-selection.tsx +++ b/packages/review/app/src/agent-selection.tsx @@ -142,6 +142,7 @@ export function AgentSelectionProvider({ endLine: event.range.toLine, }, selectedDiff: event.selectedDiff, + apiSource: event.apiSource, title: `${event.path}:${event.range.fromLine}–${event.range.toLine}`, anchor: event.anchor, }); @@ -176,7 +177,11 @@ export function AgentSelectionProvider({ if (!(await copyText(text))) throw new Error("Clipboard unavailable"); setCopiedSelection( - JSON.stringify([selection.target, selection.selectedDiff]), + JSON.stringify([ + selection.target, + selection.selectedDiff, + selection.apiSource, + ]), ); setToast({ kind: "success", @@ -225,7 +230,11 @@ export function AgentSelectionProvider({ <> {selection && copiedSelection !== - JSON.stringify([selection.target, selection.selectedDiff]) && + JSON.stringify([ + selection.target, + selection.selectedDiff, + selection.apiSource, + ]) && createPortal( + + void lenses.mark( + item.sources, + stats.state !== "viewed", + selected, + ) + } + /> + + {selected && block && !fileLens && ( + + activeHandle.current?.revealSource?.(source, sectionId) + } + /> + )} +
+ ); + })} + {lenses.progress && !lenses.progress.diagrams.length && ( +

No lenses in this review yet.

+ )} + +
+ Files {" "} + {lenses.progress + ? lens + ? new Set( + lens.ranges.map( + (source) => + lenses.progress!.files.find( + (file) => + source.file === + (source.side === "base" + ? (file.previousPath ?? file.path) + : file.path), + )?.path ?? source.file, + ), + ).size + : lenses.progress.files.length + : "…"} +
+
+
+ +
+ {fullTree && ( + markFile(path, false)} + hidden={!!lens} + /> + )} + {lens && lensTree && ( + + markFile(path, true, sectionId) + } + onToggleSection={(id) => { + const section = sections.find((section) => section.id === id); + + if (section) + void lenses.mark( + section.sources, + lenses.stats(section.sources).state !== "viewed", + ); + }} + onHandle={(handle) => { + activeHandle.current = handle; + }} + /> + )} + {lenses.error && ( +
+ {lenses.error} +
+ )} +
+
+ ); +} + +function NativeDiffView({ + scope, + lens, + treeContainer, + progress, + onToggleViewed, + onToggleSection, + onHandle, + hidden = false, +}: { + scope?: ReviewCommitScope; + lens?: ReviewDiffLens; + treeContainer?: HTMLElement; + progress?: ReviewDiffProgress; + onToggleViewed?(path: string, sectionId?: string): void; + onToggleSection?(id: string): void; + onHandle?(handle: ReviewDiffViewHandle | null): void; + hidden?: boolean; +}) { const session = useReviewSession(); const [container, setContainer] = useState(null); const [error, setError] = useState(null); - const diffViewFactory = session.bridge.diffView; - const diffViewSessionId = session.config.reviewId; + const handle = useRef(null); + + const current = useRef({ + progress, + onToggleViewed, + onToggleSection, + onHandle, + }); + current.current = { progress, onToggleViewed, onToggleSection, onHandle }; useLayoutEffect(() => { if (!container) return; setError(null); - let handle: ReviewDiffViewHandle; try { - handle = diffViewFactory.create({ container, scope }); - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)); + const view = session.bridge.diffView.create({ + container, + scope, + lens, + fileTreeContainer: treeContainer, + progress: current.current.progress, + onToggleViewed: current.current.onToggleViewed + ? (path, sectionId) => + current.current.onToggleViewed?.(path, sectionId) + : undefined, + onToggleSection: (id) => current.current.onToggleSection?.(id), + }); - return; - } - - const errorSubscription = handle.onDidError(setError); + handle.current = view; + current.current.onHandle?.(view); + const subscription = view.onDidError(setError); - return () => { - errorSubscription.dispose(); - handle.dispose(); - }; - }, [container, diffViewFactory, diffViewSessionId, scope?.commit]); + return () => { + subscription.dispose(); + current.current.onHandle?.(null); + view.dispose(); + handle.current = null; + }; + } catch (error) { + setError(String(error)); + } + }, [ + container, + session.bridge.diffView, + session.config.reviewId, + scope?.commit, + lens, + treeContainer, + ]); + useLayoutEffect(() => { + if (progress) handle.current?.setProgress?.(progress); + }, [progress]); return ( <> -
- {error ? ( -
- Diff unavailable +
+ {!hidden && error && ( +
+ {error}
- ) : null} + )} ); } + +function LensIcon({ kind }: { kind: string }) { + return ( + + ); +} diff --git a/packages/review/app/src/api-canvas.tsx b/packages/review/app/src/api-canvas.tsx index c2bc83992..4caeea16e 100644 --- a/packages/review/app/src/api-canvas.tsx +++ b/packages/review/app/src/api-canvas.tsx @@ -36,6 +36,7 @@ import { ReviewDocumentBoundary } from "./review-document-boundary"; import { reportReviewDocumentRenderError } from "./review-document-error-report"; import type { ReviewFindHost } from "./review-find"; import { DisplayedReviewVersionContext } from "./review-history-control"; +import { ReviewLensesProvider } from "./review-lenses"; import { SharingContext } from "./share-control"; import { TutorialProvider } from "./tutorial-context"; @@ -316,34 +317,37 @@ export function ApiCanvas({ - - {error &&

{error}

} - - + + {error &&

{error}

} + - {data.snapshot.target.kind !== "worktree" && ( - - )} - - - + {data.snapshot.target.kind !== "worktree" && ( + - - -
-
-
+ )} + + + + + + + + + +
diff --git a/packages/review/app/src/api-document.tsx b/packages/review/app/src/api-document.tsx index aba485ebb..88f5cc5a8 100644 --- a/packages/review/app/src/api-document.tsx +++ b/packages/review/app/src/api-document.tsx @@ -36,6 +36,7 @@ import { useReviewSession } from "./host/review-session"; import { reportReviewDocumentRenderError } from "./review-document-error-report"; import { ReviewDocumentTitle } from "./review-document-surface"; import { cssIdentifier, scrollToReviewHeading } from "./review-heading-scroll"; +import { DiagramActions } from "./review-lenses"; import { useReviewRoots } from "./review-root-context"; import type { SoftwareMapResolvedDataPayload } from "./software-map/software-map-snapshot"; @@ -297,6 +298,8 @@ export const DocumentNode = memo(function DocumentNode({ /> )); + if (node.type === "file_lens") return null; + if ( node.type === "software_map" && (!softwareMapEnabled || data.snapshot.origin?.tutorial) @@ -327,6 +330,7 @@ export const DocumentNode = memo(function DocumentNode({ ) : ( renderBlock(node.type, node, data, children) )} + ); diff --git a/packages/review/app/src/blocks.browser.test.tsx b/packages/review/app/src/blocks.browser.test.tsx index 759cbc0d4..e54f6a345 100644 --- a/packages/review/app/src/blocks.browser.test.tsx +++ b/packages/review/app/src/blocks.browser.test.tsx @@ -127,6 +127,7 @@ const rendered: Record boolean> = { trace_quote: (c) => has(c, ".review-trace-quote") && text(c).includes("queue the order"), // The map has drawn its system and is neither refreshing nor failed. + file_lens: (c) => !text(c).includes("Test file bucket"), software_map: (c) => has(c, ".software-map-canvas") && text(c).includes("Order service") && @@ -340,9 +341,11 @@ describe("block components", () => { expect(text(container)).not.toContain("Layout failed"); expect(container.querySelector("[data-block-error]")).toBeNull(); - // Every block in the fixture, nested ones included, mounts a node with content. + // Every visible block, nested ones included, mounts a node with content. const empty = elements(snapshot.document) - .filter((element) => element.type !== "step") + .filter( + (element) => element.type !== "step" && element.type !== "file_lens", + ) .map((element) => element.id) .filter((id) => { const node = container.querySelector(`[data-review-node-id="${id}"]`); diff --git a/packages/review/app/src/blocks.tsx b/packages/review/app/src/blocks.tsx index c36b6c756..c4e937d44 100644 --- a/packages/review/app/src/blocks.tsx +++ b/packages/review/app/src/blocks.tsx @@ -240,6 +240,7 @@ export const blockComponents = { database_lens: DatabaseLensBlock, image: ImageBlock, trace_quote: TraceQuoteBlock, + file_lens: () => null, software_map: SoftwareMapBlock, section: SectionBlock, callout: CalloutBlock, diff --git a/packages/review/app/src/call-tree.test.ts b/packages/review/app/src/call-tree.test.ts new file mode 100644 index 000000000..c087c72c5 --- /dev/null +++ b/packages/review/app/src/call-tree.test.ts @@ -0,0 +1,82 @@ +import { expect, it } from "vitest"; + +import { call_stack_diff } from "../../src/review-api/blocks/call_stack_diff"; +import { callTreeStops } from "./call-tree"; +import { diffSections } from "./diff-sections"; + +it("keeps separate calls into the same file as separate sections and combines matched base/head evidence", () => { + const source = { + side: "head" as const, + file: "shared.ts", + fromLine: 5, + toLine: 20, + }; + + const block = call_stack_diff.schema.parse({ + type: "call_stack_diff", + id: "calls", + title: "Request", + head: [ + { key: "root", label: "handle", source }, + { key: "first", parentKey: "root", label: "read", source }, + { key: "second", parentKey: "root", label: "write", source }, + ], + base: [{ key: "first", source: { ...source, side: "base" } }], + }); + + call_stack_diff.check(block); + const stops = callTreeStops(block); + expect(stops.map((stop) => [stop.label, stop.depth])).toEqual([ + ["handle", 0], + ["read", 1], + ["write", 1], + ]); + expect(stops[1].sources.map((source) => source.side)).toEqual([ + "head", + "base", + ]); + expect(stops[1].last).toBe(false); + expect(stops[2].last).toBe(true); + const sections = diffSections(block); + expect(new Set(sections.map((section) => section.id)).size).toBe(3); + expect( + sections.every((section) => section.sources[0].file === "shared.ts"), + ).toBe(true); +}); + +it("rejects a parent reference that would create a cyclic call tree", () => { + const block = call_stack_diff.schema.parse({ + type: "call_stack_diff", + title: "Cycle", + base: [], + head: [ + { + key: "self", + parentKey: "self", + source: { side: "head", file: "a.ts", fromLine: 1, toLine: 3 }, + }, + ], + }); + + expect(() => call_stack_diff.check(block)).toThrow("earlier frame"); +}); + +it("keeps supporting source ranges in the owning frame without adding call edges", () => { + const source = { + side: "head" as const, + file: "view.ts", + fromLine: 10, + toLine: 20, + }; + const fields = { ...source, fromLine: 2, toLine: 5 }; + const stops = callTreeStops({ + type: "call_stack_diff", + id: "tree", + title: "Create view", + base: [], + head: [{ key: "view", parentKey: null, source, contextSources: [fields] }], + }); + expect(stops).toHaveLength(1); + expect(stops[0].sources).toEqual([source, fields]); + expect(stops[0].parentId).toBeUndefined(); +}); diff --git a/packages/review/app/src/call-tree.ts b/packages/review/app/src/call-tree.ts new file mode 100644 index 000000000..457428ec3 --- /dev/null +++ b/packages/review/app/src/call-tree.ts @@ -0,0 +1,79 @@ +import type { CallStackDiffBlock } from "../../src/review-api/blocks/call_stack_diff"; +import type { Source } from "../../src/source"; + +export interface CallTreeStop { + id: string; + label: string; + sources: Source[]; + parentId?: string; + callSite?: Source; + via?: string; + depth: number; + branches: boolean[]; + last: boolean; +} + +/** Adapt authored frames to the experimental sidebar's presentation contract. */ +export function callTreeStops(block: CallStackDiffBlock): CallTreeStop[] { + const nodes = new Map< + string, + Omit + >(); + + for (const side of ["head", "base"] as const) { + let previous: string | undefined; + + for (const [index, frame] of block[side].entries()) { + const id = `${block.id}:${frame.key ?? `${side}:${index}`}`; + const existing = nodes.get(id); + + if (existing) + existing.sources.push( + frame.source, + ...(frame.contextSources ?? []), + ...(frame.callSite ? [frame.callSite] : []), + ); + else + nodes.set(id, { + id, + label: frame.label ?? frame.source.file.split("/").pop()!, + sources: [ + frame.source, + ...(frame.contextSources ?? []), + ...(frame.callSite ? [frame.callSite] : []), + ], + parentId: + frame.parentKey === null + ? undefined + : frame.parentKey + ? `${block.id}:${frame.parentKey}` + : previous, + callSite: frame.callSite, + via: frame.via?.reason, + }); + previous = id; + } + } + + const result: CallTreeStop[] = []; + + const walk = ( + parentId: string | undefined, + depth: number, + branches: boolean[], + ) => { + const children = [...nodes.values()].filter( + (node) => node.parentId === parentId, + ); + + children.forEach((node, index) => { + const last = index === children.length - 1; + result.push({ ...node, depth, branches, last }); + walk(node.id, depth + 1, [...branches, !last]); + }); + }; + + walk(undefined, 0, []); + + return result; +} diff --git a/packages/review/app/src/diff-sections.ts b/packages/review/app/src/diff-sections.ts new file mode 100644 index 000000000..34f0339eb --- /dev/null +++ b/packages/review/app/src/diff-sections.ts @@ -0,0 +1,40 @@ +import type { Block } from "../../src/review-api/document"; +import type { Source } from "../../src/source"; +import { callTreeStops } from "./call-tree"; + +/** The same scope drives diagram navigation, diff boundaries and viewed actions. */ +export interface DiffSection { + id: string; + label: string; + sources: Source[]; +} + +export function diffSections(block: Block | undefined): DiffSection[] { + if (!block) return []; + + if (block.type === "sequence") + return block.steps.flatMap((step, index) => + step.source + ? [ + { + id: step.id ?? `${block.id}:${index}`, + label: step.label, + sources: [step.source], + }, + ] + : [], + ); + + if (block.type === "call_stack_diff") return callTreeStops(block); + + if (block.type === "database_lens") + return block.useCases.flatMap((useCase) => + useCase.operations.map((operation, index) => ({ + id: operation.id ?? `${useCase.id}:${index}`, + label: operation.label, + sources: [operation.source], + })), + ); + + return []; +} diff --git a/packages/review/app/src/lens-call-tree.tsx b/packages/review/app/src/lens-call-tree.tsx new file mode 100644 index 000000000..57f39cefe --- /dev/null +++ b/packages/review/app/src/lens-call-tree.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; + +import type { CallStackDiffBlock } from "../../src/review-api/blocks/call_stack_diff"; +import type { Source } from "../../src/source"; +import { type CallTreeStop, callTreeStops } from "./call-tree"; +import { useReviewLenses } from "./review-lenses"; + +// Presentation retained from review-experimental's DiffWorkspace. +export function LensCallTree({ + block, + onReveal, +}: { + block: CallStackDiffBlock; + onReveal(source: Source, sectionId?: string): void; +}) { + const lenses = useReviewLenses()!; + const stops = callTreeStops(block); + const [active, setActive] = useState(); + + const compact = (n: number) => + new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }) + .format(n) + .toLowerCase(); + + return ( + + ); +} + +function TreeConnectors({ + stop, + parentDistance, + onCallSite, +}: { + stop: CallTreeStop; + parentDistance: number; + onCallSite: () => void; +}) { + // The section title is the visual parent of every top-level call. + const width = (stop.depth + 1) * 16; + const branchX = stop.depth === 0 ? 0.5 : width - 10; + + return ( + + {stop.branches.map((continues, index) => + continues ? ( + + ) : null, + )} + {!stop.last ? : null} + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onCallSite(); + } + } + : undefined + } + > + + {stop.callSite + ? `Go to call site of ${stop.label}` + : "No call-site location recorded"} + + + {stop.callSite ? ( + <> + + + + ) : null} + + + ); +} diff --git a/packages/review/app/src/lens-counts.tsx b/packages/review/app/src/lens-counts.tsx new file mode 100644 index 000000000..7c7d14159 --- /dev/null +++ b/packages/review/app/src/lens-counts.tsx @@ -0,0 +1,24 @@ +import type { CoverageProgress } from "../../src/viewed-coverage"; + +export function ElementCounts({ progress }: { progress: CoverageProgress }) { + const compact = (n: number) => + new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }) + .format(n) + .toLowerCase(); + + return progress.state === "viewed" ? ( + ✓ + ) : ( + <> + + +{compact(progress.remaining.additions)} + + + −{compact(progress.remaining.deletions)} + + + ); +} diff --git a/packages/review/app/src/lens-diagram.tsx b/packages/review/app/src/lens-diagram.tsx new file mode 100644 index 000000000..d579978a8 --- /dev/null +++ b/packages/review/app/src/lens-diagram.tsx @@ -0,0 +1,151 @@ +import type { Block } from "../../src/review-api/document"; +import type { Source } from "../../src/source"; +import { LensCallTree } from "./lens-call-tree"; +import { ElementCounts } from "./lens-counts"; +import { useReviewLenses } from "./review-lenses"; + +/** Compact diagrams are navigation: clicking evidence scrolls, never changes scope. */ +export function LensDiagram({ + block, + onReveal, +}: { + block: Block; + onReveal(source: Source, sectionId?: string): void; +}) { + const lenses = useReviewLenses()!; + + const viewed = (sources: Source[]) => + lenses.stats(sources).state === "viewed"; + + if (block.type === "sequence") { + const actors = Object.entries(block.actors); + const width = Math.max(280, actors.length * 100); + + const x = (id: string) => + 50 + + (actors.findIndex(([key]) => key === id) * (width - 100)) / + Math.max(1, actors.length - 1); + + return ( +
+ + {actors.map(([id, label]) => ( + + + + {label.length > 13 ? `${label.slice(0, 12)}…` : label} + + + + ))} + {block.steps.map((step, index) => { + const y = 66 + index * 62, + from = x(step.from), + to = x(step.to), + source = step.source; + + return ( + + source && onReveal(source, step.id ?? `${block.id}:${index}`) + } + onKeyDown={(event) => { + if (source && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + onReveal(source, step.id ?? `${block.id}:${index}`); + } + }} + > + + {step.label} + {source + ? ` · Total +${lenses.stats([source]).total.additions} −${lenses.stats([source]).total.deletions}` + : ""} + + + + {step.label.length > 32 + ? `${step.label.slice(0, 31)}…` + : step.label} + + {source && ( + + + + )} + + from ? -6 : 6)},${y - 4} L${to},${y} l${to > from ? -6 : 6},4` + } + /> + + ); + })} + +
+ ); + } + + if (block.type === "call_stack_diff") + return ; + + if (block.type === "database_lens") + return ( +
+ {block.useCases.map((useCase) => ( +
+ {useCase.label} + {useCase.operations.map((operation) => ( + + ))} +
+ ))} +
+ ); + + return ( +

+ Use the filtered file tree to explore this lens. +

+ ); +} diff --git a/packages/review/app/src/review-lenses.test.tsx b/packages/review/app/src/review-lenses.test.tsx new file mode 100644 index 000000000..60f5478aa --- /dev/null +++ b/packages/review/app/src/review-lenses.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; + +import { ReviewApiClient } from "../../src/review-api/client"; +import type { Snapshot } from "../../src/review-api/store"; +import { ReviewDiffView } from "./DiffView"; +import { ReviewSessionProvider } from "./host/review-session"; +import { ReviewLensesProvider } from "./review-lenses"; +import { testReviewSession } from "./review-session-test-utils"; + +it.each(["sequence", "file_lens"])( + "clears a %s lens without destroying the full comparison's native state", + async (kind) => { + const disposed: string[] = []; + + const session = testReviewSession( + {}, + { + request: async () => + Response.json({ + files: [], + diagrams: [ + { + id: "diagram", + title: "Path", + kind, + key: "diagram:diagram", + fingerprint: "f", + viewed: false, + sources: [ + { side: "head", file: "a.ts", fromLine: 1, toLine: 8 }, + ], + }, + ], + }), + diffView: { + files: async () => [], + create(spec) { + const id = spec.lens?.id ?? "full"; + spec.container.textContent = `native ${id}`; + + return { + focus() {}, + onDidError: () => ({ dispose() {} }), + dispose() { + disposed.push(id); + }, + }; + }, + }, + }, + ); + + const client = new ReviewApiClient(session.config, session.bridge.request); + + const snapshot: Snapshot = { + reviewId: "test-session", + version: 0, + title: "Test", + target: { + kind: "commits" as const, + ...{ repositoryId: "r", base: "b", head: "h" }, + }, + pins: { repositoryId: "r", base: "b", head: "h" }, + document: [], + createdAt: "2026-09-17", + }; + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + try { + await act(async () => + root.render( + + + + + , + ), + ); + + const full = container.querySelector( + ".review-diff-view-host", + )!; + + await act(async () => { + const button = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Path"), + )!; + + button.click(); + }); + expect(full.style.display).toBe("none"); + + const selectedToggle = container.querySelector(".diff-lens-toggle"); + expect(selectedToggle?.getAttribute("aria-pressed")).toBe( + kind === "file_lens" ? "true" : null, + ); + expect(selectedToggle?.getAttribute("aria-expanded")).toBe( + kind === "file_lens" ? null : "true", + ); + expect(container.querySelectorAll(".review-diff-view-host")).toHaveLength( + 2, + ); + await act(async () => { + container + .querySelector( + '[aria-expanded="true"], [aria-pressed="true"]', + )! + .click(); + }); + expect(container.querySelector(".review-diff-view-host")).toBe(full); + expect(full.style.display).toBe(""); + expect(disposed).toEqual(["diagram"]); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }, +); diff --git a/packages/review/app/src/review-lenses.tsx b/packages/review/app/src/review-lenses.tsx new file mode 100644 index 000000000..4f5fd6820 --- /dev/null +++ b/packages/review/app/src/review-lenses.tsx @@ -0,0 +1,247 @@ +import type { ReviewDiffLens } from "@dev.fast/review-protocol"; +import { + type ReactNode, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import type { ReviewApiClient } from "../../src/review-api/client"; +import { type Block, elements } from "../../src/review-api/document"; +import type { ReviewProgress } from "../../src/review-api/review-progress"; +import type { Snapshot } from "../../src/review-api/store"; +import type { Source } from "../../src/source"; +import { + type CoverageProgress, + coverageProgress, + coverageSources, + scopedCoverage, +} from "../../src/viewed-coverage"; + +interface Lenses { + progress: ReviewProgress | null; + active: ReviewDiffLens | undefined; + block(id: string): Block | undefined; + select(id: string, sources?: ReviewDiffLens["ranges"]): void; + clear(): void; + stats(sources?: readonly Source[]): CoverageProgress; + mark( + sources: readonly Source[] | undefined, + viewed: boolean, + collapseLens?: boolean, + ): Promise; + changedPaths: string[]; + unfoldRanges: readonly Source[]; + busy: boolean; + error: string | null; +} + +const Context = createContext(null); + +export const useReviewLenses = () => useContext(Context); + +export function ReviewLensesProvider({ + client, + snapshot, + children, +}: { + client: ReviewApiClient; + snapshot: Snapshot; + children: ReactNode; +}) { + const [progress, setProgress] = useState(null); + const [activeId, setActiveId] = useState(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [changedPaths, setChangedPaths] = useState([]); + const [unfoldRanges, setUnfoldRanges] = useState([]); + const generation = useRef(0); + const pending = useRef(false); + const route = `/${snapshot.reviewId}/progress`; + useEffect(() => { + const abort = new AbortController(); + generation.current++; + pending.current = false; + setBusy(false); + setProgress(null); + setActiveId(undefined); + setError(null); + setChangedPaths([]); + setUnfoldRanges([]); + void client + .read( + `${route}?version=${snapshot.version}`, + abort.signal, + ) + .then((value) => { + if (!abort.signal.aborted) setProgress(value); + }) + .catch((error) => { + if (!abort.signal.aborted) setError(String(error)); + }); + + return () => { + abort.abort(); + generation.current++; + }; + }, [client, route, snapshot.version]); + + const active = useMemo(() => { + const item = progress?.diagrams.find((item) => item.id === activeId); + + return item + ? { + id: item.id, + title: item.title, + reviewId: snapshot.reviewId, + version: snapshot.version, + ranges: item.sources, + wholeFiles: item.wholeFiles ?? false, + } + : undefined; + }, [activeId, progress?.diagrams, snapshot.reviewId, snapshot.version]); + + const mark: Lenses["mark"] = async ( + sources, + viewed, + collapseLens = false, + ) => { + if (!progress || pending.current) return; + pending.current = true; + setBusy(true); + const currentGeneration = generation.current; + + const files = progress.files + .map((file) => ({ ...file, scope: scopedCoverage(file, sources) })) + .filter((file) => file.scope.base.length || file.scope.head.length) + .map((file) => ({ + path: file.path, + fingerprint: file.fingerprint, + sources: coverageSources(file, file.scope), + })); + + try { + const next = await client.post(route, { + version: snapshot.version, + files, + viewed, + }); + + if (generation.current !== currentGeneration) return; + setProgress((current) => ({ + ...next, + diagrams: current?.diagrams ?? next.diagrams, + })); + setChangedPaths(files.map((file) => file.path)); + setUnfoldRanges(viewed ? [] : files.flatMap((file) => file.sources)); + setError(null); + + if (collapseLens && viewed) setActiveId(undefined); + } catch (error) { + if (generation.current === currentGeneration) setError(String(error)); + } finally { + if (generation.current === currentGeneration) { + pending.current = false; + setBusy(false); + } + } + }; + + const value = useMemo( + () => ({ + progress, + active, + changedPaths, + unfoldRanges, + busy, + error, + block: (id) => + elements(snapshot.document).find( + (block): block is Block => block.type !== "step" && block.id === id, + ), + select: (id) => { + if ( + progress?.diagrams.some((item) => item.id === id && !item.unavailable) + ) + setActiveId(id); + }, + clear: () => setActiveId(undefined), + stats: (sources) => coverageProgress(progress?.files ?? [], sources), + mark, + }), + [ + progress, + active, + changedPaths, + unfoldRanges, + busy, + error, + client, + route, + snapshot, + ], + ); + + return {children}; +} + +export function ViewedButton({ + progress, + onClick, + disabled, + label = "Mark viewed", +}: { + progress: CoverageProgress; + onClick(): void; + disabled?: boolean; + label?: string; +}) { + const done = progress.state === "viewed"; + + return ( + + ); +} + +export function DiagramActions({ id }: { id: string }) { + const lenses = useReviewLenses(); + const item = lenses?.progress?.diagrams.find((item) => item.id === id); + + if (!lenses || !item) return null; + const progress = lenses.stats(item.sources); + + return ( +
+ + + void lenses.mark(item.sources, progress.state !== "viewed") + } + /> +
+ ); +} diff --git a/packages/review/app/src/styles.css b/packages/review/app/src/styles.css index 3f503b6ca..fbf71a0c9 100644 --- a/packages/review/app/src/styles.css +++ b/packages/review/app/src/styles.css @@ -8074,3 +8074,478 @@ button.review-stack-row:disabled .review-stack-relation { .copy-for-agent-popover kbd span { display: inline-block; } + +.review-diagram-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + padding: 6px 0 16px; + font-size: 12px; +} + +.diff-workspace { + display: flex; + min-height: 0; + height: 100%; + color: var(--ink); + font: 11px/1.5 var(--font-mono); +} +.diff-workspace button { + font: inherit; + color: inherit; + cursor: pointer; + background: transparent; +} +.diff-workspace button:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -2px; +} +.diff-workspace-sidebar { + width: 320px; + min-width: 250px; + max-width: 50%; + resize: horizontal; + overflow: auto; + display: flex; + flex-direction: column; + border-right: 1px solid var(--rule); + background: var(--surface); +} +.diff-global-progress { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--rule); + color: var(--ink-soft); + font-size: 10px; +} +.diff-global-progress > span:first-child { + display: flex; + align-items: center; + gap: 9px; +} +.diff-progress-ring { + display: flex; + align-items: center; + gap: 5px; + font-variant-numeric: tabular-nums; +} +.diff-progress-ring circle { + fill: none; + stroke: var(--rule-soft); + stroke-width: 2; +} +.diff-progress-ring circle + circle { + stroke: var(--map-added); + transform: rotate(-90deg); + transform-origin: 10px 10px; +} +.diff-sidebar-lenses { + flex: 0 1 auto; + min-height: 70px; + max-height: 62%; + overflow: auto; +} +.diff-sidebar-heading { + padding: 9px 12px 5px; + color: var(--ink-faint); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.09em; +} +.diff-files-heading { + border-top: 1px solid var(--rule); + padding-bottom: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.diff-native-tree { + flex: 1; + min-height: 100px; + position: relative; +} +.diff-native-tree > .review-files-editor-tree { + width: 100%; + height: 100%; +} +.diff-lens-section { + border-bottom: 1px solid var(--rule); +} +.diff-lens-section.is-expanded { + background: var(--bg-2); +} +.diff-lens-row { + display: flex; + align-items: center; + padding-right: 10px; + gap: 7px; +} +.diff-lens-toggle { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 8px; + padding: 10px 10px; + border: 0; + text-align: left; +} +.diff-lens-toggle:hover { + background: var(--surface-raised); +} +.diff-lens-chevron { + color: var(--ink-faint); + width: 8px; + font-size: 15px; +} +.diff-lens-name { + min-width: 0; + flex: 1; + line-height: 1.5; +} +.diff-lens-name small { + display: block; + color: var(--ink-faint); + font-size: 9px; +} +.diff-counts { + display: inline-flex; + gap: 5px; + font-variant-numeric: tabular-nums; + font-size: 10px; + white-space: nowrap; +} +.diff-count-added { + color: var(--map-added); +} +.diff-count-removed { + color: var(--map-removed); +} +.viewed-check { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 15px; + height: 15px; + padding: 0; + border: 1px solid var(--ink-faint); + border-radius: 3px; + background: transparent; + color: var(--ink); + font: 12px/1 var(--font-mono); + cursor: pointer; +} +.viewed-check:disabled { + opacity: 0.25; + cursor: default; +} +.viewed-check--viewed, +.diff-workspace .is-viewed { + opacity: 0.42; +} +.diff-workspace-editor { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} +.diff-workspace-editor > .review-diff-view-host { + flex: 1; + height: auto; +} +.diff-workspace-error { + padding: 8px 12px; + color: var(--map-removed); +} +.lens-diagram-note { + padding: 8px 12px; + color: var(--ink-faint); +} +.lens-sequence-scroll, +.lens-flow-scroll { + overflow: auto; + padding: 10px 8px 18px; +} +.lens-sequence { + display: block; + font: 10px var(--font-mono); +} +.lens-sequence text, +.lens-flow text { + fill: var(--ink); +} +.lens-sequence rect { + fill: var(--map-card); + stroke: var(--rule-soft); +} +.lens-sequence path { + stroke: var(--ink-soft); + fill: none; +} +.lens-sequence .lens-lifeline { + stroke: var(--rule-soft); + stroke-dasharray: 3 4; +} +.lens-sequence .lens-step-hit { + fill: transparent; + stroke: none; +} +.lens-sequence-step[role="button"], +.lens-flow-node[role="button"] { + cursor: pointer; +} +.lens-sequence-step:hover .lens-step-hit { + fill: var(--surface-raised); +} +.lens-call-stacks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 6px 12px 16px; +} +.lens-stack-side { + color: var(--ink-faint); + text-transform: uppercase; + font-size: 9px; + text-align: center; + margin-bottom: 6px; +} +.lens-stack-frame button { + width: 100%; + border: 1px solid var(--rule-soft); + border-radius: 4px; + background: var(--map-card); + padding: 8px; + text-align: left; + overflow-wrap: anywhere; +} +.lens-stack-frame strong { + font-size: 10px; + font-weight: 500; +} +.lens-stack-frame small, +.lens-database-paths small { + display: block; + color: var(--ink-faint); + font-size: 9px; + margin-top: 4px; +} +.lens-stack-connector { + display: block; + text-align: center; + color: var(--ink-faint); + line-height: 20px; +} +.lens-flow { + width: 100%; + display: block; + font: 12px var(--font-mono); +} +.lens-flow-node rect { + fill: var(--map-card); + stroke: var(--rule-soft); + stroke-width: 1.5; +} +.lens-flow-node--added rect { + stroke: var(--map-added); +} +.lens-flow-node--removed rect { + stroke: var(--map-removed); +} +.lens-flow-node--modified rect { + stroke: var(--map-changed); +} +.lens-flow .lens-flow-caption { + fill: var(--ink-faint); + font-size: 10px; +} +.lens-flow-edge { + stroke: var(--ink-faint); + fill: none; +} +.lens-flow marker path { + fill: var(--ink-faint); +} +.lens-flow .lens-flow-edge-label { + font-size: 9px; + fill: var(--ink-soft); + paint-order: stroke; + stroke: var(--bg-2); + stroke-width: 4px; +} +.lens-database-paths { + padding: 8px 12px; +} +.lens-database-paths button { + display: block; + padding: 8px; + width: 100%; + margin: 6px 0; + text-align: left; + border: 1px solid var(--rule-soft); + border-radius: 4px; +} +.review-diagram-actions button:not(.viewed-check) { + color: inherit; + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 4px 8px; +} +.diff-lens-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.diff-lens-icon { + flex-shrink: 0; + color: var(--ink-soft); +} +.diff-lens-toggle { + padding-block: 10px; +} + +/* Call-tree presentation from review-experimental. */ +.review-call-row, +.review-lens-sequence button, +.review-lens-commit, +.review-lens-decisions li { + position: relative; +} +.review-lens-sidebar [aria-current="true"] { + background: var(--map-active-fill); +} +.review-lens-sidebar [aria-current="true"]::before { + content: ""; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 2px; + border-radius: 0 2px 2px 0; + background: var(--accent); +} +.review-lens-sidebar button:hover:not(:disabled):not([aria-current="true"]) { + background: color-mix(in srgb, var(--ink) 3%, transparent); +} +.review-call-tree { + min-width: 100%; + width: 100%; + padding: 0 0 12px; +} +.review-call-row { + display: flex; + align-items: center; + width: 100%; + height: 26px; + border: 0; + padding: 0 12px 0 14px; + background: transparent; + font: 12px/26px var(--font-mono); + text-align: left; + white-space: nowrap; +} +.review-call-name { + flex: 0 1 auto; + min-width: 32px; + overflow: hidden; + text-overflow: ellipsis; +} +.review-call-row--added .review-call-name { + color: var(--map-added); +} +.review-call-row--removed .review-call-name { + color: var(--map-removed); + text-decoration: line-through; + text-decoration-color: color-mix( + in srgb, + var(--map-removed) 50%, + transparent + ); +} +.review-call-row--modified .review-call-name { + color: var(--map-changed); +} +.review-call-connectors { + flex-shrink: 0; + align-self: stretch; +} +.review-call-connectors path { + stroke: var(--rule-soft); + fill: none; + stroke-width: 1; +} +.review-call-row > .review-lens-stats { + flex-shrink: 0; + margin-left: auto; + padding-left: 12px; + font-size: 10px; +} + +.review-call-entry { + position: relative; +} +.review-call-entry:has(.review-call-edge:hover), +.review-call-entry:has(.review-call-edge:focus-visible) { + z-index: 1; +} +.review-call-entry > .review-call-connectors { + position: absolute; + left: 14px; + top: 0; + z-index: 1; + pointer-events: none; + overflow: visible; +} +.review-call-edge { + pointer-events: auto; + cursor: pointer; +} +.review-call-edge .review-call-edge-hit { + stroke: transparent; + stroke-width: 10px; +} +.review-call-edge .review-call-edge-highlight { + visibility: hidden; + pointer-events: none; +} +.review-call-edge:hover > .review-call-edge-highlight, +.review-call-edge:focus-visible > .review-call-edge-highlight { + visibility: visible; + stroke: var(--ink); + stroke-width: 2px; +} + +.review-lens-stats { + display: inline-flex; + gap: 5px; + font-variant-numeric: tabular-nums; +} + +.lens-element-counts { + font-size: 10px; +} +.lens-flow .diff-count-added, +.lens-sequence .diff-count-added { + fill: var(--map-added); +} +.lens-flow .diff-count-removed, +.lens-sequence .diff-count-removed { + fill: var(--map-removed); +} + +.diff-lens-toggle.is-active { + color: var(--accent); + background: var(--map-active-fill); +} +.diff-lens-toggle.is-active .diff-lens-icon { + color: inherit; +} diff --git a/packages/review/skills/dev-review/references/document-authoring.md b/packages/review/skills/dev-review/references/document-authoring.md index cccca55c3..f658e9f26 100644 --- a/packages/review/skills/dev-review/references/document-authoring.md +++ b/packages/review/skills/dev-review/references/document-authoring.md @@ -133,3 +133,48 @@ Call `review_upload({id,repositoryId,kind:"map",pins,side:"head",model})`, using - A map element's code ranges go in `codeElements` with `sourceRanges:[{file,fromLine,toLine}]`. Files are repository-relative; line numbers are positive, inclusive, ordered and checked at `pins[side]`. Insert a `software_map` node referencing the returned resource ID. Rejected uploads save nothing: fix the reported input and retry with the same ID. After a successful upload, that ID is immutable; changed content needs a new upload ID so old review versions keep their original map. + +### Diff lenses + +Saved source-bearing diagrams appear in the Diff sidebar. Expanding a diagram +activates its lens; clicking a diagram element scrolls to that element's diff +section. Collapsing the lens restores the full comparison. These are pinned +ranges, not symbol matching across revisions. Viewed state is shared code +coverage across overlapping files, sections and diagrams; marking code viewed +does not create authored document versions. + +### File lenses + +Use `file_lens` for named groups containing whole changed files, pinned ranges, or both: + +```json +{ + "type": "file_lens", + "title": "Parser and tests", + "targets": [ + { "kind": "files", "patterns": ["**/*.test.ts"] }, + { "kind": "ranges", "sources": [ + { "side": "head", "file": "src/parser.ts", "fromLine": 40, "toLine": 85 }, + { "side": "base", "file": "src/parser.ts", "fromLine": 38, "toLine": 70 } + ] } + ] +} +``` + +File patterns match only changed files, including either path of a rename. Range +sources use inclusive, one-based lines at the review's base or head pins and can +reference unchanged files. Include both sides to select both additions and +removals. All targets are unioned; overlaps never double-count changed lines. +Whole-file targets subsume narrower targets for the same file. + +Counts and viewed actions apply only to selected changed lines. Range lenses +show surrounding context and fold code outside the selection; that context does +not add coverage. File lenses appear only in the Diff sidebar and activate +without expanding a diagram. Empty lenses remain visible, disabled, with zero +files. Existing `patterns` lenses remain supported as whole-file selections; +new lenses should use `targets`, never both fields. + +The automatic **Uncategorized changes** lens selects exactly the changed ranges +not covered by valid authored lenses. Its count, filter and viewed actions all +use those residual ranges. Viewed state does not affect membership. Do not +author a duplicate catch-all lens. diff --git a/packages/review/src/fixtures/blocks/file_lens.json b/packages/review/src/fixtures/blocks/file_lens.json new file mode 100644 index 000000000..a5073ad65 --- /dev/null +++ b/packages/review/src/fixtures/blocks/file_lens.json @@ -0,0 +1,8 @@ +[ + { + "type": "file_lens", + "id": "files-1", + "title": "Test file bucket", + "patterns": ["**/*.test.ts", "docs/**"] + } +] diff --git a/packages/review/src/review-api/blocks/call_stack_diff.ts b/packages/review/src/review-api/blocks/call_stack_diff.ts index 6832f3a7c..7c2b2289f 100644 --- a/packages/review/src/review-api/blocks/call_stack_diff.ts +++ b/packages/review/src/review-api/blocks/call_stack_diff.ts @@ -13,7 +13,16 @@ export const frameSchema = z.strictObject({ ...identity, // Optional component-local name for the same frame on both sides (even if moved). key: label.optional(), + parentKey: label.nullable().optional(), + callSite: sourceSchema.optional(), source: sourceSchema, + contextSources: z + .array(sourceSchema) + .max(1000) + .optional() + .describe( + "Supporting code owned by this frame, such as field initializers. These ranges share the frame’s diff section and coverage; they do not create call edges.", + ), label: label.optional(), via: z .strictObject({ @@ -45,6 +54,18 @@ export const call_stack_diff = { if (new Set(keys).size !== keys.length) throw new ReviewInputError(`Frame keys must be unique within ${side}.`); + for (const [index, frame] of block[side].entries()) { + if ( + frame.parentKey && + !block[side] + .slice(0, index) + .some((parent) => parent.key === frame.parentKey) + ) + throw new ReviewInputError( + "A frame parentKey must name an earlier frame on the same side.", + ); + } + // Columns may compare two paths in one snapshot. Each source keeps its own pin. } }, diff --git a/packages/review/src/review-api/blocks/file_lens.ts b/packages/review/src/review-api/blocks/file_lens.ts new file mode 100644 index 000000000..b78b1644f --- /dev/null +++ b/packages/review/src/review-api/blocks/file_lens.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { sourceSchema } from "../../source.js"; +import { ReviewInputError } from "../input-error.js"; +import { type BlockDefinition, defineBlock, label } from "./definition.js"; + +const patternsSchema = z.array(label).min(1).max(1000); + +export const fileLensTargetSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("files"), patterns: patternsSchema }), + z.strictObject({ + kind: z.literal("ranges"), + sources: z.array(sourceSchema).min(1).max(10000), + }), +]); + +export type FileLensTarget = z.infer; + +export const fileLensSchema = defineBlock("file_lens", { + title: label, + targets: z + .array(fileLensTargetSchema) + .min(1) + .max(1000) + .optional() + .describe( + "Union of whole changed-file paths/globs and pinned source ranges. Counts and viewed actions apply only to selected changed lines.", + ), + patterns: patternsSchema + .optional() + .describe( + "Legacy whole-file selectors. Use targets for new lenses; do not combine patterns and targets.", + ), +}); + +export type FileLensBlock = z.infer; + +/** Normalize saved legacy lenses without rewriting historical documents. */ +export function fileLensTargets(block: FileLensBlock): FileLensTarget[] { + return block.targets ?? [{ kind: "files", patterns: block.patterns ?? [] }]; +} + +export const file_lens = { + type: "file_lens", + schema: fileLensSchema, + check(block: FileLensBlock) { + if ((block.patterns === undefined) === (block.targets === undefined)) + throw new ReviewInputError( + "A file lens requires either targets or legacy patterns, not both.", + ); + for (const pattern of fileLensTargets(block).flatMap((target) => + target.kind === "files" ? target.patterns : [], + )) { + if ( + pattern.startsWith("/") || + pattern.split("/").includes("..") || + pattern.includes("\\") || + pattern.includes("\0") + ) + throw new ReviewInputError( + "File lens patterns must be repository-relative paths or globs using forward slashes.", + ); + } + }, +} satisfies BlockDefinition; diff --git a/packages/review/src/review-api/blocks/index.ts b/packages/review/src/review-api/blocks/index.ts index 01c9bdf46..ff841d370 100644 --- a/packages/review/src/review-api/blocks/index.ts +++ b/packages/review/src/review-api/blocks/index.ts @@ -7,6 +7,7 @@ import { code_peek } from "./code_peek.js"; import { database_lens } from "./database_lens.js"; import type { BlockDefinition } from "./definition.js"; import { divider } from "./divider.js"; +import { file_lens } from "./file_lens.js"; import { image } from "./image.js"; import { markdown } from "./markdown.js"; import { type SectionBlock, section } from "./section.js"; @@ -27,6 +28,7 @@ export const leafSchema = z.discriminatedUnion("type", [ image.schema, trace_quote.schema, software_map.schema, + file_lens.schema, ]); export type LeafBlock = z.infer; @@ -51,6 +53,7 @@ export const blocks = { image, trace_quote, software_map, + file_lens, section, callout, tutorial, diff --git a/packages/review/src/review-api/diagram-lenses.ts b/packages/review/src/review-api/diagram-lenses.ts new file mode 100644 index 000000000..50f4b9062 --- /dev/null +++ b/packages/review/src/review-api/diagram-lenses.ts @@ -0,0 +1,55 @@ +import type { ReviewDiffLens } from "@dev.fast/review-protocol"; + +import { + type Block, + type Source, + elements, + sourceReferences, +} from "./document.js"; + +export interface DiagramLens { + id: string; + title: string; + kind: string; + sources: Source[]; + fileCount?: number; + wholeFiles?: boolean; +} + +/** Derived from saved content; there is no second authored lens manifest. */ +export function diagramLenses(document: Block[]): DiagramLens[] { + return elements(document).flatMap((block): DiagramLens[] => { + if ( + block.type !== "file_lens" && + block.type !== "software_map" && + block.type !== "sequence" && + block.type !== "call_stack_diff" && + block.type !== "database_lens" + ) + return []; + + return [ + { + id: block.id!, + title: block.type === "software_map" ? "Software map" : block.title, + kind: block.type, + sources: sourceReferences([block]).map((ref) => ref.source), + }, + ]; + }); +} + +export function nativeLens( + lens: DiagramLens, + reviewId: string, + version: number, +): ReviewDiffLens { + return { + id: lens.id, + title: lens.title, + reviewId, + version, + ranges: lens.sources, + wholeFiles: lens.wholeFiles ?? false, + }; +} diff --git a/packages/review/src/review-api/document-text.ts b/packages/review/src/review-api/document-text.ts index 8f7003549..2e1a10dad 100644 --- a/packages/review/src/review-api/document-text.ts +++ b/packages/review/src/review-api/document-text.ts @@ -1,3 +1,4 @@ +import { fileLensTargets } from "./blocks/file_lens.js"; import { type Element, ReviewInputError, @@ -153,6 +154,15 @@ export function documentText( detail(`${element.traceId}, event ${element.eventId}`); detail(element.text); break; + case "file_lens": + for (const target of fileLensTargets(element)) { + if (target.kind === "files") + detail(`Files: ${target.patterns.join(", ")}`); + else + for (const source of target.sources) + detail(`Range: ${sourceText(source)}`); + } + break; case "software_map": detail( `Map: ${element.mapVersionId}${element.focusElementId ? `, focus: ${element.focusElementId}` : ""}`, diff --git a/packages/review/src/review-api/document.ts b/packages/review/src/review-api/document.ts index 265856367..2b21f4cb4 100644 --- a/packages/review/src/review-api/document.ts +++ b/packages/review/src/review-api/document.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { markdownNodes, markdownText, parseMarkdown } from "../markdown.js"; import { type Source, sourceSchema } from "../source.js"; +import { fileLensTargets } from "./blocks/file_lens.js"; import { type Block, blockSchema } from "./blocks/index.js"; import { type Step, stepSchema } from "./blocks/sequence.js"; import { ReviewInputError } from "./input-error.js"; @@ -178,12 +179,34 @@ export function sourceReferences( }, ); + if (element.type === "file_lens") + return fileLensTargets(element).flatMap((target, index) => + target.kind === "ranges" + ? target.sources.map((source, range) => ({ + id: `${element.id}:target:${index}:${range}`, + source, + })) + : [], + ); + if (element.type === "call_stack_diff") - return [...element.base, ...element.head].map((frame) => ({ - ...frame, - id: frame.id!, - peek: true, - })); + return [...element.base, ...element.head].flatMap((frame) => [ + { ...frame, id: frame.id!, peek: true }, + ...(frame.contextSources ?? []).map((source, index) => ({ + id: `${frame.id}:context:${index}`, + source, + })), + ...(frame.callSite + ? [ + { + id: `${frame.id}:call-site`, + source: frame.callSite, + label: frame.label, + peek: true, + }, + ] + : []), + ]); if (element.type === "database_lens") return element.useCases.flatMap((useCase) => diff --git a/packages/review/src/review-api/file-lenses.test.ts b/packages/review/src/review-api/file-lenses.test.ts new file mode 100644 index 000000000..9a9c1b9b0 --- /dev/null +++ b/packages/review/src/review-api/file-lenses.test.ts @@ -0,0 +1,127 @@ +import { expect, it } from "vitest"; + +import { + matchesFileLens, + uncategorizedSources, + resolveFileLens, +} from "./file-lenses.js"; + +it("matches file paths, nested test globs, docs and dot directories without scanning other files", () => { + const patterns = [ + "README.md", + "**/*.{test,spec}.ts", + "docs/**", + ".github/**", + ]; + + for (const path of [ + "README.md", + "a.test.ts", + "src/nested/a.spec.ts", + "docs/guide.md", + ".github/workflows/check.yml", + ]) + expect(matchesFileLens(patterns, { path })).toBe(true); + expect(matchesFileLens(patterns, { path: "src/main.ts" })).toBe(false); + expect(matchesFileLens(["./docs/**"], { path: "docs/guide.md" })).toBe(true); +}); + +it("keeps a renamed file in a group matching either side of the rename", () => { + const file = { path: "guide/intro.md", previousPath: "docs/intro.md" }; + expect(matchesFileLens(["docs/**"], file)).toBe(true); + expect(matchesFileLens(["guide/**"], file)).toBe(true); + expect(matchesFileLens(["src/**"], file)).toBe(false); +}); + +it("keeps partially covered files until both sides are covered, regardless of viewed state", () => { + const file = { + path: "new.ts", + previousPath: "old.ts", + fingerprint: "same", + changed: { + base: [[1, 4] as [number, number]], + head: [[4, 7] as [number, number]], + }, + viewed: { + base: [[1, 4] as [number, number]], + head: [[4, 7] as [number, number]], + }, + }; + const head = { + side: "head" as const, + file: "new.ts", + fromLine: 5, + toLine: 7, + }; + const base = { + side: "base" as const, + file: "old.ts", + fromLine: 2, + toLine: 4, + }; + expect(uncategorizedSources([file], [head])).toEqual([base]); + expect(uncategorizedSources([file], [head, { ...base, toLine: 3 }])).toEqual([ + { ...base, fromLine: 4 }, + ]); + expect(uncategorizedSources([file], [head, base])).toEqual([]); + expect( + uncategorizedSources([file], [{ ...head, file: "other.ts" }, base]), + ).toEqual([head]); +}); + +it("unions mixed targets and canonicalizes renamed files without losing unchanged references", () => { + const file = { + path: "new.ts", + previousPath: "old.ts", + fingerprint: "f", + changed: { base: [], head: [] }, + viewed: { base: [], head: [] }, + }; + const base = { + side: "base" as const, + file: "old.ts", + fromLine: 1, + toLine: 10, + }; + const head = { + side: "head" as const, + file: "new.ts", + fromLine: 1, + toLine: 12, + }; + const context = { + side: "head" as const, + file: "context.ts", + fromLine: 3, + toLine: 6, + }; + const result = resolveFileLens( + { + type: "file_lens", + title: "Mixed", + targets: [ + { kind: "files", patterns: ["old.ts"] }, + { + kind: "ranges", + sources: [ + { ...head, fromLine: 4, toLine: 8 }, + context, + { ...context, fromLine: 5, toLine: 9 }, + ], + }, + ], + }, + [file], + new Map([[file.path, [base, head]]]), + ); + expect(result.sources).toEqual([base, head, { ...context, toLine: 9 }]); + expect(result.fileCount).toBe(2); + expect(result.wholeFiles).toBe(false); + expect( + resolveFileLens( + { type: "file_lens", title: "Legacy", patterns: ["old.ts"] }, + [file], + new Map([[file.path, [base, head]]]), + ).wholeFiles, + ).toBe(true); +}); diff --git a/packages/review/src/review-api/file-lenses.ts b/packages/review/src/review-api/file-lenses.ts new file mode 100644 index 000000000..aff2c0ec3 --- /dev/null +++ b/packages/review/src/review-api/file-lenses.ts @@ -0,0 +1,90 @@ +import { posix } from "node:path"; + +import type { Source } from "../source.js"; +import { + type CoverageFile, + coverageSources, + unionIntervals, + scopedCoverage, + subtractIntervals, +} from "../viewed-coverage.js"; +import { fileLensTargets, type FileLensBlock } from "./blocks/file_lens.js"; + +/** Subtract authored coverage, independently of viewed state. */ +export function uncategorizedSources( + files: readonly CoverageFile[], + sources: readonly Source[], +): Source[] { + return files.flatMap((file) => { + const covered = scopedCoverage(file, sources); + return coverageSources(file, { + base: subtractIntervals(file.changed.base, covered.base), + head: subtractIntervals(file.changed.head, covered.head), + }); + }); +} + +/** Resolve all targets into one union; a whole-file range subsumes narrower ranges. */ +export function resolveFileLens( + block: FileLensBlock, + files: readonly CoverageFile[], + fileSources: ReadonlyMap, +) { + const targets = fileLensTargets(block); + const selected = targets.flatMap((target) => + target.kind === "ranges" + ? target.sources + : files + .filter((file) => matchesFileLens(target.patterns, file)) + .flatMap((file) => fileSources.get(file.path) ?? []), + ); + const groups = new Map(); + for (const source of selected) { + const key = JSON.stringify([source.side, source.file]); + const group = groups.get(key) ?? []; + group.push(source); + groups.set(key, group); + } + const sources = [...groups.values()].flatMap((group) => + unionIntervals( + group.map((source) => [source.fromLine - 1, source.toLine]), + ).map(([start, end]) => ({ + ...group[0], + fromLine: start + 1, + toLine: end, + })), + ); + const fileCount = new Set( + sources.map( + (source) => + files.find( + (file) => + source.file === + (source.side === "base" + ? (file.previousPath ?? file.path) + : file.path), + )?.path ?? source.file, + ), + ).size; + return { + sources, + fileCount, + wholeFiles: targets.every((target) => target.kind === "files"), + }; +} + +/** Evaluate authored patterns against the changed-file list, never the filesystem. */ +export function matchesFileLens( + patterns: readonly string[], + file: { path: string; previousPath?: string }, +): boolean { + return patterns.some((pattern) => { + const normalized = pattern.startsWith("./") ? pattern.slice(2) : pattern; + + return [file.path, file.previousPath].some( + (path) => + path !== undefined && + (path === normalized || posix.matchesGlob(path, normalized)), + ); + }); +} diff --git a/packages/review/src/review-api/http.ts b/packages/review/src/review-api/http.ts index fcf6039fc..f21adc5c5 100644 --- a/packages/review/src/review-api/http.ts +++ b/packages/review/src/review-api/http.ts @@ -9,11 +9,14 @@ import { HttpJsonError } from "../server/http-json.js"; import { mountSharingHost } from "../sharing/host.js"; import type { SharedReviewStore } from "../sharing/import.js"; import { SharedReviewData } from "../sharing/routes.js"; +import { scopedCoverage } from "../viewed-coverage.js"; + import { authoringTools } from "./authoring-tools.js"; import { documentText } from "./document-text.js"; import { ReviewInputError, sourceSchema } from "./document.js"; import type { LocalReviewData } from "./local-data.js"; import { inspectQuerySchema, readQuerySchemas } from "./read-schemas.js"; +import { progressUpdateSchema, reviewProgress } from "./review-progress.js"; import { type ReviewStore, type Snapshot, @@ -112,6 +115,60 @@ export function createReviewApi( app.get("/", (context) => context.json(catalog())); app.get("/authoring", (context) => context.json(authoringTools())); + app.get("/:id/progress", async (context) => { + if (!data) throw new ReviewInputError("Source data is unavailable.", 409); + + const query = readQuerySchemas.get + .pick({ version: true }) + .parse(context.req.query()); + + return context.json( + await reviewProgress( + store, + data, + store.read(context.req.param("id"), query.version), + ), + ); + }); + app.post("/:id/progress", async (context) => { + if (!data) throw new ReviewInputError("Source data is unavailable.", 409); + + const input = progressUpdateSchema.parse( + await readBoundedRequestJson(context.req.raw), + ); + + const id = context.req.param("id"); + + const snapshot = store.read(id); + const progress = await reviewProgress(store, data, snapshot); + + const files = input.files.map((update) => { + const file = progress.files.find((file) => file.path === update.path); + + if (!file || file.fingerprint !== update.fingerprint) + throw new ReviewInputError( + "This file changed. Reload before marking it viewed.", + 409, + ); + + return { + path: file.path, + fingerprint: file.fingerprint, + scope: scopedCoverage(file, update.sources), + }; + }); + + if (store.read(id).version !== snapshot.version) + throw new ReviewInputError( + "Review changed during this update. Try again.", + 409, + ); + store.updateViewedCoverage(id, files, input.viewed); + + return context.json( + await reviewProgress(store, data, store.read(id, input.version)), + ); + }); app.get("/:id/activity", (context) => { const id = context.req.param("id"); readReview(id); @@ -406,7 +463,11 @@ export function createReviewApi( return context.json( await data.quote( await data.comparison( - (await data.resolveSource(readReview(context.req.param("id"), input.version))).pins, + ( + await data.resolveSource( + readReview(context.req.param("id"), input.version), + ) + ).pins, input.commit, ), @@ -556,7 +617,6 @@ export function createReviewApi( .pick({ version: true }) .parse(context.req.query()); - const selection = AgentSelectionSchema.parse( await readBoundedRequestJson(context.req.raw), ); @@ -578,7 +638,10 @@ export function createReviewApi( if (!data) throw new ReviewInputError("Source data is unavailable.", 409); const source = await data.quote( - await data.comparison((await data.resolveSource(snapshot)).pins, selection.apiSource?.commit), + await data.comparison( + (await data.resolveSource(snapshot)).pins, + selection.apiSource?.commit, + ), { side: target.side, file: target.path, diff --git a/packages/review/src/review-api/review-api.test.ts b/packages/review/src/review-api/review-api.test.ts index 8deb8938b..a5af13fba 100644 --- a/packages/review/src/review-api/review-api.test.ts +++ b/packages/review/src/review-api/review-api.test.ts @@ -1034,3 +1034,301 @@ it("preserves unfinished section status after authoring stops and restores it fr }); expect(store.read(reviewId).document[0]).not.toHaveProperty("status"); }); + +it("persists partial coverage outside document versions and resets it for a changed file", async () => { + const { reviewId } = await create(); + const version = store.read(reviewId).version; + store.updateViewedCoverage( + reviewId, + [ + { + path: "a.ts", + fingerprint: "old", + scope: { base: [], head: [[0, 10]] }, + }, + ], + true, + ); + store.updateViewedCoverage( + reviewId, + [ + { + path: "a.ts", + fingerprint: "old", + scope: { base: [], head: [[5, 15]] }, + }, + ], + true, + ); + expect(store.viewedCoverage(reviewId).get("a.ts")?.coverage.head).toEqual([ + [0, 15], + ]); + expect(store.read(reviewId).version).toBe(version); + await store.close(); + store = new ReviewStore(database, providers); + expect(store.viewedCoverage(reviewId).get("a.ts")?.coverage.head).toEqual([ + [0, 15], + ]); + store.updateViewedCoverage( + reviewId, + [{ path: "a.ts", fingerprint: "old", scope: { base: [], head: [[4, 8]] } }], + false, + ); + expect(store.viewedCoverage(reviewId).get("a.ts")?.coverage.head).toEqual([ + [0, 4], + [8, 15], + ]); + store.updateViewedCoverage( + reviewId, + [ + { + path: "a.ts", + fingerprint: "new", + scope: { base: [], head: [[20, 22]] }, + }, + ], + true, + ); + expect(store.viewedCoverage(reviewId).get("a.ts")?.coverage.head).toEqual([ + [20, 22], + ]); +}); + +it("preserves unchanged partial file coverage across pins and rejects stale writes after either file side changes", async () => { + const { createReviewApi } = await import("./http.js"); + const { reviewProgress } = await import("./review-progress.js"); + const { reviewId } = await create(); + const data = new LocalReviewData(store); + let head = "first\nsecond\ncontext"; + let base = "first\nold\ncontext"; + const patch = "@@ -1,3 +1,3 @@\n first\n-old\n+second\n context\n"; + vi.spyOn(data, "changes").mockImplementation((async ( + _pins: typeof pins, + file?: string, + ) => + file + ? patch + : [ + { path: "a.ts", status: "modified", additions: 1, deletions: 1 }, + ]) as typeof data.changes); + vi.spyOn(data, "file").mockImplementation(async (_pins, side, file) => ({ + file, + side, + commit: _pins[side], + text: side === "head" ? head : base, + })); + const api = createReviewApi(store, data); + const initial = await reviewProgress(store, data, store.read(reviewId)); + + const mark = (fingerprint: string, version: number) => + api.request(`/${reviewId}/progress`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version, + viewed: true, + files: [ + { + path: "a.ts", + fingerprint, + sources: [{ side: "head", file: "a.ts", fromLine: 2, toLine: 2 }], + }, + ], + }), + }); + + expect((await mark(initial.files[0].fingerprint, 0)).status).toBe(200); + await store.execute( + request({ type: "repin", reviewId, pins: { ...pins, head: "new-pin" } }), + ); + expect( + (await reviewProgress(store, data, store.read(reviewId))).files[0].viewed + .head, + ).toEqual([[1, 2]]); + head += "\nchanged outside the hunk"; + expect( + (await reviewProgress(store, data, store.read(reviewId))).files[0].viewed + .head, + ).toEqual([]); + expect((await mark(initial.files[0].fingerprint, 0)).status).toBe(409); + const next = await reviewProgress(store, data, store.read(reviewId)); + expect( + (await mark(next.files[0].fingerprint, store.read(reviewId).version)) + .status, + ).toBe(200); + base += "\nnew base context"; + expect( + (await reviewProgress(store, data, store.read(reviewId))).files[0].viewed + .head, + ).toEqual([]); +}); + +it("resolves file lenses to whole changed files, preserves empty groups, and shares viewed coverage", async () => { + const { reviewProgress } = await import("./review-progress.js"); + const { coverageProgress } = await import("../viewed-coverage.js"); + const { reviewId } = await create(); + + for (const content of [ + { type: "file_lens", title: "Docs", patterns: ["docs/**", "docs/old.md"] }, + { type: "file_lens", title: "Guide", patterns: ["guide/**"] }, + { type: "file_lens", title: "Tests", patterns: ["**/*.test.ts"] }, + ]) + await store.execute( + request({ type: "edit", reviewId, edit: { type: "insert", content } }), + ); + const data = new LocalReviewData(store); + vi.spyOn(data, "changes").mockImplementation((async ( + _pins: typeof pins, + file?: string, + ) => + file + ? "@@ -1,2 +1,2 @@\n-old\n+new\n context\n" + : [ + { + path: "guide/intro.md", + previousPath: "docs/old.md", + status: "renamed", + additions: 1, + deletions: 1, + }, + ]) as typeof data.changes); + vi.spyOn(data, "file").mockImplementation(async (_pins, side, file) => ({ + file, + side, + commit: _pins[side], + text: `${side}\ncontext\nmore context`, + })); + const initial = await reviewProgress(store, data, store.read(reviewId)); + const [docs, guide, tests] = initial.diagrams; + expect(docs.fileCount).toBe(1); + expect(docs.sources).toEqual([ + { side: "base", file: "docs/old.md", fromLine: 1, toLine: 3 }, + { side: "head", file: "guide/intro.md", fromLine: 1, toLine: 3 }, + ]); + expect(guide.sources).toEqual(docs.sources); + expect(tests.fileCount).toBe(0); + expect(tests.sources).toEqual([]); + expect(tests.unavailable).toBeTruthy(); + store.updateViewedCoverage( + reviewId, + initial.files.map((file) => ({ + path: file.path, + fingerprint: file.fingerprint, + scope: file.changed, + })), + true, + ); + const viewed = await reviewProgress(store, data, store.read(reviewId)); + expect(coverageProgress(viewed.files, docs.sources).state).toBe("viewed"); + expect(coverageProgress(viewed.files, guide.sources).state).toBe("viewed"); + expect(coverageProgress(viewed.files).total).toEqual({ + additions: 1, + deletions: 1, + }); +}); + +it("validates range lens evidence and scopes progress and Uncategorized to distinct changed lines", async () => { + const { reviewProgress } = await import("./review-progress.js"); + const { coverageProgress, scopedCoverage } = + await import("../viewed-coverage.js"); + const { reviewId } = await create(); + const selected = { + side: "head" as const, + file: "src/a.ts", + fromLine: 2, + toLine: 2, + }; + await edit(reviewId, { + type: "insert", + content: { + type: "file_lens", + title: "One line", + targets: [{ kind: "ranges", sources: [selected, selected] }], + }, + }); + expect(providers.validateSource).toHaveBeenCalledWith( + pins, + selected, + expect.anything(), + ); + const data = new LocalReviewData(store); + vi.spyOn(data, "changes").mockImplementation((async ( + _pins: typeof pins, + file?: string, + ) => + file + ? "@@ -1,3 +1,3 @@\n-old1\n-old2\n-old3\n+new1\n+new2\n+new3\n" + : [ + { path: "src/a.ts", status: "modified", additions: 3, deletions: 3 }, + ]) as typeof data.changes); + vi.spyOn(data, "file").mockImplementation(async (_pins, side, file) => ({ + file, + side, + commit: _pins[side], + text: `${side}1\n${side}2\n${side}3`, + })); + const result = await reviewProgress(store, data, store.read(reviewId)); + const lens = result.diagrams[0], + rest = result.diagrams.find( + (lens) => lens.id === "automatic-uncategorized", + )!; + expect(lens.sources).toEqual([selected]); + expect(lens.wholeFiles).toBe(false); + expect(coverageProgress(result.files, lens.sources).total).toEqual({ + additions: 1, + deletions: 0, + }); + expect(coverageProgress(result.files, rest.sources).total).toEqual({ + additions: 2, + deletions: 3, + }); + expect(rest.wholeFiles).toBe(false); + store.updateViewedCoverage( + reviewId, + result.files.map((file) => ({ + path: file.path, + fingerprint: file.fingerprint, + scope: scopedCoverage(file, lens.sources), + })), + true, + ); + const viewed = await reviewProgress(store, data, store.read(reviewId)); + expect(coverageProgress(viewed.files, lens.sources).state).toBe("viewed"); + expect(coverageProgress(viewed.files, rest.sources).remaining).toEqual({ + additions: 2, + deletions: 3, + }); + vi.spyOn(data, "quote").mockRejectedValue(new Error("Stale range")); + const stale = await reviewProgress(store, data, store.read(reviewId)); + expect(stale.diagrams[0].unavailable).toBeTruthy(); + expect( + coverageProgress(stale.files, stale.diagrams.at(-1)!.sources).total, + ).toEqual({ additions: 3, deletions: 3 }); +}); + +it("rejects ambiguous lens scopes and validates range sources during authoring", async () => { + const { reviewId } = await create(); + for (const scope of [ + {}, + { patterns: ["**"], targets: [{ kind: "files", patterns: ["**"] }] }, + ]) + await expect( + edit(reviewId, { + type: "insert", + content: { type: "file_lens", title: "Invalid", ...scope }, + }), + ).rejects.toThrow(/either targets or legacy patterns/); + vi.mocked(providers.validateSource).mockRejectedValue( + new Error("File is unavailable"), + ); + await expect( + edit(reviewId, { + type: "insert", + content: { + type: "file_lens", + title: "Missing", + targets: [{ kind: "ranges", sources: [source] }], + }, + }), + ).rejects.toThrow("File is unavailable"); +}); diff --git a/packages/review/src/review-api/review-progress.ts b/packages/review/src/review-api/review-progress.ts new file mode 100644 index 000000000..3c2ad27ff --- /dev/null +++ b/packages/review/src/review-api/review-progress.ts @@ -0,0 +1,227 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { type Source, sourceSchema } from "../source.js"; +import { parseUnifiedPatch } from "../unified-diff.js"; +import { + type CoverageFile, + emptyCoverage, + unionIntervals, +} from "../viewed-coverage.js"; +import { type DiagramLens, diagramLenses } from "./diagram-lenses.js"; +import { elements } from "./document.js"; +import { resolveFileLens, uncategorizedSources } from "./file-lenses.js"; +import type { LocalReviewData } from "./local-data.js"; +import type { ReviewStore, Snapshot } from "./store.js"; + +export const progressUpdateSchema = z.strictObject({ + version: z.number().int().nonnegative(), + files: z + .array( + z.strictObject({ + path: z.string(), + fingerprint: z.string().min(1), + sources: z.array(sourceSchema), + }), + ) + .max(10000), + viewed: z.boolean(), +}); + +export interface ReviewProgress { + files: CoverageFile[]; + diagrams: (DiagramLens & { unavailable?: string })[]; +} + +const hash = (parts: (string | null)[]) => + createHash("sha256").update(JSON.stringify(parts)).digest("hex"); + +/** Coverage survives new pins only when both complete file contents are unchanged. */ +export async function reviewProgress( + store: ReviewStore, + data: LocalReviewData, + snapshot: Snapshot, +): Promise { + const marks = store.viewedCoverage(snapshot.reviewId); + const fileSources = new Map(); + + const files = await Promise.all( + (await data.changes(snapshot.pins)).map( + async (file): Promise => { + const patch = await data.changes(snapshot.pins, file.path); + const changed = emptyCoverage(); + + for (const hunk of parseUnifiedPatch(file.path, patch)) + for (const line of hunk.lines) { + if (line.kind === "add") + changed.head.push([line.newLine! - 1, line.newLine!]); + + if (line.kind === "remove") + changed.base.push([line.oldLine! - 1, line.oldLine!]); + } + + changed.base = unionIntervals(changed.base); + changed.head = unionIntervals(changed.head); + + const [base, head] = await Promise.all([ + file.status === "added" + ? null + : data + .file(snapshot.pins, "base", file.previousPath ?? file.path) + .then((value) => value.text) + .catch(() => null), + file.status === "deleted" + ? null + : data + .file(snapshot.pins, "head", file.path) + .then((value) => value.text) + .catch(() => null), + ]); + + // Unsupported/binary content has no line coverage. Never carry uncertain progress. + const readable = + (file.status === "added" || base !== null) && + (file.status === "deleted" || head !== null); + + const fingerprint = hash([ + snapshot.pins.repositoryId, + base, + head, + ...(readable ? [] : [snapshot.pins.base, snapshot.pins.head, patch]), + ]); + + const stored = marks.get(file.path); + fileSources.set(file.path, [ + ...(file.status !== "added" + ? [ + { + side: "base" as const, + file: file.previousPath ?? file.path, + fromLine: 1, + toLine: base?.split("\n").length ?? 1, + }, + ] + : []), + ...(file.status !== "deleted" + ? [ + { + side: "head" as const, + file: file.path, + fromLine: 1, + toLine: head?.split("\n").length ?? 1, + }, + ] + : []), + ]); + + return { + path: file.path, + previousPath: file.previousPath, + fingerprint, + changed, + viewed: + stored?.fingerprint === fingerprint + ? stored.coverage + : emptyCoverage(), + }; + }, + ), + ); + + const diagrams = await Promise.all( + diagramLenses(snapshot.document).map(async (lens) => { + const block = elements(snapshot.document).find( + (block) => block.id === lens.id, + )!; + + try { + if (block.type === "file_lens") { + // Explicit ranges use the same pin validation as diagram evidence. + await Promise.all( + lens.sources.map((source) => data.quote(snapshot.pins, source)), + ); + const resolved = resolveFileLens(block, files, fileSources); + return { + ...lens, + ...resolved, + unavailable: resolved.fileCount + ? undefined + : "No files match these targets", + }; + } + + if (block.type === "software_map") { + const map = await data.map(snapshot.pins, block.mapVersionId); + + const selected = block.focusElementId + ? map.elements.filter( + (element) => + element.id === block.focusElementId || + element.path === block.focusElementId, + ) + : map.elements; + + lens.sources = selected.flatMap((element) => [ + ...(element.sourceRanges ?? []).map((source) => ({ + ...source, + side: map.side, + })), + ...(element.coverage?.files ?? []).flatMap((file) => + file.ranges.map((range) => ({ + ...range, + file: file.path, + side: map.side, + })), + ), + ]); + } + + await Promise.all( + lens.sources.map((source) => data.quote(snapshot.pins, source)), + ); + + return { + ...lens, + unavailable: lens.sources.length ? undefined : "No source links", + }; + } catch { + return { + ...lens, + unavailable: "Source links need updating at these pins", + }; + } + }), + ); + + const uncategorized = uncategorizedSources( + files, + diagrams + .filter((lens) => !lens.unavailable) + .flatMap((lens) => lens.sources), + ); + diagrams.push({ + id: "automatic-uncategorized", + title: "Uncategorized changes", + kind: "file_lens", + sources: uncategorized, + wholeFiles: false, + fileCount: new Set( + uncategorized.map( + (source) => + files.find( + (file) => + source.file === + (source.side === "base" + ? (file.previousPath ?? file.path) + : file.path), + )?.path ?? source.file, + ), + ).size, + unavailable: uncategorized.length + ? undefined + : "All changed lines are covered by lenses", + }); + + return { files, diagrams }; +} diff --git a/packages/review/src/review-api/store.ts b/packages/review/src/review-api/store.ts index 7cf5ec82c..6319224d4 100644 --- a/packages/review/src/review-api/store.ts +++ b/packages/review/src/review-api/store.ts @@ -5,6 +5,12 @@ import { isDeepStrictEqual } from "node:util"; import type { ReviewApiSummary } from "@dev.fast/review-protocol"; import { z } from "zod"; +import { + type Coverage, + coverageSchema, + emptyCoverage, + updateCoverage, +} from "../viewed-coverage.js"; import { ReviewActivity } from "./activity.js"; import { type Block, @@ -290,6 +296,10 @@ export class ReviewStore { .exec(`CREATE TABLE IF NOT EXISTS repositories(id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL); CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY, repository_id TEXT NOT NULL REFERENCES repositories(id), kind TEXT NOT NULL, mime_type TEXT NOT NULL, data BLOB NOT NULL);`); + this.db.exec( + "CREATE TABLE IF NOT EXISTS review_coverage(review_id TEXT REFERENCES reviews(id), file TEXT, fingerprint TEXT NOT NULL, coverage TEXT NOT NULL, PRIMARY KEY(review_id,file));", + ); + this.db.exec("DROP TABLE IF EXISTS review_viewed"); // Import progress lives apart from the editable snapshots: restoring an // older version or deleting the review must not look like an unfinished // import to the next sweep. @@ -306,6 +316,62 @@ export class ReviewStore { ) this.db.exec("ALTER TABLE legacy_imports ADD COLUMN map_revision TEXT"); } + /** Reader progress never creates a document version or authoring event. */ + viewedCoverage( + reviewId: string, + ): Map { + this.assertExists(reviewId); + + return new Map( + this.db + .prepare( + "SELECT file,fingerprint,coverage FROM review_coverage WHERE review_id=?", + ) + .all(reviewId) + .map((row) => [ + String(row.file), + { + fingerprint: String(row.fingerprint), + coverage: coverageSchema.parse(JSON.parse(String(row.coverage))), + }, + ]), + ); + } + updateViewedCoverage( + reviewId: string, + files: { path: string; fingerprint: string; scope: Coverage }[], + viewed: boolean, + ): void { + this.assertExists(reviewId); + this.db.exec("BEGIN IMMEDIATE"); + + try { + const current = this.viewedCoverage(reviewId); + + for (const file of files) { + const previous = current.get(file.path); + + const coverage = updateCoverage( + previous?.fingerprint === file.fingerprint + ? previous.coverage + : emptyCoverage(), + file.scope, + viewed, + ); + + this.db + .prepare( + "INSERT OR REPLACE INTO review_coverage(review_id,file,fingerprint,coverage) VALUES(?,?,?,?)", + ) + .run(reviewId, file.path, file.fingerprint, JSON.stringify(coverage)); + } + + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } /** The last legacy revisions imported for a review, kept after deletion. */ legacyImport(reviewId: string): LegacyImportProgress | null { const row = this.db @@ -615,7 +681,11 @@ export class ReviewStore { }; this.commitCommand(command.commandId, request, result, () => { - for (const table of ["review_attention", "versions"]) + for (const table of [ + "review_coverage", + "review_attention", + "versions", + ]) this.db .prepare(`DELETE FROM ${table} WHERE review_id=?`) .run(op.reviewId); diff --git a/packages/review/src/viewed-coverage.test.ts b/packages/review/src/viewed-coverage.test.ts new file mode 100644 index 000000000..4e5613a4c --- /dev/null +++ b/packages/review/src/viewed-coverage.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "vitest"; + +import { + type CoverageFile, + coverageProgress, + scopedCoverage, + updateCoverage, +} from "./viewed-coverage"; + +const file: CoverageFile = { + path: "new.ts", + previousPath: "old.ts", + fingerprint: "same", + changed: { base: [[4, 7]], head: [[9, 20]] }, + viewed: { base: [], head: [] }, +}; + +it("overlapping lenses count changed lines once and share completion", () => { + const sources = [ + { side: "head" as const, file: "new.ts", fromLine: 10, toLine: 15 }, + { side: "head" as const, file: "new.ts", fromLine: 12, toLine: 18 }, + ]; + + const scope = scopedCoverage(file, sources); + expect(scope.head).toEqual([[9, 18]]); + const updated = { ...file, viewed: updateCoverage(file.viewed, scope, true) }; + expect(coverageProgress([updated], sources)).toEqual({ + state: "viewed", + total: { additions: 9, deletions: 0 }, + remaining: { additions: 0, deletions: 0 }, + }); + expect(coverageProgress([updated])).toEqual({ + state: "partial", + total: { additions: 11, deletions: 3 }, + remaining: { additions: 2, deletions: 3 }, + }); +}); + +it("unchecking one overlapping scope preserves coverage outside it, including the opposite side", () => { + const viewed = { + base: [[4, 7] as [number, number]], + head: [[9, 20] as [number, number]], + }; + + const result = updateCoverage(viewed, { base: [], head: [[12, 16]] }, false); + expect(result).toEqual({ + base: [[4, 7]], + head: [ + [9, 12], + [16, 20], + ], + }); + expect( + scopedCoverage(file, [ + { side: "base", file: "old.ts", fromLine: 1, toLine: 100 }, + ]).base, + ).toEqual([[4, 7]]); +}); + +it("unchanged context alone is neutral and does not inflate completion", () => { + const sources = [ + { side: "head" as const, file: "new.ts", fromLine: 1, toLine: 8 }, + ]; + + expect(coverageProgress([file], sources)).toEqual({ + state: "unread", + total: { additions: 0, deletions: 0 }, + remaining: { additions: 0, deletions: 0 }, + }); +}); diff --git a/packages/review/src/viewed-coverage.ts b/packages/review/src/viewed-coverage.ts new file mode 100644 index 000000000..a324d32e0 --- /dev/null +++ b/packages/review/src/viewed-coverage.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; + +import type { Source } from "./source.js"; + +/** Zero-based, half-open intervals; always normalized at the storage boundary. */ +export type LineInterval = [number, number]; + +export interface Coverage { + base: LineInterval[]; + head: LineInterval[]; +} + +export const coverageSchema = z.object({ + base: z.array( + z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]), + ), + head: z.array( + z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]), + ), +}); + +export interface ChangeCounts { + additions: number; + deletions: number; +} + +export interface CoverageProgress { + state: "unread" | "partial" | "viewed"; + total: ChangeCounts; + remaining: ChangeCounts; +} + +export interface CoverageFile { + path: string; + previousPath?: string; + fingerprint: string; + changed: Coverage; + viewed: Coverage; +} + +export const emptyCoverage = (): Coverage => ({ base: [], head: [] }); + +export function unionIntervals( + ranges: readonly LineInterval[], +): LineInterval[] { + const result: LineInterval[] = []; + + for (const [start, end] of [...ranges].sort((a, b) => a[0] - b[0])) { + if (end <= start) continue; + const last = result.at(-1); + + if (last && start <= last[1]) last[1] = Math.max(last[1], end); + else result.push([start, end]); + } + + return result; +} + +export function intersectIntervals( + left: readonly LineInterval[], + right: readonly LineInterval[], +): LineInterval[] { + const result: LineInterval[] = []; + + const a = unionIntervals(left), + b = unionIntervals(right); + + let i = 0, + j = 0; + + while (i < a.length && j < b.length) { + const start = Math.max(a[i][0], b[j][0]), + end = Math.min(a[i][1], b[j][1]); + + if (start < end) result.push([start, end]); + + if (a[i][1] < b[j][1]) i++; + else j++; + } + + return result; +} + +export function subtractIntervals( + left: readonly LineInterval[], + right: readonly LineInterval[], +): LineInterval[] { + const result: LineInterval[] = []; + const removed = unionIntervals(right); + + for (const [start, end] of unionIntervals(left)) { + let cursor = start; + + for (const [from, to] of removed) { + if (to <= cursor) continue; + + if (from >= end) break; + + if (from > cursor) result.push([cursor, from]); + cursor = Math.min(end, Math.max(cursor, to)); + } + + if (cursor < end) result.push([cursor, end]); + } + + return result; +} + +export function updateCoverage( + current: Coverage, + scope: Coverage, + viewed: boolean, +): Coverage { + const apply = (side: "base" | "head") => + viewed + ? unionIntervals([...current[side], ...scope[side]]) + : subtractIntervals(current[side], scope[side]); + + return { base: apply("base"), head: apply("head") }; +} + +export function scopedCoverage( + file: CoverageFile, + sources?: readonly Source[], +): Coverage { + if (!sources) return file.changed; + + const side = (side: "base" | "head") => + intersectIntervals( + file.changed[side], + sources.flatMap((source): LineInterval[] => + source.side === side && + source.file === + (side === "base" ? (file.previousPath ?? file.path) : file.path) + ? [[source.fromLine - 1, source.toLine]] + : [], + ), + ); + + return { base: side("base"), head: side("head") }; +} + +export function coverageProgress( + files: readonly CoverageFile[], + sources?: readonly Source[], +): CoverageProgress { + const total = { additions: 0, deletions: 0 }, + remaining = { additions: 0, deletions: 0 }; + + const count = (ranges: LineInterval[]) => + ranges.reduce((sum, [start, end]) => sum + end - start, 0); + + for (const file of files) { + const scope = scopedCoverage(file, sources); + total.additions += count(scope.head); + total.deletions += count(scope.base); + remaining.additions += count( + subtractIntervals(scope.head, file.viewed.head), + ); + remaining.deletions += count( + subtractIntervals(scope.base, file.viewed.base), + ); + } + + const size = total.additions + total.deletions, + unread = remaining.additions + remaining.deletions; + + return { + total, + remaining, + state: + size > 0 && unread === 0 + ? "viewed" + : unread < size + ? "partial" + : "unread", + }; +} + +export function coverageSources( + file: CoverageFile, + coverage = file.viewed, +): Source[] { + return (["base", "head"] as const).flatMap((side) => + coverage[side].map(([start, end]) => ({ + side, + file: side === "base" ? (file.previousPath ?? file.path) : file.path, + fromLine: start + 1, + toLine: end, + })), + ); +} From 3e6b6bf81d7094f8384c0b487ca8dccc85ba9e47 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 19 Sep 2026 14:49:37 -0700 Subject: [PATCH 4/5] Define portable code evidence and explicit display targets Consume the final shared diffr DTO and schema at its rewritten immutable revision. Keep Source navigation separate from displayed evidence, represent range/result targets explicitly, and accept legacy saved payloads without manufacturing missing source trees. Package contracts precede their consumers. AI assistance: reorganized with Codex. --- docs/plans/diffr-evidence.md | 65 ++++++++++ packages/review-protocol/package.json | 3 +- .../scripts/generate-native-source.mjs | 2 + packages/review-protocol/src/contracts.ts | 46 ++++--- packages/review/package.json | 3 +- packages/review/src/source.ts | 119 ++++++++++++++++++ packages/review/tsdown.config.ts | 3 +- pnpm-lock.yaml | 21 ++++ pnpm-workspace.yaml | 2 + 9 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 docs/plans/diffr-evidence.md diff --git a/docs/plans/diffr-evidence.md b/docs/plans/diffr-evidence.md new file mode 100644 index 000000000..391baaa5a --- /dev/null +++ b/docs/plans/diffr-evidence.md @@ -0,0 +1,65 @@ +# Direct diffr evidence in Review + +## Agreed behavior + +An AI model retrieves hits, calls the diffr JS API (hydrate, result selection, postprocess, optional Jev), inspects pretty output, then submits the structured selected results through Review's existing MCP/HTTP/code-mode authoring operations. Review validates, persists, and renders the supplied evidence. It does not rerun the search or require a separate resource upload. + +Source locations remain `{side,file,fromLine,toLine}`. Displayable code evidence becomes `Source | SearchResultData`. A Source displays its explicit side/ranges; a diffr result retains its complete comparison and displays only the sides selected by `display`. Head-only evidence must remain head-only even for modified files. Focus/navigation coordinates do not add a display side. + +Diffr results use `display: both | lhs | rhs` and a joint Comparison union: shared unchanged content has `sources.same` plus both file references; paired content has lhs/rhs sources; added/deleted files retain only their actual side. Review consumes shared method-free transport types and a runtime schema; its separate structural declarations must not drift from diffr. Search highlights and fold visibility are rendered from the supplied trees, without another context-expansion or opposite-side inference pass. + +## Contracts to scaffold, then implement + +```diff ++export type CodeEvidence = Source | SearchResultData; ++export const codeEvidenceSchema = z.union([sourceSchema, searchResultDataSchema]); + + // Code peek, sequence step, frame, and code-backed operation fields: +-source: sourceSchema ++source: codeEvidenceSchema + + // File lens targets: ++{kind: "results", results: SearchResultData[]} + + // Native inline editor content: +-path: string; +-side: ReviewDiffSide; +-ranges: readonly ReviewInlineEditorRange[]; ++content: ++ | {kind: "source"; path: string; side: ReviewDiffSide; ranges: readonly ReviewInlineEditorRange[]} ++ | {kind: "diffr"; result: SearchResultData}; +``` + +Existing multiple-range, selection, count, event, and presentation behavior must be accounted for in implementation. Use existing Source locations for navigation within evidence, not a new focus/storage abstraction. + +## Stored state + +Review persists submitted results inline in its existing versioned document JSON (`versions.snapshot`). Existing Source blocks remain valid. Legacy diffr `kind` is upgraded to `display` on read, preserving its original trees and fold state; absent historical counterparts are not reconstructed. No SQL migration, new table, resource-upload requirement, or retained-result wrapper. Save only authored evidence, not every candidate. Inline duplication is acceptable initially. + +Diffr's StoredDiff and Store backend schemas are unchanged: they cache query-independent computed trees, never selected highlights or processed visibility. A Review must reopen with its authored evidence even if that cache is cleared. + +Validate receiving repository/commit compatibility, file identities and source text, source pairing, and region/span bounds. Treat submitted worktree paths as metadata, never as permission or instructions to open paths. On repin/worktree refresh, incompatible results must be stale or require replacement; never reinterpret their trees against new pins. Sharing/version restore must carry the evidence intact. + +## Implementation and verification + +1. Commit this plan in both repos. Amend each plan commit with API and stored-JSON contract scaffolding; incomplete builds are permitted for this layer. +2. Create a separate implementation change on top in each repo. Do not amend behavior into the contract layer. +3. Implement shared diffr serialization/schema, Review validation/persistence/transport, and native rendering with exact side preservation and highlight/fold support. +4. Test meaningful behavior: round trips, invalid/pin-mismatched evidence, existing Source input, left/right/paired/unchanged results, nested highlights/folds, reopen with diffr cache unavailable, repin handling, and lenses/other evidence-bearing blocks. +5. Build a Review dev app. Query a subset of the implementation's own pinned diff via real diffr JS calls. Submit the selected structured evidence through the real Review authoring API, reopen the document, and inspect it in the app. Include a head-only result from a modified file and verify no base-side rows are manufactured. + +## Change control + +The user requires a stop and explicit flag before any API/data-flow deviation or additional storage schema is introduced. The contract changes listed here are the agreed baseline. Do not silently add an upload/resource model, a server-side search flow, storage tables, or new result wrappers. Record any necessary deviation and obtain the user's direction before dependent implementation. + +Implemented with Codex assistance. + +## Approved transport additions + +The native ReviewDiffLens carries a per-target ADT: `{kind: "ranges", ranges: Source[]}` or `{kind: "results", results: SearchResultData[]}`, replacing its flat ranges property. ReviewInlineFindSpec uses the same content union as ReviewInlineEditorSpec so pre-mount find searches precisely the evidence that will render. Approved after the initial scaffold; no additional stored state. + +## Approved progress/section propagation + +Following review, `DiagramLens.targets` and `ReviewDiffSection.targets` carry the existing `ReviewDiffLensTarget[]` ADT alongside coordinate-only `sources`. File globs resolve to range targets on the server; authored diffr results remain result targets. This preserves mixed lenses and per-step ownership without a stored-state change. The user approved this extension and clarified that only changes to previously agreed contracts require an explicit flag. + +The existing authoring command request bound is 8 MiB to accommodate edits containing multiple complete results. Larger payloads still fail explicitly; results are never silently truncated. See the verification record for the actual API-to-dev-app test. diff --git a/packages/review-protocol/package.json b/packages/review-protocol/package.json index 0136ff2c1..160f8bc90 100644 --- a/packages/review-protocol/package.json +++ b/packages/review-protocol/package.json @@ -22,7 +22,8 @@ "dependencies": { "@dev.fast/json": "workspace:*", "@dev.fast/trace-protocol": "workspace:*", - "zod": "4.4.3" + "zod": "4.4.3", + "diffr": "git+ssh://git@github.com/devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3" }, "devDependencies": { "esbuild": "0.28.1", diff --git a/packages/review-protocol/scripts/generate-native-source.mjs b/packages/review-protocol/scripts/generate-native-source.mjs index 6482e848c..4f9eb6e50 100644 --- a/packages/review-protocol/scripts/generate-native-source.mjs +++ b/packages/review-protocol/scripts/generate-native-source.mjs @@ -5,6 +5,7 @@ // statements, behind one zod/v4 import configured for the Trusted Types CSP // before any schema is built. bug-report.ts is deliberately not part of the // overlay. +import { createRequire } from "node:module"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -107,6 +108,7 @@ const jsonSourceRoot = : sourceRoot; const MODULE_PATHS = [ + createRequire(import.meta.url).resolve("diffr/types"), path.join(jsonSourceRoot, "runtime-value.ts"), path.join(jsonSourceRoot, "json.ts"), traceContractsPath, diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index 0be340400..59d4c9568 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -3,6 +3,7 @@ import { ReviewAgentTraceEventSchema, ReviewAgentTraceSessionSchema, } from "@dev.fast/trace-protocol"; +import type { SearchResultData } from "diffr/types"; import { z } from "zod"; import type { ReviewApiSummary } from "./review-api-client.js"; @@ -123,11 +124,16 @@ export interface ReviewInlineEditorRange { export interface ReviewInlineEditorSpec { container: HTMLElement; - path: string; title: string; description?: string; - side: ReviewDiffSide; - ranges: readonly ReviewInlineEditorRange[]; + content: + | { + kind: "source"; + path: string; + side: ReviewDiffSide; + ranges: readonly ReviewInlineEditorRange[]; + } + | { kind: "diffr"; result: SearchResultData }; /** Original authored selections, before display ranges are merged. */ countRanges?: readonly ReviewInlineEditorRange[]; heightMode: ReviewInlineEditorHeightMode; @@ -150,9 +156,7 @@ export interface ReviewInlineFindResult { } export interface ReviewInlineFindSpec { - path: string; - side: ReviewDiffSide; - ranges: readonly ReviewInlineEditorRange[]; + content: ReviewInlineEditorSpec["content"]; } export interface ReviewInlineEditorHandle extends ReviewDisposable { @@ -175,6 +179,18 @@ export interface ReviewInlineEditorFactory { ): Promise; } +export type ReviewDiffLensTarget = + | { + kind: "ranges"; + ranges: readonly { + side: "base" | "head"; + file: string; + fromLine: number; + toLine: number; + }[]; + } + | { kind: "results"; results: readonly SearchResultData[] }; + /** A lens is scoped to one immutable saved review version. */ export interface ReviewDiffLens { /** Filter files while retaining ordinary diff context/folding within them. */ @@ -183,12 +199,7 @@ export interface ReviewDiffLens { title: string; reviewId: string; version: number; - ranges: readonly { - side: "base" | "head"; - file: string; - fromLine: number; - toLine: number; - }[]; + targets: readonly ReviewDiffLensTarget[]; } /** Reader progress is supplied independently of the immutable comparison. */ @@ -197,16 +208,17 @@ export interface ReviewDiffProgressFile { state: "unread" | "partial" | "viewed"; remaining: { additions: number; deletions: number }; total: { additions: number; deletions: number }; - viewedRanges: ReviewDiffLens["ranges"]; - changedRanges: ReviewDiffLens["ranges"]; - unfoldRanges?: ReviewDiffLens["ranges"]; + viewedRanges: Extract["ranges"]; + changedRanges: Extract["ranges"]; + unfoldRanges?: Extract["ranges"]; } export interface ReviewDiffSection { + targets: readonly ReviewDiffLensTarget[]; files?: readonly ReviewDiffProgressFile[]; id: string; label: string; - sources: ReviewDiffLens["ranges"]; + sources: Extract["ranges"]; state: "unread" | "partial" | "viewed"; total: { additions: number; deletions: number }; remaining: { additions: number; deletions: number }; @@ -233,7 +245,7 @@ export interface ReviewDiffViewHandle extends ReviewDisposable { focus(): void; setProgress?(progress: ReviewDiffProgress): void; revealSource?( - source: ReviewDiffLens["ranges"][number], + source: Extract["ranges"][number], sectionId?: string, ): void; onDidError(listener: (message: string) => void): ReviewDisposable; diff --git a/packages/review/package.json b/packages/review/package.json index ef6746d51..5275ec033 100644 --- a/packages/review/package.json +++ b/packages/review/package.json @@ -93,7 +93,8 @@ "smol-toml": "1.8.0", "typescript": "6.0.3", "zod": "4.4.3", - "zustand": "4.5.7" + "zustand": "4.5.7", + "diffr": "git+ssh://git@github.com/devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3" }, "devDependencies": { "@dev.fast/json": "workspace:*", diff --git a/packages/review/src/source.ts b/packages/review/src/source.ts index 2ca548bc7..1f8f11469 100644 --- a/packages/review/src/source.ts +++ b/packages/review/src/source.ts @@ -1,3 +1,6 @@ +import type { ReviewDiffLensTarget } from "@dev.fast/review-protocol"; +import { searchResultDataSchema } from "diffr/schema"; +import type { SearchResultData, RegionData } from "diffr/types"; import { z } from "zod"; const label = z.string().trim().min(1); @@ -16,6 +19,44 @@ export const sourceSchema = z export type Source = z.infer; +/** Read legacy saved evidence without changing its retained trees or fold state. + * Old one-sided payloads stay one-sided; missing historical data is not invented. */ +export function upgradeStoredEvidence(value: unknown): unknown { + if (Array.isArray(value)) { + const items = value.map(upgradeStoredEvidence); + return items.some((item, index) => item !== value[index]) ? items : value; + } + if (!value || typeof value !== "object") return value; + const object = value as Record; + if ( + typeof object.kind === "string" && + ["lhs", "rhs", "combined", "unchanged"].includes(object.kind) && + object.scope && + object.file && + object.sources + ) { + const { kind, ...evidence } = object; + return { + ...evidence, + display: kind === "lhs" || kind === "rhs" ? kind : "both", + }; + } + let upgraded: Record | undefined; + for (const [key, child] of Object.entries(object)) { + const next = upgradeStoredEvidence(child); + if (next !== child) (upgraded ??= { ...object })[key] = next; + } + return upgraded ?? value; +} +export const searchEvidenceSchema = z.preprocess( + upgradeStoredEvidence, + searchResultDataSchema, +); + +/** Authored evidence preserves the supplied diff sides and presentation. */ +export const codeEvidenceSchema = z.union([sourceSchema, searchEvidenceSchema]); +export type CodeEvidence = Source | SearchResultData; + export type SourceRange = Pick; /** Thrown by the pure checks; each boundary translates it for its clients. */ @@ -74,3 +115,81 @@ export function codePeekSource(props: { toLine: props.toLine, }; } + +/** Coordinate projections are only for navigation/coverage, never display reconstruction. */ +export function evidenceSources(evidence: CodeEvidence): Source[] { + if (!("display" in evidence)) return [evidence]; + const ranges: Source[] = []; + for (const [key, side] of [ + ["lhs", "base"], + ["rhs", "head"], + ] as const) { + if (evidence.display !== "both" && evidence.display !== key) continue; + const source = evidence.sources.same ?? evidence.sources[key]; + const file = evidence.file[key]; + if (!source || !file) continue; + const walk = (regions: RegionData[]) => { + for (const region of regions) { + if (region.visibility?.collapsed) continue; + if (region.kind === "fold") walk(region.children); + else { + const toLine = region.end.line + Number(region.end.column > 0); + if (toLine > region.start.line) + ranges.push({ + side, + file: file.path, + fromLine: region.start.line + 1, + toLine, + }); + } + } + }; + walk(source.regions); + } + return ranges; +} + +/** A navigation destination; it does not replace the displayed evidence. */ +export function evidenceLocation(evidence: CodeEvidence): Source { + if (!("display" in evidence)) return evidence; + for (const [key, side] of [ + ["rhs", "head"], + ["lhs", "base"], + ] as const) { + if (evidence.display !== "both" && evidence.display !== key) continue; + const file = evidence.file[key], + source = evidence.sources.same ?? evidence.sources[key]; + if (!file || !source) continue; + const highlight = (regions: RegionData[]): number | undefined => { + for (const region of regions) { + if (region.visibility?.collapsed) continue; + const line = + region.kind === "fold" + ? highlight(region.children) + : region.search_highlights?.[0]?.line; + if (line !== undefined) return line; + } + }; + const line = highlight(source.regions); + if (line !== undefined) + return { side, file: file.path, fromLine: line + 1, toLine: line + 1 }; + } + const ranges = evidenceSources(evidence); + const visible = ranges.find((range) => range.side === "head") ?? ranges[0]; + if (visible) return visible; + const side = + evidence.display !== "lhs" && evidence.file.rhs ? "head" : "base"; + const file = side === "head" ? evidence.file.rhs! : evidence.file.lhs!; + return { side, file: file.path, fromLine: 1, toLine: 1 }; +} + +/** Preserve display payloads; coordinate projections are a separate concern. */ +export function evidenceTargets( + evidence: readonly CodeEvidence[], +): ReviewDiffLensTarget[] { + return evidence.map((source) => + "display" in source + ? { kind: "results", results: [source] } + : { kind: "ranges", ranges: [source] }, + ); +} diff --git a/packages/review/tsdown.config.ts b/packages/review/tsdown.config.ts index df0bb13b8..7cba8eeaf 100644 --- a/packages/review/tsdown.config.ts +++ b/packages/review/tsdown.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ }, deps: { alwaysBundle: [ + /^diffr\/(schema|types)$/, "@dev.fast/trace-core", /^@dev\.fast\/json$/, /^@dev\.fast\/local-vcs$/, @@ -65,7 +66,7 @@ export default defineConfig({ ], // Re-inlining core's public declaration graph exhausts the default Node heap. // Its only remaining declaration edge is the side-effect import handled above. - dts: { neverBundle: ["@dev.fast/trace-core"] }, + dts: { neverBundle: ["@dev.fast/trace-core", /^diffr\//] }, onlyBundle: false, neverBundle: ["typescript"], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca841669a..398cbeac9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: decode-named-character-reference: specifier: 1.3.0 version: 1.3.0 + diffr: + specifier: git+ssh://git@github.com/devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3 + version: git+https://git@github.com:devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3 elkjs: specifier: 0.11.1 version: 0.11.1 @@ -231,6 +234,9 @@ importers: '@dev.fast/trace-protocol': specifier: workspace:* version: link:../trace-protocol + diffr: + specifier: git+ssh://git@github.com/devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3 + version: git+https://git@github.com:devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3 zod: specifier: 4.4.3 version: 4.4.3 @@ -1309,6 +1315,10 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typesafe-ai/sdk@0.6.0': + resolution: {integrity: sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw==} + engines: {node: '>=20'} + '@typescript/typescript-aix-ppc64@7.0.1-rc': resolution: {integrity: sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw==} engines: {node: '>=16.20.0'} @@ -1847,6 +1857,10 @@ packages: diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diffr@git+https://git@github.com:devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3: + resolution: {commit: f9419f48bfa9553e9d252483dd06719c949a1cf3, repo: git@github.com:devdotfast/diffr.git, type: git} + version: 0.0.0 + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -3684,6 +3698,8 @@ snapshots: '@types/unist@3.0.3': {} + '@typesafe-ai/sdk@0.6.0': {} + '@typescript/typescript-aix-ppc64@7.0.1-rc': optional: true @@ -4120,6 +4136,11 @@ snapshots: diff3@0.0.3: {} + diffr@git+https://git@github.com:devdotfast/diffr.git#f9419f48bfa9553e9d252483dd06719c949a1cf3: + dependencies: + '@typesafe-ai/sdk': 0.6.0 + zod: 4.4.3 + dts-resolver@3.0.0: {} dunder-proto@1.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b58246fc0..fbb64420b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,8 @@ verifyDepsBeforeRun: false minimumReleaseAge: 10080 minimumReleaseAgeStrict: true minimumReleaseAgeExclude: + # Existing diffr dependency, required by the coordinated local integration. + - "@typesafe-ai/sdk@0.6.0" # Vitest 4.1.11 requires this exact release, which is not yet seven days old. - obug@2.2.1 - "@dev.fast/trace-protocol@0.5.0" From fb710009c0be9f397b45e720158d41f6d50b4fc8 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Sat, 19 Sep 2026 14:49:37 -0700 Subject: [PATCH 5/5] Accept diffr evidence in existing authoring blocks Use Source or SearchResultData in code peeks, sequence and stack frames, database operations and file lenses. Test shared sources, independent display, legacy reads and rejected payloads against the public contract. AI assistance: reorganized with Codex. --- .../src/review-api/blocks/call_stack_diff.ts | 6 +-- .../review/src/review-api/blocks/code_peek.ts | 4 +- .../src/review-api/blocks/database_lens.ts | 4 +- .../review/src/review-api/blocks/file_lens.ts | 5 ++ .../review/src/review-api/blocks/sequence.ts | 4 +- packages/review/src/source.test.ts | 52 +++++++++++++++++++ 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/packages/review/src/review-api/blocks/call_stack_diff.ts b/packages/review/src/review-api/blocks/call_stack_diff.ts index 7c2b2289f..52f860a9e 100644 --- a/packages/review/src/review-api/blocks/call_stack_diff.ts +++ b/packages/review/src/review-api/blocks/call_stack_diff.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { sourceSchema } from "../../source.js"; +import { codeEvidenceSchema, sourceSchema } from "../../source.js"; import { ReviewInputError } from "../input-error.js"; import { type BlockDefinition, @@ -15,9 +15,9 @@ export const frameSchema = z.strictObject({ key: label.optional(), parentKey: label.nullable().optional(), callSite: sourceSchema.optional(), - source: sourceSchema, + source: codeEvidenceSchema, contextSources: z - .array(sourceSchema) + .array(codeEvidenceSchema) .max(1000) .optional() .describe( diff --git a/packages/review/src/review-api/blocks/code_peek.ts b/packages/review/src/review-api/blocks/code_peek.ts index a6381264d..470db9bf5 100644 --- a/packages/review/src/review-api/blocks/code_peek.ts +++ b/packages/review/src/review-api/blocks/code_peek.ts @@ -1,10 +1,10 @@ -import { sourceSchema } from "../../source.js"; +import { codeEvidenceSchema } from "../../source.js"; import { defineBlock, text } from "./definition.js"; export const code_peek = { type: "code_peek", schema: defineBlock("code_peek", { - source: sourceSchema, + source: codeEvidenceSchema, // Not rendered yet. caption: text.optional(), }), diff --git a/packages/review/src/review-api/blocks/database_lens.ts b/packages/review/src/review-api/blocks/database_lens.ts index ba9673169..41c944030 100644 --- a/packages/review/src/review-api/blocks/database_lens.ts +++ b/packages/review/src/review-api/blocks/database_lens.ts @@ -1,7 +1,7 @@ import { type JsonValue, jsonValueSchema } from "@dev.fast/review-protocol"; import { z } from "zod"; -import { sourceSchema } from "../../source.js"; +import { codeEvidenceSchema } from "../../source.js"; import { ReviewInputError } from "../input-error.js"; import { type BlockDefinition, @@ -69,7 +69,7 @@ export const operationSchema = z.strictObject({ actor: label, label, detail: label.optional(), - source: sourceSchema, + source: codeEvidenceSchema, }); // The legacy MDX audit rejected these empty forms after a lens with diff --git a/packages/review/src/review-api/blocks/file_lens.ts b/packages/review/src/review-api/blocks/file_lens.ts index b78b1644f..ebb4454c5 100644 --- a/packages/review/src/review-api/blocks/file_lens.ts +++ b/packages/review/src/review-api/blocks/file_lens.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { searchEvidenceSchema } from "../../source.js"; import { sourceSchema } from "../../source.js"; import { ReviewInputError } from "../input-error.js"; import { type BlockDefinition, defineBlock, label } from "./definition.js"; @@ -7,6 +8,10 @@ import { type BlockDefinition, defineBlock, label } from "./definition.js"; const patternsSchema = z.array(label).min(1).max(1000); export const fileLensTargetSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("results"), + results: z.array(searchEvidenceSchema).min(1), + }), z.strictObject({ kind: z.literal("files"), patterns: patternsSchema }), z.strictObject({ kind: z.literal("ranges"), diff --git a/packages/review/src/review-api/blocks/sequence.ts b/packages/review/src/review-api/blocks/sequence.ts index 7e05de08a..57fe7fcfb 100644 --- a/packages/review/src/review-api/blocks/sequence.ts +++ b/packages/review/src/review-api/blocks/sequence.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { sourceSchema } from "../../source.js"; +import { codeEvidenceSchema } from "../../source.js"; import { codeFields } from "./code.js"; import { type BlockDefinition, @@ -19,7 +19,7 @@ export const stepSchema = z to: label, label, style: z.enum(["call", "return", "async"]).default("call"), - source: sourceSchema.optional(), + source: codeEvidenceSchema.optional(), explanation: label.optional(), code: z.strictObject(codeFields).optional(), }) diff --git a/packages/review/src/source.test.ts b/packages/review/src/source.test.ts index 5ef76e955..5eebc8425 100644 --- a/packages/review/src/source.test.ts +++ b/packages/review/src/source.test.ts @@ -7,6 +7,11 @@ import { sliceSourceRange, sourceSchema, } from "./source"; +import { + codeEvidenceSchema, + evidenceSources, + upgradeStoredEvidence, +} from "./source.js"; describe("sourceSchema", () => { it("accepts a pinned range and rejects one that ends before it starts", () => { @@ -82,3 +87,50 @@ describe("requireVisibleSource", () => { expect(() => requireVisibleSource("x", range)).not.toThrow(); }); }); + +it("reads old saved diffr evidence without losing selected sides or fold state", () => { + const legacy = { + kind: "rhs", + scope: { + repo: "/gone", + baseWorktree: { commitId: "base", path: "/gone/base" }, + headWorktree: { commitId: "head", path: "/gone/head" }, + }, + file: { rhs: { path: "a.ts", oid: "a".repeat(40), mode: "100644" } }, + sources: { + rhs: { + text: "match\nhidden\n", + 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: 0, end_column: 5 }], + }, + { + kind: "leaf", + id: 2, + fold_state_id: 2, + alignment_id: 2, + start: { line: 1, column: 0 }, + end: { line: 2, column: 0 }, + visibility: { collapsed: true }, + }, + ], + }, + }, + }; + const evidence = codeEvidenceSchema.parse(upgradeStoredEvidence(legacy)); + expect(evidenceSources(evidence)).toEqual([ + { side: "head", file: "a.ts", fromLine: 1, toLine: 1 }, + ]); + if (!("display" in evidence)) throw new Error("Expected search evidence"); + expect(evidence.display).toBe("rhs"); + expect(evidence.sources.rhs?.regions[1].visibility?.collapsed).toBe(true); + expect(evidence.sources.rhs?.text).toBe("match\nhidden\n"); + expect(evidence.sources.lhs).toBeUndefined(); + expect(legacy.kind).toBe("rhs"); +});