diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 5a562f246..d26932e68 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -1,6 +1,7 @@ /** * One pull request or comparison on the page: its files listed from GitHub, each diffed by diffr in - * a worker once its text is fetched, nearest the reader first, into Review Desktop's multi-diff view. + * a worker once its text is fetched, nearest the reader first, into Review Desktop's multi-diff view + * (on a phone, plain rows: mobileDiff.ts). * A file joins the list once diffed; the list keeps the reader's place as files arrive above. */ import { Emitter } from "vs/base/common/event.js"; @@ -29,6 +30,8 @@ import { targetPath, } from "./github.js"; import type { Lens } from "./lenses.js"; +import { MobileDiffView, type MobileFindMatch } from "./mobileDiff.js"; +import { mobileViewport } from "./nativeDiffScroll.js"; import type { ReviewDiffFileWire, StructuralLineCounts } from "./protocol.js"; import { orderReviewDiffFiles } from "./review/common/reviewChangedFilesModel.js"; import { @@ -108,12 +111,14 @@ export class Comparison extends Disposable { summaryErrors: [], pluginErrors: [], }; + /** Whether it draws the phone's view, as the viewport was when it opened. */ + readonly mobile = mobileViewport.matches; preview: Preview | undefined; change: Change | undefined; error: string | undefined; private readonly files = new Map(); private order: string[] = []; - private view: ReviewFilesDiffView | undefined; + private view: ReviewFilesDiffView | MobileDiffView | undefined; private session: StructuralDiffSession | undefined; private viewed: ViewedProgress | undefined; /** Files the reader asked for, most recent first. */ @@ -426,7 +431,17 @@ export class Comparison extends Disposable { /** The editor showing one side of a file, while it is on screen. */ codeEditor(path: string, side: "original" | "modified") { - return this.view?.codeEditor(path, side); + return this.view instanceof ReviewFilesDiffView + ? this.view.codeEditor(path, side) + : undefined; + } + + /** Marks find's matches where the view draws its own text; false where editors draw it. */ + showFindMatches(matches: MobileFindMatch[], current: number): boolean { + if (!(this.view instanceof MobileDiffView)) return false; + this.view.showFindMatches(matches, current); + + return true; } goToChange(direction: "next" | "previous"): void { @@ -583,35 +598,52 @@ export class Comparison extends Disposable { ), ); - const structural = createStructuralDiffEditors( - this.instantiation, - entries, - store, - session, - viewedState, - ); - - const input = store.add( - structural.instantiation.createInstance( - ReviewFilesEditorInput, + // A phone draws plain rows the browser scrolls and selects; the desktop draws diff editors. + let input: ReviewFilesEditorInput | undefined; + let view: ReviewFilesDiffView | MobileDiffView; + + if (this.mobile) + view = store.add( + new MobileDiffView( + this.container, + session, + (path) => void viewed.toggleFile(path), + this.wordWrap, + ), + ); + else { + const structural = createStructuralDiffEditors( + this.instantiation, entries, + store, session, - this.wordWrap, - ), - ); + viewedState, + ); - const view = (this.view = store.add( - structural.instantiation.createInstance( - ReviewFilesDiffView, - this.container, - this.overflow, - this.layout, - undefined, - (path: string) => void viewed.toggleFile(path), - undefined, - undefined, - ), - )); + input = store.add( + structural.instantiation.createInstance( + ReviewFilesEditorInput, + entries, + session, + this.wordWrap, + ), + ); + + view = store.add( + structural.instantiation.createInstance( + ReviewFilesDiffView, + this.container, + this.overflow, + this.layout, + undefined, + (path: string) => void viewed.toggleFile(path), + undefined, + undefined, + ), + ); + } + + this.view = view; view.setProgress(viewed.progress); store.add(viewed.onDidChange(() => view.setProgress(viewed.progress))); @@ -624,7 +656,9 @@ export class Comparison extends Disposable { ); store.add(view.onDidScroll(() => this.pump())); this.observe(session, entries, view, store); - await view.setInput(input, undefined); + + if (input && view instanceof ReviewFilesDiffView) + await view.setInput(input, undefined); if (this._store.isDisposed) return; @@ -910,7 +944,7 @@ export class Comparison extends Disposable { private observe( session: StructuralDiffSession, entries: readonly ReviewFilesEditorEntry[], - view: ReviewFilesDiffView, + view: ReviewFilesDiffView | MobileDiffView, store: DisposableStore, ): void { const rendered = new Set(); diff --git a/apps/diffr-web/src/find.ts b/apps/diffr-web/src/find.ts index ad7e8194f..4954a0813 100644 --- a/apps/diffr-web/src/find.ts +++ b/apps/diffr-web/src/find.ts @@ -286,6 +286,8 @@ export class FindBar { /** Mark the matches in the files on screen, once their editors exist. */ private decorate(): void { cancelAnimationFrame(this.frame); + + if (this.comparison()?.showFindMatches(this.matches, this.current)) return; const current = this.matches[this.current]; const groups = new Map(); @@ -335,6 +337,8 @@ export class FindBar { } private clearDecorations(): void { + this.comparison()?.showFindMatches([], -1); + for (const collection of this.decorations.values()) collection.clear(); this.decorations.clear(); } diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index e8b0bbce4..53b5a8f34 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -460,7 +460,10 @@ window.addEventListener("popstate", route); mobileViewport.addEventListener("change", () => { narrow.set(mobileViewport.matches, undefined); - renderHeader(); + + // Each view is drawn for one kind of screen; diffr's results are cached, so reopening is quick. + if (comparison && comparison.mobile !== mobileViewport.matches) route(); + else renderHeader(); }); /** A `z` waiting for its fold command. */ diff --git a/apps/diffr-web/src/mobileDiff.css b/apps/diffr-web/src/mobileDiff.css new file mode 100644 index 000000000..db64ddecb --- /dev/null +++ b/apps/diffr-web/src/mobileDiff.css @@ -0,0 +1,504 @@ +/* The phone's diff (mobileDiff.ts): plain rows the browser scrolls and selects. Every fold control + is at least a row tall and the gutter wide; bands and file headers are 44px, a thumb's width. */ + +.mdiff { + --mdiff-line: 22px; + --mdiff-font: "Geist Mono", ui-monospace, Menlo, monospace; + position: relative; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior-y: contain; + /* mobileDiff.ts keeps the reader's row in place itself, in every browser. */ + overflow-anchor: none; + -webkit-text-size-adjust: 100%; + outline: none; + background: var(--vscode-editor-background); + padding-bottom: calc(40dvh + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.mdiff-status { + padding: 12px 16px; + color: var(--vscode-descriptionForeground); +} + +.mdiff-status[hidden], +.mdiff-file[hidden] { + display: none; +} + +.mdiff-file { + border-bottom: 1px solid var(--vscode-panel-border); +} + +.mdiff-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + box-sizing: border-box; + padding: 4px 4px 4px 12px; + background: var(--vscode-sideBar-background); + border-bottom: 1px solid var(--vscode-panel-border); + cursor: pointer; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.mdiff-header::before { + content: ""; + flex: none; + width: 12px; + height: 12px; + background: var(--vscode-descriptionForeground); + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; + transform: rotate(90deg); + transition: transform 120ms ease; +} + +.mdiff-header.is-collapsed::before { + transform: none; +} + +.mdiff-header:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mdiff-header:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mdiff-header-title { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.mdiff-header-path { + display: flex; + min-width: 0; + white-space: nowrap; +} + +/* The directory gives way first, so the file's own name stays readable. */ +.mdiff-header-dir { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-descriptionForeground); +} + +.mdiff-header-name { + flex: none; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; +} + +.mdiff-header-note { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.mdiff-header-note[hidden] { + display: none; +} + +.mdiff-header-counts { + flex: none; + display: flex; + gap: 6px; + font-family: var(--mdiff-font); + font-size: 12px; +} + +.mdiff-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.mdiff-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.mdiff-header .review-viewed-check { + margin: 0 15px; +} + +.mdiff-header .review-viewed-check[hidden] { + display: none; +} + +/* The 14px box keeps its look; the 44px around it takes the tap. */ +.mdiff-header .review-viewed-check::before { + content: ""; + position: absolute; + inset: -15px; +} + +.mdiff-body { + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; +} + +.mdiff-body.is-message { + padding: 12px 16px; + color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; +} + +.mdiff-rows { + display: inline-block; + min-width: 100%; + vertical-align: top; + font-family: var(--mdiff-font); + font-size: 13px; + line-height: var(--mdiff-line); + color: var(--vscode-editor-foreground); +} + +.mdiff-chunk { + content-visibility: auto; +} + +.mdiff-empty { + padding: 12px 16px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--vscode-descriptionForeground); +} + +.mdiff-row { + display: flex; + min-height: var(--mdiff-line); + white-space: pre; +} + +.mdiff-row.is-added.is-tinted { + background: var(--vscode-diffEditor-insertedLineBackground); +} + +.mdiff-row.is-removed.is-tinted { + background: var(--vscode-diffEditor-removedLineBackground); +} + +.mdiff-row.is-revealed { + animation: mdiff-reveal 1.4s ease-out; +} + +@keyframes mdiff-reveal { + from { + box-shadow: inset 3px 0 0 var(--vscode-focusBorder); + background-color: var(--vscode-editor-findMatchHighlightBackground); + } +} + +/* The gutter stays put while the code scrolls sideways under it. */ +.mdiff-gutter { + position: sticky; + left: 0; + z-index: 1; + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + box-sizing: border-box; + width: calc(var(--mdiff-digits, 3) * 1ch + 36px); + padding-right: 8px; + background: var(--vscode-editor-background); + color: var(--vscode-editorLineNumber-foreground); + user-select: none; + -webkit-user-select: none; +} + +.no-numbers .mdiff-gutter { + width: 28px; +} + +.no-numbers .mdiff-lineno { + display: none; +} + +.is-added.is-tinted > .mdiff-gutter { + background: + linear-gradient( + var(--vscode-diffEditor-insertedLineBackground), + var(--vscode-diffEditor-insertedLineBackground) + ), + var(--vscode-editor-background); + box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-addedResourceForeground); +} + +.is-removed.is-tinted > .mdiff-gutter { + background: + linear-gradient( + var(--vscode-diffEditor-removedLineBackground), + var(--vscode-diffEditor-removedLineBackground) + ), + var(--vscode-editor-background); + box-shadow: inset 3px 0 0 + var(--vscode-gitDecoration-deletedResourceForeground); +} + +/* A line that heads a fold is one control from edge to edge: tap anywhere on it. */ +.mdiff-row.has-fold { + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.mdiff-row.has-fold:active { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.mdiff-chevron { + position: absolute; + inset: 0 auto 0 0; + width: 100%; + margin: 0; + padding: 0 0 0 8px; + border: 0; + background: none; + color: var(--vscode-descriptionForeground); + text-align: left; + cursor: pointer; +} + +.mdiff-chevron::before { + content: ""; + display: inline-block; + width: 12px; + height: 12px; + vertical-align: middle; + background: currentColor; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; + transform: rotate(90deg); +} + +.mdiff-chevron[aria-expanded="false"]::before { + transform: none; +} + +.mdiff-chevron:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mdiff-code { + flex: 1; + padding: 0 16px 0 8px; +} + +.is-wrapped .mdiff-body { + overflow-x: hidden; +} + +.is-wrapped .mdiff-rows { + display: block; +} + +.is-wrapped .mdiff-row { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.mdiff-changed { + border-radius: 2px; +} + +.is-added .mdiff-changed { + background: var(--vscode-diffEditor-insertedTextBackground); +} + +.is-removed .mdiff-changed { + background: var(--vscode-diffEditor-removedTextBackground); +} + +.mdiff-comment { + color: var(--mdiff-comment); + font-style: var(--mdiff-comment-style); +} +.mdiff-keyword { + color: var(--mdiff-keyword); + font-style: var(--mdiff-keyword-style); +} +.mdiff-type { + color: var(--mdiff-type); + font-style: var(--mdiff-type-style); +} +.mdiff-function { + color: var(--mdiff-function); + font-style: var(--mdiff-function-style); +} +.mdiff-variable { + color: var(--mdiff-variable); + font-style: var(--mdiff-variable-style); +} +.mdiff-string { + color: var(--mdiff-string); + font-style: var(--mdiff-string-style); +} +.mdiff-lineno { + font-variant-numeric: tabular-nums; +} +.mdiff-number { + color: var(--mdiff-number); + font-style: var(--mdiff-number-style); +} +.mdiff-constant { + color: var(--mdiff-constant); + font-style: var(--mdiff-constant-style); +} +.mdiff-operator { + color: var(--mdiff-operator); + font-style: var(--mdiff-operator-style); +} +.mdiff-heading { + color: var(--mdiff-heading); + font-style: var(--mdiff-heading-style); +} + +/* A fold folded into its header line: `opener ⋯ 12 lines closer`. */ +.mdiff-pill { + display: inline-block; + margin: 0 4px; + padding: 0 8px; + border-radius: 10px; + line-height: calc(var(--mdiff-line) - 4px); + font-size: 12px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-badge-background); + user-select: none; + -webkit-user-select: none; +} + +/* What a collapsed region leaves: as wide as the screen, whatever the code's width. */ +.mdiff-band { + position: sticky; + left: 0; + box-sizing: border-box; + width: var(--mdiff-width, 100vw); + min-height: 44px; + padding: 0 12px 0 0; + border-block: 1px solid var(--vscode-panel-border); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + background: color-mix( + in srgb, + var(--vscode-sideBar-background) 60%, + var(--vscode-editor-background) + ); + white-space: normal; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.mdiff-band + .mdiff-band, +.mdiff-chunk + .mdiff-chunk > .mdiff-band:first-child { + border-top: 0; +} + +.mdiff-band:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mdiff-band:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mdiff-band.kind-inserted { + box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-addedResourceForeground); +} + +.mdiff-band.kind-removed { + box-shadow: inset 3px 0 0 + var(--vscode-gitDecoration-deletedResourceForeground); +} + +.mdiff-band.kind-modified { + box-shadow: inset 3px 0 0 + var(--vscode-gitDecoration-modifiedResourceForeground); +} + +.mdiff-band-title { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + padding-left: calc(8px + min(var(--mdiff-indent, 0), 8) * 0.5ch); +} + +.mdiff-band-chevron { + flex: none; + width: 28px; + height: 12px; + background: currentColor; + mask: url("data:image/svg+xml;utf8,") + center / 12px 12px no-repeat; +} + +.mdiff-band-caption { + flex: none; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--mdiff-comment, var(--vscode-descriptionForeground)); + background: var(--vscode-badge-background); +} + +.mdiff-band-label { + min-width: 0; + overflow-wrap: anywhere; +} + +/* A summary reads as prose at the screen's width: wrapped, with each line hanging from its own + indent, at a size meant for reading rather than scanning. */ +.mdiff-pseudocode { + margin: 0 0 12px calc(44px + min(var(--mdiff-indent, 0), 8) * 0.5ch); + padding: 8px 12px; + border-left: 2px solid + var(--mdiff-comment, var(--vscode-descriptionForeground)); + border-radius: 0 6px 6px 0; + font-family: var(--mdiff-font); + font-size: 14px; + line-height: 1.55; + color: var(--vscode-editor-foreground); + background: var(--vscode-editor-background); + user-select: text; + -webkit-user-select: text; +} + +.mdiff-pseudocode-line { + padding-left: calc(min(var(--mdiff-indent, 0), 16) * 0.75ch + 2ch); + text-indent: -2ch; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +::highlight(diffr-find) { + background-color: var(--vscode-editor-findMatchHighlightBackground); +} + +::highlight(diffr-find-current) { + background-color: var(--vscode-editor-findMatchBackground); +} diff --git a/apps/diffr-web/src/mobileDiff.ts b/apps/diffr-web/src/mobileDiff.ts new file mode 100644 index 000000000..ff4ac6693 --- /dev/null +++ b/apps/diffr-web/src/mobileDiff.ts @@ -0,0 +1,1135 @@ +/** + * The comparison on a phone: every file as a plain unified diff the browser scrolls, selects and + * finds in natively. diffr's folds are the session's, as in the desktop view: a collapsed region + * leaves a band (with its pseudocode, for a summary) or folds into its header line, and a line that + * heads a fold carries its chevron. Every fold control is the full row or band, for a thumb. + */ +import { Emitter } from "vs/base/common/event.js"; +import { Disposable, toDisposable } from "vs/base/common/lifecycle.js"; +import { type IObservable, autorun } from "vs/base/common/observable.js"; +import { + FontStyle, + TokenMetadata, +} from "vs/editor/common/encodedTokenAttributes.js"; + +import { showLineNumbers } from "./display.js"; + +import "./mobileDiff.css"; +import type { ReviewDiffProgress, ReviewDiffProgressFile } from "./protocol.js"; +import { ReviewViewedCheckbox } from "./review/browser/reviewTooltip.js"; +import { + type ReviewFileCounts, + type StructuralSource, + type StructuralSyntaxSpan, + type StructuralTextDiff, + binarySizeLabel, + isBinaryCounts, + structuralHighlights, + utf16Column, +} from "./review/common/reviewStructuralDiff.js"; +import { + SYNTAX_SCOPES, + type SyntaxRole, + syntaxRole, +} from "./review/common/reviewSyntaxRoles.js"; +import { + type UnifiedBand, + type UnifiedItem, + type UnifiedLine, + sourceLines, + unifiedItems, +} from "./review/common/reviewUnifiedRows.js"; +import type { ReviewFilesEditorEntry } from "./review/services/reviewFilesDiffView.js"; +import type { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; +import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; +import { IStandaloneThemeService } from "./standalone/common/standaloneTheme.js"; +import { element } from "./ui.js"; + +/** Rows per block the browser may skip laying out while it is off screen. */ +const CHUNK = 120; + +/** Characters of a line drawn, as the desktop editor stops rendering past them. */ +const MAX_LINE = 10_000; + +/** A file's body is built within this distance of the screen and released past the second. */ +const BUILD_MARGIN = "150% 0px"; + +const RELEASE_MARGIN = "600% 0px"; + +/** Rows an undrawn file's placeholder is tall. */ +const PLACEHOLDER_ROWS = 12; + +const LOADING = "Loading diff…"; + +/** One find match, as find.ts counts: 1-based line and column. */ +export interface MobileFindMatch { + path: string; + side: "original" | "modified"; + line: number; + column: number; + length: number; +} + +interface FileView { + readonly entry: ReviewFilesEditorEntry; + readonly section: HTMLElement; + readonly header: HTMLElement; + readonly name: HTMLElement; + readonly note: HTMLElement; + readonly countsNode: HTMLElement; + readonly viewed: ReviewViewedCheckbox; + readonly body: HTMLElement; + /** Why the file has no diff to show yet, or ever. */ + state: string | undefined; + collapsed: boolean; + /** The reader or a lens folded it; a hidden file's own fold waits until then. */ + collapseSet: boolean; + hidden?: string; + counts?: ReviewFileCounts; + progress?: ReviewDiffProgressFile; + viewedApplied?: string; + /** Its body is drawn; otherwise a placeholder holds its last height. */ + built: boolean; + near: boolean; +} + +export class MobileDiffView extends Disposable { + private readonly _onDidScroll = this._register(new Emitter()); + /** The list scrolled or its topmost file changed. */ + readonly onDidScroll = this._onDidScroll.event; + private readonly _onDidRequestFile = this._register(new Emitter()); + /** A file still waiting came on screen. */ + readonly onDidRequestFile = this._onDidRequestFile.event; + + private readonly root: HTMLElement; + private readonly status: HTMLElement; + private readonly files = new Map(); + private readonly bySection = new WeakMap(); + private order: FileView[] = []; + private progress: ReviewDiffProgress | undefined; + private readonly build: IntersectionObserver; + private readonly release: IntersectionObserver; + private readonly request: IntersectionObserver; + private scrollFrame = 0; + /** A selection when the press began: the tap ends it, and must not fold anything. */ + private pressedOverSelection = false; + private find: { matches: MobileFindMatch[]; current: number } | undefined; + private reveal: { cancel(): void } | undefined; + + constructor( + container: HTMLElement, + private readonly session: StructuralDiffSession, + private readonly onToggleViewed: (path: string) => void, + wordWrap: IObservable, + ) { + super(); + this.root = container.appendChild(element("div", "mdiff")); + this.root.tabIndex = -1; + this.status = this.root.appendChild(element("div", "mdiff-status")); + this.status.setAttribute("role", "status"); + this._register(toDisposable(() => this.root.remove())); + + this.build = new IntersectionObserver( + (entries) => this.preserve(() => this.onBuild(entries)), + { root: this.root, rootMargin: BUILD_MARGIN }, + ); + this.release = new IntersectionObserver( + (entries) => this.onRelease(entries), + { root: this.root, rootMargin: RELEASE_MARGIN }, + ); + this.request = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const file = this.bySection.get(entry.target); + + if (entry.isIntersecting && file?.state === LOADING) + this._onDidRequestFile.fire(file.entry.file.path); + } + }, + { root: this.root }, + ); + this._register( + toDisposable(() => { + this.build.disconnect(); + this.release.disconnect(); + this.request.disconnect(); + this.reveal?.cancel(); + cancelAnimationFrame(this.scrollFrame); + + for (const file of this.files.values()) file.viewed.dispose(); + CSS.highlights?.delete("diffr-find"); + CSS.highlights?.delete("diffr-find-current"); + }), + ); + + this.root.addEventListener( + "scroll", + () => { + if (this.scrollFrame) return; + this.scrollFrame = requestAnimationFrame(() => { + this.scrollFrame = 0; + this._onDidScroll.fire(); + }); + }, + { passive: true }, + ); + this.root.addEventListener( + "pointerdown", + () => { + this.pressedOverSelection = !( + window.getSelection()?.isCollapsed ?? true + ); + this.reveal?.cancel(); + }, + { passive: true }, + ); + this.root.addEventListener("click", (event) => this.onClick(event)); + this.root.addEventListener("keydown", (event) => { + if ( + (event.key === "Enter" || event.key === " ") && + event.target instanceof HTMLElement && + event.target.matches(".mdiff-band") + ) { + event.preventDefault(); + this.onClick(event); + } + }); + + const resize = new ResizeObserver(() => + this.root.style.setProperty( + "--mdiff-width", + `${this.root.clientWidth}px`, + ), + ); + + resize.observe(this.root); + this._register(toDisposable(() => resize.disconnect())); + + this._register( + autorun((reader) => + this.root.classList.toggle("is-wrapped", wordWrap.read(reader)), + ), + ); + this._register( + autorun((reader) => + this.root.classList.toggle("no-numbers", !showLineNumbers.read(reader)), + ), + ); + + const themes = StandaloneServices.get(IStandaloneThemeService); + const paint = () => this.paintSyntax(themes); + this._register(themes.onDidColorThemeChange(paint)); + paint(); + + this._register( + session.onDidChange((change) => + this.preserve(() => { + for (const path of change.files) { + const file = this.files.get(path); + + if (file?.built) this.renderBody(file); + } + }), + ), + ); + } + + /** The theme's colour for each of diffr's syntax roles, as CSS variables. */ + private paintSyntax( + themes: ReturnType>, + ): void { + const tokens = themes.getColorTheme().tokenTheme; + const colors = tokens.getColorMap(); + + for (const [role, scope] of Object.entries(SYNTAX_SCOPES)) { + const metadata = tokens.match(0, scope); + const color = colors[TokenMetadata.getForeground(metadata)]; + this.root.style.setProperty(`--mdiff-${role}`, color?.toString() ?? ""); + this.root.style.setProperty( + `--mdiff-${role}-style`, + TokenMetadata.getFontStyle(metadata) & FontStyle.Italic + ? "italic" + : "normal", + ); + } + } + + startLoading(entries: readonly ReviewFilesEditorEntry[]): void { + for (const entry of entries) { + if (this.files.has(entry.file.path)) continue; + const file = this.createFile(entry); + this.files.set(entry.file.path, file); + this.bySection.set(file.section, file); + this.order.push(file); + this.root.append(file.section); + this.build.observe(file.section); + this.release.observe(file.section); + this.request.observe(file.section); + } + + this.showStatus(); + } + + private createFile(entry: ReviewFilesEditorEntry): FileView { + const path = entry.file.path; + const section = element("section", "mdiff-file"); + section.dataset.path = path; + const header = section.appendChild(element("header", "mdiff-header")); + header.setAttribute("role", "button"); + header.tabIndex = 0; + const title = header.appendChild(element("span", "mdiff-header-title")); + const at = path.lastIndexOf("/"); + const name = title.appendChild(element("span", "mdiff-header-path")); + + if (at >= 0) + name.append(element("span", "mdiff-header-dir", path.slice(0, at + 1))); + name.append(element("span", "mdiff-header-name", path.slice(at + 1))); + + if (entry.file.previousPath) + name.title = `${entry.file.previousPath} → ${path}`; + const note = title.appendChild(element("span", "mdiff-header-note")); + + const countsNode = header.appendChild( + element("span", "mdiff-header-counts"), + ); + + const viewed = new ReviewViewedCheckbox(document, () => + this.onToggleViewed(path), + ); + + header.append(viewed.element); + const body = section.appendChild(element("div", "mdiff-body")); + + const file: FileView = { + entry, + section, + header, + name, + note, + countsNode, + viewed, + body, + state: LOADING, + collapsed: false, + collapseSet: false, + built: false, + near: false, + }; + + this.renderHeader(file); + this.renderBody(file); + + return file; + } + + fileLoaded(path: string, error?: string): void { + const file = this.files.get(path); + + if (!file) return; + file.state = error; + this.preserve(() => { + this.applyViewed(file); + this.renderHeader(file); + this.renderBody(file); + }); + this.showStatus(); + this.applyFind(); + } + + loadingFailed(message: string): void { + for (const file of this.files.values()) + if (file.state === LOADING) + this.fileLoaded(file.entry.file.path, message); + this.showStatus(); + } + + /** A file diffr hides by default (generated, vendored), and why. */ + hideFile(path: string, label: string): void { + const file = this.files.get(path); + + if (!file) return; + file.hidden = label; + + if (!file.collapseSet) file.collapsed = true; + this.preserve(() => { + this.renderHeader(file); + this.renderBody(file); + }); + } + + fileCounts(path: string, counts: ReviewFileCounts): void { + const file = this.files.get(path); + + if (!file) return; + file.counts = counts; + this.renderHeader(file); + } + + setFileCollapsed(path: string, collapsed: boolean): void { + const file = this.files.get(path); + + if (!file) return; + file.collapseSet = true; + + if (file.collapsed === collapsed) return; + file.collapsed = collapsed; + this.preserve(() => { + this.renderHeader(file); + this.renderBody(file); + }); + } + + listOnly(paths: ReadonlySet | undefined): void { + for (const file of this.order) + file.section.hidden = !!paths && !paths.has(file.entry.file.path); + } + + setProgress(progress: ReviewDiffProgress): void { + this.progress = progress; + this.preserve(() => { + for (const file of this.order) { + file.progress = progress.files.find( + (candidate) => candidate.path === file.entry.file.path, + ); + this.applyViewed(file); + this.renderHeader(file); + } + }); + } + + /** A file folds once viewed, and opens again once it is not, as the desktop view does. */ + private applyViewed(file: FileView): void { + const state = file.progress?.state; + + if (!state || state === file.viewedApplied || file.state) return; + const previous = file.viewedApplied; + file.viewedApplied = state; + + const collapsed = + state === "viewed" + ? true + : previous === "viewed" || + this.progress?.changedPaths?.includes(file.entry.file.path) + ? false + : undefined; + + if (collapsed === undefined || collapsed === file.collapsed) return; + file.collapsed = collapsed; + file.collapseSet = true; + this.renderBody(file); + } + + private renderHeader(file: FileView): void { + const { entry } = file; + const binary = !!entry.file.binary || isBinaryCounts(file.counts); + const locked = binary || !!file.state; + file.header.setAttribute( + "aria-expanded", + String(!file.collapsed && !locked), + ); + file.header.classList.toggle("is-collapsed", file.collapsed || locked); + file.header.setAttribute( + "aria-label", + `${file.collapsed ? "Expand" : "Collapse"} ${entry.file.path}`, + ); + file.note.textContent = + binary && isBinaryCounts(file.counts) + ? `Binary file${binarySizeLabel(file.counts) ? ` · ${binarySizeLabel(file.counts)}` : ""}` + : (file.hidden ?? (entry.file.status === "deleted" ? "Deleted" : "")); + file.note.hidden = !file.note.textContent; + const remaining = file.progress?.remaining; + + const counts = + file.counts && !isBinaryCounts(file.counts) ? file.counts : undefined; + + const added = remaining?.additions ?? counts?.added; + const removed = remaining?.deletions ?? counts?.removed; + file.countsNode.replaceChildren(); + + if (added) + file.countsNode.append(element("span", "mdiff-added", `+${added}`)); + + if (removed) + file.countsNode.append(element("span", "mdiff-removed", `−${removed}`)); + file.viewed.update(file.progress?.state, entry.file.path, !file.progress); + file.viewed.element.hidden = entry.file.status === "unchanged"; + } + + /** Draw a file's body if it is near the screen, else hold its place. */ + private renderBody(file: FileView): void { + const diff = this.session.getTextDiff(file.entry.file.path); + file.body.classList.remove("is-message"); + + if (file.state || !diff || file.collapsed) { + file.built = false; + file.body.style.height = ""; + file.body.replaceChildren(); + + const message = + file.state ?? + (file.collapsed + ? undefined + : this.session.getFileResult(file.entry.file.path)?.error); + + if (message) { + file.body.classList.add("is-message"); + file.body.textContent = message; + } + + return; + } + + if (!file.near) { + // Off screen, a file keeps the height it was last drawn at, or a few rows' worth: diffr + // folds most of a file away. + file.body.style.height ||= `${PLACEHOLDER_ROWS * 22}px`; + file.built = false; + file.body.replaceChildren(); + + return; + } + + file.built = true; + file.body.style.height = ""; + file.body.replaceChildren(this.renderDiff(file.entry.file.path, diff)); + this.applyFind(); + } + + private onBuild(entries: IntersectionObserverEntry[]): void { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const file = this.bySection.get(entry.target); + + if (!file || file.near) continue; + file.near = true; + this.renderBody(file); + } + } + + private onRelease(entries: IntersectionObserverEntry[]): void { + for (const entry of entries) { + if (entry.isIntersecting) continue; + const file = this.bySection.get(entry.target); + + if (!file?.near) continue; + file.near = false; + + if (!file.built) continue; + file.body.style.height = `${file.body.offsetHeight}px`; + file.built = false; + file.body.replaceChildren(); + } + } + + private renderDiff(path: string, diff: StructuralTextDiff): HTMLElement { + const items = unifiedItems(diff, (id) => + this.session.isRegionCollapsed(path, id), + ); + + const text = { + lhs: sourceLines(diff.lhs?.text), + rhs: sourceLines(diff.rhs?.text), + }; + + const syntax = { lhs: syntaxByLine(diff.lhs), rhs: syntaxByLine(diff.rhs) }; + const changed = changedByLine(diff); + const rows = element("div", "mdiff-rows"); + rows.style.setProperty( + "--mdiff-digits", + String(String(Math.max(text.lhs.length, text.rhs.length)).length), + ); + let chunk: HTMLElement | undefined; + let previous: UnifiedItem | undefined; + items.forEach((item, index) => { + if (index % CHUNK === 0) { + chunk = rows.appendChild(element("div", "mdiff-chunk")); + chunk.style.containIntrinsicBlockSize = `auto ${Math.min(CHUNK, items.length - index) * 20}px`; + } + + const startsChange = + item.kind === "line" + ? item.change !== "context" && + (previous?.kind !== "line" || previous.change === "context") + : item.change !== "unchanged"; + + chunk!.append( + item.kind === "band" + ? this.renderBand(item, startsChange) + : this.renderLine(item, text, syntax, changed, startsChange), + ); + previous = item; + }); + + if (!items.length) + rows.append(element("div", "mdiff-empty", "No changes to show")); + + return rows; + } + + private renderLine( + line: UnifiedLine, + text: Record<"lhs" | "rhs", string[]>, + syntax: Record<"lhs" | "rhs", Map>, + changed: Record<"lhs" | "rhs", Map>, + startsChange: boolean, + ): HTMLElement { + const row = element("div", `mdiff-row is-${line.change}`); + + if (line.tinted) row.classList.add("is-tinted"); + + if (startsChange) row.classList.add("is-change-start"); + + if (line.base !== undefined) row.dataset.base = String(line.base); + + if (line.head !== undefined) row.dataset.head = String(line.head); + const number = line.side === "lhs" ? line.base! : line.head!; + const gutter = row.appendChild(element("span", "mdiff-gutter")); + + const fold = + line.fold ?? + (line.pill && { foldStateId: line.pill.foldStateId, collapsed: true }); + + if (fold) { + row.classList.add("has-fold"); + row.classList.toggle("is-folded", fold.collapsed); + row.dataset.fold = String(fold.foldStateId); + row.dataset.collapsed = String(fold.collapsed); + const button = gutter.appendChild(element("button", "mdiff-chevron")); + button.type = "button"; + button.setAttribute("aria-expanded", String(!fold.collapsed)); + button.setAttribute( + "aria-label", + `${fold.collapsed ? "Expand" : "Fold"} scope at line ${number + 1}`, + ); + } + + gutter.append(element("span", "mdiff-lineno", String(number + 1))); + const code = row.appendChild(element("span", "mdiff-code")); + const source = text[line.side][number] ?? ""; + code.append( + renderCode( + source, + syntax[line.side].get(number), + line.tinted ? changed[line.side].get(number) : undefined, + ), + ); + + if (line.pill) { + const pill = code.appendChild( + element("span", "mdiff-pill", `⋯ ${line.pill.label}`), + ); + + pill.setAttribute("aria-hidden", "true"); + + if (line.pill.closer) + code.append(element("span", "mdiff-closer", ` ${line.pill.closer}`)); + } + + return row; + } + + private renderBand(band: UnifiedBand, startsChange: boolean): HTMLElement { + const node = element("div", `mdiff-band kind-${band.change}`); + + if (startsChange) node.classList.add("is-change-start"); + node.setAttribute("role", "button"); + node.tabIndex = 0; + node.dataset.fold = String(band.foldStateId); + node.style.setProperty("--mdiff-indent", String(band.indent)); + const summary = band.detail.length > 0; + node.setAttribute( + "aria-label", + `Expand ${band.hidden} hidden line${band.hidden === 1 ? "" : "s"}${summary ? ", summarized" : `: ${band.title}`}`, + ); + const title = node.appendChild(element("span", "mdiff-band-title")); + title.append(element("span", "mdiff-band-chevron")); + + if (summary) + title.append(element("span", "mdiff-band-caption", "Pseudocode")); + title.append(element("span", "mdiff-band-label", band.title)); + + if (summary) { + const detail = node.appendChild(element("div", "mdiff-pseudocode")); + + for (const text of band.detail) { + const line = detail.appendChild( + element("div", "mdiff-pseudocode-line", text.trimStart() || " "), + ); + + line.style.setProperty( + "--mdiff-indent", + String(text.length - text.trimStart().length), + ); + } + } + + return node; + } + + private onClick(event: Event): void { + const target = event.target; + + if (!(target instanceof Element)) return; + const overSelection = this.pressedOverSelection; + this.pressedOverSelection = false; + + if (target.closest(".review-viewed-check")) return; + const section = target.closest(".mdiff-file"); + const file = section && this.files.get(section.dataset.path!); + + if (!file) return; + const path = file.entry.file.path; + + if (target.closest(".mdiff-header")) { + if (file.state || file.entry.file.binary || isBinaryCounts(file.counts)) + return; + this.setFileCollapsed(path, !file.collapsed); + + return; + } + + // Ending a selection, or making one, is not a request to fold. + if (overSelection || !(window.getSelection()?.isCollapsed ?? true)) return; + + const band = target.closest(".mdiff-band"); + + if (band) { + this.session.setRegionCollapsed(path, Number(band.dataset.fold), false); + + return; + } + + const row = target.closest(".mdiff-row.has-fold"); + + if (row) + this.session.setRegionCollapsed( + path, + Number(row.dataset.fold), + row.dataset.collapsed !== "true", + ); + } + + /** + * Keep the row at the top of the screen where it is while `change` redraws what is above or + * around it. Browsers anchor scrolling on their own only when they redraw in place. + */ + private preserve(change: () => void): void { + const top = this.root.getBoundingClientRect().top; + const anchor = this.anchor(top); + change(); + + if (!anchor) return; + const node = anchor.find(); + + if (!node) return; + const delta = node.getBoundingClientRect().top - top - anchor.offset; + + if (Math.abs(delta) >= 1) this.root.scrollTop += delta; + } + + private anchor(top: number) { + const bounds = this.root.getBoundingClientRect(); + + if (!bounds.height) return undefined; + + const hit = document + .elementsFromPoint(bounds.left + bounds.width / 2, top + 1) + .find( + (node) => + this.root.contains(node) && + node.closest(".mdiff-row, .mdiff-band, .mdiff-file"), + ); + + const node = hit?.closest( + ".mdiff-row, .mdiff-band, .mdiff-file", + ); + + const section = node?.closest(".mdiff-file"); + + if (!node || !section) return undefined; + const path = section.dataset.path!; + + const selector = node.classList.contains("mdiff-row") + ? node.dataset.head !== undefined + ? `.mdiff-row[data-head="${node.dataset.head}"]` + : `.mdiff-row[data-base="${node.dataset.base}"]` + : node.classList.contains("mdiff-band") + ? `.mdiff-band[data-fold="${node.dataset.fold}"]` + : undefined; + + const offset = node.getBoundingClientRect().top - top; + + return { + offset, + find: () => { + const current = this.files.get(path)?.section; + + return ( + (selector && current?.querySelector(selector)) || current + ); + }, + }; + } + + /** The topmost file on screen. */ + get activePath(): string | undefined { + const top = this.root.scrollTop; + const visible = this.order.filter((file) => !file.section.hidden); + + let low = 0, + high = visible.length - 1, + found: FileView | undefined; + + while (low <= high) { + const middle = (low + high) >> 1; + const file = visible[middle]!; + + if (file.section.offsetTop <= top + 1) { + found = file; + low = middle + 1; + } else high = middle - 1; + } + + return (found ?? visible[0])?.entry.file.path; + } + + /** Scroll a file's header to the top; `open` also unfolds it. */ + revealFile(path: string, open = false): void { + const file = this.files.get(path); + + if (!file) return; + + if (open) this.setFileCollapsed(path, false); + this.settle(() => { + this.root.scrollTop = file.section.offsetTop; + + return true; + }); + } + + /** Scroll one side's 1-based `line` a third of the way down, once its folds have opened. */ + revealLine(path: string, side: "original" | "modified", line: number): void { + const file = this.files.get(path); + + if (!file) return; + this.setFileCollapsed(path, false); + file.near = true; + + if (!file.built) this.renderBody(file); + this.settle(() => { + const row = this.rowFor(file, side, line - 1); + + if (!row) return false; + + const delta = + row.getBoundingClientRect().top - + this.root.getBoundingClientRect().top - + this.root.clientHeight / 3; + + if (Math.abs(delta) > 1) this.root.scrollTop += delta; + row.classList.remove("is-revealed"); + void row.offsetWidth; + row.classList.add("is-revealed"); + + return true; + }); + } + + private rowFor(file: FileView, side: "original" | "modified", line: number) { + return side === "original" + ? (file.body.querySelector( + `.mdiff-row.is-removed[data-base="${line}"]`, + ) ?? + file.body.querySelector( + `.mdiff-row[data-base="${line}"]`, + )) + : file.body.querySelector(`.mdiff-row[data-head="${line}"]`); + } + + /** + * Run `apply` over the next frames, as folds open and files above settle, until it reports it + * found its target, the reader touches the list, or a second passes. + */ + private settle(apply: () => boolean): void { + this.reveal?.cancel(); + + let frame = 0, + frames = 0, + done = false; + + const step = () => { + const found = apply(); + done ||= found; + + if (++frames < (done ? 4 : 60)) frame = requestAnimationFrame(step); + }; + + const reveal = { + cancel: () => { + cancelAnimationFrame(frame); + + if (this.reveal === reveal) this.reveal = undefined; + }, + }; + + this.reveal = reveal; + frame = requestAnimationFrame(step); + } + + /** The next or previous run of changes, drawing the files it passes. */ + goToChange(direction: "next" | "previous"): void { + // Changes land on the reading line, a third of the way down, so they are measured from it. + const line = + this.root.getBoundingClientRect().top + this.root.clientHeight / 3; + + const active = this.order.findIndex( + (file) => file.entry.file.path === this.activePath, + ); + + const sequence = ( + direction === "next" + ? this.order.slice(Math.max(0, active)) + : this.order.slice(0, active + 1).reverse() + ).filter((file) => !file.section.hidden && !file.collapsed); + + for (const file of sequence) { + if (!file.built && !file.state) { + file.near = true; + this.preserve(() => this.renderBody(file)); + } + + const starts = [ + ...file.body.querySelectorAll(".is-change-start"), + ]; + + const target = + direction === "next" + ? starts.find((node) => node.getBoundingClientRect().top - line > 8) + : starts + .reverse() + .find((node) => node.getBoundingClientRect().top - line < -8); + + if (target) { + this.root.scrollTop += target.getBoundingClientRect().top - line; + + return; + } + } + } + + scroll(to: number | "top" | "end"): void { + this.reveal?.cancel(); + + if (to === "top") this.root.scrollTop = 0; + else if (to === "end") this.root.scrollTop = this.root.scrollHeight; + else this.root.scrollTop += to; + } + + focus(): void { + this.root.focus({ preventScroll: true }); + } + + /** Mark find's matches in the files drawn, with the CSS Custom Highlight API where it exists. */ + showFindMatches(matches: MobileFindMatch[], current: number): void { + this.find = matches.length ? { matches, current } : undefined; + this.applyFind(); + } + + private applyFind(): void { + const highlights = CSS.highlights; + + if (!highlights) return; + const all: Range[] = []; + const current: Range[] = []; + const find = this.find; + + if (find) { + find.matches.forEach((match, index) => { + const file = this.files.get(match.path); + + if (!file?.built) return; + const row = this.rowFor(file, match.side, match.line - 1); + const code = row?.querySelector(".mdiff-code"); + const range = code && textRange(code, match.column - 1, match.length); + + if (!range) return; + (index === find.current ? current : all).push(range); + }); + } + + highlights.set("diffr-find", new Highlight(...all)); + highlights.set("diffr-find-current", new Highlight(...current)); + } + + private showStatus(): void { + const states = this.order.map((file) => file.state); + const loading = states.filter((state) => state === LOADING).length; + this.status.hidden = !loading; + this.status.textContent = loading + ? `Diffing ${this.order.length - loading} of ${this.order.length} files…` + : ""; + } + + // The desktop view's surface, which a phone has no use for: its file tree is a sheet + // (mobileFiles.ts), and it has no editors. + toggleFileTree(): void {} + codeEditor(): undefined { + return undefined; + } +} + +const syntaxCache = new WeakMap< + StructuralSource, + Map +>(); + +function syntaxByLine(source: StructuralSource | undefined) { + if (!source) return new Map(); + let byLine = syntaxCache.get(source); + + if (!byLine) { + byLine = new Map(); + + for (const span of source.syntax ?? []) { + const group = byLine.get(span.line); + + if (group) group.push(span); + else byLine.set(span.line, [span]); + } + + syntaxCache.set(source, byLine); + } + + return byLine; +} + +const changedCache = new WeakMap< + StructuralTextDiff, + Record<"lhs" | "rhs", Map> +>(); + +/** Each side's changed token spans by zero-based line, as zero-based UTF-16 ranges. */ +function changedByLine(diff: StructuralTextDiff) { + let result = changedCache.get(diff); + + if (!result) { + const highlights = structuralHighlights(diff); + + const group = ( + spans: readonly { + startLineNumber: number; + startColumn: number; + endColumn: number; + }[], + ) => { + const byLine = new Map(); + + for (const span of spans) { + const line = span.startLineNumber - 1; + const ranges = byLine.get(line) ?? []; + ranges.push([span.startColumn - 1, span.endColumn - 1]); + byLine.set(line, ranges); + } + + return byLine; + }; + + result = { + lhs: group(highlights.original), + rhs: group(highlights.modified), + }; + changedCache.set(diff, result); + } + + return result; +} + +/** A line's text, split where its syntax role or changed tint begins or ends. */ +function renderCode( + text: string, + syntax: readonly StructuralSyntaxSpan[] | undefined, + changed: readonly [number, number][] | undefined, +): DocumentFragment | Text { + const shown = text.length > MAX_LINE ? `${text.slice(0, MAX_LINE)}…` : text; + + if (!syntax?.length && !changed?.length) + return document.createTextNode(shown); + const roles: [number, number, SyntaxRole][] = []; + + for (const span of syntax ?? []) { + const role = syntaxRole(span.capture); + + if (role) + roles.push([ + utf16Column(text, span.start_column) - 1, + utf16Column(text, span.end_column) - 1, + role, + ]); + } + + const cuts = new Set([0, shown.length]); + + for (const [start, end] of [...roles, ...(changed ?? [])]) { + cuts.add(Math.min(start, shown.length)); + cuts.add(Math.min(end, shown.length)); + } + + const points = [...cuts].sort((a, b) => a - b); + const fragment = document.createDocumentFragment(); + + for (let index = 0; index < points.length - 1; index++) { + const start = points[index]!, + end = points[index + 1]!; + + if (start === end) continue; + const role = roles.find(([from, to]) => from <= start && end <= to)?.[2]; + const tinted = changed?.some(([from, to]) => from <= start && end <= to); + const piece = shown.slice(start, end); + + if (!role && !tinted) { + fragment.append(piece); + continue; + } + + const span = element("span", undefined, piece); + + if (role) span.className = `mdiff-${role}`; + + if (tinted) span.classList.add("mdiff-changed"); + fragment.append(span); + } + + return fragment; +} + +/** The DOM range over `length` UTF-16 units of `node`'s text, from `offset`. */ +function textRange( + node: Node, + offset: number, + length: number, +): Range | undefined { + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + + let seen = 0, + started = false; + + for (let text = walker.nextNode(); text; text = walker.nextNode()) { + const size = text.textContent?.length ?? 0; + + if (!started && offset < seen + size) { + range.setStart(text, offset - seen); + started = true; + } + + if (started && offset + length <= seen + size) { + range.setEnd(text, offset + length - seen); + + return range; + } + + seen += size; + } + + return undefined; +} diff --git a/apps/diffr-web/src/review/common/reviewSyntaxRoles.ts b/apps/diffr-web/src/review/common/reviewSyntaxRoles.ts new file mode 100644 index 000000000..33c4a2ad2 --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewSyntaxRoles.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// diffr's highlight captures, named by the Whiteboard theme's roles (extensions/review-themes). +export type SyntaxRole = + | "comment" + | "keyword" + | "type" + | "function" + | "variable" + | "string" + | "number" + | "constant" + | "operator" + | "heading"; + +/** Most specific first: a capture takes the first rule its dotted name starts with. */ +const RULES: readonly (readonly [string, SyntaxRole])[] = [ + ["comment", "comment"], + ["string.special.symbol", "constant"], + ["string", "string"], + ["character", "string"], + ["escape", "number"], + ["number", "number"], + ["float", "number"], + ["boolean", "constant"], + ["constant.builtin", "constant"], + ["constant", "constant"], + ["keyword.operator", "operator"], + ["keyword", "keyword"], + ["conditional", "keyword"], + ["repeat", "keyword"], + ["include", "keyword"], + ["exception", "keyword"], + ["storageclass", "keyword"], + ["preproc", "keyword"], + ["define", "keyword"], + ["operator", "operator"], + ["punctuation", "operator"], + ["variable.builtin", "keyword"], + ["variable", "variable"], + ["property", "variable"], + ["field", "variable"], + ["label", "variable"], + ["function", "function"], + ["method", "function"], + ["attribute", "type"], + ["constructor", "type"], + ["type", "type"], + ["module", "type"], + ["namespace", "type"], + ["tag", "type"], + ["markup.heading", "heading"], + ["text.title", "heading"], +]; + +export function syntaxRole(capture: string): SyntaxRole | undefined { + for (const [prefix, role] of RULES) { + if (capture === prefix || capture.startsWith(prefix + ".")) return role; + } + return undefined; +} + +/** The theme scope whose colour paints each role. */ +export const SYNTAX_SCOPES: Record = { + comment: "comment", + keyword: "keyword", + type: "entity.name.type", + function: "entity.name.function", + variable: "variable.other", + string: "string", + number: "constant.numeric", + constant: "constant.language", + operator: "keyword.operator", + heading: "markup.heading", +}; diff --git a/apps/diffr-web/src/review/common/reviewUnifiedRows.ts b/apps/diffr-web/src/review/common/reviewUnifiedRows.ts new file mode 100644 index 000000000..329c696af --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewUnifiedRows.ts @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// One file as a unified diff, in the order the reader sees it: lines, and the bands diffr's folds +// leave in their place. The same rows, gaps and foldables the diff editor draws from, without it. +import { + bandDetail, + regionLines, + type StructuralFoldable, + type StructuralGap, + type StructuralRegion, + structuralContextGaps, + structuralFoldables, + structuralHighlights, + structuralRows, + type StructuralTextDiff, +} from "./reviewStructuralDiff.js"; + +export interface UnifiedFold { + readonly foldStateId: number; + readonly collapsed: boolean; +} + +export interface UnifiedLine { + readonly kind: "line"; + readonly change: "added" | "removed" | "context"; + /** Within a change, whether diffr counts the line as changed; the rest only moved with it. */ + readonly tinted: boolean; + /** Zero-based line on each side it exists on. */ + readonly base?: number; + readonly head?: number; + /** The side its text and syntax come from: the base for a removed line, else the head. */ + readonly side: "lhs" | "rhs"; + /** The fold this line heads, if it has the chevron. */ + readonly fold?: UnifiedFold; + /** A fold folded into this line, as `opener ⋯ label closer`. */ + readonly pill?: { + readonly foldStateId: number; + readonly label: string; + readonly closer: string; + }; +} + +export interface UnifiedBand { + readonly kind: "band"; + readonly foldStateId: number; + readonly change: StructuralGap["change"]; + /** Lines the band hides, the larger side's count. */ + readonly hidden: number; + /** diffr's one-line label, or a count. */ + readonly title: string; + /** A summary's pseudocode, one entry per line; empty without one. */ + readonly detail: readonly string[]; + /** Columns of indentation of the first line it hides. */ + readonly indent: number; +} + +export type UnifiedItem = UnifiedLine | UnifiedBand; + +/** The side's text, split as diffr counts lines. */ +export function sourceLines(text: string | undefined): string[] { + return text === undefined ? [] : text.replace(/\r\n/g, "\n").split("\n"); +} + +function indentOf(text: string | undefined): number { + let columns = 0; + + for (const character of text ?? "") { + if (character === " ") columns++; + else if (character === "\t") columns += 4; + else break; + } + + return columns; +} + +/** + * The file's rows. Paired lines diffr found unchanged show once; a run of changed rows shows its + * base lines, then its head lines. A collapsed region hides its lines on both sides and leaves one + * band, or folds into its header line. `collapsed` is the session's state for a fold. + */ +export function unifiedItems( + diff: StructuralTextDiff, + collapsed: (foldStateId: number) => boolean | undefined, +): UnifiedItem[] { + const base = sourceLines(diff.lhs?.text); + const head = sourceLines(diff.rhs?.text); + const highlights = structuralHighlights(diff); + const changedBase = new Set(highlights.originalLines); + const changedHead = new Set(highlights.modifiedLines); + + const lines: Omit[] = []; + // A changed run's lines on each side, and whether each was paired with a line on the other. + let removed: [number, boolean][] = []; + let added: [number, boolean][] = []; + const flush = () => { + for (const [line, paired] of removed) + lines.push({ + kind: "line", + change: "removed", + tinted: changedBase.has(line + 1) || !paired, + base: line, + side: "lhs", + }); + for (const [line, paired] of added) + lines.push({ + kind: "line", + change: "added", + tinted: changedHead.has(line + 1) || !paired, + head: line, + side: "rhs", + }); + removed = []; + added = []; + }; + + for (const [left, right] of structuralRows(diff)) { + const changed = + left === null || + right === null || + changedBase.has(left + 1) || + changedHead.has(right + 1); + + if (changed) { + if (left !== null) removed.push([left, right !== null]); + if (right !== null) added.push([right, left !== null]); + continue; + } + + flush(); + lines.push({ + kind: "line", + change: "context", + tinted: false, + base: left, + head: right, + side: "rhs", + }); + } + + flush(); + + // Each hidden line belongs to the first collapsed gap over it, so overlapping gaps show once. + const gaps = structuralContextGaps( + diff, + (id) => collapsed(id) === true, + collapsed, + ).filter((gap) => gap.collapsed); + const hiddenBase = new Map(); + const hiddenHead = new Map(); + gaps.forEach((gap, index) => { + for (let line = 0; line < gap.originalCount; line++) + if (!hiddenBase.has(gap.originalStart - 1 + line)) + hiddenBase.set(gap.originalStart - 1 + line, index); + for (let line = 0; line < gap.modifiedCount; line++) + if (!hiddenHead.has(gap.modifiedStart - 1 + line)) + hiddenHead.set(gap.modifiedStart - 1 + line, index); + }); + + // Folding one line saves nothing, and on a phone every chevron makes its row a tap target. + const spans = new Map(); + const measure = (region: StructuralRegion) => { + const { start, end } = regionLines(region); + const id = region.fold_state_id; + spans.set(id, Math.max(spans.get(id) ?? 0, end - start)); + if (region.kind === "fold") region.children.forEach(measure); + }; + for (const side of [diff.lhs, diff.rhs]) if (side) measure(side.root); + const chevrons = (foldables: StructuralFoldable[]) => + new Map( + foldables + .filter( + (foldable) => + foldable.chevron && + ((spans.get(foldable.foldStateId) ?? 0) > 1 || + collapsed(foldable.foldStateId) === true), + ) + .map((foldable) => [foldable.line, foldable]), + ); + const baseFoldables = structuralFoldables(diff.lhs); + const headFoldables = structuralFoldables(diff.rhs); + const baseChevrons = chevrons(baseFoldables); + const headChevrons = chevrons(headFoldables); + + // A fold folded into its header line puts its pill on the head side's header. + const pills = new Map>(); + for (const gap of gaps) { + if (gap.band || gap.owner === "base") continue; + const foldable = headFoldables.find( + (candidate) => + candidate.foldStateId === gap.foldStateId && candidate.inline, + ); + if (foldable?.inline) + pills.set(gap.modifiedStart - 2, { + foldStateId: gap.foldStateId, + label: foldable.inline.label, + closer: foldable.inline.closer, + }); + } + + const foldOf = (foldable: StructuralFoldable | undefined) => + foldable && { + foldStateId: foldable.foldStateId, + collapsed: collapsed(foldable.foldStateId) ?? foldable.collapsedByDefault, + }; + + const items: UnifiedItem[] = []; + const banded = new Set(); + + for (const line of lines) { + const gapIndex = + (line.head !== undefined ? hiddenHead.get(line.head) : undefined) ?? + (line.base !== undefined ? hiddenBase.get(line.base) : undefined); + + if (gapIndex !== undefined) { + const gap = gaps[gapIndex]!; + + if (gap.band && !banded.has(gapIndex)) { + banded.add(gapIndex); + const detail = bandDetail(gap.label); + const count = Math.max(gap.originalCount, gap.modifiedCount); + items.push({ + kind: "band", + foldStateId: gap.foldStateId, + change: gap.change, + hidden: count, + title: detail + ? `${count} hidden line${count === 1 ? "" : "s"}` + : gap.label.split("\n")[0]!, + detail: detail ? detail.split("\n") : [], + indent: indentOf( + gap.modifiedCount + ? head[gap.modifiedStart - 1] + : base[gap.originalStart - 1], + ), + }); + } + + continue; + } + + const fold = + line.side === "lhs" + ? foldOf(baseChevrons.get(line.base!)) + : foldOf( + headChevrons.get(line.head!) ?? + (line.base !== undefined + ? baseChevrons.get(line.base) + : undefined), + ); + const pill = line.head !== undefined ? pills.get(line.head) : undefined; + items.push({ ...line, fold, pill }); + } + + return items; +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts index 2ed469ba9..e21fb6015 100644 --- a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts +++ b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts @@ -19,80 +19,9 @@ import { utf16Column, type StructuralSyntaxSpan, } from "../common/reviewStructuralDiff.js"; +import { syntaxRole, SYNTAX_SCOPES } from "../common/reviewSyntaxRoles.js"; import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; -type Role = - | "comment" - | "keyword" - | "type" - | "function" - | "variable" - | "string" - | "number" - | "constant" - | "operator" - | "heading"; - -/** Most specific first: a capture takes the first rule its dotted name starts with. */ -const RULES: readonly (readonly [string, Role])[] = [ - ["comment", "comment"], - ["string.special.symbol", "constant"], - ["string", "string"], - ["character", "string"], - ["escape", "number"], - ["number", "number"], - ["float", "number"], - ["boolean", "constant"], - ["constant.builtin", "constant"], - ["constant", "constant"], - ["keyword.operator", "operator"], - ["keyword", "keyword"], - ["conditional", "keyword"], - ["repeat", "keyword"], - ["include", "keyword"], - ["exception", "keyword"], - ["storageclass", "keyword"], - ["preproc", "keyword"], - ["define", "keyword"], - ["operator", "operator"], - ["punctuation", "operator"], - ["variable.builtin", "keyword"], - ["variable", "variable"], - ["property", "variable"], - ["field", "variable"], - ["label", "variable"], - ["function", "function"], - ["method", "function"], - ["attribute", "type"], - ["constructor", "type"], - ["type", "type"], - ["module", "type"], - ["namespace", "type"], - ["tag", "type"], - ["markup.heading", "heading"], - ["text.title", "heading"], -]; - -function role(capture: string): Role | undefined { - for (const [prefix, role] of RULES) { - if (capture === prefix || capture.startsWith(prefix + ".")) return role; - } - return undefined; -} - -const SCOPES: Record = { - comment: "comment", - keyword: "keyword", - type: "entity.name.type", - function: "entity.name.function", - variable: "variable.other", - string: "string", - number: "constant.numeric", - constant: "constant.language", - operator: "keyword.operator", - heading: "markup.heading", -}; - /** Model tokens also reach unified mode's removed-code view zones and copied HTML. */ export class StructuralSyntax extends Disposable { private readonly themes = StandaloneServices.get(IStandaloneThemeService); @@ -150,10 +79,10 @@ export class StructuralSyntax extends Disposable { const tokens: number[] = []; // The protocol supplies sorted, non-overlapping spans, with inner captures taking precedence. for (const span of spans) { - const kind = role(span.capture); + const kind = syntaxRole(span.capture); if (!kind || span.line >= model.getLineCount()) continue; const text = model.getLineContent(span.line + 1); - const metadata = theme.match(0, SCOPES[kind]); + const metadata = theme.match(0, SYNTAX_SCOPES[kind]); tokens.push( span.line, utf16Column(text, span.start_column) - 1,