From c4c599b3f35cc66f85d5af7dd68529e99b603248 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Mon, 12 Oct 2026 13:00:34 -0500 Subject: [PATCH 01/19] Draw diffr-web's comparison as plain rows on every screen The phone's DOM renderer now draws the desktop too, in split or unified layout, beside the changed-files tree, and the Monaco diff editors are gone. - Split pairs the lines diffr paired, with a hatched filler where a side has none. Unwrapped, each side scrolls sideways on its own and a file's segments stay level; wrapped, both sides of a row wrap to one height. Selecting text keeps to the side it started on. - Unified on a desktop shows both sides' line numbers. - Rows are drawn in blocks of 120 only near the screen and emptied again far from it, at their measured height. Expanding the 25k-row pnpm-lock.yaml in split took one 440 ms task and scrolled at a 46 ms p95 frame; it now takes 35 ms and scrolls at 10 ms. - Folds work as before; on a desktop the gutter folds, so clicking code only places the caret that z and j/k act on. - The files tree, its summary and a drag sash move into FilesSidebar. Deleted: the multi-diff view, its structural folds, syntax decorations and per-scope viewed controls, native-scroll shims, the overflow-widget host and the CSS that styled them. The bundle drops from 2.95 MB of JS (782 kB gzip) to 2.11 MB (about 555 kB). Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/comparison.ts | 127 +- .../src/{mobileDiff.css => diffView.css} | 439 ++++-- .../src/{mobileDiff.ts => diffView.ts} | 896 ++++++++++-- apps/diffr-web/src/filesSidebar.ts | 237 +++ apps/diffr-web/src/find.ts | 65 +- apps/diffr-web/src/main.ts | 10 +- apps/diffr-web/src/mobileFiles.ts | 2 +- apps/diffr-web/src/nativeDiffScroll.ts | 279 ---- .../src/review/browser/media/review.css | 504 +------ .../src/review/common/reviewDiffRows.ts | 393 +++++ .../review/common/reviewStructuralViewed.ts | 128 -- .../src/review/common/reviewUnifiedRows.ts | 258 ---- .../review/services/reviewFilesDiffView.ts | 1291 ----------------- .../src/review/services/reviewMultiDiff.ts | 339 ----- .../review/services/reviewStructuralDiff.ts | 326 ----- .../review/services/reviewStructuralFolds.ts | 1118 -------------- .../review/services/reviewStructuralSyntax.ts | 106 -- .../review/services/reviewStructuralViewed.ts | 167 --- .../services/reviewStructuralViewedControl.ts | 132 -- apps/diffr-web/src/styles.css | 187 +-- apps/diffr-web/src/viewport.ts | 4 + 21 files changed, 1747 insertions(+), 5261 deletions(-) rename apps/diffr-web/src/{mobileDiff.css => diffView.css} (52%) rename apps/diffr-web/src/{mobileDiff.ts => diffView.ts} (51%) create mode 100644 apps/diffr-web/src/filesSidebar.ts delete mode 100644 apps/diffr-web/src/nativeDiffScroll.ts create mode 100644 apps/diffr-web/src/review/common/reviewDiffRows.ts delete mode 100644 apps/diffr-web/src/review/common/reviewStructuralViewed.ts delete mode 100644 apps/diffr-web/src/review/common/reviewUnifiedRows.ts delete mode 100644 apps/diffr-web/src/review/services/reviewFilesDiffView.ts delete mode 100644 apps/diffr-web/src/review/services/reviewMultiDiff.ts delete mode 100644 apps/diffr-web/src/review/services/reviewStructuralDiff.ts delete mode 100644 apps/diffr-web/src/review/services/reviewStructuralFolds.ts delete mode 100644 apps/diffr-web/src/review/services/reviewStructuralSyntax.ts delete mode 100644 apps/diffr-web/src/review/services/reviewStructuralViewed.ts delete mode 100644 apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts create mode 100644 apps/diffr-web/src/viewport.ts diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index d26932e68..275f20961 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -1,17 +1,16 @@ /** * 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 - * (on a phone, plain rows: mobileDiff.ts). + * a worker once its text is fetched, nearest the reader first, into plain rows (diffView.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"; import { Disposable, DisposableStore } from "vs/base/common/lifecycle.js"; import type { IObservable } from "vs/base/common/observable.js"; import { URI } from "vs/base/common/uri.js"; -import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; import { cache, cacheKey, cached } from "./cache.js"; +import { type DiffEntry, DiffView, type FindMatch } from "./diffView.js"; import type { Classified, Diffed, @@ -30,8 +29,6 @@ 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 { @@ -41,16 +38,7 @@ import { reviewFileCounts, } from "./review/common/reviewStructuralDiff.js"; import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; -import { - ReviewFilesDiffView, - type ReviewFilesEditorEntry, - ReviewFilesEditorInput, -} from "./review/services/reviewFilesDiffView.js"; -import { createStructuralDiffEditors } from "./review/services/reviewStructuralDiff.js"; import { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; -import { structuralFoldCommand } from "./review/services/reviewStructuralFolds.js"; -import { StructuralViewedState } from "./review/services/reviewStructuralViewed.js"; -import { ScopeViewedControl } from "./review/services/reviewStructuralViewedControl.js"; import { ViewedProgress } from "./viewed.js"; /** Files fetched or diffed at once. The engine spreads them over its workers. */ @@ -88,7 +76,7 @@ export interface Work { interface File { changed: ChangedFile; - entry: ReviewFilesEditorEntry; + entry: DiffEntry; classified?: Classified; state: "waiting" | "fetching" | "diffing" | "done"; } @@ -111,14 +99,12 @@ 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 | MobileDiffView | undefined; + private view: DiffView | undefined; private session: StructuralDiffSession | undefined; private viewed: ViewedProgress | undefined; /** Files the reader asked for, most recent first. */ @@ -130,7 +116,6 @@ export class Comparison extends Disposable { constructor( private readonly container: HTMLElement, - private readonly overflow: HTMLElement, readonly target: Target, private readonly engine: Engine, private readonly layout: ReviewDiffLayoutSetting, @@ -429,19 +414,9 @@ export class Comparison extends Disposable { this.view?.revealLine(path, side, line); } - /** The editor showing one side of a file, while it is on screen. */ - codeEditor(path: string, side: "original" | "modified") { - 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; + /** Marks find's matches in the files drawn. */ + showFindMatches(matches: FindMatch[], current: number): void { + this.view?.showFindMatches(matches, current); } goToChange(direction: "next" | "previous"): void { @@ -456,13 +431,9 @@ export class Comparison extends Disposable { this.view?.toggleFileTree(); } - /** A fold command at the caret of the focused diff editor (`StructuralFoldControls.command`). */ + /** One of the diffr TUI's `z` fold commands (`DiffView.foldCommand`). */ foldCommand(command: string): void { - const editor = this.instantiation - .invokeFunction((accessor) => accessor.get(ICodeEditorService)) - .getFocusedCodeEditor(); - - if (editor) structuralFoldCommand(editor, command); + this.view?.foldCommand(command); } /** Folds or unfolds everything diffr can fold, in every file. */ @@ -551,18 +522,8 @@ export class Comparison extends Disposable { path: `/${owner}/${repo}/blob/${commit}/${path}`, }); - const entries: ReviewFilesEditorEntry[] = ordered.map((file) => ({ + const entries: DiffEntry[] = ordered.map((file) => ({ file, - original: URI.from({ - scheme: "diffr", - authority: "base", - path: `/${file.previousPath ?? file.path}`, - }), - modified: URI.from({ - scheme: "diffr", - authority: "head", - path: `/${file.path}`, - }), goToFileResource: file.status === "deleted" ? blob(change.base, file.previousPath ?? file.path) @@ -587,62 +548,17 @@ export class Comparison extends Disposable { new ViewedProgress(targetPath(this.target)), )); - const viewedState = store.add( - new StructuralViewedState( + const view = store.add( + new DiffView( + this.container, session, - entries, - () => viewed.progress, - viewed.onDidChange, - (ranges, isViewed) => viewed.setViewed(ranges, isViewed), - (editor, onToggle) => new ScopeViewedControl(editor, onToggle), + (path) => void viewed.toggleFile(path), + this.wordWrap, + this.layout, + this.instantiation, ), ); - // 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, - viewedState, - ); - - 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); @@ -657,11 +573,6 @@ export class Comparison extends Disposable { store.add(view.onDidScroll(() => this.pump())); this.observe(session, entries, view, store); - if (input && view instanceof ReviewFilesDiffView) - await view.setInput(input, undefined); - - if (this._store.isDisposed) return; - // Paths alone say which files diffr hides (generated, vendored, lockfiles); those go last. const previews = await this.engine.preview( entries.map((entry) => this.request(this.files.get(entry.file.path)!)), @@ -943,8 +854,8 @@ export class Comparison extends Disposable { /** Review Desktop's observeSession: each result goes to the view once. */ private observe( session: StructuralDiffSession, - entries: readonly ReviewFilesEditorEntry[], - view: ReviewFilesDiffView | MobileDiffView, + entries: readonly DiffEntry[], + view: DiffView, store: DisposableStore, ): void { const rendered = new Set(); diff --git a/apps/diffr-web/src/mobileDiff.css b/apps/diffr-web/src/diffView.css similarity index 52% rename from apps/diffr-web/src/mobileDiff.css rename to apps/diffr-web/src/diffView.css index db64ddecb..dadefd8a5 100644 --- a/apps/diffr-web/src/mobileDiff.css +++ b/apps/diffr-web/src/diffView.css @@ -1,15 +1,15 @@ -/* The phone's diff (mobileDiff.ts): plain rows the browser scrolls and selects. Every fold control +/* The phone's diff (diffView.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; +.dv { + --dv-line: 22px; + --dv-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. */ + /* diffView.ts keeps the reader's row in place itself, in every browser. */ overflow-anchor: none; -webkit-text-size-adjust: 100%; outline: none; @@ -18,21 +18,61 @@ box-sizing: border-box; } -.mdiff-status { +.dv-host { + display: flex; + height: 100%; + min-height: 0; +} + +.dv-host > .dv { + flex: 1; + min-width: 0; +} + +.dv-sidebar { + position: relative; + flex: none; + height: 100%; + box-sizing: border-box; + background: var(--vscode-sideBar-background); +} + +.dv-sidebar[hidden] { + display: none; +} + +/* Drag to widen or narrow the file tree. */ +.dv-sash { + position: absolute; + top: 0; + right: -3px; + z-index: 5; + width: 6px; + height: 100%; + cursor: col-resize; + touch-action: none; +} + +.dv-sash:hover, +.dv-sash:active { + background: var(--vscode-sash-hoverBorder, var(--vscode-focusBorder)); +} + +.dv-status { padding: 12px 16px; color: var(--vscode-descriptionForeground); } -.mdiff-status[hidden], -.mdiff-file[hidden] { +.dv-status[hidden], +.dv-file[hidden] { display: none; } -.mdiff-file { +.dv-file { border-bottom: 1px solid var(--vscode-panel-border); } -.mdiff-header { +.dv-header { position: sticky; top: 0; z-index: 2; @@ -51,7 +91,7 @@ touch-action: manipulation; } -.mdiff-header::before { +.dv-header::before { content: ""; flex: none; width: 12px; @@ -63,41 +103,41 @@ transition: transform 120ms ease; } -.mdiff-header.is-collapsed::before { +.dv-header.is-collapsed::before { transform: none; } -.mdiff-header:active { +.dv-header:active { background: var(--vscode-toolbar-hoverBackground); } -.mdiff-header:focus-visible { +.dv-header:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } -.mdiff-header-title { +.dv-header-title { flex: 1; min-width: 0; display: flex; flex-direction: column; } -.mdiff-header-path { +.dv-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 { +.dv-header-dir { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--vscode-descriptionForeground); } -.mdiff-header-name { +.dv-header-name { flex: none; max-width: 100%; overflow: hidden; @@ -105,7 +145,7 @@ font-weight: 600; } -.mdiff-header-note { +.dv-header-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -113,92 +153,92 @@ color: var(--vscode-descriptionForeground); } -.mdiff-header-note[hidden] { +.dv-header-note[hidden] { display: none; } -.mdiff-header-counts { +.dv-header-counts { flex: none; display: flex; gap: 6px; - font-family: var(--mdiff-font); + font-family: var(--dv-font); font-size: 12px; } -.mdiff-added { +.dv-added { color: var(--vscode-gitDecoration-addedResourceForeground); } -.mdiff-removed { +.dv-removed { color: var(--vscode-gitDecoration-deletedResourceForeground); } -.mdiff-header .review-viewed-check { +.dv-header .review-viewed-check { margin: 0 15px; } -.mdiff-header .review-viewed-check[hidden] { +.dv-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 { +.dv-header .review-viewed-check::before { content: ""; position: absolute; inset: -15px; } -.mdiff-body { +.dv-body { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; } -.mdiff-body.is-message { +.dv-body.is-message { padding: 12px 16px; color: var(--vscode-descriptionForeground); overflow-wrap: anywhere; } -.mdiff-rows { +.dv-rows { display: inline-block; min-width: 100%; vertical-align: top; - font-family: var(--mdiff-font); + font-family: var(--dv-font); font-size: 13px; - line-height: var(--mdiff-line); + line-height: var(--dv-line); color: var(--vscode-editor-foreground); } -.mdiff-chunk { +.dv-chunk { content-visibility: auto; } -.mdiff-empty { +.dv-empty { padding: 12px 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--vscode-descriptionForeground); } -.mdiff-row { +.dv-row { display: flex; - min-height: var(--mdiff-line); + min-height: var(--dv-line); white-space: pre; } -.mdiff-row.is-added.is-tinted { +.dv-row.is-added.is-tinted { background: var(--vscode-diffEditor-insertedLineBackground); } -.mdiff-row.is-removed.is-tinted { +.dv-row.is-removed.is-tinted { background: var(--vscode-diffEditor-removedLineBackground); } -.mdiff-row.is-revealed { - animation: mdiff-reveal 1.4s ease-out; +.dv-row.is-revealed { + animation: dv-reveal 1.4s ease-out; } -@keyframes mdiff-reveal { +@keyframes dv-reveal { from { box-shadow: inset 3px 0 0 var(--vscode-focusBorder); background-color: var(--vscode-editor-findMatchHighlightBackground); @@ -206,7 +246,7 @@ } /* The gutter stays put while the code scrolls sideways under it. */ -.mdiff-gutter { +.dv-gutter { position: sticky; left: 0; z-index: 1; @@ -215,7 +255,7 @@ align-items: center; justify-content: flex-end; box-sizing: border-box; - width: calc(var(--mdiff-digits, 3) * 1ch + 36px); + width: calc(var(--dv-digits, 3) * 1ch + 36px); padding-right: 8px; background: var(--vscode-editor-background); color: var(--vscode-editorLineNumber-foreground); @@ -223,15 +263,15 @@ -webkit-user-select: none; } -.no-numbers .mdiff-gutter { +.no-numbers .dv-gutter { width: 28px; } -.no-numbers .mdiff-lineno { +.no-numbers .dv-lineno { display: none; } -.is-added.is-tinted > .mdiff-gutter { +.is-added.is-tinted > .dv-gutter { background: linear-gradient( var(--vscode-diffEditor-insertedLineBackground), @@ -241,7 +281,7 @@ box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-addedResourceForeground); } -.is-removed.is-tinted > .mdiff-gutter { +.is-removed.is-tinted > .dv-gutter { background: linear-gradient( var(--vscode-diffEditor-removedLineBackground), @@ -253,17 +293,17 @@ } /* A line that heads a fold is one control from edge to edge: tap anywhere on it. */ -.mdiff-row.has-fold { +.dv-row.has-fold { cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } -.mdiff-row.has-fold:active { +.dv-row.has-fold:active { background-color: var(--vscode-toolbar-hoverBackground); } -.mdiff-chevron { +.dv-chevron { position: absolute; inset: 0 auto 0 0; width: 100%; @@ -276,7 +316,7 @@ cursor: pointer; } -.mdiff-chevron::before { +.dv-chevron::before { content: ""; display: inline-block; width: 12px; @@ -288,96 +328,96 @@ transform: rotate(90deg); } -.mdiff-chevron[aria-expanded="false"]::before { +.dv-chevron[aria-expanded="false"]::before { transform: none; } -.mdiff-chevron:focus-visible { +.dv-chevron:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } -.mdiff-code { +.dv-code { flex: 1; padding: 0 16px 0 8px; } -.is-wrapped .mdiff-body { +.is-wrapped .dv-body { overflow-x: hidden; } -.is-wrapped .mdiff-rows { +.is-wrapped .dv-rows { display: block; } -.is-wrapped .mdiff-row { +.is-wrapped .dv-row { white-space: pre-wrap; overflow-wrap: anywhere; } -.mdiff-changed { +.dv-changed { border-radius: 2px; } -.is-added .mdiff-changed { +.is-added .dv-changed { background: var(--vscode-diffEditor-insertedTextBackground); } -.is-removed .mdiff-changed { +.is-removed .dv-changed { background: var(--vscode-diffEditor-removedTextBackground); } -.mdiff-comment { - color: var(--mdiff-comment); - font-style: var(--mdiff-comment-style); +.dv-comment { + color: var(--dv-comment); + font-style: var(--dv-comment-style); } -.mdiff-keyword { - color: var(--mdiff-keyword); - font-style: var(--mdiff-keyword-style); +.dv-keyword { + color: var(--dv-keyword); + font-style: var(--dv-keyword-style); } -.mdiff-type { - color: var(--mdiff-type); - font-style: var(--mdiff-type-style); +.dv-type { + color: var(--dv-type); + font-style: var(--dv-type-style); } -.mdiff-function { - color: var(--mdiff-function); - font-style: var(--mdiff-function-style); +.dv-function { + color: var(--dv-function); + font-style: var(--dv-function-style); } -.mdiff-variable { - color: var(--mdiff-variable); - font-style: var(--mdiff-variable-style); +.dv-variable { + color: var(--dv-variable); + font-style: var(--dv-variable-style); } -.mdiff-string { - color: var(--mdiff-string); - font-style: var(--mdiff-string-style); +.dv-string { + color: var(--dv-string); + font-style: var(--dv-string-style); } -.mdiff-lineno { +.dv-lineno { font-variant-numeric: tabular-nums; } -.mdiff-number { - color: var(--mdiff-number); - font-style: var(--mdiff-number-style); +.dv-number { + color: var(--dv-number); + font-style: var(--dv-number-style); } -.mdiff-constant { - color: var(--mdiff-constant); - font-style: var(--mdiff-constant-style); +.dv-constant { + color: var(--dv-constant); + font-style: var(--dv-constant-style); } -.mdiff-operator { - color: var(--mdiff-operator); - font-style: var(--mdiff-operator-style); +.dv-operator { + color: var(--dv-operator); + font-style: var(--dv-operator-style); } -.mdiff-heading { - color: var(--mdiff-heading); - font-style: var(--mdiff-heading-style); +.dv-heading { + color: var(--dv-heading); + font-style: var(--dv-heading-style); } /* A fold folded into its header line: `opener ⋯ 12 lines closer`. */ -.mdiff-pill { +.dv-pill { display: inline-block; margin: 0 4px; padding: 0 8px; border-radius: 10px; - line-height: calc(var(--mdiff-line) - 4px); + line-height: calc(var(--dv-line) - 4px); font-size: 12px; color: var(--vscode-descriptionForeground); background: var(--vscode-badge-background); @@ -386,11 +426,11 @@ } /* What a collapsed region leaves: as wide as the screen, whatever the code's width. */ -.mdiff-band { +.dv-band { position: sticky; left: 0; box-sizing: border-box; - width: var(--mdiff-width, 100vw); + width: var(--dv-width, 100vw); min-height: 44px; padding: 0 12px 0 0; border-block: 1px solid var(--vscode-panel-border); @@ -409,43 +449,43 @@ touch-action: manipulation; } -.mdiff-band + .mdiff-band, -.mdiff-chunk + .mdiff-chunk > .mdiff-band:first-child { +.dv-band + .dv-band, +.dv-chunk + .dv-chunk > .dv-band:first-child { border-top: 0; } -.mdiff-band:active { +.dv-band:active { background: var(--vscode-toolbar-hoverBackground); } -.mdiff-band:focus-visible { +.dv-band:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } -.mdiff-band.kind-inserted { +.dv-band.kind-inserted { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-addedResourceForeground); } -.mdiff-band.kind-removed { +.dv-band.kind-removed { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-deletedResourceForeground); } -.mdiff-band.kind-modified { +.dv-band.kind-modified { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-modifiedResourceForeground); } -.mdiff-band-title { +.dv-band-title { display: flex; align-items: center; gap: 8px; min-height: 44px; - padding-left: calc(8px + min(var(--mdiff-indent, 0), 8) * 0.5ch); + padding-left: calc(8px + min(var(--dv-indent, 0), 8) * 0.5ch); } -.mdiff-band-chevron { +.dv-band-chevron { flex: none; width: 28px; height: 12px; @@ -454,7 +494,7 @@ center / 12px 12px no-repeat; } -.mdiff-band-caption { +.dv-band-caption { flex: none; padding: 1px 6px; border-radius: 4px; @@ -462,24 +502,23 @@ font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; - color: var(--mdiff-comment, var(--vscode-descriptionForeground)); + color: var(--dv-comment, var(--vscode-descriptionForeground)); background: var(--vscode-badge-background); } -.mdiff-band-label { +.dv-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); +.dv-pseudocode { + margin: 0 0 12px calc(44px + min(var(--dv-indent, 0), 8) * 0.5ch); padding: 8px 12px; - border-left: 2px solid - var(--mdiff-comment, var(--vscode-descriptionForeground)); + border-left: 2px solid var(--dv-comment, var(--vscode-descriptionForeground)); border-radius: 0 6px 6px 0; - font-family: var(--mdiff-font); + font-family: var(--dv-font); font-size: 14px; line-height: 1.55; color: var(--vscode-editor-foreground); @@ -488,8 +527,8 @@ -webkit-user-select: text; } -.mdiff-pseudocode-line { - padding-left: calc(min(var(--mdiff-indent, 0), 16) * 0.75ch + 2ch); +.dv-pseudocode-line { + padding-left: calc(min(var(--dv-indent, 0), 16) * 0.75ch + 2ch); text-indent: -2ch; overflow-wrap: anywhere; white-space: pre-wrap; @@ -502,3 +541,173 @@ ::highlight(diffr-find-current) { background-color: var(--vscode-editor-findMatchBackground); } + +/* The header's link to the file on GitHub. */ +/* The codicon stylesheet makes it a block, so the glyph is centred by its line. */ +.dv-open.codicon { + flex: none; + width: 36px; + height: 36px; + line-height: 36px; + text-align: center; + border-radius: 6px; + color: var(--vscode-descriptionForeground); + text-decoration: none; +} + +.dv-open:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.dv-open:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Split: each segment between bands is two panes that scroll sideways apart (diffView.ts keeps + a file's segments level), or, wrapped, one grid whose rows wrap both sides to one height. */ +.dv-body:has(> .dv-rows.is-split) { + overflow-x: hidden; +} + +.dv-rows.is-split { + display: block; + width: 100%; +} + +.dv-seg { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.dv-pane { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: thin; +} + +.dv-pane.is-lhs, +.dv-seg.is-grid > [data-side="lhs"] { + border-right: 1px solid var(--vscode-panel-border); +} + +.dv-pane-rows { + display: inline-block; + min-width: 100%; + vertical-align: top; +} + +.dv-seg.is-grid > .dv-row { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Where one side has no line beside the other's: hatched, as the diff editor draws it. */ +.dv-filler { + min-height: var(--dv-line); + background: repeating-linear-gradient( + -45deg, + transparent 0 5px, + var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px + ); +} + +/* A selection stays on the side it began on. */ +.dv[data-selecting="lhs"] [data-side="rhs"], +.dv[data-selecting="rhs"] [data-side="lhs"] { + user-select: none; + -webkit-user-select: none; +} + +/* Where `zj`/`zk` moved the fold commands' place. */ +.dv-row.is-caret { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* A desktop: the editor's 12px code at Monaco's 18px rows, and controls sized for a pointer. The + gutter folds; the code is left to select. */ +.dv:not(.is-compact) { + --dv-line: 18px; + padding-bottom: 40vh; +} + +.dv:not(.is-compact) .dv-rows { + font-size: 12px; +} + +.dv:not(.is-compact) .dv-header { + min-height: 34px; + padding: 2px 4px 2px 12px; +} + +.dv:not(.is-compact) .dv-header .review-viewed-check { + margin: 0 10px 0 4px; +} + +.dv:not(.is-compact) .dv-header .review-viewed-check::before { + inset: -8px; +} + +.dv:not(.is-compact) .dv-open { + width: 26px; + height: 26px; +} + +.dv:not(.is-compact) .dv-header:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.dv:not(.is-compact) .dv-row.has-fold { + cursor: auto; +} + +.dv:not(.is-compact) .dv-rows:not(.is-split) .dv-gutter { + width: calc(var(--dv-digits, 3) * 2ch + 44px); +} + +.dv:not(.is-compact) .dv-rows:not(.is-split) .dv-lineno { + min-width: calc(var(--dv-digits, 3) * 1ch); + margin-left: 8px; + text-align: right; +} + +.dv:not(.is-compact) .dv-row.has-fold .dv-gutter { + cursor: pointer; +} + +.dv:not(.is-compact) .dv-row.has-fold .dv-gutter:hover { + color: var(--vscode-editorLineNumber-activeForeground); +} + +.dv:not(.is-compact) .dv-row.has-fold:active { + background-color: transparent; +} + +.dv:not(.is-compact) .dv-band, +.dv:not(.is-compact) .dv-band-title { + min-height: 28px; +} + +.dv:not(.is-compact) .dv-band { + font-size: 12px; +} + +.dv:not(.is-compact) .dv-band:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.dv:not(.is-compact) .dv-pseudocode { + margin: 0 0 8px calc(40px + min(var(--dv-indent, 0), 8) * 0.5ch); + padding: 6px 12px; + font-size: 12px; + line-height: 1.5; +} + +.dv:not(.is-compact) .dv-pill { + font-size: 11px; +} diff --git a/apps/diffr-web/src/mobileDiff.ts b/apps/diffr-web/src/diffView.ts similarity index 51% rename from apps/diffr-web/src/mobileDiff.ts rename to apps/diffr-web/src/diffView.ts index ff4ac6693..f2acad3d0 100644 --- a/apps/diffr-web/src/mobileDiff.ts +++ b/apps/diffr-web/src/diffView.ts @@ -1,29 +1,50 @@ /** - * 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. + * The comparison: every file as plain rows the browser scrolls, selects and finds in natively, + * unified or split. diffr's folds are the session's: 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. On a phone every fold control is the full row or band, for a thumb; on a desktop the + * gutter folds, so a click in the code is free to select, and the file tree sits beside the list. */ 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 type { URI } from "vs/base/common/uri.js"; import { FontStyle, TokenMetadata, } from "vs/editor/common/encodedTokenAttributes.js"; +import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; import { showLineNumbers } from "./display.js"; -import "./mobileDiff.css"; -import type { ReviewDiffProgress, ReviewDiffProgressFile } from "./protocol.js"; +import "./diffView.css"; +import { FilesSidebar } from "./filesSidebar.js"; +import { nestedIds } from "./folds.js"; +import type { + ReviewDiffFileWire, + ReviewDiffProgress, + ReviewDiffProgressFile, +} from "./protocol.js"; import { ReviewViewedCheckbox } from "./review/browser/reviewTooltip.js"; +import { + type SplitCell, + type SplitItem, + type UnifiedBand, + type UnifiedItem, + type UnifiedLine, + sourceLines, + splitItems, + unifiedItems, +} from "./review/common/reviewDiffRows.js"; import { type ReviewFileCounts, + type StructuralFoldable, type StructuralSource, type StructuralSyntaxSpan, type StructuralTextDiff, binarySizeLabel, isBinaryCounts, + structuralFoldables, structuralHighlights, utf16Column, } from "./review/common/reviewStructuralDiff.js"; @@ -32,18 +53,12 @@ import { 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 { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.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"; +import { mobileViewport } from "./viewport.js"; /** Rows per block the browser may skip laying out while it is off screen. */ const CHUNK = 120; @@ -61,8 +76,15 @@ const PLACEHOLDER_ROWS = 12; const LOADING = "Loading diff…"; +/** What drawing a file's lines reads: each side's text, syntax and changed spans by line. */ +interface RowContext { + readonly text: Record<"lhs" | "rhs", string[]>; + readonly syntax: Record<"lhs" | "rhs", Map>; + readonly changed: Record<"lhs" | "rhs", Map>; +} + /** One find match, as find.ts counts: 1-based line and column. */ -export interface MobileFindMatch { +export interface FindMatch { path: string; side: "original" | "modified"; line: number; @@ -71,7 +93,7 @@ export interface MobileFindMatch { } interface FileView { - readonly entry: ReviewFilesEditorEntry; + readonly entry: DiffEntry; readonly section: HTMLElement; readonly header: HTMLElement; readonly name: HTMLElement; @@ -91,17 +113,47 @@ interface FileView { /** Its body is drawn; otherwise a placeholder holds its last height. */ built: boolean; near: boolean; + /** How far each side of the split view is scrolled sideways, kept across its segments. */ + readonly scrolled: { lhs: number; rhs: number }; + /** Its body's blocks of rows, while it is drawn. */ + chunks: Chunk[]; +} + +/** One changed file of the comparison. */ +export interface DiffEntry { + readonly file: ReviewDiffFileWire; + /** Where its header's link opens the file. */ + readonly goToFileResource: URI; } -export class MobileDiffView extends Disposable { +/** A block of a file's rows, drawn only near the screen. */ +interface Chunk { + readonly element: HTMLElement; + readonly file: FileView; + /** The first and last line it holds on each side. */ + readonly lines: Record<"lhs" | "rhs", [number, number] | undefined>; + /** Whether a run of changes starts in it. */ + readonly changes: boolean; + built: boolean; + readonly draw: (into: HTMLElement) => void; +} + +export class DiffView 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. */ + /** A file still waiting came on screen, or the reader picked it in the tree. */ readonly onDidRequestFile = this._onDidRequestFile.event; + private readonly host: HTMLElement; private readonly root: HTMLElement; + private readonly sidebar: FilesSidebar; + private sidebarHidden = false; + /** Drawn for a phone: unified, with every fold control a thumb's size. */ + private compact = mobileViewport.matches; + private split: boolean; + private wrapped = false; private readonly status: HTMLElement; private readonly files = new Map(); private readonly bySection = new WeakMap(); @@ -109,11 +161,14 @@ export class MobileDiffView extends Disposable { private progress: ReviewDiffProgress | undefined; private readonly build: IntersectionObserver; private readonly release: IntersectionObserver; + private readonly chunkBuild: IntersectionObserver; + private readonly chunkRelease: IntersectionObserver; + private readonly chunkOf = new WeakMap(); 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 find: { matches: FindMatch[]; current: number } | undefined; private reveal: { cancel(): void } | undefined; constructor( @@ -121,13 +176,42 @@ export class MobileDiffView extends Disposable { private readonly session: StructuralDiffSession, private readonly onToggleViewed: (path: string) => void, wordWrap: IObservable, + private readonly layout: ReviewDiffLayoutSetting, + instantiation: IInstantiationService, ) { super(); - this.root = container.appendChild(element("div", "mdiff")); + this.host = container.appendChild(element("div", "dv-host")); + this.sidebar = this._register(new FilesSidebar(instantiation)); + this.host.append(this.sidebar.element); + this.root = this.host.appendChild(element("div", "dv")); this.root.tabIndex = -1; - this.status = this.root.appendChild(element("div", "mdiff-status")); + this.status = this.root.appendChild(element("div", "dv-status")); this.status.setAttribute("role", "status"); - this._register(toDisposable(() => this.root.remove())); + this.split = this.wantsSplit(); + this.applyScreen(); + this._register(toDisposable(() => this.host.remove())); + + this._register( + this.sidebar.onDidOpenFile((path) => { + this._onDidRequestFile.fire(path); + this.revealFile(path); + }), + ); + this._register( + this.onDidScroll(() => this.sidebar.setActiveFile(this.activePath)), + ); + + const screen = () => { + this.compact = mobileViewport.matches; + this.applyScreen(); + this.relayout(); + }; + + mobileViewport.addEventListener("change", screen); + this._register( + toDisposable(() => mobileViewport.removeEventListener("change", screen)), + ); + this._register(layout.onDidChange(() => this.relayout())); this.build = new IntersectionObserver( (entries) => this.preserve(() => this.onBuild(entries)), @@ -137,6 +221,27 @@ export class MobileDiffView extends Disposable { (entries) => this.onRelease(entries), { root: this.root, rootMargin: RELEASE_MARGIN }, ); + this.chunkBuild = new IntersectionObserver( + (entries) => + this.preserve(() => { + for (const entry of entries) { + const chunk = this.chunkOf.get(entry.target); + + if (entry.isIntersecting && chunk) this.fillChunk(chunk); + } + }), + { root: this.root, rootMargin: BUILD_MARGIN }, + ); + this.chunkRelease = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const chunk = this.chunkOf.get(entry.target); + + if (!entry.isIntersecting && chunk) this.emptyChunk(chunk); + } + }, + { root: this.root, rootMargin: RELEASE_MARGIN }, + ); this.request = new IntersectionObserver( (entries) => { for (const entry of entries) { @@ -152,6 +257,8 @@ export class MobileDiffView extends Disposable { toDisposable(() => { this.build.disconnect(); this.release.disconnect(); + this.chunkBuild.disconnect(); + this.chunkRelease.disconnect(); this.request.disconnect(); this.reveal?.cancel(); cancelAnimationFrame(this.scrollFrame); @@ -175,20 +282,48 @@ export class MobileDiffView extends Disposable { ); this.root.addEventListener( "pointerdown", - () => { + (event) => { this.pressedOverSelection = !( window.getSelection()?.isCollapsed ?? true ); this.reveal?.cancel(); + + // A selection stays on the side it starts on, as each side reads on its own. + const side = + event.target instanceof Element && + event.target.closest("[data-side]")?.dataset.side; + + if (side) this.root.dataset.selecting = side; }, { passive: true }, ); + // Each side of a split file scrolls sideways as one, across the bands that break it up. + this.root.addEventListener( + "scroll", + (event) => { + const pane = event.target; + + if (!(pane instanceof HTMLElement) || !pane.matches(".dv-pane")) return; + // SAFETY: renderSplit gives every pane a side of "lhs" or "rhs". + const side = pane.dataset.side as "lhs" | "rhs"; + const file = this.bySection.get(pane.closest(".dv-file")!); + + if (!file || file.scrolled[side] === pane.scrollLeft) return; + file.scrolled[side] = pane.scrollLeft; + + for (const other of file.body.querySelectorAll( + `.dv-pane[data-side="${side}"]`, + )) + if (other !== pane) other.scrollLeft = pane.scrollLeft; + }, + { capture: true, 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.target.matches(".dv-band") ) { event.preventDefault(); this.onClick(event); @@ -196,19 +331,20 @@ export class MobileDiffView extends Disposable { }); const resize = new ResizeObserver(() => - this.root.style.setProperty( - "--mdiff-width", - `${this.root.clientWidth}px`, - ), + this.root.style.setProperty("--dv-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)), - ), + autorun((reader) => { + this.wrapped = wordWrap.read(reader); + this.root.classList.toggle("is-wrapped", this.wrapped); + + // Split, a wrapped file is one grid and an unwrapped one two panes: it is drawn again. + if (this.split) this.relayout(); + }), ); this._register( autorun((reader) => @@ -244,9 +380,9 @@ export class MobileDiffView extends Disposable { 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(`--dv-${role}`, color?.toString() ?? ""); this.root.style.setProperty( - `--mdiff-${role}-style`, + `--dv-${role}-style`, TokenMetadata.getFontStyle(metadata) & FontStyle.Italic ? "italic" : "normal", @@ -254,7 +390,38 @@ export class MobileDiffView extends Disposable { } } - startLoading(entries: readonly ReviewFilesEditorEntry[]): void { + /** Split as the setting says, except on a phone, which has no room for two sides. */ + private wantsSplit(): boolean { + return !this.compact && this.layout.get() === "split"; + } + + private applyScreen(): void { + this.root.classList.toggle("is-compact", this.compact); + this.sidebar.hidden = this.compact || this.sidebarHidden; + } + + /** Draw the drawn files again, for a new layout, keeping the reader's row in place. */ + private relayout(): void { + const split = this.wantsSplit(); + const changed = split !== this.split; + this.split = split; + + if (!changed && !split) return; + this.preserve(() => { + for (const file of this.order) if (file.built) this.renderBody(file); + }); + } + + startLoading(entries: readonly DiffEntry[]): void { + this.sidebar.setFiles( + Array.from( + new Map(entries.map((entry) => [entry.file.path, entry.file])).values(), + ), + ); + + for (const entry of entries) + this.sidebar.setFileState(entry.file.path, "loading"); + for (const entry of entries) { if (this.files.has(entry.file.path)) continue; const file = this.createFile(entry); @@ -270,35 +437,42 @@ export class MobileDiffView extends Disposable { this.showStatus(); } - private createFile(entry: ReviewFilesEditorEntry): FileView { + private createFile(entry: DiffEntry): FileView { const path = entry.file.path; - const section = element("section", "mdiff-file"); + const section = element("section", "dv-file"); section.dataset.path = path; - const header = section.appendChild(element("header", "mdiff-header")); + const header = section.appendChild(element("header", "dv-header")); header.setAttribute("role", "button"); header.tabIndex = 0; - const title = header.appendChild(element("span", "mdiff-header-title")); + const title = header.appendChild(element("span", "dv-header-title")); const at = path.lastIndexOf("/"); - const name = title.appendChild(element("span", "mdiff-header-path")); + const name = title.appendChild(element("span", "dv-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))); + name.append(element("span", "dv-header-dir", path.slice(0, at + 1))); + name.append(element("span", "dv-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 note = title.appendChild(element("span", "dv-header-note")); - const countsNode = header.appendChild( - element("span", "mdiff-header-counts"), - ); + const countsNode = header.appendChild(element("span", "dv-header-counts")); const viewed = new ReviewViewedCheckbox(document, () => this.onToggleViewed(path), ); + const open = header.appendChild( + element("a", "dv-open codicon codicon-link-external"), + ); + + open.href = entry.goToFileResource.toString(true); + open.target = "_blank"; + open.rel = "noopener"; + open.title = "Open file on GitHub"; + open.setAttribute("aria-label", `Open ${path} on GitHub`); header.append(viewed.element); - const body = section.appendChild(element("div", "mdiff-body")); + const body = section.appendChild(element("div", "dv-body")); const file: FileView = { entry, @@ -314,6 +488,8 @@ export class MobileDiffView extends Disposable { collapseSet: false, built: false, near: false, + scrolled: { lhs: 0, rhs: 0 }, + chunks: [], }; this.renderHeader(file); @@ -327,6 +503,7 @@ export class MobileDiffView extends Disposable { if (!file) return; file.state = error; + this.sidebar.setFileState(path, error ? "error" : undefined, error); this.preserve(() => { this.applyViewed(file); this.renderHeader(file); @@ -362,6 +539,7 @@ export class MobileDiffView extends Disposable { if (!file) return; file.counts = counts; + this.sidebar.setCounts(path, counts); this.renderHeader(file); } @@ -380,12 +558,15 @@ export class MobileDiffView extends Disposable { } listOnly(paths: ReadonlySet | undefined): void { + this.sidebar.listOnly(paths); + for (const file of this.order) file.section.hidden = !!paths && !paths.has(file.entry.file.path); } setProgress(progress: ReviewDiffProgress): void { this.progress = progress; + this.sidebar.setProgress(progress); this.preserve(() => { for (const file of this.order) { file.progress = progress.files.find( @@ -446,11 +627,10 @@ export class MobileDiffView extends Disposable { const removed = remaining?.deletions ?? counts?.removed; file.countsNode.replaceChildren(); - if (added) - file.countsNode.append(element("span", "mdiff-added", `+${added}`)); + if (added) file.countsNode.append(element("span", "dv-added", `+${added}`)); if (removed) - file.countsNode.append(element("span", "mdiff-removed", `−${removed}`)); + file.countsNode.append(element("span", "dv-removed", `−${removed}`)); file.viewed.update(file.progress?.state, entry.file.path, !file.progress); file.viewed.element.hidden = entry.file.status === "unchanged"; } @@ -459,6 +639,7 @@ export class MobileDiffView extends Disposable { private renderBody(file: FileView): void { const diff = this.session.getTextDiff(file.entry.file.path); file.body.classList.remove("is-message"); + this.dropChunks(file); if (file.state || !diff || file.collapsed) { file.built = false; @@ -491,8 +672,10 @@ export class MobileDiffView extends Disposable { file.built = true; file.body.style.height = ""; - file.body.replaceChildren(this.renderDiff(file.entry.file.path, diff)); - this.applyFind(); + file.body.replaceChildren( + this.renderDiff(file.entry.file.path, diff, file), + ); + this.fillNear(file); } private onBuild(entries: IntersectionObserverEntry[]): void { @@ -517,63 +700,320 @@ export class MobileDiffView extends Disposable { if (!file.built) continue; file.body.style.height = `${file.body.offsetHeight}px`; file.built = false; + this.dropChunks(file); file.body.replaceChildren(); } } - private renderDiff(path: string, diff: StructuralTextDiff): HTMLElement { - const items = unifiedItems(diff, (id) => - this.session.isRegionCollapsed(path, id), - ); + /** A file's blocks, ready to draw when they near the screen. */ + private renderDiff( + path: string, + diff: StructuralTextDiff, + file: FileView, + ): HTMLElement { + const collapsed = (id: number) => this.session.isRegionCollapsed(path, id); - const text = { - lhs: sourceLines(diff.lhs?.text), - rhs: sourceLines(diff.rhs?.text), + const context: RowContext = { + text: { + lhs: sourceLines(diff.lhs?.text), + rhs: sourceLines(diff.rhs?.text), + }, + syntax: { lhs: syntaxByLine(diff.lhs), rhs: syntaxByLine(diff.rhs) }, + changed: changedByLine(diff), }; - const syntax = { lhs: syntaxByLine(diff.lhs), rhs: syntaxByLine(diff.rhs) }; - const changed = changedByLine(diff); - const rows = element("div", "mdiff-rows"); + const rows = element("div", this.split ? "dv-rows is-split" : "dv-rows"); rows.style.setProperty( - "--mdiff-digits", - String(String(Math.max(text.lhs.length, text.rhs.length)).length), + "--dv-digits", + String( + String(Math.max(context.text.lhs.length, context.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" + if (this.split) { + const items = splitItems(diff, collapsed); + let inChange = false; + + const starts = items.map((item) => { + if (item.kind === "band") { + inChange = false; + + return item.change !== "unchanged"; + } + + const changed = + item.left?.change === "removed" || item.right?.change === "added"; + + const starts = changed && !inChange; + inChange = changed; + + return starts; + }); + + this.planChunks( + rows, + file, + items, + starts, + (item) => + item.kind === "band" + ? {} + : { lhs: item.left?.line, rhs: item.right?.line }, + (into, slice, sliceStarts) => + this.fillSplit(into, slice, sliceStarts, context), + ); + } else { + const items = unifiedItems(diff, collapsed); + + const starts = items.map((item, index) => { + const previous = items[index - 1]; + + return item.kind === "line" ? item.change !== "context" && - (previous?.kind !== "line" || previous.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), + this.planChunks( + rows, + file, + items, + starts, + (item) => + item.kind === "band" ? {} : { lhs: item.base, rhs: item.head }, + (into, slice, sliceStarts) => + slice.forEach((item, index) => + into.append( + item.kind === "band" + ? this.renderBand(item, sliceStarts[index]!) + : this.renderLine(item, context, sliceStarts[index]!), + ), + ), ); - previous = item; - }); + } - if (!items.length) - rows.append(element("div", "mdiff-empty", "No changes to show")); + if (!file.chunks.length) + rows.append(element("div", "dv-empty", "No changes to show")); return rows; } + /** + * Cut `items` into blocks of rows, each held at its estimated height until it nears the screen, + * then drawn, and emptied again, at its measured height, once far from it. A file of thousands + * of rows draws only the ones around the reader. + */ + private planChunks( + rows: HTMLElement, + file: FileView, + items: readonly T[], + starts: readonly boolean[], + linesOf: (item: T) => { lhs?: number; rhs?: number }, + draw: ( + into: HTMLElement, + slice: readonly T[], + starts: readonly boolean[], + ) => void, + ): void { + const line = this.compact ? 22 : 18; + const band = this.compact ? 44 : 28; + const pseudocode = this.compact ? 22 : 18; + + for (let first = 0; first < items.length; first += CHUNK) { + const slice = items.slice(first, first + CHUNK); + const sliceStarts = starts.slice(first, first + CHUNK); + const lines: Chunk["lines"] = { lhs: undefined, rhs: undefined }; + let height = 0; + + for (const item of slice) { + height += + item.kind === "band" + ? band + + (item.detail.length ? item.detail.length * pseudocode + 24 : 0) + : line; + const at = linesOf(item); + + for (const side of ["lhs", "rhs"] as const) { + const value = at[side]; + + if (value === undefined) continue; + const range = lines[side]; + lines[side] = range + ? [Math.min(range[0], value), Math.max(range[1], value)] + : [value, value]; + } + } + + const node = rows.appendChild(element("div", "dv-chunk")); + node.style.height = `${height}px`; + node.style.containIntrinsicBlockSize = `auto ${height}px`; + + const chunk: Chunk = { + element: node, + file, + lines, + changes: sliceStarts.some(Boolean), + built: false, + draw: (into) => draw(into, slice, sliceStarts), + }; + + file.chunks.push(chunk); + this.chunkOf.set(node, chunk); + this.chunkBuild.observe(node); + this.chunkRelease.observe(node); + } + } + + private fillChunk(chunk: Chunk): void { + if (chunk.built) return; + chunk.built = true; + chunk.element.style.height = ""; + chunk.draw(chunk.element); + + for (const pane of chunk.element.querySelectorAll(".dv-pane")) + pane.scrollLeft = + chunk.file.scrolled[pane.dataset.side === "rhs" ? "rhs" : "lhs"]; + this.applyFind(); + } + + private emptyChunk(chunk: Chunk): void { + if (!chunk.built) return; + chunk.element.style.height = `${chunk.element.offsetHeight}px`; + chunk.built = false; + chunk.element.replaceChildren(); + } + + /** Draw a file's blocks that are on or near the screen now, so it never shows empty. */ + private fillNear(file: FileView): void { + const view = this.root.getBoundingClientRect(); + const margin = this.root.clientHeight * 1.5; + + for (const chunk of file.chunks) { + const bounds = chunk.element.getBoundingClientRect(); + + if ( + bounds.bottom > view.top - margin && + bounds.top < view.bottom + margin + ) + this.fillChunk(chunk); + } + } + + /** Draw the block that holds one side's 0-based `line`. */ + private fillLine(file: FileView, side: "lhs" | "rhs", line: number): void { + const chunk = file.chunks.find((chunk) => { + const range = chunk.lines[side]; + + return range && range[0] <= line && line <= range[1]; + }); + + if (chunk && !chunk.built) this.preserve(() => this.fillChunk(chunk)); + } + + private dropChunks(file: FileView): void { + for (const chunk of file.chunks) { + this.chunkBuild.unobserve(chunk.element); + this.chunkRelease.unobserve(chunk.element); + } + + file.chunks = []; + } + + /** + * Split rows, in segments between bands. Unwrapped, a segment is two panes that scroll sideways + * apart, kept level with the file's other segments; wrapped, it is one grid, so the two sides of + * a row wrap to the same height. + */ + private fillSplit( + into: HTMLElement, + items: readonly SplitItem[], + starts: readonly boolean[], + context: RowContext, + ): void { + let segment: ((left: HTMLElement, right: HTMLElement) => void) | undefined; + + const open = () => { + if (this.wrapped) { + const grid = into.appendChild(element("div", "dv-seg is-grid")); + + return (left: HTMLElement, right: HTMLElement) => + grid.append(left, right); + } + + const seg = into.appendChild(element("div", "dv-seg")); + + const side = (name: "lhs" | "rhs") => { + const pane = seg.appendChild(element("div", `dv-pane is-${name}`)); + pane.dataset.side = name; + + return pane.appendChild(element("div", "dv-pane-rows")); + }; + + const [left, right] = [side("lhs"), side("rhs")]; + + return (cellLeft: HTMLElement, cellRight: HTMLElement) => { + left.append(cellLeft); + right.append(cellRight); + }; + }; + + const cell = ( + cell: SplitCell | undefined, + side: "lhs" | "rhs", + starts: boolean, + ) => { + if (!cell) { + const filler = element("div", "dv-filler"); + filler.dataset.side = side; + filler.classList.toggle("is-change-start", starts); + + return filler; + } + + return this.renderLine( + { + kind: "line", + change: cell.change, + tinted: cell.tinted, + base: side === "lhs" ? cell.line : undefined, + head: side === "rhs" ? cell.line : undefined, + side, + fold: cell.fold, + pill: cell.pill, + }, + context, + starts, + side, + ); + }; + + items.forEach((item, index) => { + if (item.kind === "band") { + segment = undefined; + into.append(this.renderBand(item, starts[index]!)); + + return; + } + + segment ??= open(); + segment( + cell(item.left, "lhs", starts[index]!), + cell(item.right, "rhs", false), + ); + }); + } + + /** One line; `side`, in the split view, the side it is drawn on. */ private renderLine( line: UnifiedLine, - text: Record<"lhs" | "rhs", string[]>, - syntax: Record<"lhs" | "rhs", Map>, - changed: Record<"lhs" | "rhs", Map>, + { text, syntax, changed }: RowContext, startsChange: boolean, + side?: "lhs" | "rhs", ): HTMLElement { - const row = element("div", `mdiff-row is-${line.change}`); + const row = element("div", `dv-row is-${line.change}`); + + if (side) row.dataset.side = side; if (line.tinted) row.classList.add("is-tinted"); @@ -583,7 +1023,7 @@ export class MobileDiffView extends Disposable { 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 gutter = row.appendChild(element("span", "dv-gutter")); const fold = line.fold ?? @@ -594,7 +1034,7 @@ export class MobileDiffView extends Disposable { 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")); + const button = gutter.appendChild(element("button", "dv-chevron")); button.type = "button"; button.setAttribute("aria-expanded", String(!fold.collapsed)); button.setAttribute( @@ -603,8 +1043,23 @@ export class MobileDiffView extends Disposable { ); } - gutter.append(element("span", "mdiff-lineno", String(number + 1))); - const code = row.appendChild(element("span", "mdiff-code")); + // Unified, a desktop has room for both sides' numbers, so a removed line's base number never + // sits among the head's. + if (!side && !this.compact) + gutter.append( + element( + "span", + "dv-lineno", + line.base === undefined ? "" : String(line.base + 1), + ), + element( + "span", + "dv-lineno", + line.head === undefined ? "" : String(line.head + 1), + ), + ); + else gutter.append(element("span", "dv-lineno", String(number + 1))); + const code = row.appendChild(element("span", "dv-code")); const source = text[line.side][number] ?? ""; code.append( renderCode( @@ -616,48 +1071,47 @@ export class MobileDiffView extends Disposable { if (line.pill) { const pill = code.appendChild( - element("span", "mdiff-pill", `⋯ ${line.pill.label}`), + element("span", "dv-pill", `⋯ ${line.pill.label}`), ); pill.setAttribute("aria-hidden", "true"); if (line.pill.closer) - code.append(element("span", "mdiff-closer", ` ${line.pill.closer}`)); + code.append(element("span", "dv-closer", ` ${line.pill.closer}`)); } return row; } private renderBand(band: UnifiedBand, startsChange: boolean): HTMLElement { - const node = element("div", `mdiff-band kind-${band.change}`); + const node = element("div", `dv-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)); + node.style.setProperty("--dv-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")); + const title = node.appendChild(element("span", "dv-band-title")); + title.append(element("span", "dv-band-chevron")); - if (summary) - title.append(element("span", "mdiff-band-caption", "Pseudocode")); - title.append(element("span", "mdiff-band-label", band.title)); + if (summary) title.append(element("span", "dv-band-caption", "Pseudocode")); + title.append(element("span", "dv-band-label", band.title)); if (summary) { - const detail = node.appendChild(element("div", "mdiff-pseudocode")); + const detail = node.appendChild(element("div", "dv-pseudocode")); for (const text of band.detail) { const line = detail.appendChild( - element("div", "mdiff-pseudocode-line", text.trimStart() || " "), + element("div", "dv-pseudocode-line", text.trimStart() || " "), ); line.style.setProperty( - "--mdiff-indent", + "--dv-indent", String(text.length - text.trimStart().length), ); } @@ -673,14 +1127,14 @@ export class MobileDiffView extends Disposable { const overSelection = this.pressedOverSelection; this.pressedOverSelection = false; - if (target.closest(".review-viewed-check")) return; - const section = target.closest(".mdiff-file"); + if (target.closest(".review-viewed-check, .dv-open")) return; + const section = target.closest(".dv-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 (target.closest(".dv-header")) { if (file.state || file.entry.file.binary || isBinaryCounts(file.counts)) return; this.setFileCollapsed(path, !file.collapsed); @@ -691,7 +1145,7 @@ export class MobileDiffView extends Disposable { // 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"); + const band = target.closest(".dv-band"); if (band) { this.session.setRegionCollapsed(path, Number(band.dataset.fold), false); @@ -699,7 +1153,17 @@ export class MobileDiffView extends Disposable { return; } - const row = target.closest(".mdiff-row.has-fold"); + const line = target.closest(".dv-row"); + + if (line) this.focusRow(line, false); + + // A phone folds from anywhere on the row; a desktop from its gutter, leaving the code to + // select and click. + const row = target + .closest( + this.compact ? ".dv-row.has-fold" : ".dv-row.has-fold .dv-gutter", + ) + ?.closest(".dv-row"); if (row) this.session.setRegionCollapsed( @@ -737,24 +1201,22 @@ export class MobileDiffView extends Disposable { .find( (node) => this.root.contains(node) && - node.closest(".mdiff-row, .mdiff-band, .mdiff-file"), + node.closest(".dv-row, .dv-band, .dv-file"), ); - const node = hit?.closest( - ".mdiff-row, .mdiff-band, .mdiff-file", - ); + const node = hit?.closest(".dv-row, .dv-band, .dv-file"); - const section = node?.closest(".mdiff-file"); + const section = node?.closest(".dv-file"); if (!node || !section) return undefined; const path = section.dataset.path!; - const selector = node.classList.contains("mdiff-row") + const selector = node.classList.contains("dv-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}"]` + ? `.dv-row[data-head="${node.dataset.head}"]` + : `.dv-row[data-base="${node.dataset.base}"]` + : node.classList.contains("dv-band") + ? `.dv-band[data-fold="${node.dataset.fold}"]` : undefined; const offset = node.getBoundingClientRect().top - top; @@ -817,6 +1279,7 @@ export class MobileDiffView extends Disposable { if (!file.built) this.renderBody(file); this.settle(() => { + this.fillLine(file, side === "original" ? "lhs" : "rhs", line - 1); const row = this.rowFor(file, side, line - 1); if (!row) return false; @@ -838,12 +1301,10 @@ export class MobileDiffView extends Disposable { private rowFor(file: FileView, side: "original" | "modified", line: number) { return side === "original" ? (file.body.querySelector( - `.mdiff-row.is-removed[data-base="${line}"]`, + `.dv-row.is-removed[data-base="${line}"]`, ) ?? - file.body.querySelector( - `.mdiff-row[data-base="${line}"]`, - )) - : file.body.querySelector(`.mdiff-row[data-head="${line}"]`); + file.body.querySelector(`.dv-row[data-base="${line}"]`)) + : file.body.querySelector(`.dv-row[data-head="${line}"]`); } /** @@ -898,21 +1359,37 @@ export class MobileDiffView extends Disposable { this.preserve(() => this.renderBody(file)); } - const starts = [ - ...file.body.querySelectorAll(".is-change-start"), - ]; + // Only blocks with a change past the reading line are drawn to look in them. + const chunks = file.chunks.filter((chunk) => { + if (!chunk.changes) return false; + const bounds = chunk.element.getBoundingClientRect(); - const target = - direction === "next" - ? starts.find((node) => node.getBoundingClientRect().top - line > 8) - : starts - .reverse() - .find((node) => node.getBoundingClientRect().top - line < -8); + return direction === "next" + ? bounds.bottom - line > 8 + : bounds.top - line < -8; + }); - if (target) { - this.root.scrollTop += target.getBoundingClientRect().top - line; + if (direction === "previous") chunks.reverse(); - return; + for (const chunk of chunks) { + this.preserve(() => this.fillChunk(chunk)); + + const starts = [ + ...chunk.element.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; + } } } } @@ -930,7 +1407,7 @@ export class MobileDiffView extends Disposable { } /** Mark find's matches in the files drawn, with the CSS Custom Highlight API where it exists. */ - showFindMatches(matches: MobileFindMatch[], current: number): void { + showFindMatches(matches: FindMatch[], current: number): void { this.find = matches.length ? { matches, current } : undefined; this.applyFind(); } @@ -949,7 +1426,7 @@ export class MobileDiffView extends Disposable { if (!file?.built) return; const row = this.rowFor(file, match.side, match.line - 1); - const code = row?.querySelector(".mdiff-code"); + const code = row?.querySelector(".dv-code"); const range = code && textRange(code, match.column - 1, match.length); if (!range) return; @@ -970,9 +1447,148 @@ export class MobileDiffView extends Disposable { : ""; } - // 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 {} + /** Show or hide the desktop's file tree; a phone's is a sheet (mobileFiles.ts). */ + toggleFileTree(): void { + this.sidebarHidden = !this.sidebarHidden; + this.applyScreen(); + } + + /** + * The diffr TUI's `z` commands, at the row the reader last clicked or reached with `zj`/`zk`, + * else the row on the reading line: `a` toggles the fold there, `o` opens and `c` closes it (in + * capitals, with every fold inside it), and `j`/`k` go to the next or previous fold. + */ + foldCommand(command: string): void { + const row = this.currentRow(); + const section = row?.closest(".dv-file"); + const path = section?.dataset.path; + const diff = path ? this.session.getTextDiff(path) : undefined; + + if (!row || !path || !diff) return; + + const side = + row.dataset.head !== undefined && row.dataset.side !== "lhs" + ? "rhs" + : "lhs"; + + const line = Number(side === "rhs" ? row.dataset.head : row.dataset.base); + + const foldables = structuralFoldables( + side === "rhs" ? diff.rhs : diff.lhs, + ).filter((foldable) => foldable.chevron); + + if (command === "j" || command === "k") { + const rows = [ + ...this.root.querySelectorAll(".dv-row.has-fold"), + ]; + + const at = rows.indexOf(row); + + const next = + command === "j" + ? rows.find( + (candidate, index) => + index > at && + candidate.getBoundingClientRect().top > + row.getBoundingClientRect().top, + ) + : rows.findLast( + (candidate, index) => + index < at && + candidate.getBoundingClientRect().top < + row.getBoundingClientRect().top, + ); + + if (next) this.focusRow(next); + + return; + } + + const letter = command.toLowerCase(); + + const collapsed = (foldable: StructuralFoldable) => + this.session.isRegionCollapsed(path, foldable.foldStateId) ?? + foldable.collapsedByDefault; + + const foldable = + foldables.find((candidate) => candidate.line === line) ?? + foldables + .filter( + (candidate) => + candidate.rail && + !collapsed(candidate) && + candidate.rail.start <= line && + line <= candidate.rail.end, + ) + .sort( + (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), + )[0]; + + if (!foldable || !"aoc".includes(letter)) return; + const collapse = letter === "a" ? !collapsed(foldable) : letter === "c"; + const id = foldable.foldStateId; + + for (const each of command !== letter ? [id, ...nestedIds(diff, id)] : [id]) + this.session.setRegionCollapsed(path, each, collapse); + } + + private caret: { path: string; selector: string } | undefined; + + /** The row fold commands act on. */ + private currentRow(): HTMLElement | undefined { + if (this.caret) { + const row = this.files + .get(this.caret.path) + ?.body.querySelector(this.caret.selector); + + const bounds = row?.getBoundingClientRect(); + const view = this.root.getBoundingClientRect(); + + if (row && bounds && bounds.bottom > view.top && bounds.top < view.bottom) + return row; + } + + const view = this.root.getBoundingClientRect(); + + return ( + document + .elementsFromPoint( + view.left + view.width / 2, + view.top + view.height / 3, + ) + .find((node) => this.root.contains(node) && node.closest(".dv-row")) + ?.closest(".dv-row") ?? undefined + ); + } + + /** Remember `row` for the next fold command, and bring it to the reading line. */ + private focusRow(row: HTMLElement, scroll = true): void { + const path = row.closest(".dv-file")?.dataset.path; + + if (!path) return; + const side = row.dataset.side; + + const selector = + row.dataset.head !== undefined + ? `.dv-row[data-head="${row.dataset.head}"]${side ? `[data-side="${side}"]` : ""}` + : `.dv-row[data-base="${row.dataset.base}"]${side ? `[data-side="${side}"]` : ""}`; + + this.caret = { path, selector }; + + for (const old of this.root.querySelectorAll(".dv-row.is-caret")) + old.classList.remove("is-caret"); + + // A click already shows where it is; a key that moves the place marks it. + if (!scroll) return; + row.classList.add("is-caret"); + const bounds = row.getBoundingClientRect(); + const view = this.root.getBoundingClientRect(); + + if (bounds.top < view.top + 40 || bounds.bottom > view.bottom - 40) + this.root.scrollTop += bounds.top - view.top - this.root.clientHeight / 3; + } + + // Review Desktop's surface for find in its editors: these rows are no editor. codeEditor(): undefined { return undefined; } @@ -1093,9 +1709,9 @@ function renderCode( const span = element("span", undefined, piece); - if (role) span.className = `mdiff-${role}`; + if (role) span.className = `dv-${role}`; - if (tinted) span.classList.add("mdiff-changed"); + if (tinted) span.classList.add("dv-changed"); fragment.append(span); } diff --git a/apps/diffr-web/src/filesSidebar.ts b/apps/diffr-web/src/filesSidebar.ts new file mode 100644 index 000000000..76168844b --- /dev/null +++ b/apps/diffr-web/src/filesSidebar.ts @@ -0,0 +1,237 @@ +/** + * The desktop's file list beside the diff: the changed-files tree, under a line of what is left to + * review. A phone lists files in a sheet instead (mobileFiles.ts). + */ +import { Emitter } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; +import { IHoverService } from "vs/platform/hover/browser/hover.js"; +import type { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; + +import type { ReviewDiffFileWire, ReviewDiffProgress } from "./protocol.js"; + +import "./review/browser/media/review.css"; +import { ReviewChangedFilesTree } from "./review/browser/reviewChangedFilesTree.js"; +import { + REVIEW_COUNTS_PENDING_TOOLTIP, + ReviewTooltip, + reviewChangesTooltip, +} from "./review/browser/reviewTooltip.js"; +import { + type ReviewFileCounts, + isBinaryCounts, +} from "./review/common/reviewStructuralDiff.js"; +import { element } from "./ui.js"; + +const DEFAULT_WIDTH = 260; + +const MIN_WIDTH = 160; + +const MAX_WIDTH = 480; + +export class FilesSidebar extends Disposable { + readonly element = element("div", "dv-sidebar review-files-editor-tree"); + private readonly _onDidOpenFile = this._register(new Emitter()); + /** The reader picked a file in the tree. */ + readonly onDidOpenFile = this._onDidOpenFile.event; + private readonly summary = element("div", "review-files-editor-summary"); + private readonly summaryTooltip: ReviewTooltip; + private readonly tree: ReviewChangedFilesTree; + private files: readonly ReviewDiffFileWire[] = []; + private listed: ReadonlySet | undefined; + private readonly counts = new Map(); + private progress: ReviewDiffProgress | undefined; + + constructor(instantiation: IInstantiationService) { + super(); + this.element.style.width = `${DEFAULT_WIDTH}px`; + this.element.append(this.summary); + this.summary.hidden = true; + this.summaryTooltip = this._register( + new ReviewTooltip( + instantiation.invokeFunction((accessor) => accessor.get(IHoverService)), + this.summary, + ), + ); + this.tree = this._register( + instantiation.createInstance(ReviewChangedFilesTree, this.element), + ); + this._register( + this.tree.onDidOpenFile((file) => this._onDidOpenFile.fire(file.path)), + ); + + const sash = this.element.appendChild(element("div", "dv-sash")); + sash.addEventListener("pointerdown", (down) => { + const start = this.element.offsetWidth; + sash.setPointerCapture(down.pointerId); + + const move = (event: PointerEvent) => { + const width = Math.min( + MAX_WIDTH, + Math.max(MIN_WIDTH, start + event.clientX - down.clientX), + ); + + this.element.style.width = `${width}px`; + this.layout(); + }; + + sash.addEventListener("pointermove", move); + sash.addEventListener( + "lostpointercapture", + () => sash.removeEventListener("pointermove", move), + { once: true }, + ); + }); + + const size = new ResizeObserver(() => this.layout()); + size.observe(this.element); + this._register({ dispose: () => size.disconnect() }); + } + + get hidden(): boolean { + return this.element.hidden === true; + } + + set hidden(hidden: boolean) { + this.element.hidden = hidden; + + if (!hidden) this.layout(); + } + + layout(): void { + if (this.element.hidden) return; + this.tree.layout( + this.element.clientHeight - + (this.summary.hidden ? 0 : this.summary.offsetHeight), + this.element.clientWidth, + ); + } + + setFiles(files: readonly ReviewDiffFileWire[]): void { + this.files = files; + this.list(); + } + + /** List only `paths`, as a lens does; `undefined` lists every file. */ + listOnly(paths: ReadonlySet | undefined): void { + this.listed = paths; + this.list(); + } + + private list(): void { + this.tree.setFiles( + this.files.filter((file) => this.listed?.has(file.path) ?? true), + ); + this.renderSummary(); + } + + setFileState( + path: string, + state: "loading" | "error" | undefined, + message?: string, + ): void { + this.tree.setFileState(path, state, message); + } + + setCounts(path: string, counts: ReviewFileCounts): void { + this.counts.set(path, counts); + this.tree.setCounts(path, counts); + this.renderSummary(); + } + + setProgress(progress: ReviewDiffProgress): void { + this.progress = progress; + this.tree.setProgressFiles(progress.files); + this.renderSummary(); + } + + /** Follow the file on top of the diff, unless the reader scrolled the tree away from it. */ + setActiveFile(path: string | undefined): void { + this.tree.setActiveFile(path, "follow"); + } + + private renderSummary(): void { + const paths = this.files + .map((file) => file.path) + .filter((path) => this.listed?.has(path) ?? true); + + const complete = paths.every((path) => this.counts.has(path)); + const total = { added: 0, removed: 0 }; + const remaining = { added: 0, removed: 0 }; + + const progress = new Map( + this.progress?.files.map((file) => [file.path, file]), + ); + + for (const path of paths) { + const counts = this.counts.get(path); + + if (!counts || isBinaryCounts(counts)) continue; + total.added += counts.added; + total.removed += counts.removed; + remaining.added += + progress.get(path)?.remaining.additions ?? counts.added; + remaining.removed += + progress.get(path)?.remaining.deletions ?? counts.removed; + } + + const height = this.summary.offsetHeight; + this.summary.replaceChildren( + element( + "span", + "review-files-editor-summary-files", + this.listed + ? `${paths.length} of ${this.files.length} files` + : `${paths.length} files`, + ), + ); + + if (complete) { + const format = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }); + + const size = total.added + total.removed; + const viewed = Math.max(0, size - remaining.added - remaining.removed); + const percent = size ? Math.round((viewed * 100) / size) : 100; + + const bar = element( + "span", + "review-files-editor-summary-progress", + `${percent}%`, + ); + + bar.setAttribute("role", "progressbar"); + bar.setAttribute("aria-label", "Changed lines viewed"); + bar.setAttribute("aria-valuemin", "0"); + bar.setAttribute("aria-valuemax", "100"); + bar.setAttribute("aria-valuenow", String(percent)); + bar.setAttribute( + "aria-valuetext", + `${viewed} of ${size} changed lines viewed`, + ); + bar.style.setProperty("--viewed-progress", `${percent}%`); + this.summary.append( + element("span", undefined, "Remaining"), + element( + "span", + "review-files-editor-summary-added", + `+${format.format(remaining.added).toLowerCase()}`, + ), + element( + "span", + "review-files-editor-summary-removed", + `−${format.format(remaining.removed).toLowerCase()}`, + ), + bar, + ); + } else this.summary.append(element("span", undefined, "Counting changes…")); + + this.summaryTooltip.content = complete + ? reviewChangesTooltip(total.added, total.removed) + : REVIEW_COUNTS_PENDING_TOOLTIP; + this.summary.hidden = false; + + if (height !== this.summary.offsetHeight) this.layout(); + } +} diff --git a/apps/diffr-web/src/find.ts b/apps/diffr-web/src/find.ts index 4954a0813..828d1da48 100644 --- a/apps/diffr-web/src/find.ts +++ b/apps/diffr-web/src/find.ts @@ -1,10 +1,7 @@ -import { Range } from "vs/editor/common/core/range.js"; /** * ⌘/Ctrl+F: find text in every diffed file, not only the lines on screen, which is all the - * browser's own find can see of a virtualized list. A match opens the folds around it. + * browser's own find can see of files not drawn yet. A match opens the folds around it. */ -import type { IEditorDecorationsCollection } from "vs/editor/common/editorCommon.js"; - import type { Comparison } from "./comparison.js"; type Side = "original" | "modified"; @@ -73,8 +70,6 @@ export class FindBar { private current = -1; private searched: Comparison | undefined; private timer: ReturnType | undefined; - private decorations = new Map(); - private frame = 0; constructor(private readonly comparison: () => Comparison | undefined) { this.element = document.createElement("div"); @@ -283,66 +278,12 @@ export class FindBar { this.decorate(); } - /** Mark the matches in the files on screen, once their editors exist. */ + /** Mark the matches in the files drawn; the view marks the rest as it draws them. */ 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(); - - for (const match of this.matches) { - const key = `${match.side}\0${match.path}`; - const group = groups.get(key); - - if (group) group.push(match); - else groups.set(key, [match]); - } - - let frames = 0; - - const apply = () => { - const comparison = this.comparison(); - this.clearDecorations(); - - if (!comparison || this.element.hidden) return; - - for (const group of groups.values()) { - const { path, side } = group[0]!; - const editor = comparison.codeEditor(path, side); - - // Unified, a removed line is drawn outside the editor's model. - if (!editor || editor.getModel()?.uri.authority !== sideAuthority(side)) - continue; - this.decorations.set( - editor, - editor.createDecorationsCollection( - group.map((m) => ({ - range: new Range(m.line, m.column, m.line, m.column + m.length), - options: { - description: "diffr-find", - className: m === current ? "currentFindMatch" : "findMatch", - stickiness: 1, - }, - })), - ), - ); - } - - // Files render, and a reveal settles, over the next frames. - if (++frames < 20) this.frame = requestAnimationFrame(apply); - }; - - this.frame = requestAnimationFrame(apply); + this.comparison()?.showFindMatches(this.matches, this.current); } private clearDecorations(): void { this.comparison()?.showFindMatches([], -1); - - for (const collection of this.decorations.values()) collection.clear(); - this.decorations.clear(); } } - -/** The authority of each side's model URIs (comparison.ts). */ -const sideAuthority = (side: Side) => (side === "original" ? "base" : "head"); diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 53b5a8f34..97fa20cd6 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -17,7 +17,6 @@ import { FindBar } from "./find.js"; import { parseTarget, targetPath } from "./github.js"; import { renderLensBar } from "./lenses.js"; import { MobileFiles } from "./mobileFiles.js"; -import { mobileViewport } from "./nativeDiffScroll.js"; import { Panels } from "./panels.js"; import { agentPluginsSection } from "./pluginsSection.js"; import { quickOpen } from "./quickOpen.js"; @@ -31,6 +30,7 @@ import { import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; import { applyTheme } from "./theme.js"; import { actionButton, element } from "./ui.js"; +import { mobileViewport } from "./viewport.js"; const instantiation = StandaloneServices.initialize({}); @@ -87,8 +87,6 @@ let summaryNoticeDismissed = false; const body = root.appendChild(element("main", "app-body")); -const overflow = root.appendChild(element("div", "monaco-editor app-overflow")); - let engine: Engine | undefined; let comparison: Comparison | undefined; @@ -180,7 +178,6 @@ function route(): void { const current = (comparison = new Comparison( container, - overflow, target, currentEngine(), layout, @@ -460,10 +457,7 @@ window.addEventListener("popstate", route); mobileViewport.addEventListener("change", () => { narrow.set(mobileViewport.matches, undefined); - - // 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(); + renderHeader(); }); /** A `z` waiting for its fold command. */ diff --git a/apps/diffr-web/src/mobileFiles.ts b/apps/diffr-web/src/mobileFiles.ts index b5584f880..a64e99158 100644 --- a/apps/diffr-web/src/mobileFiles.ts +++ b/apps/diffr-web/src/mobileFiles.ts @@ -1,8 +1,8 @@ import { Disposable } from "vs/base/common/lifecycle.js"; import type { Comparison } from "./comparison.js"; -import { mobileViewport } from "./nativeDiffScroll.js"; import { actionButton, element } from "./ui.js"; +import { mobileViewport } from "./viewport.js"; /** A native, searchable bottom sheet; navigating still goes through the existing diff view. */ export class MobileFiles extends Disposable { diff --git a/apps/diffr-web/src/nativeDiffScroll.ts b/apps/diffr-web/src/nativeDiffScroll.ts deleted file mode 100644 index 5de0b8806..000000000 --- a/apps/diffr-web/src/nativeDiffScroll.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Dimension } from "vs/base/browser/dom.js"; -import { Disposable } from "vs/base/common/lifecycle.js"; -import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; -import type { MultiDiffEditorWidget } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; - -import { element } from "./ui.js"; - -export const mobileViewport = window.matchMedia( - "(max-width: 760px), (pointer: coarse) and (max-height: 500px)", -); - -/** Let the browser own touch inertia; Monaco still owns virtualized diffs and structural folds. */ -export class NativeDiffScroll extends Disposable { - readonly viewport = element("div", "app-diff-viewport"); - private readonly scroller = element("div", "app-native-scroll"); - private readonly extent = element("div", "app-native-scroll-extent"); - private enabled = false; - private syncing = false; - private widget: MultiDiffEditorWidget | undefined; - - constructor(parent: HTMLElement) { - super(); - this.extent.append(this.viewport); - this.scroller.append(this.extent); - parent.append(this.scroller); - this.scroller.addEventListener( - "scroll", - () => { - if (!this.enabled || this.syncing || !this.widget) return; - this.syncing = true; - const requested = this.scroller.scrollTop; - this.widget.setScrollTop(requested); - // Measuring an incoming file can move the viewport during setScrollTop. - const measured = this.widget.getScrollTop(); - - if (measured !== requested) this.scroller.scrollTop = measured; - this.syncing = false; - }, - { passive: true }, - ); - - // Monaco's document-level Gesture handler prevents native scrolling, including inertia. - for (const type of ["touchstart", "touchmove", "touchend", "touchcancel"]) { - this.scroller.addEventListener( - type, - (event) => { - if (this.enabled) event.stopPropagation(); - }, - { passive: true }, - ); - } - - // Leave fold pills and gutters to Monaco. Plain text uses the browser's selection handles. - for (const type of ["pointerdown", "mousedown", "contextmenu"]) { - this.scroller.addEventListener( - type, - (event) => { - if (!this.enabled || !(event.target instanceof Element)) return; - - if ( - event.target.closest(".view-line, pre.diff-hidden-lines-detail") && - !event.target.closest( - ".review-fold-pill, .review-fold-hint, .review-fold-closer", - ) - ) - event.stopPropagation(); - }, - true, - ); - } - - const copy = (event: ClipboardEvent) => { - if (!this.enabled) return; - const selection = window.getSelection(); - - if ( - !selection || - selection.isCollapsed || - !selection.anchorNode || - !this.viewport.contains(selection.anchorNode) - ) - return; - // Monaco renders ordinary spaces as non-breaking spaces to preserve code indentation. - event.clipboardData?.setData( - "text/plain", - selection.toString().replace(/\u00a0/g, " "), - ); - - if (event.clipboardData) { - event.preventDefault(); - event.stopPropagation(); - } - }; - - // Copy targets the focused element, which can be outside the selected code. - document.addEventListener("copy", copy, true); - this._register({ - dispose: () => document.removeEventListener("copy", copy, true), - }); - } - - attach(widget: MultiDiffEditorWidget): void { - this.widget = widget; - this._register(widget.onDidChangeContentHeight(() => this.updateExtent())); - this._register( - widget.onDidScroll(() => { - if (!this.enabled || this.syncing) return; - this.syncing = true; - this.scroller.scrollTop = widget.getScrollTop(); - this.syncing = false; - }), - ); - } - - layout(width: number, height: number): void { - const enabled = mobileViewport.matches; - const top = this.widget?.getScrollTop() ?? 0; - const enteringNative = enabled && !this.enabled; - this.enabled = enabled; - this.scroller.classList.toggle("is-native", enabled); - this.viewport.style.height = `${height}px`; - this.widget?.layout(new Dimension(width, height)); - this.updateExtent(); - - // Height changes (including Safari's collapsing toolbar) must not restart native inertia. - if (enteringNative) this.scroller.scrollTop = top; - } - - private updateExtent(): void { - this.extent.style.height = this.enabled - ? `${Math.max(this.scroller.clientHeight, this.widget?.getContentHeight() ?? 0)}px` - : "100%"; - } -} - -/** Monaco owns horizontal geometry; the browser retains vertical touch inertia. */ -export function horizontalTouchScroll(editor: ICodeEditor) { - const node = editor.getContainerDomNode(); - - let gesture: - | { - x: number; - y: number; - lastX: number; - time: number; - horizontal?: boolean; - velocity: number; - surface?: HTMLElement; - } - | undefined; - - let frame = 0; - let suppressClickUntil = 0; - - const position = (surface?: HTMLElement) => - surface ? surface.scrollLeft : editor.getScrollLeft(); - - const scrollTo = (left: number, surface?: HTMLElement) => { - if (surface) surface.scrollLeft = left; - else editor.setScrollLeft(left); - }; - - const stop = () => { - cancelAnimationFrame(frame); - frame = 0; - }; - - const start = (event: TouchEvent) => { - stop(); - suppressClickUntil = 0; - gesture = undefined; - - if ( - !mobileViewport.matches || - event.touches.length !== 1 || - !window.getSelection()?.isCollapsed - ) - return; - const touch = event.touches[0]; - gesture = { - x: touch.clientX, - y: touch.clientY, - lastX: touch.clientX, - time: performance.now(), - velocity: 0, - surface: - event.target instanceof Element - ? (event.target.closest( - "pre.diff-hidden-lines-detail", - ) ?? undefined) - : undefined, - }; - }; - - const move = (event: TouchEvent) => { - if (!gesture || event.touches.length !== 1) { - gesture = undefined; - - return; - } - - const x = event.touches[0].clientX; - const y = event.touches[0].clientY; - - if (gesture.horizontal === undefined) { - if (Math.max(Math.abs(x - gesture.x), Math.abs(y - gesture.y)) < 8) - return; - gesture.horizontal = Math.abs(x - gesture.x) > Math.abs(y - gesture.y); - } - - if (!gesture.horizontal) return; - event.preventDefault(); - const now = performance.now(); - const delta = gesture.lastX - x; - gesture.velocity = delta / Math.max(1, now - gesture.time); - scrollTo(position(gesture.surface) + delta, gesture.surface); - suppressClickUntil = now + 500; - gesture.lastX = x; - gesture.time = now; - }; - - const end = () => { - const finished = gesture; - gesture = undefined; - - if (!finished?.horizontal || performance.now() - finished.time > 100) - return; - let velocity = finished.velocity; - let last = performance.now(); - - const coast = (now: number) => { - const elapsed = Math.min(32, now - last); - last = now; - const before = position(finished.surface); - scrollTo(before + velocity * elapsed, finished.surface); - velocity *= Math.exp(-elapsed / 180); - - if ( - Math.abs(velocity) > 0.02 && - position(finished.surface) !== before && - mobileViewport.matches - ) - frame = requestAnimationFrame(coast); - }; - - frame = requestAnimationFrame(coast); - }; - - const cancel = () => { - gesture = undefined; - stop(); - }; - - const click = (event: MouseEvent) => { - if (performance.now() < suppressClickUntil) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - }; - - node.addEventListener("click", click, true); - node.addEventListener("touchstart", start, { passive: true }); - node.addEventListener("touchmove", move, { passive: false }); - node.addEventListener("touchend", end); - node.addEventListener("touchcancel", cancel); - const modelChange = editor.onDidChangeModel(cancel); - - return { - dispose() { - cancel(); - modelChange.dispose(); - node.removeEventListener("click", click, true); - node.removeEventListener("touchstart", start); - node.removeEventListener("touchmove", move); - node.removeEventListener("touchend", end); - node.removeEventListener("touchcancel", cancel); - }, - }; -} diff --git a/apps/diffr-web/src/review/browser/media/review.css b/apps/diffr-web/src/review/browser/media/review.css index a21c4fb0a..1d4c312ad 100644 --- a/apps/diffr-web/src/review/browser/media/review.css +++ b/apps/diffr-web/src/review/browser/media/review.css @@ -740,71 +740,8 @@ body unicode-bidi: plaintext; } -.review-multidiff-counts { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 4px; - font-size: 12px; - font-variant-numeric: tabular-nums; -} - -.review-multidiff-counts[hidden] { - display: none; -} - -.review-multidiff-additions { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.review-multidiff-deletions { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.review-counts-folded .review-multidiff-additions { - color: var(--vscode-descriptionForeground); -} - -.review-multidiff-open-container { - display: inline-flex; - align-items: center; - margin: 0 3px; -} - -.review-multidiff-open-container[hidden] { - display: none; -} - -.review-multidiff-open.monaco-button { - display: inline-flex; - align-items: center; - gap: 6px; - width: auto; - min-width: 0; - height: 24px; - line-height: 22px; - padding: 0 9px; - border: 1px solid - color-mix(in srgb, var(--vscode-icon-foreground) 20%, transparent); - border-radius: 5px; - background: color-mix(in srgb, var(--vscode-icon-foreground) 5%, transparent); - color: var(--vscode-icon-foreground); - font-size: 12px; - white-space: nowrap; -} - -.review-multidiff-open.monaco-button .codicon[class*="codicon-"] { - margin: 0; - font-size: 13px; -} - -.review-multidiff-open.monaco-button:hover { - background: var(--vscode-toolbar-hoverBackground); -} - .review-files-editor, -.review-files-editor-tree, -.review-files-editor-diffs { +.review-files-editor-tree { min-width: 0; min-height: 0; height: 100%; @@ -868,49 +805,6 @@ body font-weight: 600; } -.review-files-editor-diffs { - position: relative; - min-width: 0; - min-height: 0; - overflow: hidden; - background: var(--vscode-multiDiffEditor-background); -} - -.review-files-editor-diffs - .monaco-component.multiDiffEditor - .multiDiffEntry - > .header - > .header-content { - margin-top: 0; -} - -.review-files-editor-diffs - .monaco-component.multiDiffEditor - .multiDiffEntry - > .header - > .header-content - .file-path - .title { - font-size: 12px; -} - -.review-files-editor-diffs - .monaco-component.multiDiffEditor - .multiDiffEntry - > .header - > .header-content - > .actions { - padding: 0; -} - -.review-files-editor-diffs - .multiDiffEntry - > .header - > .header-content - > .actions.has-no-actions { - display: none; -} - .review-canvas-part > .review-canvas-container { display: grid; grid-template-rows: minmax(0, 1fr); @@ -926,19 +820,6 @@ body user-select: text; } -/* Hosts fixedOverflowWidgets (hover, definition) for the inline peek editors. - Lives in the workbench container — inside .monaco-workbench so theme - variables apply, outside .review-canvas-root whose container-query - containment would re-anchor and clip position: fixed widgets. The widgets - position themselves against the viewport; z-index matches the workbench's - other overflow-widget hosts (scm, keybindings editor). */ -.review-overflow-widgets { - position: fixed; - top: 0; - left: 0; - z-index: 5000; -} - /* The host carries tabindex="-1" so the canvas app can focus it programmatically (desktop-entry's mount handle calls container.focus()). It is a document surface rather than a control, and it is not tab-reachable, so suppress the @@ -948,28 +829,6 @@ body outline: none; } -.review-reveal-line { - background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent); - border-left: 2px solid var(--vscode-panel-border); -} - -/* 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; @@ -1012,33 +871,6 @@ body 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; @@ -1071,9 +903,6 @@ body animation: none; } } -.review-counts-viewed { - opacity: 0.45; -} /* The viewed box: one look for the canvas lens rows and the diff headers. The canvas mounts into this document, so inside it the canvas tokens win; @@ -1103,11 +932,7 @@ body background: var(--review-check-board); cursor: pointer; } -/* The headers give the box the 32px column their old 16px box had. */ -.review-multidiff-open-container + .review-viewed-check, -.review-diff-group > .review-viewed-check { - margin: 0 9px; -} + .review-viewed-check:hover:not(:disabled) { border-color: var(--review-check-ink); } @@ -1123,10 +948,7 @@ body .review-viewed-check.is-empty { visibility: hidden; } -/* File headers without a viewed action leave Open file at the trailing edge. */ -.review-multidiff-open-container + .review-viewed-check.is-empty { - display: none; -} + .review-viewed-check[aria-checked="mixed"]::after { content: ""; position: absolute; @@ -1230,9 +1052,7 @@ body } .review-workbench .monaco-tl-twistie.codicon-tree-item-expanded::before, .review-workbench .collapse-button .codicon-chevron-down::before, -.review-workbench .collapse-button .codicon-chevron-right::before, -.review-workbench .review-diff-group-toggle .codicon-chevron-down::before, -.review-workbench .review-diff-group-toggle .codicon-chevron-right::before { +.review-workbench .collapse-button .codicon-chevron-right::before { content: ""; display: block; flex: none; @@ -1246,8 +1066,7 @@ body } .review-workbench .monaco-tl-twistie.collapsed.codicon-tree-item-expanded::before, -.review-workbench .collapse-button .codicon-chevron-right::before, -.review-workbench .review-diff-group-toggle .codicon-chevron-right::before { +.review-workbench .collapse-button .codicon-chevron-right::before { transform: rotate(0deg); } @@ -1332,323 +1151,10 @@ body --review-mark-color: var(--vscode-disabledForeground); } -/* Folds get out of the way: ghost ink on the code's own background, in the - chrome font. The theme sets the unchangedRegion background, foreground and - shadow. The row keeps Monaco's 24px height: its view zone and the handle - transforms assume it, and a shorter row rides up over the previous line. */ -.review-workbench .monaco-editor .diff-hidden-lines .center { - font: 11px/24px var(--review-chrome-font, monospace); -} -.review-workbench .monaco-editor .diff-hidden-lines .center:hover { - color: var(--vscode-descriptionForeground); -} - -/* Scope chevrons, rails and braces in structural diffs. The marker blue is - fixed because some themes make focusBorder transparent. */ -.review-workbench .monaco-editor { - --review-scope-marker: #2b55e6; -} -.review-workbench .monaco-editor:is(.vs-dark, .hc-black) { - --review-scope-marker: #5b7cff; -} -.review-workbench .monaco-editor .review-fold-chevron { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0.45; -} -.review-workbench .monaco-editor .review-fold-chevron.is-collapsed { - opacity: 0.7; -} -.review-workbench .monaco-editor .review-fold-chevron.is-target { - opacity: 1; - color: var(--review-scope-marker); -} -/* Keep the full gutter hit target; only the glyph needs less visual weight. */ -.review-workbench .monaco-editor .review-fold-chevron.codicon, -.review-workbench .monaco-editor .diff-fold-chevron.codicon, -.review-workbench .monaco-editor .diff-fold-chevron .codicon { - font-size: 12px; -} -.review-workbench .monaco-editor.review-fold-rail .view-lines { - cursor: pointer; -} -.review-workbench .monaco-editor .review-scope-rails { - z-index: 1; - overflow: hidden; - pointer-events: none; -} -.review-workbench .monaco-editor .review-scope-rail { - position: absolute; - width: 7px; - transform: translateX(-3px); - pointer-events: auto; - cursor: pointer; -} -.review-workbench .monaco-editor .review-scope-rail::before { - content: ""; - position: absolute; - left: 3px; - height: 100%; - border-left: 1px solid var(--vscode-editorIndentGuide-background1); -} -.review-workbench .monaco-editor .review-scope-rail.is-active::before { - border-left-color: var(--review-scope-marker); -} -.review-workbench .monaco-editor .review-scope-rail.is-target::before { - border-left-width: 2px; -} -.review-workbench .monaco-editor .review-scope-brace { - color: var(--review-scope-marker) !important; -} -.review-workbench .monaco-editor .review-scope-brace.is-target { - color: var(--vscode-editor-foreground) !important; - background: color-mix(in srgb, var(--review-scope-marker) 30%, transparent); - outline: 1px solid var(--review-scope-marker); - border-radius: 2px; -} -.review-workbench .monaco-editor .review-fold-hint { - margin-left: 2ch; - color: var(--review-scope-marker); - font-family: var(--review-chrome-font, inherit); - font-size: 11px; -} - -/* A scope folded into its header line: `opener { ⋯ N lines }`. */ -.review-workbench .monaco-editor .review-fold-pill { - margin: 0 0.5ch; - padding: 0 0.6ch; - border: 1px solid var(--vscode-editorIndentGuide-activeBackground1); - border-radius: 3px; - color: var(--vscode-descriptionForeground); - font-size: 0.92em; - cursor: pointer; -} -.review-workbench .monaco-editor .review-fold-pill.is-target { - border-color: var(--review-scope-marker); - color: var(--vscode-editor-foreground); -} -.review-workbench .monaco-editor .review-fold-closer { - color: var(--vscode-editor-foreground); - cursor: pointer; -} - -/* A fold inside removed code, drawn as a row of the removed block. */ -.review-workbench .monaco-editor .diff-fold-placeholder { - color: var(--vscode-descriptionForeground) !important; - opacity: 0.8; -} -.review-workbench .monaco-editor .diff-fold-placeholder-hit:hover { - background: color-mix( - in srgb, - var(--vscode-editor-foreground) 4%, - transparent - ); -} - -/* Align fold labels with the hidden code's indent. */ -.review-workbench - .review-files-editor - .monaco-editor - .diff-hidden-lines:not(.kind-inserted):not(.kind-removed) - .center { - background: transparent; - box-shadow: none; -} -.review-workbench - .review-files-editor - .monaco-editor - .diff-hidden-lines - .diff-hidden-lines-label { - flex: 1; - justify-content: flex-start !important; - color: var(--vscode-descriptionForeground); -} -.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal { - appearance: none; - border: 0; - background: transparent; - padding: 0; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} -.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal::before { - content: "\22EF\00a0"; -} -.review-workbench .monaco-editor .diff-hidden-lines .diff-fold-reveal:hover { - color: var(--review-scope-marker); -} -.review-workbench - .monaco-editor - .diff-hidden-lines - .diff-fold-reveal:focus-visible { - outline: 1px solid var(--review-scope-marker); - outline-offset: 2px; -} - -/* The canvas stylesheet resets every element to border-box, and the Diff - view's editors live inside the canvas. Monaco sizes the fold's drag - handles as content-box (4px tall plus 6px of transparent borders) and - offsets the whole widget by that sum, so a border-box handle is 4px short - and the fold row rides up over the previous line. */ -.monaco-editor .diff-hidden-lines .top, -.monaco-editor .diff-hidden-lines .bottom { - box-sizing: content-box; -} .review-changed-files-label { flex: 1; min-width: 0; } -/* Left inset 10px and title gap 5px mirror the file header below it (5px - padding + 5px collapse-button margin, then 5px to the path), so the - section chevron and the file chevron sit on one vertical line. */ -.review-diff-group { - height: 48px; - min-height: 48px; - box-sizing: border-box; - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px 4px 10px; - border-top: 1px solid var(--vscode-panel-border); - background: var(--vscode-editor-background); - color: var(--vscode-editor-foreground); -} -.review-diff-group[hidden] { - display: none; -} -.review-diff-group-title { - font-size: 13px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.review-diff-group-counts { - margin-left: auto; - font: 11px var(--vscode-editor-font-family, monospace); - color: var(--vscode-descriptionForeground); - white-space: nowrap; -} -.review-diff-group.is-viewed > span { - opacity: 0.45; -} - -.review-diff-group-toggle { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; - border: 0; - padding: 0; - color: inherit; - background: transparent; - cursor: pointer; - text-align: left; -} -.review-diff-group-toggle:focus-visible { - outline: 1px solid var(--vscode-focusBorder); -} - -/* The surrounding navigation owns the top edge of the diff pane. */ -.multi-diff-sticky-section .review-diff-group, -.review-files-editor-diffs .multiDiffEntry:first-child > .review-diff-group { - border-top: 0; -} - -/* Placeholder rows share the scope chevron lane after the line numbers. */ -.review-workbench .monaco-editor .diff-fold-chevron { - appearance: none; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - height: 100%; - border: 0; - padding: 0; - background: transparent; - color: var(--vscode-descriptionForeground); - opacity: 0.7; - cursor: pointer; -} -.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-chevron, -.review-workbench .monaco-editor .structural-fold.is-target .diff-fold-reveal, -.review-workbench .monaco-editor .diff-fold-chevron.is-target { - color: var(--review-scope-marker); - opacity: 1; -} -.review-workbench .monaco-editor .diff-fold-chevron:focus-visible { - outline: 1px solid var(--review-scope-marker); - outline-offset: -2px; -} -.review-workbench .monaco-editor .diff-fold-placeholder.is-target { - color: var(--review-scope-marker) !important; - opacity: 1; -} - -.review-workbench - .monaco-editor - .diff-hidden-lines - .diff-fold-chevron - .codicon { - color: inherit; -} - -.review-workbench .monaco-editor .review-scope-viewed-control { - display: flex; - align-items: center; - box-sizing: border-box; - gap: 8px; - padding: 0 8px; - border: 1px solid - var(--vscode-widget-border, var(--vscode-editorWidget-border)); - border-radius: 5px; - background: var( - --vscode-editorWidget-background, - var(--vscode-editor-background) - ); - font: 11px/20px var(--monaco-monospace-font); - z-index: 8; -} -.review-workbench .monaco-editor .review-scope-viewed-control > span:empty { - display: none; -} -.review-workbench .monaco-editor .review-scope-viewed-status { - color: var(--vscode-descriptionForeground); -} -.review-workbench - .monaco-editor - .review-scope-viewed-control - .review-viewed-check { - margin-left: 4px; -} -/* Keep the shared 14px glyph while making the padded space around it clickable. */ -.review-workbench - .monaco-editor - .review-scope-viewed-control - .review-viewed-check::before { - content: ""; - position: absolute; - inset: -5px; -} -.review-workbench .monaco-editor .review-scope-viewed-ink, -.review-workbench .monaco-editor .review-scope-viewed-ink span, -.review-workbench .monaco-editor .review-fold-pill.is-viewed { - color: var(--vscode-descriptionForeground) !important; -} -.review-workbench .monaco-editor .review-scope-viewed-tint { - background: color-mix( - in srgb, - var(--vscode-editor-background) 70%, - transparent - ); -} -.review-workbench .monaco-editor .review-fold-pill.is-viewed:not(.is-target) { - opacity: 0.65; -} .review-files-editor-summary { flex-wrap: wrap; diff --git a/apps/diffr-web/src/review/common/reviewDiffRows.ts b/apps/diffr-web/src/review/common/reviewDiffRows.ts new file mode 100644 index 000000000..19138b8ed --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewDiffRows.ts @@ -0,0 +1,393 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +// One file's diff, in the order the reader sees it: lines, and the bands diffr's folds leave in +// their place. Unified, a changed run shows its base lines then its head lines; split, each row +// pairs the lines diffr paired. 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 DiffFold { + readonly foldStateId: number; + readonly collapsed: boolean; +} + +/** A fold folded into its header line, as `opener ⋯ label closer`. */ +export interface DiffPill { + readonly foldStateId: number; + readonly label: string; + readonly closer: string; +} + +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?: DiffFold; + readonly pill?: DiffPill; +} + +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; + +/** One side of a split row: a line of that side. */ +export interface SplitCell { + readonly change: "added" | "removed" | "context"; + readonly tinted: boolean; + /** Zero-based line on its side. */ + readonly line: number; + readonly side: "lhs" | "rhs"; + readonly fold?: DiffFold; + readonly pill?: DiffPill; +} + +/** A row of the split view; a side without a line there is left empty. */ +export interface SplitRow { + readonly kind: "pair"; + readonly left?: SplitCell; + readonly right?: SplitCell; +} + +export type SplitItem = SplitRow | 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; +} + +/** + * What the session's fold state does to a file, for either layout: the lines each collapsed gap + * hides, the band it leaves, and each side's chevrons and header-line pills. + */ +function foldLayout( + diff: StructuralTextDiff, + collapsed: (foldStateId: number) => boolean | undefined, +) { + const base = sourceLines(diff.lhs?.text); + const head = sourceLines(diff.rhs?.text); + + // 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); + + // A fold folded into its header line puts its pill on that side's header. + const pills = (side: "base" | "head") => { + const byLine = new Map(); + const foldables = side === "base" ? baseFoldables : headFoldables; + + for (const gap of gaps) { + if (gap.band || (side === "head") !== (gap.owner !== "base")) continue; + const foldable = foldables.find( + (candidate) => + candidate.foldStateId === gap.foldStateId && candidate.inline, + ); + if (foldable?.inline) + byLine.set( + (side === "base" ? gap.originalStart : gap.modifiedStart) - 2, + { + foldStateId: gap.foldStateId, + label: foldable.inline.label, + closer: foldable.inline.closer, + }, + ); + } + + return byLine; + }; + + const fold = ( + foldable: StructuralFoldable | undefined, + ): DiffFold | undefined => + foldable && { + foldStateId: foldable.foldStateId, + collapsed: collapsed(foldable.foldStateId) ?? foldable.collapsedByDefault, + }; + + const band = (gap: StructuralGap): UnifiedBand => { + const detail = bandDetail(gap.label); + const count = Math.max(gap.originalCount, gap.modifiedCount); + + return { + 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], + ), + }; + }; + + const highlights = structuralHighlights(diff); + + return { + gaps, + hiddenBase, + hiddenHead, + baseChevrons: chevrons(baseFoldables), + headChevrons: chevrons(headFoldables), + basePills: pills("base"), + headPills: pills("head"), + changedBase: new Set(highlights.originalLines), + changedHead: new Set(highlights.modifiedLines), + fold, + band, + }; +} + +/** + * The file's unified 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 layout = foldLayout(diff, collapsed); + const { changedBase, changedHead } = layout; + + 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(); + + const items: UnifiedItem[] = []; + const banded = new Set(); + + for (const line of lines) { + const gapIndex = + (line.head !== undefined + ? layout.hiddenHead.get(line.head) + : undefined) ?? + (line.base !== undefined ? layout.hiddenBase.get(line.base) : undefined); + + if (gapIndex !== undefined) { + const gap = layout.gaps[gapIndex]!; + + if (gap.band && !banded.has(gapIndex)) { + banded.add(gapIndex); + items.push(layout.band(gap)); + } + + continue; + } + + const fold = + line.side === "lhs" + ? layout.fold(layout.baseChevrons.get(line.base!)) + : layout.fold( + layout.headChevrons.get(line.head!) ?? + (line.base !== undefined + ? layout.baseChevrons.get(line.base) + : undefined), + ); + const pill = + line.head !== undefined + ? layout.headPills.get(line.head) + : layout.basePills.get(line.base!); + items.push({ ...line, fold, pill }); + } + + return items; +} + +/** + * The file's split rows: each of diffr's row pairs, its base line on the left and its head line on + * the right. A collapsed region hides its lines and leaves one band across both sides, or folds + * into its header line on each side that has one. + */ +export function splitItems( + diff: StructuralTextDiff, + collapsed: (foldStateId: number) => boolean | undefined, +): SplitItem[] { + const layout = foldLayout(diff, collapsed); + const items: SplitItem[] = []; + const banded = new Set(); + + for (const [left, right] of structuralRows(diff)) { + const hiddenLeft = left === null ? undefined : layout.hiddenBase.get(left); + const hiddenRight = + right === null ? undefined : layout.hiddenHead.get(right); + + for (const gapIndex of [hiddenLeft, hiddenRight]) { + if (gapIndex === undefined || banded.has(gapIndex)) continue; + const gap = layout.gaps[gapIndex]!; + + if (!gap.band) continue; + banded.add(gapIndex); + items.push(layout.band(gap)); + } + + const showLeft = left !== null && hiddenLeft === undefined; + const showRight = right !== null && hiddenRight === undefined; + + if (!showLeft && !showRight) continue; + const changed = + left === null || + right === null || + layout.changedBase.has(left + 1) || + layout.changedHead.has(right + 1); + + items.push({ + kind: "pair", + left: showLeft + ? { + change: changed ? "removed" : "context", + tinted: + changed && (layout.changedBase.has(left + 1) || right === null), + line: left, + side: "lhs", + fold: layout.fold(layout.baseChevrons.get(left)), + pill: layout.basePills.get(left), + } + : undefined, + right: showRight + ? { + change: changed ? "added" : "context", + tinted: + changed && (layout.changedHead.has(right + 1) || left === null), + line: right, + side: "rhs", + fold: layout.fold(layout.headChevrons.get(right)), + pill: layout.headPills.get(right), + } + : undefined, + }); + } + + return items; +} diff --git a/apps/diffr-web/src/review/common/reviewStructuralViewed.ts b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts deleted file mode 100644 index 5d6a980ff..000000000 --- a/apps/diffr-web/src/review/common/reviewStructuralViewed.ts +++ /dev/null @@ -1,128 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ -import type { ReviewDiffLens, ReviewDiffProgressFile } from "../../protocol.js"; -import { - regionLines, - type StructuralRegion, - type StructuralTextDiff, -} from "./reviewStructuralDiff.js"; - -type Sources = ReviewDiffLens["ranges"]; -type Source = Sources[number]; - -/** All members of a fold state, including a bundled doc comment and both revisions. */ -export function structuralViewedScopes( - diff: StructuralTextDiff, - path: string, - previousPath = path, -): ReadonlyMap { - const result = new Map(); - const scopes = new Set(); - for (const [side, source, file] of [ - ["base", diff.lhs, previousPath], - ["head", diff.rhs, path], - ] as const) { - const visit = (region: StructuralRegion) => { - if (region.kind !== "fold") return; - if (region.syntax) scopes.add(region.fold_state_id); - const span = regionLines(region); - const fromLine = (region.syntax?.start.line ?? span.start) + 1; - const toLine = region.syntax ? region.syntax.end.line + 1 : span.end; - if (toLine >= fromLine) { - const ranges = result.get(region.fold_state_id) ?? []; - ranges.push({ side, file, fromLine, toLine }); - result.set(region.fold_state_id, ranges); - } - region.children.forEach(visit); - }; - // The file header owns the root's viewed control. - if (source?.root.kind === "fold") source.root.children.forEach(visit); - } - for (const [id, ranges] of result) { - if (scopes.has(id)) result.set(id, mergeSources(ranges)); - else result.delete(id); - } - return result; -} - -/** Viewed state depends on changed-line coverage, never on whether a fold is open. */ -export function structuralViewedProgress( - sources: Sources, - file: ReviewDiffProgressFile | undefined, -) { - const changes = intersectSources(file?.changedRanges ?? [], sources); - const viewed = intersectSources(changes, file?.viewedRanges ?? []); - const count = (ranges: Sources, side: Source["side"]) => - ranges.reduce( - (sum, r) => sum + (r.side === side ? r.toLine - r.fromLine + 1 : 0), - 0, - ); - const total = { - additions: count(changes, "head"), - deletions: count(changes, "base"), - }; - const remaining = { - additions: total.additions - count(viewed, "head"), - deletions: total.deletions - count(viewed, "base"), - }; - const size = total.additions + total.deletions, - unread = remaining.additions + remaining.deletions; - const state = - size > 0 && unread === 0 ? "viewed" : unread < size ? "partial" : "unread"; - return { state, total, remaining } as const; -} - -/** Lens marks can cover individual changes without covering any complete syntax scope. */ -export function structuralViewedRanges( - scopes: ReadonlyMap | undefined, - file: ReviewDiffProgressFile | undefined, -): Sources { - const ranges = intersectSources( - file?.changedRanges ?? [], - file?.viewedRanges ?? [], - ); - for (const sources of scopes?.values() ?? []) { - if (structuralViewedProgress(sources, file).state === "viewed") - ranges.push(...sources); - } - return mergeSources(ranges); -} - -function intersectSources(a: Sources, b: Sources): Source[] { - return mergeSources( - a.flatMap((left) => - b.flatMap((right) => { - if (left.side !== right.side || left.file !== right.file) return []; - const fromLine = Math.max(left.fromLine, right.fromLine), - toLine = Math.min(left.toLine, right.toLine); - return fromLine <= toLine ? [{ ...left, fromLine, toLine }] : []; - }), - ), - ); -} - -function mergeSources(ranges: Sources): Source[] { - const result: Source[] = []; - for (const range of [...ranges].sort( - (a, b) => - a.side.localeCompare(b.side) || - a.file.localeCompare(b.file) || - a.fromLine - b.fromLine, - )) { - const last = result[result.length - 1]; - if ( - last && - last.side === range.side && - last.file === range.file && - range.fromLine <= last.toLine + 1 - ) { - result[result.length - 1] = { - ...last, - toLine: Math.max(last.toLine, range.toLine), - }; - } else result.push(range); - } - return result; -} diff --git a/apps/diffr-web/src/review/common/reviewUnifiedRows.ts b/apps/diffr-web/src/review/common/reviewUnifiedRows.ts deleted file mode 100644 index 329c696af..000000000 --- a/apps/diffr-web/src/review/common/reviewUnifiedRows.ts +++ /dev/null @@ -1,258 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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/reviewFilesDiffView.ts b/apps/diffr-web/src/review/services/reviewFilesDiffView.ts deleted file mode 100644 index 596fbe791..000000000 --- a/apps/diffr-web/src/review/services/reviewFilesDiffView.ts +++ /dev/null @@ -1,1291 +0,0 @@ -import { - $, - addDisposableListener, - append, - getWindow, - scheduleAtNextAnimationFrame, -} from "vs/base/browser/dom.js"; -import { - Orientation, - SplitView, -} from "vs/base/browser/ui/splitview/splitview.js"; -import { disposableTimeout } from "vs/base/common/async.js"; -import { Emitter, Event } from "vs/base/common/event.js"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ - -import "../browser/media/review.css"; -import { - Disposable, - DisposableStore, - MutableDisposable, - toDisposable, -} from "vs/base/common/lifecycle.js"; -import { - autorun, - observableValue, - type IObservable, -} from "vs/base/common/observable.js"; -import { isEqual } from "vs/base/common/resources.js"; -import { URI } from "vs/base/common/uri.js"; -import { ElementSizeObserver } from "vs/editor/browser/config/elementSizeObserver.js"; -import { - type ICodeEditor, - type IDiffEditor, - isDiffEditor, -} from "vs/editor/browser/editorBrowser.js"; -import { RefCounted } from "vs/editor/browser/widget/diffEditor/utils.js"; -import type { IDocumentDiffItem } from "vs/editor/browser/widget/multiDiffEditor/model.js"; -import { MultiDiffEditorViewModel } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js"; -import { - MultiDiffEditorWidget, - type RevealOptions, -} from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js"; -import type { IMultiDiffEditorViewState } from "vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js"; -import { - EditorOption, - IDiffEditorOptions, -} from "vs/editor/common/config/editorOptions.js"; -import { Range } from "vs/editor/common/core/range.js"; -import { ILanguageService } from "vs/editor/common/languages/language.js"; -import { PLAINTEXT_LANGUAGE_ID } from "vs/editor/common/languages/modesRegistry.js"; -import { IModelService } from "vs/editor/common/services/model.js"; -import { IHoverService } from "vs/platform/hover/browser/hover.js"; -import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; - -import { showLineNumbers } from "../../display.js"; -import { mobileViewport, NativeDiffScroll } from "../../nativeDiffScroll.js"; -import type { - ReviewDiffProgress, - ReviewDiffLens, - ReviewDiffViewSpec, -} from "../../protocol.js"; -import { type ReviewDiffFileWire } from "../../protocol.js"; -import { ReviewChangedFilesTree } from "../browser/reviewChangedFilesTree.js"; -import { - REVIEW_COUNTS_PENDING_TOOLTIP, - reviewChangesTooltip, - reviewCountsTooltip, - ReviewTooltip, - type ReviewTooltipContent, -} from "../browser/reviewTooltip.js"; -import { - binarySizeLabel, - isBinaryCounts, - type ReviewFileCounts, -} from "../common/reviewStructuralDiff.js"; -import type { ReviewDiffLayoutSetting } from "./reviewDiffLayout.js"; -import { - reviewMultiDiffLabelUris, - ReviewMultiDiffUIElementFactory, -} from "./reviewMultiDiff.js"; -import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; - -const FILE_TREE_MINIMUM_WIDTH = 180; -const DIFF_MINIMUM_WIDTH = 0; -const FILE_TREE_COLLAPSE_WIDTH = 760; -const REVIEW_FILES_DIFF_EDITOR_OPTIONS = { - hideUnchangedRegions: { enabled: true }, - originalEditable: false, - readOnly: true, - domReadOnly: true, - renderLineHighlight: "none", - cursorBlinking: "solid", - glyphMargin: false, - lineNumbersMinChars: 3, - - // Review Desktop's editor defaults (reviewConfigurationDefaults.ts), which - // the page has no configuration service to supply. - fontFamily: '"Geist Mono", Menlo, Monaco, "Courier New", monospace', - fontSize: 12, - minimap: { enabled: false }, - scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 }, - renderIndicators: false, - // Each file's editor keeps an overview ruler where its hidden scrollbar would be; drawn alone, - // the caret's mark there reads as a stray blue dash beside the code. - hideCursorInOverviewRuler: true, - overviewRulerLanes: 0, -} satisfies IDiffEditorOptions; - -export interface ReviewFilesEditorEntry { - readonly sectionId?: string; - readonly sectionStart?: boolean; - readonly file: ReviewDiffFileWire; - readonly original: URI | undefined; - readonly modified: URI | undefined; - readonly goToFileResource: URI; -} - -/** - * The files of one comparison as the multi-diff widget's model. Review - * Desktop builds this on the workbench's MultiDiffEditorInput and resolves - * text through content providers; here a file's models are made from its - * diffr result once the file is diffed, and only diffed files are listed. - */ -export class ReviewFilesEditorInput extends Disposable { - private readonly changes = this._register(new Emitter()); - private readonly documents = new Map>(); - private current: readonly RefCounted[] = []; - private loading: boolean; - private ready: ReadonlySet = new Set(); - private listed: ReadonlySet | undefined; - - constructor( - readonly entries: readonly ReviewFilesEditorEntry[], - private readonly session: StructuralDiffSession, - /** Whether long lines wrap, in every file. */ - private readonly wordWrap: IObservable, - @IModelService private readonly modelService: IModelService, - @ILanguageService private readonly languageService: ILanguageService, - @IInstantiationService - private readonly instantiationService: IInstantiationService, - ) { - super(); - this.loading = entries.length > 0; - this._register( - toDisposable(() => { - for (const document of this.documents.values()) document.dispose(); - this.documents.clear(); - }), - ); - } - - setReadyFiles(paths: ReadonlySet, loading: boolean): void { - this.ready = paths; - this.loading = loading; - this.update(); - } - - /** List only `paths`, as a lens does; `undefined` lists every ready file. */ - setListedFiles(paths: ReadonlySet | undefined): void { - this.listed = paths; - this.update(); - } - - private update(): void { - this.current = this.entries.flatMap((entry) => { - const path = entry.file.path; - if (!this.ready.has(path) || (this.listed && !this.listed.has(path))) - return []; - const document = this.documents.get(path) ?? this.createDocument(entry); - return document ? [document] : []; - }); - this.changes.fire(); - } - - private createDocument( - entry: ReviewFilesEditorEntry, - ): RefCounted | undefined { - const diff = this.session.getFileResult(entry.file.path)?.diff; - if (!diff) return undefined; - const store = new DisposableStore(); - const model = (uri: URI, text: string) => - store.add( - this.modelService.getModel(uri) ?? - this.modelService.createModel( - text, - this.languageService.createById(PLAINTEXT_LANGUAGE_ID), - uri, - ), - ); - const text = (side: "lhs" | "rhs") => - diff.type === "text" ? (diff[side]?.text ?? "") : ""; - const wordWrap = this.wordWrap; - // Line numbers sit right-aligned against the split's sash; one spare column keeps the longest - // clear of it. - const longest = Math.max( - text("lhs").split("\n").length, - text("rhs").split("\n").length, - ); - // Every hidden line comes from the one fold model: diffr's regions, - // never the diff editor's own unchanged-region hiding. - const options = { - ...REVIEW_FILES_DIFF_EDITOR_OPTIONS, - hideOriginalLineNumbers: - entry.file.status === "added" || entry.file.status === "unchanged", - hideUnchangedRegions: { - enabled: true, - minimumLineCount: 1, - contextLineCount: 0, - }, - folding: false, - lineNumbersMinChars: Math.max( - REVIEW_FILES_DIFF_EDITOR_OPTIONS.lineNumbersMinChars, - String(longest).length + 1, - ), - // Structural rails use one geometry through code, folds and deleted rows. - guides: { indentation: false, bracketPairs: false }, - // Fold controls follow the line numbers, with breathing room before code. - lineDecorationsWidth: 24, - glyphMargin: true, - experimental: { useTrueInlineView: false }, - } satisfies IDiffEditorOptions; - const item: IDocumentDiffItem = { - collapsed: observableValue("fileCollapsed", false), - original: model(entry.original!, text("lhs")), - modified: model(entry.modified!, text("rhs")), - labelUris: reviewMultiDiffLabelUris(entry.file), - get options() { - return { - ...options, - lineNumbers: showLineNumbers.get() - ? ("on" as const) - : ("off" as const), - hideOriginalLineNumbers: - mobileViewport.matches || options.hideOriginalLineNumbers, - glyphMargin: !mobileViewport.matches && options.glyphMargin, - lineNumbersMinChars: mobileViewport.matches - ? Math.max(3, String(longest).length) - : options.lineNumbersMinChars, - lineHeight: mobileViewport.matches ? 24 : 0, - fontSize: mobileViewport.matches ? 12 : options.fontSize, - lineDecorationsWidth: mobileViewport.matches - ? 24 - : options.lineDecorationsWidth, - wordWrap: wordWrap.get() ? ("on" as const) : ("off" as const), - }; - }, - onOptionsDidChange: Event.any( - Event.fromObservableLight(wordWrap), - Event.fromObservableLight(showLineNumbers), - Event.fromDOMEventEmitter(mobileViewport, "change"), - ), - }; - const document = RefCounted.createOfNonDisposable(item, store, this); - this.documents.set(entry.file.path, document); - return document; - } - - async getViewModel(): Promise { - const input = this; - // Its instantiation service is a plain parameter, not an injected one. - return this._register( - new MultiDiffEditorViewModel( - { - documents: { - get value() { - return input.current.length || !input.loading - ? input.current - : "loading"; - }, - onDidChange: this.changes.event, - }, - }, - this.instantiationService, - ), - ); - } -} - -/** - * The changed-files diff UI — a file list beside a multi-diff widget — as a - * plain widget. It owns no editor pane, so the Review canvas can mount it into - * a container the app supplies. The container's size drives the layout: the - * canvas gives the host element its bounds through CSS, not through a pane - * layout call. - */ -export class ReviewFilesDiffView extends Disposable { - private readonly _onDidChangeActiveControl = this._register( - new Emitter(), - ); - readonly onDidChangeActiveControl = this._onDidChangeActiveControl.event; - 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()); - /** The reader asked for a file from the tree, diffed or not. */ - readonly onDidRequestFile = this._onDidRequestFile.event; - private readonly diffContainer: HTMLElement; - - private readonly root: HTMLElement; - private readonly splitView: SplitView; - private readonly changedFilesTree: ReviewChangedFilesTree | undefined; - private readonly widget: MultiDiffEditorWidget; - private pendingViewState: IMultiDiffEditorViewState | undefined; - private viewModel: MultiDiffEditorViewModel | undefined; - private input: ReviewFilesEditorInput | undefined; - private readonly readyFiles = new Set(); - private readonly fileStates = new Map(); - private readonly settleHold = this._register( - new MutableDisposable(), - ); - /** Full structural counts for views without persisted coverage. */ - private readonly streamStats = new Map< - string, - { counts: ReviewFileCounts; tooltip?: ReviewTooltipContent } - >(); - private readonly headerFactory: ReviewMultiDiffUIElementFactory; - private readonly summary: HTMLElement; - private readonly summaryTooltip: ReviewTooltip; - /** 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 pendingOpen = false; - private pendingSectionId: string | undefined; - private pendingSource: ReviewDiffLens["ranges"][number] | undefined; - private progress: ReviewDiffProgress | undefined; - private documentCollapsed = false; - private fileTreeHidden = false; - private readonly initializedDocumentItems = new WeakSet(); - private readonly viewedApplied = new Map(); - private readonly streamStatus: HTMLElement; - private offscreen = false; - private layoutDeferred = false; - private sideBySide = true; - viewedScope: "lens" | undefined; - private listedPaths: ReadonlySet | undefined; - - constructor( - private readonly container: HTMLElement, - overflowWidgetsDomNode: HTMLElement | undefined, - layout: ReviewDiffLayoutSetting, - private readonly fileTreeContainer: HTMLElement | undefined, - private readonly onToggleViewed: - | ((path: string, sectionId?: string) => void) - | undefined, - private readonly onToggleSection: ((id: string) => void) | undefined, - private readonly document: ReviewDiffViewSpec["document"], - @IInstantiationService - private readonly reviewInstantiationService: IInstantiationService, - @IHoverService hoverService: IHoverService, - ) { - super(); - this.root = append(container, $(".review-files-editor")); - const fileTree = document - ? container.ownerDocument.createElement("div") - : append( - this.fileTreeContainer ?? this.root, - $(".review-files-editor-tree"), - ); - const diffContainer = append(this.root, $(".review-files-editor-diffs")); - this.diffContainer = diffContainer; - if (document) fileTree.hidden = true; - - const factory = this.reviewInstantiationService.createInstance( - ReviewMultiDiffUIElementFactory, - () => - this.input - ? this.input.entries.map((entry) => ({ - original: entry.original, - modified: entry.modified, - additions: - entry.file.status === "unchanged" || entry.file.binary - ? undefined - : (this.entryProgress(entry)?.remaining.additions ?? - (this.fileTreeContainer || this.document - ? undefined - : this.lineCounts(entry.file.path)?.added)), - deletions: - entry.file.status === "unchanged" || entry.file.binary - ? undefined - : (this.entryProgress(entry)?.remaining.deletions ?? - (this.fileTreeContainer || this.document - ? undefined - : this.lineCounts(entry.file.path)?.removed)), - collapseLocked: !!entry.file.binary, - countsTooltip: - this.progressTooltip(entry) ?? - this.streamStats.get(entry.file.path)?.tooltip, - viewedState: this.entryProgress(entry)?.state, - viewedScope: this.viewedScope, - onToggleViewed: - entry.file.status !== "unchanged" && this.onToggleViewed - ? () => this.onToggleViewed!(entry.file.path, entry.sectionId) - : undefined, - sectionId: entry.sectionId, - sectionCollapsed: - !!entry.sectionId && - this.collapsedSections.has(entry.sectionId), - onToggleSectionCollapsed: () => { - if (entry.sectionId) { - if (this.collapsedSections.has(entry.sectionId)) - this.collapsedSections.delete(entry.sectionId); - else this.collapsedSections.add(entry.sectionId); - this.headerFactory.refreshHeaders(); - } - }, - section: entry.sectionStart - ? this.progress?.sections?.find( - (section) => section.id === entry.sectionId, - ) - : undefined, - onToggleSection: () => - entry.sectionId && this.onToggleSection?.(entry.sectionId), - // A binary is folded like a hidden file, with its size as the reason. - note: - entry.file.status === "unchanged" - ? "Unchanged" - : entry.file.binary - ? this.binaryNote(entry.file.path) - : this.hiddenFiles.get(entry.file.path), - noteTooltip: entry.file.binary - ? { label: "No text to show · binary files stay folded" } - : undefined, - // A binary has no text to open. - // The file on GitHub, at the comparison's head. - onDidOpen: - entry.file.binary || entry.goToFileResource.scheme !== "https" - ? undefined - : () => { - if (document?.onDidOpen) { - document.onDidOpen(); - return; - } - container.ownerDocument.defaultView?.open( - entry.goToFileResource.toString(true), - "_blank", - "noopener", - ); - }, - })) - : [], - "auto", - // Hover and definition widgets must escape the canvas root, whose - // container-query containment clips position: fixed descendants. - overflowWidgetsDomNode, - false, - undefined, - ); - factory.alwaysShowScrollbars = Boolean(document); - // A document embed shows one file, so its header stays pinned at the top. - factory.scrollbarBelowResourceHeader = Boolean(document); - if (document) factory.bottomScrollPadding = 0; - this.headerFactory = factory; - - const nativeScroll = this._register(new NativeDiffScroll(diffContainer)); - - this.widget = this._register( - this.reviewInstantiationService.createInstance( - MultiDiffEditorWidget, - nativeScroll.viewport, - factory, - undefined, - ), - ); - nativeScroll.attach(this.widget); - // 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.sideBySide = !mobileViewport.matches && layout.get() === "split"; - this.widget.setRenderSideBySide(this.sideBySide); - }; - this._register(layout.onDidChange(applyLayout)); - mobileViewport.addEventListener("change", applyLayout); - this._register( - toDisposable(() => - mobileViewport.removeEventListener("change", applyLayout), - ), - ); - applyLayout(); - if (document) { - this._register( - this.widget.onDidChangeContentHeight(() => { - const height = Math.max(40, this.widget.getContentHeight()); - document.onDidChangeHeight( - document.heightMode === "capped" ? Math.min(400, height) : height, - ); - }), - ); - } - 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.onDidScroll(() => this._onDidScroll.fire())); - this._register( - this.widget.onDidChangeActiveItem(() => - this.syncFileSelectionFromWidget(), - ), - ); - this.summary = append(fileTree, $(".review-files-editor-summary")); - this.summary.hidden = true; - this.summaryTooltip = this._register( - new ReviewTooltip(hoverService, this.summary), - ); - this.changedFilesTree = document - ? undefined - : this._register( - this.reviewInstantiationService.createInstance( - ReviewChangedFilesTree, - fileTree, - ), - ); - if (this.changedFilesTree) - this._register( - this.changedFilesTree.onDidOpenFile((file) => { - this._onDidRequestFile.fire(file.path); - this.revealFile(file.path); - }), - ); - this.splitView = this._register( - new SplitView(this.root, { - orientation: Orientation.HORIZONTAL, - proportionalLayout: true, - }), - ); - if (!this.fileTreeContainer && !document) - 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`; - nativeScroll.layout(width, height ?? 0); - }, - maximumSize: Number.POSITIVE_INFINITY, - minimumSize: DIFF_MINIMUM_WIDTH, - onDidChange: Event.None, - }, - 740, - ); - - if (this.fileTreeContainer) { - const treeSize = this._register( - new ElementSizeObserver(this.fileTreeContainer, undefined), - ); - this._register(treeSize.onDidChange(() => this.layout())); - treeSize.startObserving(); - this._register(toDisposable(() => fileTree.remove())); - } - if (document) { - const visibility = new IntersectionObserver( - ([entry]) => { - this.offscreen = !entry.isIntersecting; - if (!this.offscreen && this.layoutDeferred) this.layout(); - }, - { rootMargin: "200px 0px" }, - ); - visibility.observe(container); - this._register(toDisposable(() => visibility.disconnect())); - } - 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.settleHold.clear(); - this.pendingViewState = viewState; - this.input = input; - input.setListedFiles(this.listedPaths); - this.listFiles(); - 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, - initialScrollPosition: this.document ? "top" : "firstChange", - }); - if (viewState) { - this.settle(() => { - this.widget.setViewState(viewState); - this.syncFileSelectionFromWidget(); - }).add( - toDisposable(() => { - this.pendingViewState = undefined; - this.widget.clearPendingRestorationState(); - }), - ); - } - this.changedFilesTree?.setFiles( - Array.from( - new Map( - input.entries.map((entry) => [entry.file.path, entry.file]), - ).values(), - ), - ); - this.syncFileSelectionFromWidget(); - this._register( - autorun((reader) => { - const items = viewModel.items.read(reader); - if (this.document) - for (const item of items) { - if (this.initializedDocumentItems.has(item)) continue; - this.initializedDocumentItems.add(item); - item.collapsed.set(this.documentCollapsed, undefined); - } - this.applyHiddenFiles(items); - this.applyFileFolds(); - this.applyViewedFiles(); - 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) { - if (this.pendingSource) - this.revealSource(this.pendingSource, this.pendingSectionId); - else { - if (this.pendingOpen) - this.itemFor(entry)?.collapsed.set(false, undefined); - this.reveal(entry); - } - } - }); - }), - ); - } - - startLoading(entries: readonly ReviewFilesEditorEntry[]): void { - this.changedFilesTree?.setFiles( - Array.from( - new Map(entries.map((entry) => [entry.file.path, entry.file])).values(), - ), - ); - 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): 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.fileStates.values()].some((state) => state === "Loading diff…"), - ); - this.showStreamStatus(); - } - - /** List only `paths` in the tree and summary, as a lens does; `undefined` lists every file. */ - listOnly(paths: ReadonlySet | undefined): void { - this.listedPaths = paths; - this.input?.setListedFiles(paths); - this.listFiles(); - this.renderSummary(); - } - - private listFiles(): void { - this.changedFilesTree?.setFiles( - Array.from( - new Map( - (this.input?.entries ?? []) - .filter((entry) => this.listedPaths?.has(entry.file.path) ?? true) - .map((entry) => [entry.file.path, entry.file]), - ).values(), - ), - ); - } - - /** Coverage counts are independent of fold state. */ - fileCounts(path: string, counts: ReviewFileCounts): void { - this.streamStats.set(path, { - counts, - // A binary's header shows no counts, so it has no counts tooltip. - tooltip: isBinaryCounts(counts) - ? undefined - : reviewChangesTooltip(counts.added, counts.removed), - }); - // Coverage owns line counts in a hosted tree; a binary's size has no other source. - if (!this.fileTreeContainer || isBinaryCounts(counts)) - this.changedFilesTree?.setCounts(path, counts); - this.headerFactory.refreshHeaders(); - this.renderSummary(); - } - - private lineCounts(path: string) { - const counts = this.streamStats.get(path)?.counts; - return isBinaryCounts(counts) ? undefined : counts; - } - - private binaryNote(path: string): string { - const counts = this.streamStats.get(path)?.counts; - const size = isBinaryCounts(counts) ? binarySizeLabel(counts) : undefined; - return size ? `Binary file · ${size}` : "Binary file"; - } - - private renderSummary(): void { - if (this.fileTreeContainer || !this.input) return; - const previousHeight = this.summary.offsetHeight; - const all = new Set( - (this.input?.entries ?? []).map((entry) => entry.file.path), - ); - const paths = this.listedPaths - ? new Set([...all].filter((path) => this.listedPaths!.has(path))) - : all; - const complete = [...paths].every((path) => this.streamStats.has(path)); - const total = { added: 0, removed: 0 }; - const remaining = { added: 0, removed: 0 }; - const progressByPath = new Map( - this.progress?.files.map((file) => [file.path, file]), - ); - for (const path of paths) { - const counts = this.lineCounts(path); - if (counts) { - total.added += counts.added; - total.removed += counts.removed; - const progress = progressByPath.get(path); - remaining.added += progress?.remaining.additions ?? counts.added; - remaining.removed += progress?.remaining.deletions ?? counts.removed; - } - } - this.summary.replaceChildren(); - const files = append( - this.summary, - $("span.review-files-editor-summary-files"), - ); - files.textContent = this.listedPaths - ? `${paths.size} of ${all.size} files` - : `${paths.size} files`; - if (complete) { - append(this.summary, $("span")).textContent = "Remaining"; - const format = new Intl.NumberFormat("en", { - notation: "compact", - maximumFractionDigits: 1, - }); - append( - this.summary, - $("span.review-files-editor-summary-added"), - ).textContent = `+${format.format(remaining.added).toLowerCase()}`; - append( - this.summary, - $("span.review-files-editor-summary-removed"), - ).textContent = `−${format.format(remaining.removed).toLowerCase()}`; - const size = total.added + total.removed; - const viewed = Math.max(0, size - remaining.added - remaining.removed); - const percent = size ? Math.round((viewed * 100) / size) : 100; - const progress = append( - this.summary, - $("span.review-files-editor-summary-progress"), - ); - progress.setAttribute("role", "progressbar"); - progress.setAttribute("aria-label", "Changed lines viewed"); - progress.setAttribute("aria-valuemin", "0"); - progress.setAttribute("aria-valuemax", "100"); - progress.setAttribute("aria-valuenow", String(percent)); - progress.setAttribute( - "aria-valuetext", - `${viewed} of ${size} changed lines viewed`, - ); - progress.style.setProperty("--viewed-progress", `${percent}%`); - progress.textContent = `${percent}%`; - } else append(this.summary, $("span")).textContent = "Counting changes…"; - this.summaryTooltip.content = complete - ? reviewChangesTooltip(total.added, total.removed) - : REVIEW_COUNTS_PENDING_TOOLTIP; - const wasHidden = this.summary.hidden; - this.summary.hidden = false; - if (wasHidden || previousHeight !== this.summary.offsetHeight) - 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); - } - } - - /** Files to fold or unfold once they join the list. */ - private readonly fileFolds = new Map(); - - /** Fold or unfold a file's body, as its header's chevron does, now or once it is listed. */ - setFileCollapsed(path: string, collapsed: boolean): void { - this.fileFolds.set(path, collapsed); - this.applyFileFolds(); - } - - private applyFileFolds(): void { - for (const [path, collapsed] of this.fileFolds) { - const entry = this.input?.entries.find( - (entry) => entry.file.path === path, - ); - const item = entry && this.itemFor(entry); - if (!item) continue; - // After a hidden file's own fold, which waits for the same item. - this.hiddenApplied.add(path); - item.collapsed.set(collapsed, undefined); - this.fileFolds.delete(path); - } - } - - private readonly collapsedSections = new Set(); - private readonly sectionViewed = new Map(); - private entryProgress(entry: ReviewFilesEditorEntry) { - return ( - entry.sectionId - ? this.progress?.sections?.find( - (section) => section.id === entry.sectionId, - )?.files - : this.progress?.files - )?.find((file) => file.path === entry.file.path); - } - private progressTooltip( - entry: ReviewFilesEditorEntry, - ): ReviewTooltipContent | undefined { - const file = this.entryProgress(entry); - return file ? reviewCountsTooltip(file) : undefined; - } - setProgress(progress: ReviewDiffProgress): void { - this.progress = progress; - for (const section of progress.sections ?? []) { - const previous = this.sectionViewed.get(section.id); - if (section.state === "viewed" && previous !== "viewed") - this.collapsedSections.add(section.id); - else if (previous === "viewed" && section.state !== "viewed") - this.collapsedSections.delete(section.id); - this.sectionViewed.set(section.id, section.state); - } - - this.changedFilesTree?.setProgressFiles(progress.files); - this.renderSummary(); - this.headerFactory.refreshHeaders(); - this.applyViewedFiles(); - } - private applyViewedFiles(): void { - for (const entry of this.input?.entries ?? []) { - const file = this.entryProgress(entry); - if (!file) continue; - const key = `${entry.sectionId ?? ""}:${file.path}`; - const previous = this.viewedApplied.get(key); - if (previous === file.state) continue; - const item = this.itemFor(entry); - if (!item) continue; - if (file.state === "viewed") item.collapsed.set(true, undefined); - else if ( - previous === "viewed" || - this.progress?.changedPaths?.includes(file.path) - ) - item.collapsed.set(false, undefined); - this.viewedApplied.set(key, file.state); - } - } - revealSource( - source: ReviewDiffLens["ranges"][number], - sectionId?: string, - ): void { - this.settleHold.clear(); - const entry = this.input?.entries.find( - (entry) => - (!sectionId || entry.sectionId === sectionId) && - (!entry.sectionId || - this.progress?.sections - ?.find((section) => section.id === entry.sectionId) - ?.sources.some( - (range) => - range.file === source.file && - range.side === source.side && - range.fromLine <= source.fromLine && - range.toLine >= source.fromLine, - )) && - source.file === - (source.side === "base" - ? (entry.file.previousPath ?? entry.file.path) - : entry.file.path), - ); - if (!entry) return; - if (entry.sectionId && this.collapsedSections.delete(entry.sectionId)) - this.headerFactory.refreshHeaders(); - this.pendingSource = source; - this.pendingSectionId = sectionId; - if (this.fileStates.has(entry.file.path)) { - this.pendingPath = entry.file.path; - return; - } - this.itemFor(entry)?.collapsed.set(false, undefined); - this.reveal(entry, { - highlight: true, - side: source.side === "base" ? "original" : "modified", - range: new Range(source.fromLine, 1, source.toLine, 1), - }); - this.pendingSource = undefined; - } - - /** Scroll to a file, or to it once its diff has loaded; `open` also unfolds a folded file. */ - revealFile(path: string, open = false): void { - this.settleHold.clear(); - const entry = this.input?.entries.find((entry) => entry.file.path === path); - if (!entry) return; - this.pendingPath = this.fileStates.has(path) ? path : undefined; - this.pendingOpen = open; - this.showStreamStatus(); - if (this.pendingPath) return; - if (open) this.itemFor(entry)?.collapsed.set(false, undefined); - this.reveal(entry); - } - - /** - * Scroll so one side's `line` (1-based) of a diffed file sits a third of the way down the list, - * opening the file if it is folded. Its folds are the session's; open them first. - */ - revealLine(path: string, side: "original" | "modified", line: number): void { - this.settleHold.clear(); - const entry = this.input?.entries.find((entry) => entry.file.path === path); - if (!entry || !this.readyFiles.has(path)) return; - this.itemFor(entry)?.collapsed.set(false, undefined); - this.widget.reveal(entry, { highlight: false }); - // The file renders, and its folds open, over the next frames. - this.settle(() => { - const offset = this.lineOffset(entry, side, line); - if (offset === undefined) return; - const delta = offset - this.diffContainer.clientHeight / 3; - if (Math.abs(delta) > 1) - this.widget.setScrollTop(this.widget.getScrollTop() + delta); - }); - } - - /** The editor that shows one side of a file, while the file is rendered. */ - codeEditor( - path: string, - side: "original" | "modified", - ): ICodeEditor | undefined { - const entry = this.input?.entries.find((entry) => entry.file.path === path); - const diffEditor = - entry?.modified && - this.widget.tryGetCodeEditor(entry.modified)?.diffEditor; - if (!isDiffEditor(diffEditor)) return undefined; - // Unified, the base side's lines are drawn inside the head side's editor. - return side === "original" && this.sideBySide - ? diffEditor.getOriginalEditor() - : diffEditor.getModifiedEditor(); - } - - /** Pixels from the top of the list to one side's line, as it is laid out now. */ - private lineOffset( - entry: ReviewFilesEditorEntry, - side: "original" | "modified", - line: number, - ): number | undefined { - const diffEditor = - entry.modified && - this.widget.tryGetCodeEditor(entry.modified)?.diffEditor; - const editor = this.codeEditor(entry.file.path, side); - const node = editor?.getDomNode(); - if (!isDiffEditor(diffEditor) || !editor || !node) return undefined; - let top: number | undefined; - if (side === "original" && !this.sideBySide) { - // A removed line sits in the block drawn above its change's head lines; any other base line - // is shifted by the changes before it. - const lineHeight = editor.getOption(EditorOption.lineHeight); - let shift = 0; - for (const change of diffEditor.getDiffComputationResult()?.changes2 ?? - []) { - if (line < change.original.startLineNumber) break; - if (line < change.original.endLineNumberExclusive) { - top = - editor.getTopForLineNumber(change.modified.startLineNumber) - - (change.original.endLineNumberExclusive - line) * lineHeight; - break; - } - shift = - change.modified.endLineNumberExclusive - - change.original.endLineNumberExclusive; - } - top ??= editor.getTopForLineNumber(line + shift); - } else top = editor.getTopForLineNumber(line); - return ( - node.getBoundingClientRect().top - - this.diffContainer.getBoundingClientRect().top + - top - - editor.getScrollTop() - ); - } - - /** The file at the top of the list, among those diffed. */ - get activePath(): string | undefined { - const resource = this.widget.getActiveItem(); - return ( - resource && - this.input?.entries.find( - (entry) => - sameResource(entry.original, resource.original) && - sameResource(entry.modified, resource.modified), - )?.file.path - ); - } - - get viewportHeight(): number { - return this.diffContainer.clientHeight; - } - - /** - * Pixels from the reading line (the list's top edge) to the first line of - * `source`, negative once scrolled past. Rendered files measure their - * editor; the rest are placed by file order around the topmost file, a - * million pixels per file, which keeps them sorted and on the right side. - */ - sourceOffset(source: ReviewDiffLens["ranges"][number]): number | undefined { - const entries = this.input?.entries ?? []; - const pathOf = (entry: (typeof entries)[number]) => - source.side === "base" - ? (entry.file.previousPath ?? entry.file.path) - : entry.file.path; - const index = entries.findIndex((entry) => pathOf(entry) === source.file); - if (index < 0) return undefined; - const entry = entries[index]!; - const resource = source.side === "base" ? entry.original : entry.modified; - const editor = resource && this.widget.tryGetCodeEditor(resource)?.editor; - const node = editor?.getDomNode(); - if (editor && node) { - return ( - node.getBoundingClientRect().top - - this.diffContainer.getBoundingClientRect().top + - editor.getTopForLineNumber(source.fromLine) - - editor.getScrollTop() - ); - } - const active = this.viewModel?.activeDiffItem.get(); - const activeIndex = active - ? entries.findIndex( - (e) => - sameResource(active.modifiedUri, e.modified) && - sameResource(active.originalUri, e.original), - ) - : 0; - return (index - Math.max(0, activeIndex)) * 1_000_000 + source.fromLine; - } - - 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.pendingViewState ?? - (this.viewModel ? this.widget.getViewState() : undefined) - ); - } - - getActiveControl(): IDiffEditor | undefined { - return this.widget.getActiveControl(); - } - - /** The next or previous change, into the next file past the last. */ - goToChange(direction: "next" | "previous"): void { - this.settleHold.clear(); - if (direction === "next") this.widget.goToNextChange(); - else this.widget.goToPreviousChange(); - } - - /** Scrolls by `delta` pixels, or to the top or the end. */ - scroll(to: number | "top" | "end"): void { - this.settleHold.clear(); - this.widget.setScrollTop( - to === "top" - ? 0 - : to === "end" - ? this.widget.getContentHeight() - : this.widget.getScrollTop() + to, - ); - } - - toggleFileTree(): void { - this.fileTreeHidden = !this.fileTreeHidden; - this.layout(); - } - - setCollapsed(collapsed: boolean): void { - this.settleHold.clear(); - this.documentCollapsed = collapsed; - for (const item of this.viewModel?.items.get() ?? []) - item.collapsed.set(collapsed, undefined); - } - - focus(): void { - this.widget.getActiveControl()?.focus(); - } - - layout(): void { - this.layoutDeferred = this.offscreen; - if (this.offscreen) return; - const width = this.container.clientWidth; - const height = this.container.clientHeight; - if (width <= 0 || height <= 0) return; - - const fileTreeVisible = - !this.fileTreeContainer && - !this.fileTreeHidden && - !mobileViewport.matches && - width > FILE_TREE_COLLAPSE_WIDTH; - if ( - !this.fileTreeContainer && - !this.document && - this.splitView.isViewVisible(0) !== fileTreeVisible - ) { - this.splitView.setViewVisible(0, fileTreeVisible); - } - - this.splitView.layout(width, height); - if (this.fileTreeContainer) - this.changedFilesTree?.layout( - this.fileTreeContainer.clientHeight, - this.fileTreeContainer.clientWidth, - ); - } - private reveal( - resource: { original: URI | undefined; modified: URI | undefined }, - options: RevealOptions = { highlight: true }, - ): void { - this.widget.reveal(resource, options); - this.settle(() => { - if (this.itemFor(resource)) - this.widget.reveal(resource, { ...options, highlight: false }); - }); - } - - /** - * Streamed files and lazy editor measurements move content after the widget - * first scrolls; re-apply `apply` as heights settle, until the reader takes - * over, another reveal or input replaces it, or 30s pass. - */ - private settle(apply: () => void): DisposableStore { - const hold = (this.settleHold.value = new DisposableStore()); - const frame = hold.add(new MutableDisposable()); - const schedule = () => { - if (frame.value) return; - frame.value = scheduleAtNextAnimationFrame(getWindow(this.root), () => { - apply(); - frame.clear(); - }); - }; - hold.add(this.widget.onDidChangeContentHeight(schedule)); - for (const type of ["wheel", "pointerdown", "keydown", "touchstart"]) - hold.add( - addDisposableListener( - this.diffContainer, - type, - () => this.settleHold.clear(), - { capture: true, passive: true }, - ), - ); - hold.add(disposableTimeout(() => this.settleHold.clear(), 30_000)); - schedule(); - return hold; - } - - private itemFor(resource: { - original: URI | undefined; - modified: URI | undefined; - }) { - return this.viewModel?.items - .get() - .find( - (item) => - sameResource(item.originalUri, resource.original) && - sameResource(item.modifiedUri, resource.modified), - ); - } - - 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; - // The tree follows the diff from file to file, but leaves a sidebar the reader scrolled alone - // while the same file stays on top. - this.changedFilesTree?.setActiveFile( - input.entries[index].file.path, - "follow", - ); - } -} -function sameResource(left: URI | undefined, right: URI | undefined): boolean { - return left === undefined - ? right === undefined - : !!right && isEqual(left, right); -} diff --git a/apps/diffr-web/src/review/services/reviewMultiDiff.ts b/apps/diffr-web/src/review/services/reviewMultiDiff.ts deleted file mode 100644 index 9f6f32668..000000000 --- a/apps/diffr-web/src/review/services/reviewMultiDiff.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { Button } from "vs/base/browser/ui/button/button.js"; -import { IconLabel } from "vs/base/browser/ui/iconLabel/iconLabel.js"; -import { observableValue } from "vs/base/common/observable.js"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ -import { isEqual } from "vs/base/common/resources.js"; -import { URI } from "vs/base/common/uri.js"; -import type { IDiffCodeEditorWidgetOptions } from "vs/editor/browser/widget/diffEditor/diffEditorWidget.js"; -import type { - IResourceLabel, - IResourceHeaderMetadata, - IWorkbenchUIElementFactory, -} from "vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js"; -import { IHoverService } from "vs/platform/hover/browser/hover.js"; - -import type { - ReviewDiffProgressState, - ReviewDiffSection, -} from "../../protocol.js"; -import type { ReviewDiffFileWire } from "../../protocol.js"; -import { reviewBottomScrollPadding } from "../browser/reviewScrollPadding.js"; -import { - reviewCountsTooltip, - ReviewTooltip, - type ReviewTooltipContent, - ReviewViewedCheckbox, -} from "../browser/reviewTooltip.js"; - -export interface ReviewMultiDiffHeaderEntry { - readonly original: URI | undefined; - 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; - /** Tooltip for the note, e.g. why a file stays folded. */ - readonly noteTooltip?: ReviewTooltipContent; - /** A file with no text to diff: it stays folded and its header never opens it. */ - readonly collapseLocked?: boolean; - /** Tooltip for the counts: what is left of the file, then its whole. */ - readonly countsTooltip?: ReviewTooltipContent; - readonly sectionId?: string; - readonly section?: ReviewDiffSection; - readonly sectionCollapsed?: boolean; - readonly onToggleSectionCollapsed?: () => void; - readonly onToggleSection?: () => void; - readonly onDidOpen?: () => void; - readonly viewedState?: ReviewDiffProgressState; - readonly viewedScope?: "lens"; - readonly onToggleViewed?: () => void; -} - -export class ReviewMultiDiffUIElementFactory implements IWorkbenchUIElementFactory { - bottomScrollPadding = reviewBottomScrollPadding; - alwaysShowScrollbars = false; - scrollbarBelowResourceHeader = false; - - get headerClickToCollapse(): boolean { - 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", - readonly overflowWidgetsDomNode: HTMLElement | undefined, - readonly hideResourceHeader = false, - readonly codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions | undefined, - @IHoverService private readonly hoverService: IHoverService, - ) {} - - isResourceCollapseLocked( - uris: Parameters[0], - ): boolean { - return ( - !!uris && - !!this.entries().find( - (entry) => - sameResource(entry.original, uris.original) && - sameResource(entry.modified, uris.modified), - )?.collapseLocked - ); - } - - getResourceSectionId( - uris: Parameters[0], - ): string | undefined { - // Lens entries encode their section identity in both source URI fragments. - return uris?.modified?.fragment || uris?.original?.fragment || undefined; - } - - createResourceSectionHeader(element: HTMLElement, sticky = false) { - const height = observableValue(this, 0); - const bodyHidden = observableValue(this, false); - let uris: Parameters[0]; - let current: ReviewMultiDiffHeaderEntry | undefined; - const counts = document.createElement("span"); - counts.className = "review-diff-group-counts"; - const countsTooltip = new ReviewTooltip(this.hoverService, counts); - const viewed = new ReviewViewedCheckbox(element.ownerDocument, () => - current?.onToggleSection?.(), - ); - const refresh = () => { - const entry = (current = - uris && - this.entries().find( - (entry) => - sameResource(entry.original, uris!.original) && - sameResource(entry.modified, uris!.modified), - )); - const section = - sticky && entry?.sectionId - ? this.entries().find( - (candidate) => - candidate.sectionId === entry.sectionId && candidate.section, - )?.section - : entry?.section; - bodyHidden.set(entry?.sectionCollapsed ?? false, undefined); - element.replaceChildren(); - element.className = "review-diff-group"; - element.hidden = !section; - height.set(section ? 48 : 0, undefined); - if (!section) return; - const toggle = document.createElement("button"); - toggle.className = "review-diff-group-toggle"; - toggle.setAttribute("aria-expanded", String(!entry?.sectionCollapsed)); - toggle.setAttribute( - "aria-label", - `${entry?.sectionCollapsed ? "Expand" : "Collapse"} section: ${section.label}`, - ); - toggle.onclick = () => entry?.onToggleSectionCollapsed?.(); - const chevron = document.createElement("span"); - chevron.className = `codicon codicon-chevron-${entry?.sectionCollapsed ? "right" : "down"}`; - const title = document.createElement("span"); - title.className = "review-diff-group-title"; - title.textContent = section.label; - counts.textContent = - section.total.additions + section.total.deletions === 0 - ? "Unchanged" - : section.state === "viewed" - ? "Viewed" - : section.state === "folded" - ? "Folded" - : `+${compactCount(section.remaining.additions)} −${compactCount(section.remaining.deletions)}`; - countsTooltip.content = - section.total.additions + section.total.deletions === 0 - ? undefined - : reviewCountsTooltip(section); - viewed.update( - section.state, - section.label, - section.total.additions + section.total.deletions === 0, - ); - element.classList.toggle("is-viewed", section.state === "viewed"); - toggle.append(chevron, title); - element.append(toggle, counts, viewed.element); - }; - this.headers.add(refresh); - return { - height, - bodyHidden, - setUris: (value: typeof uris) => { - uris = value; - refresh(); - }, - dispose: () => { - this.headers.delete(refresh); - countsTooltip.dispose(); - viewed.dispose(); - element.remove(); - }, - }; - } - - createResourceLabel(element: HTMLElement): IResourceLabel { - element.classList.add("review-path-label"); - // The desktop's workbench ResourceLabel is this IconLabel plus a file - // icon theme, which the page has none of. - const label = new IconLabel(element, { supportHighlights: false }); - // A deleted file keeps a legible name: the tree's minus already says - // deleted, so the header never strikes the path through. - return { - setUri: (uri) => { - // The header prints the whole path, and the CSS elides it from the left. - label.setLabel(uri ? reviewMultiDiffLabelPath(uri) : "", undefined, { - title: uri ? reviewMultiDiffLabelPath(uri) : undefined, - }); - }, - setLabel: (name, description) => label.setLabel(name, description), - dispose() { - label.dispose(); - }, - }; - } - - createResourceHeaderMetadata(element: HTMLElement): IResourceHeaderMetadata { - const ownerDocument = element.ownerDocument; - const additions = ownerDocument.createElement("span"); - additions.className = "review-multidiff-additions"; - const deletions = ownerDocument.createElement("span"); - deletions.className = "review-multidiff-deletions"; - const counts = ownerDocument.createElement("span"); - 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 noteTooltip = new ReviewTooltip(this.hoverService, note); - - const openContainer = ownerDocument.createElement("span"); - openContainer.className = "review-multidiff-open-container"; - element.append(openContainer); - const open = new Button(openContainer, { - ariaLabel: "Open File", - title: "Open File", - supportIcons: true, - }); - open.label = "Open file"; - open.element.classList.add("review-multidiff-open"); - const countsTooltip = new ReviewTooltip(this.hoverService, counts); - const viewed = new ReviewViewedCheckbox(ownerDocument, () => - current?.onToggleViewed?.(), - ); - element.append(viewed.element); - let current: ReviewMultiDiffHeaderEntry | undefined; - let lastUris: Parameters[0]; - const openListener = open.onDidClick(() => current?.onDidOpen?.()); - const refresh = () => setUris(lastUris); - this.headers.add(refresh); - - const setUris: IResourceHeaderMetadata["setUris"] = (uris) => { - lastUris = uris; - current = uris - ? this.entries().find( - (entry) => - sameResource(entry.original, uris.original) && - sameResource(entry.modified, uris.modified), - ) - : undefined; - element.hidden = !current; - if (!current) return; - const hasCounts = - current.additions !== undefined && current.deletions !== undefined; - counts.hidden = !hasCounts; - if (hasCounts) { - additions.textContent = `+${compactCount(current.additions!)}`; - deletions.textContent = `−${compactCount(current.deletions!)}`; - counts.setAttribute( - "aria-label", - `${current.additions} lines added, ${current.deletions} lines removed`, - ); - } - countsTooltip.content = current.countsTooltip; - const path = current.modified ?? current.original; - viewed.update( - current.viewedState, - `${current.viewedScope === "lens" ? "selected lens in " : ""}${path ? reviewMultiDiffLabelPath(path) : "file"}`, - !current.onToggleViewed, - ); - counts.classList.toggle( - "review-counts-viewed", - current.viewedState === "viewed" || current.viewedState === "folded", - ); - counts.classList.toggle( - "review-counts-folded", - current.viewedState === "folded", - ); - if (current.viewedState === "viewed") { - additions.textContent = - current.viewedScope === "lens" ? "Lens viewed" : "Viewed"; - deletions.textContent = ""; - } else if (current.viewedState === "folded") { - // A hidden file's note already says why it is folded. - additions.textContent = "Folded"; - deletions.textContent = ""; - counts.hidden ||= !!current.note; - } - - note.hidden = !current.note; - note.textContent = current.note ?? ""; - noteTooltip.content = current.noteTooltip; - openContainer.hidden = !current.onDidOpen; - }; - return { - setUris, - dispose: () => { - this.headers.delete(refresh); - countsTooltip.dispose(); - noteTooltip.dispose(); - viewed.dispose(); - openListener.dispose(); - open.dispose(); - element.replaceChildren(); - }, - }; - } -} - -function sameResource(left: URI | undefined, right: URI | undefined): boolean { - return left === undefined - ? right === undefined - : !!right && isEqual(left, right); -} - -export function reviewMultiDiffLabelUris(file: ReviewDiffFileWire): { - readonly original: URI | undefined; - readonly modified: URI | undefined; -} { - return { - original: - file.status === "added" - ? undefined - : reviewFileLabelUri(file.previousPath ?? file.path), - modified: - file.status === "deleted" ? undefined : reviewFileLabelUri(file.path), - }; -} - -function reviewFileLabelUri(path: string): URI { - return URI.from({ scheme: "file", path: `/${path}` }); -} - -function reviewMultiDiffLabelPath(uri: URI): string { - return uri.path.startsWith("/") ? uri.path.slice(1) : uri.path; -} - -const compactCount = (count: number) => - new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }) - .format(count) - .toLowerCase(); diff --git a/apps/diffr-web/src/review/services/reviewStructuralDiff.ts b/apps/diffr-web/src/review/services/reviewStructuralDiff.ts deleted file mode 100644 index 27ee9c3a3..000000000 --- a/apps/diffr-web/src/review/services/reviewStructuralDiff.ts +++ /dev/null @@ -1,326 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationError } from "vs/base/common/errors.js"; -import { Event } from "vs/base/common/event.js"; -import { DisposableMap, DisposableStore } from "vs/base/common/lifecycle.js"; -import { autorun, type IObservable } from "vs/base/common/observable.js"; -import type { IDiffEditor } from "vs/editor/browser/editorBrowser.js"; -import { ICodeEditorService } from "vs/editor/browser/services/codeEditorService.js"; -import type { UnchangedRegion } from "vs/editor/browser/widget/diffEditor/diffEditorViewModel.js"; -import { IDiffProviderFactoryService } from "vs/editor/browser/widget/diffEditor/diffProviderFactoryService.js"; -import { LineRange } from "vs/editor/common/core/ranges/lineRange.js"; -import type { - IDocumentDiffProvider, - IDocumentDiff, -} from "vs/editor/common/diff/documentDiffProvider.js"; -import { DetailedLineRangeMapping } from "vs/editor/common/diff/rangeMapping.js"; -import { IInstantiationService } from "vs/platform/instantiation/common/instantiation.js"; -import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection.js"; - -import { - structuralContextGaps, - structuralContextScopes, - structuralRows, - structuralHighlights, -} from "../common/reviewStructuralDiff.js"; -import type { ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; -import { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; -import { - StructuralFoldControls, - StructuralFoldHover, -} from "./reviewStructuralFolds.js"; -import { StructuralSyntax } from "./reviewStructuralSyntax.js"; -import type { StructuralViewedState } from "./reviewStructuralViewed.js"; - -/** - * The structural diff provider for these entries' editors, and the fold - * controls on each one. The view creates each file's models from diffr's - * result, under the entry's URIs, once the file is diffed. - */ -export function createStructuralDiffEditors( - instantiation: IInstantiationService, - entries: readonly ReviewFilesEditorEntry[], - lifetime: DisposableStore, - session: StructuralDiffSession, - viewed?: StructuralViewedState, -): { - instantiation: IInstantiationService; - entries: readonly ReviewFilesEditorEntry[]; -} { - const unchanged = new Set( - entries - .filter((e) => e.file.status === "unchanged") - .map((e) => e.file.path), - ); - const pairs = new Map( - entries.map((e) => [ - e.original!.toString() + "\n" + e.modified!.toString(), - e.file.path, - ]), - ); - const factory: IDiffProviderFactoryService = { - _serviceBrand: undefined, - createDiffProvider: () => - new StructuralDiffProvider(session, pairs, unchanged), - }; - const child = lifetime.add( - instantiation.createChild( - new ServiceCollection([IDiffProviderFactoryService, factory]), - ), - ); - attachStructuralEditors(instantiation, entries, session, lifetime, viewed); - return { instantiation: child, entries }; -} - -/** Adapts session snapshots and fold state to Monaco's diff interface. */ -export class StructuralDiffProvider implements IDocumentDiffProvider { - private path: string | undefined; - readonly onDidChange: Event; - constructor( - private readonly session: StructuralDiffSession, - private readonly pairs: ReadonlyMap, - private readonly unchanged: ReadonlySet, - ) { - this.onDidChange = Event.map( - Event.filter( - session.onDidChange, - (change) => this.path !== undefined && change.files.has(this.path), - ), - () => undefined, - ); - } - async computeDiff( - ...[original, modified, _options, token]: Parameters< - IDocumentDiffProvider["computeDiff"] - > - ): Promise { - if (token.isCancellationRequested) throw new CancellationError(); - const path = this.pairs.get( - original.uri.with({ fragment: "" }).toString() + - "\n" + - modified.uri.with({ fragment: "" }).toString(), - ); - this.path = path; - if (path !== undefined && this.unchanged.has(path)) { - if (original.getValue() !== modified.getValue()) - throw new Error("Referenced context file changed; reload the session."); - return { changes: [], moves: [], identical: true, quitEarly: false }; - } - if ( - path !== undefined && - this.session.getFileResult(path)?.diff?.type === "binary" - ) { - return { - changes: [], - moves: [], - identical: false, - quitEarly: false, - changeHighlights: { original: [], modified: [] }, - }; - } - const diff = - path === undefined ? undefined : this.session.getTextDiff(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 Whiteboard's editor snapshots; reload the session.", - ); - } - 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, - contextScopes: structuralContextScopes(diff), - // Every collapsed region is hidden, labelled by the wire, as a band or on its header line. - // StructuralFoldControls draws the fold controls. - contextGaps: structuralContextGaps( - diff, - (id) => this.session.isRegionCollapsed(path!, id) === true, - (id) => this.session.isRegionCollapsed(path!, id), - ).map((gap) => ({ ...gap, foldControl: false, breadcrumbs: false })), - changeHighlights: highlights, - }; - } -} - -/** - * 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[], - session: StructuralDiffSession, - lifetime: DisposableStore, - viewed?: StructuralViewedState, -): 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, - ]), - ); - const watched = lifetime.add( - new DisposableMap(), - ); - function watch(editor: IDiffEditor) { - const widget = editor as unknown as { - unchangedRegions?: IObservable; - }; - if (!widget.unchangedRegions) return; - const store = new DisposableStore(); - watched.set(editor, store); - const pathOf = () => { - // Inner editors change models before the diff editor publishes its new pair. - // Resolve the models being rendered, not the previous file's pair. - const original = editor.getOriginalEditor().getModel(); - const modified = editor.getModifiedEditor().getModel(); - return original && modified - ? pairs.get( - original.uri.with({ fragment: "" }).toString() + - "\n" + - modified.uri.with({ fragment: "" }).toString(), - ) - : undefined; - }; - const hover = new StructuralFoldHover(); - store.add( - new StructuralFoldControls( - editor.getOriginalEditor(), - "lhs", - pathOf, - session, - widget.unchangedRegions, - hover, - viewed, - ), - ); - store.add( - new StructuralFoldControls( - editor.getModifiedEditor(), - "rhs", - pathOf, - session, - widget.unchangedRegions, - hover, - viewed, - ), - ); - store.add( - new StructuralSyntax( - editor.getOriginalEditor(), - "lhs", - pathOf, - session, - editor.onDidChangeModel, - ), - ); - store.add( - new StructuralSyntax( - editor.getModifiedEditor(), - "rhs", - pathOf, - session, - editor.onDidChangeModel, - ), - ); - let revealed = new Set(); - store.add( - autorun((reader) => { - const path = pathOf(); - const regions = widget.unchangedRegions!.read(reader); - if (!path || !session.getTextDiff(path)) return; - const gaps = structuralContextGaps( - session.getTextDiff(path)!, - (id) => session.isRegionCollapsed(path, id) === true, - (id) => session.isRegionCollapsed(path, id), - ); - const next = new Set(); - const gapOf = (region: UnchangedRegion) => - gaps.find( - (g) => - g.originalStart === region.originalLineNumber && - g.modifiedStart === region.modifiedLineNumber && - g.foldStateId === region.foldStateId, - ); - for (const region of regions) { - const shown = - region.visibleLineCountTop.read(reader) + - region.visibleLineCountBottom.read(reader); - const fullyShown = shown >= region.lineCount; - if (shown > 0 && !fullyShown && !region.band) { - // A fold on its header line has no band to show a partial reveal, so open it fully. - const gap = gapOf(region); - if (gap) session.setRegionCollapsed(path, gap.foldStateId, false); - continue; - } - if (fullyShown) { - next.add(region); - if (revealed.has(region)) continue; - const gap = gapOf(region); - if (!gap) continue; - session.setRegionCollapsed(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; - session.setRegionCollapsed(path, gap.foldStateId, true); - } - } - revealed = next; - }), - ); - } - - lifetime.add( - editors.onDiffEditorRemove((editor) => watched.deleteAndDispose(editor)), - ); - lifetime.add(editors.onDiffEditorAdd(watch)); - for (const editor of editors.listDiffEditors()) watch(editor); -} diff --git a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts b/apps/diffr-web/src/review/services/reviewStructuralFolds.ts deleted file mode 100644 index 98b7a9d6b..000000000 --- a/apps/diffr-web/src/review/services/reviewStructuralFolds.ts +++ /dev/null @@ -1,1118 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from "vs/base/common/codicons.js"; -import { Disposable } from "vs/base/common/lifecycle.js"; -import { - autorun, - observableValue, - type IObservable, -} from "vs/base/common/observable.js"; -import { ThemeIcon } from "vs/base/common/themables.js"; -import { - MouseTargetType, - type ICodeEditor, - type IOverlayWidget, - type IOverlayWidgetPosition, - type IEditorMouseEvent, -} from "vs/editor/browser/editorBrowser.js"; -import type { UnchangedRegion } from "vs/editor/browser/widget/diffEditor/diffEditorViewModel.js"; -import { EditorOption } from "vs/editor/common/config/editorOptions.js"; -import { CursorColumns } from "vs/editor/common/core/cursorColumns.js"; -import { Range } from "vs/editor/common/core/range.js"; -import { - InjectedTextCursorStops, - type IModelDeltaDecoration, - type ITextModel, -} from "vs/editor/common/model.js"; - -import { nestedIds } from "../../folds.js"; -import { - horizontalTouchScroll, - mobileViewport, -} from "../../nativeDiffScroll.js"; -import { - bandDetail, - structuralFoldables, - type StructuralFoldable, - type StructuralTextDiff, -} from "../common/reviewStructuralDiff.js"; -import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; -import type { StructuralViewedState } from "./reviewStructuralViewed.js"; -import type { ScopeViewedControl } from "./reviewStructuralViewedControl.js"; - -/** The scope under the pointer. */ -export interface FoldTarget { - readonly foldable: StructuralFoldable; - /** The pointer is on the scope's rail. */ - readonly rail: boolean; - /** The pointer is on the scope's chevron, or on a folded scope's pill. */ - readonly control: boolean; - readonly collapsed: boolean; -} - -/** Transient presentation state shared only by the two panes of one file. */ -export class StructuralFoldHover { - readonly state = observableValue< - | { - readonly owner: StructuralFoldControls; - readonly path: string | undefined; - readonly target: FoldTarget | undefined; - readonly column: boolean; - } - | undefined - >("structuralFoldHover", undefined); -} - -const CHEVRON = "review-fold-chevron"; -const RAIL_CURSOR = "review-fold-rail"; -/** Marks the text after a folded header, so that a click on it unfolds the scope. */ -const PILL = Symbol("review-fold-pill"); - -const controls = new WeakMap(); - -/** Runs a fold command (see `StructuralFoldControls.command`) in a structural diff editor. */ -export function structuralFoldCommand( - editor: ICodeEditor, - command: string, -): void { - controls.get(editor)?.command(command); -} - -/** - * Chevrons, rails and header-line folds for one side of a structural diff - * editor. The pointer's scope shows its chevron, rail and braces. A click on - * the chevron or the rail sets the scope's fold state in the session. - */ -export class StructuralFoldControls extends Disposable { - private readonly decorations = this.editor.createDecorationsCollection(); - private readonly foldables = new WeakMap< - StructuralTextDiff, - StructuralFoldable[] - >(); - private readonly rails = new ScopeRails(this.editor); - private readonly touchFolds = new TouchFoldButtons( - this.editor, - (foldable) => { - this.toggle( - { - foldable, - collapsed: this.isFolded(foldable) || this.isSummaryFolded(foldable), - rail: false, - control: true, - }, - false, - ); - }, - ); - private hovered: FoldTarget | undefined; - private overChevronColumn = false; - private pressed: FoldTarget | undefined; - private readonly viewedControls = new Map(); - - constructor( - private readonly editor: ICodeEditor, - private readonly side: "lhs" | "rhs", - private readonly path: () => string | undefined, - private readonly session: StructuralDiffSession, - /** The regions the diff editor hides now. */ - private readonly regions: IObservable, - private readonly sharedHover: StructuralFoldHover, - private readonly viewed?: StructuralViewedState, - ) { - super(); - controls.set(editor, this); - this.installPinchFolding(); - this._register(horizontalTouchScroll(this.editor)); - this._register({ dispose: () => controls.delete(editor) }); - this._register({ - dispose: () => { - for (const control of this.viewedControls.values()) control.dispose(); - this.viewedControls.clear(); - }, - }); - if (viewed) this._register(viewed.onDidChange(() => this.render())); - this._register({ - dispose: () => { - if (this.sharedHover.state.get()?.owner === this) { - this.sharedHover.state.set(undefined, undefined); - } - this.decorations.clear(); - this.rails.dispose(); - this.touchFolds.dispose(); - }, - }); - this._register( - editor.onMouseMove((e) => { - this.hover(this.targetAt(e), this.inChevronColumn(e)); - }), - ); - this._register(editor.onMouseLeave(() => this.hover(undefined))); - this._register( - editor.onMouseDown((e) => { - const target = e.event.leftButton ? this.targetAt(e) : undefined; - this.pressed = target?.rail || target?.control ? target : undefined; - if (this.pressed) { - e.event.preventDefault(); - } - }), - ); - this._register( - editor.onMouseUp((e) => { - const pressed = this.pressed; - this.pressed = undefined; - const target = this.targetAt(e); - if ( - pressed && - target?.foldable === pressed.foldable && - target.rail === pressed.rail && - target.control === pressed.control - ) { - this.toggle(pressed, e.event.altKey); - } - }), - ); - this._register( - editor.onDidChangeModel(() => { - this.hovered = undefined; - this.render(); - }), - ); - // Folding changes which lines show, and so the rail's length. - this._register(editor.onDidContentSizeChange(() => this.render())); - this._register(editor.onDidScrollChange(() => this.render(false))); - this._register(editor.onDidLayoutChange(() => this.render())); - this._register(editor.onDidChangeViewZones(() => this.render())); - this._register(editor.onDidChangeConfiguration(() => this.render())); - // The pills follow what the editor hides, not the fold state. - this._register( - autorun((reader) => { - this.sharedHover.state.read(reader); - for (const region of regions.read(reader)) { - region.visibleLineCountTop.read(reader); - region.visibleLineCountBottom.read(reader); - } - this.render(); - }), - ); - this._register( - session.onDidChange((change) => { - const path = this.path(); - if (path && change.files.has(path)) { - this.hovered = undefined; - this.render(); - } - }), - ); - this.render(); - } - - /** - * The diffr TUI's fold commands, at the caret where the TUI takes its top row. `a` toggles, `o` - * opens and `c` closes the scope whose chevron is on the caret's line, else the innermost open - * scope around it; capitals take the folds inside too. `j` and `k` go to the next or previous - * chevron. - */ - command(command: string): void { - const path = this.path(); - const diff = path ? this.session.getTextDiff(path) : undefined; - const position = this.editor.getPosition(); - if (!path || !diff || !position) { - return; - } - const line = position.lineNumber - 1; - const foldables = this.foldablesOf(diff).filter((f) => f.chevron); - if (command === "j" || command === "k") { - const lines = foldables.map((f) => f.line).sort((a, b) => a - b); - const next = - command === "j" - ? lines.find((l) => l > line) - : lines.findLast((l) => l < line); - if (next !== undefined) { - this.editor.setPosition({ lineNumber: next + 1, column: 1 }); - this.editor.revealLineInCenterIfOutsideViewport(next + 1); - } - return; - } - const foldable = - foldables.find((f) => f.line === line) ?? - foldables - .filter( - (f) => - f.rail && - !this.isCollapsed(path, f) && - f.rail.start <= line && - line <= f.rail.end, - ) - .sort( - (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), - )[0]; - const letter = command.toLowerCase(); - if (!foldable || !"aoc".includes(letter)) { - return; - } - const collapse = - letter === "a" ? !this.isCollapsed(path, foldable) : letter === "c"; - // A caret in hidden lines reveals them, so it waits on the header. - if (collapse) { - this.editor.setPosition({ lineNumber: foldable.line + 1, column: 1 }); - } - this.setCollapsed(path, diff, foldable, collapse, command !== letter); - } - - private toggle(target: FoldTarget, recursive: boolean): void { - const path = this.path(); - const diff = path ? this.session.getTextDiff(path) : undefined; - if (!path || !diff) { - return; - } - // A rail click puts the caret in the body, and a caret in hidden lines reveals them. - if (target.rail) { - this.editor.setPosition({ - lineNumber: target.foldable.line + 1, - column: 1, - }); - } - this.setCollapsed( - path, - diff, - target.foldable, - !target.collapsed, - recursive, - ); - } - - private installPinchFolding(): void { - const node = this.editor.getContainerDomNode(); - let pinch: - | { distance: number; line: number; y: number; done: boolean } - | undefined; - const distance = (touches: TouchList) => - Math.hypot( - touches[0].clientX - touches[1].clientX, - touches[0].clientY - touches[1].clientY, - ); - const start = (event: TouchEvent) => { - if (!mobileViewport.matches || event.touches.length !== 2) { - pinch = undefined; - return; - } - event.preventDefault(); - const x = (event.touches[0].clientX + event.touches[1].clientX) / 2; - const y = (event.touches[0].clientY + event.touches[1].clientY) / 2; - const position = this.editor.getTargetAtClientPoint(x, y)?.position; - pinch = { - distance: distance(event.touches), - line: position ? position.lineNumber - 1 : -1, - y, - done: false, - }; - }; - const move = (event: TouchEvent) => { - if (!pinch || event.touches.length !== 2) return; - event.preventDefault(); - if (pinch.done) return; - const delta = distance(event.touches) - pinch.distance; - if (Math.abs(delta) < Math.max(24, pinch.distance * 0.18)) return; - pinch.done = true; - const path = this.path(); - const diff = path ? this.session.getTextDiff(path) : undefined; - if (!path || !diff) return; - const collapse = delta < 0; - const line = pinch.line; - const localY = pinch.y - node.getBoundingClientRect().top; - const candidates = this.foldablesOf(diff).filter( - (f) => f.chevron && this.isCollapsed(path, f) !== collapse, - ); - const containing = candidates.filter( - (f) => - f.line === line || - (collapse && f.rail && f.rail.start <= line && line <= f.rail.end), - ); - const foldable = - containing.sort( - (a, b) => - (a.rail ? a.rail.end - a.rail.start : 0) - - (b.rail ? b.rail.end - b.rail.start : 0), - )[0] ?? - candidates - .map((f) => ({ - f, - distance: Math.abs( - this.editor.getTopForLineNumber(f.line + 1) - - this.editor.getScrollTop() + - this.editor.getOption(EditorOption.lineHeight) / 2 - - localY, - ), - })) - .filter((f) => f.distance <= 44) - .sort((a, b) => a.distance - b.distance)[0]?.f; - if (foldable) this.setCollapsed(path, diff, foldable, collapse, false); - }; - const end = () => { - pinch = undefined; - }; - const gesture = (event: Event) => { - if (mobileViewport.matches) event.preventDefault(); - }; - node.addEventListener("touchstart", start, { passive: false }); - node.addEventListener("touchmove", move, { passive: false }); - node.addEventListener("touchend", end); - node.addEventListener("touchcancel", end); - node.addEventListener("gesturestart", gesture); - node.addEventListener("gesturechange", gesture); - this._register({ - dispose: () => { - node.removeEventListener("touchstart", start); - node.removeEventListener("touchmove", move); - node.removeEventListener("touchend", end); - node.removeEventListener("touchcancel", end); - node.removeEventListener("gesturestart", gesture); - node.removeEventListener("gesturechange", gesture); - }, - }); - } - - /** Recursively, every fold inside takes the same state. */ - private setCollapsed( - path: string, - diff: StructuralTextDiff, - foldable: StructuralFoldable, - collapsed: boolean, - recursive: boolean, - ): void { - const id = foldable.foldStateId; - for (const each of recursive ? [id, ...nestedIds(diff, id)] : [id]) { - this.session.setRegionCollapsed(path, each, collapsed); - } - } - - private inChevronColumn(e: IEditorMouseEvent): boolean { - const node = this.editor.getDomNode(); - if (!node) { - return false; - } - const bounds = node.getBoundingClientRect(); - const x = - ((e.event.browserEvent.clientX - bounds.left) * node.offsetWidth) / - bounds.width; - const layout = this.editor.getLayoutInfo(); - return x >= layout.decorationsLeft && x < layout.contentLeft; - } - - private hover( - target: FoldTarget | undefined, - overChevronColumn = false, - ): void { - const hovered = this.hovered; - if ( - this.overChevronColumn === overChevronColumn && - hovered?.foldable === target?.foldable && - hovered?.rail === target?.rail && - hovered?.control === target?.control - ) { - return; - } - this.hovered = target; - this.overChevronColumn = overChevronColumn; - if (target || overChevronColumn) { - this.sharedHover.state.set( - { owner: this, path: this.path(), target, column: overChevronColumn }, - undefined, - ); - } else if (this.sharedHover.state.get()?.owner === this) { - this.sharedHover.state.set(undefined, undefined); - } - } - - private render(updateDecorations = true): void { - const model = this.editor.getModel(); - const path = this.path(); - const diff = path ? this.session.getTextDiff(path) : undefined; - const hover = this.sharedHover.state.get(); - const shared = hover?.path === path ? hover : undefined; - const target = shared?.target; - this.editor.getDomNode()?.classList.toggle(RAIL_CURSOR, !!target?.rail); - if (!model || !path || !diff) { - this.decorations.clear(); - this.rails.hide(); - this.touchFolds.hide(); - for (const control of this.viewedControls.values()) control.hide(); - return; - } - const decorations: IModelDeltaDecoration[] = []; - // Scrolling only moves controls; replacing model decorations also invalidates text layout. - if (updateDecorations) { - for (const range of this.viewed?.getViewedRanges(path) ?? []) { - if (range.side !== (this.side === "rhs" ? "head" : "base")) continue; - decorations.push({ - range: new Range( - range.fromLine, - 1, - range.toLine, - model.getLineMaxColumn(range.toLine), - ), - options: { - description: "review-scope-viewed", - isWholeLine: true, - inlineClassName: "review-scope-viewed-ink", - className: "review-scope-viewed-tint", - zIndex: 5, - }, - }); - } - for (const foldable of this.foldablesOf(diff)) { - // A function and its doc comment can share one fold state. Every - // member participates in hover, rather than only the first match. - const active = target?.foldable.foldStateId === foldable.foldStateId; - if (this.isFolded(foldable)) { - decorations.push(...this.folded(model, foldable, active)); - } else if (active) { - decorations.push( - ...this.lit(model, { - ...target!, - foldable, - collapsed: this.isSummaryFolded(foldable), - }), - ); - } else if ( - shared?.column && - foldable.chevron && - (!this.isCollapsed(path, foldable) || this.isSummaryFolded(foldable)) - ) { - decorations.push( - this.chevron(foldable, this.isSummaryFolded(foldable), false), - ); - } - } - } - const hidden = this.regions - .get() - .map((region) => - this.side === "rhs" - ? region.getHiddenModifiedRange(undefined) - : region.getHiddenOriginalRange(undefined), - ); - const visible = this.editor.getVisibleRanges(); - const first = visible[0]?.startLineNumber ?? 1; - const last = visible.at(-1)?.endLineNumber ?? 0; - const headerVisible = (scope: StructuralFoldable) => - scope.line + 1 >= first && scope.line + 1 <= last; - const intersectsViewport = (scope: StructuralFoldable) => - scope.line + 1 <= last && !!scope.rail && scope.rail.end + 1 >= first; - this.touchFolds.show( - this.foldablesOf(diff).filter( - (foldable) => - foldable.chevron && - headerVisible(foldable) && - !hidden.some((range) => range.contains(foldable.line + 1)), - ), - (foldable) => this.isFolded(foldable) || this.isSummaryFolded(foldable), - ); - const visibleScopes = this.foldablesOf(diff).filter( - (foldable) => - intersectsViewport(foldable) && - !this.isFolded(foldable) && - !hidden.some((range) => range.contains(foldable.line + 1)), - ); - this.rails.show(model, visibleScopes, target); - if (updateDecorations) this.decorations.set(decorations); - // Completed scopes remain discoverable after the pointer leaves. Only - // visible headers get widgets, so folded descendants never float over code. - const wanted = new Map< - number, - { target: FoldTarget; progress: ReturnType } - >(); - const completed = this.foldablesOf(diff).filter( - (scope) => - intersectsViewport(scope) && - this.viewed?.get(path, scope.foldStateId).state === "viewed", - ); - for (const scope of this.foldablesOf(diff)) { - if ( - !scope.rail || - !headerVisible(scope) || - hidden.some((range) => range.contains(scope.line + 1)) - ) - continue; - const active = scope.foldStateId === target?.foldable.foldStateId; - const progress = this.viewed?.get(path, scope.foldStateId); - if ( - !progress || - progress.total.additions + progress.total.deletions === 0 - ) - continue; - if (!active && progress.state !== "viewed") continue; - // A viewed function already accounts for its viewed return/body folds. - // Keep their actions on hover, but don't stack persistent completion badges. - if ( - !active && - completed.some( - (outer) => - outer.line < scope.line && outer.rail!.end >= scope.rail!.end, - ) - ) - continue; - const top = - this.editor.getTopForLineNumber(scope.line + 1) - - this.editor.getScrollTop(); - if (top < 0 || top >= this.editor.getLayoutInfo().height) continue; - if (active || !wanted.has(scope.line)) - wanted.set(scope.line, { - target: { - foldable: scope, - collapsed: this.isFolded(scope), - rail: false, - control: false, - }, - progress, - }); - } - for (const [line, control] of this.viewedControls) { - if (!wanted.has(line)) { - control.dispose(); - this.viewedControls.delete(line); - } - } - for (const [line, entry] of wanted) { - let control = this.viewedControls.get(line); - if (!control) { - control = this.viewed!.createControl(this.editor, (target) => { - const path = this.path(); - if (!path) return; - // Keep the caret out of the body that the viewed mark will fold. - this.editor.setPosition({ - lineNumber: target.foldable.line + 1, - column: 1, - }); - void this.viewed!.toggle(path, target.foldable.foldStateId); - }); - this.viewedControls.set(line, control); - } - control.show(entry.target, entry.progress); - } - } - - /** A folded scope on its header line: a chevron to unfold it, then `⋯ N lines` and the closer. */ - private folded( - model: ITextModel, - foldable: StructuralFoldable, - targeted: boolean, - ): IModelDeltaDecoration[] { - const line = foldable.line + 1; - const end = model.getLineMaxColumn(line); - const inline = foldable.inline!; - const viewed = - this.viewed?.get(this.path()!, foldable.foldStateId).state === "viewed"; - return [ - this.chevron(foldable, true, targeted), - { - range: new Range(line, end, line, end), - options: { - description: "review-fold-pill", - // Monaco drops text on an empty range without this. - showIfCollapsed: true, - after: { - content: `${viewed ? "✓" : "⋯"} ${inline.label}`, - inlineClassName: `review-fold-pill${targeted ? " is-target" : ""}${viewed ? " is-viewed" : ""}`, - cursorStops: InjectedTextCursorStops.None, - attachedData: PILL, - }, - }, - }, - { - range: new Range(line, end, line, end), - options: { - description: "review-fold-closer", - showIfCollapsed: true, - after: { - content: inline.closer, - inlineClassName: "review-fold-closer", - cursorStops: InjectedTextCursorStops.None, - attachedData: PILL, - }, - }, - }, - ]; - } - - /** An open scope the pointer is in: its chevron and braces, and when targeted, a fold hint. */ - private lit(model: ITextModel, target: FoldTarget): IModelDeltaDecoration[] { - const { foldable } = target; - const line = foldable.line + 1; - const targeted = target.rail || target.control; - const state = targeted ? " is-target" : ""; - const result: IModelDeltaDecoration[] = foldable.chevron - ? [this.chevron(foldable, target.collapsed, targeted)] - : []; - for (const brace of foldable.braces - ? [foldable.braces.opener, foldable.braces.closer] - : []) { - result.push({ - range: new Range( - brace.line, - brace.column, - brace.line, - brace.column + 1, - ), - options: { - description: "review-scope-brace", - inlineClassName: `review-scope-brace${state}`, - }, - }); - } - if (targeted && foldable.rail && !mobileViewport.matches) { - const lines = foldable.rail.end - foldable.rail.start; - const end = model.getLineMaxColumn(line); - result.push({ - range: new Range(line, end, line, end), - options: { - description: "review-fold-hint", - showIfCollapsed: true, - after: { - content: `click · ${target.collapsed ? "expand" : "fold"} ${lines} line${lines === 1 ? "" : "s"}`, - inlineClassName: "review-fold-hint", - cursorStops: InjectedTextCursorStops.None, - }, - }, - }); - } - return result; - } - - private chevron( - foldable: StructuralFoldable, - collapsed: boolean, - targeted: boolean, - ): IModelDeltaDecoration { - const line = foldable.line + 1; - return { - range: new Range(line, 1, line, 1), - options: { - description: CHEVRON, - linesDecorationsClassName: `${CHEVRON} ${ThemeIcon.asClassName(collapsed ? Codicon.chevronRight : Codicon.chevronDown)}${collapsed ? " is-collapsed" : ""}${targeted ? " is-target" : ""}`, - }, - }; - } - - private targetAt(e: IEditorMouseEvent): FoldTarget | undefined { - for (const control of this.viewedControls.values()) { - const viewed = control.targetOf( - e.event.browserEvent.target as HTMLElement | null, - ); - if (viewed) return { ...viewed, rail: false, control: false }; - } - const model = this.editor.getModel(); - const path = this.path(); - const diff = path ? this.session.getTextDiff(path) : undefined; - const summary = ( - e.event.browserEvent.target as HTMLElement | null - )?.closest?.("[data-summary-fold-state-id]"); - if (summary && diff) { - const foldable = this.foldablesOf(diff).find( - (f) => - f.rail && - f.foldStateId === - Number(summary.getAttribute("data-summary-fold-state-id")), - ); - if (foldable) - return { foldable, rail: true, control: false, collapsed: true }; - } - const position = e.target.position; - const rail = this.rails.targetOf( - e.event.browserEvent.target as HTMLElement | null, - ); - if (rail) { - return { - foldable: rail, - rail: true, - control: false, - collapsed: this.isSummaryFolded(rail), - }; - } - if (!model || !path || !diff || !position) { - return undefined; - } - const foldables = this.foldablesOf(diff); - // A deleted row in the unified view is a view zone after the modified line above it. Inside - // a body it reads the innermost body around its gap, as a body line does. - const element = e.event.browserEvent.target as HTMLElement | null; - if ( - (e.target.type === MouseTargetType.CONTENT_VIEW_ZONE || - e.target.type === MouseTargetType.GUTTER_VIEW_ZONE) && - element?.closest?.( - ".line-delete-selectable, .inline-original-margin-view-zone, .inline-deleted-margin-view-zone", - ) - ) { - // The zone follows this 1-based line, so it is the 0-based line below the gap. - const below = e.target.detail.afterLineNumber; - const around = foldables - .filter( - (f) => - f.rail && - !this.isCollapsed(path, f) && - f.rail.start <= below && - below <= f.rail.end, - ) - .sort( - (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), - ); - return around[0] - ? { - foldable: around[0], - rail: false, - control: false, - collapsed: false, - } - : undefined; - } - const line = position.lineNumber - 1; - const content = - e.target.type === MouseTargetType.CONTENT_TEXT || - e.target.type === MouseTargetType.CONTENT_EMPTY; - const gutter = - e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN || - e.target.type === MouseTargetType.GUTTER_LINE_NUMBERS || - e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS; - if (!content && !gutter) { - return undefined; - } - const onChevron = this.inChevronColumn(e); - - // A folded scope's header: its chevron and its pill unfold it. - const folded = foldables.find( - (f) => f.line === line && (this.isFolded(f) || this.isSummaryFolded(f)), - ); - if (folded) { - const onPill = - e.target.type === MouseTargetType.CONTENT_TEXT && - e.target.detail.injectedText?.options.attachedData === PILL; - return { - foldable: folded, - rail: false, - control: onChevron || onPill, - collapsed: true, - }; - } - - const open = foldables.filter((f) => !this.isCollapsed(path, f)); - const railsHere = open - .filter((f) => f.rail && f.rail.start <= line && line <= f.rail.end) - .sort( - (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), - ); - if ( - content && - e.target.mouseColumn - 1 < leadingWidth(model, position.lineNumber) - ) { - const column = e.target.mouseColumn - 1; - const rail = railsHere.find( - (f) => leadingWidth(model, f.line + 1) === column, - ); - if (rail) { - return { - foldable: rail, - rail: true, - control: false, - collapsed: this.isSummaryFolded(rail), - }; - } - } - // A scope without a rail (a statement) is a target only from its chevron; elsewhere on its - // line the pointer reads the innermost body around it, as diffr's TUI does. - const header = open.find( - (f) => f.chevron && f.line === line && (f.rail || onChevron), - ); - if (header) { - return { - foldable: header, - rail: false, - control: onChevron, - collapsed: false, - }; - } - // A blank spot in the chevron column reveals controls without lighting a scope. - if (this.inChevronColumn(e)) { - return undefined; - } - // Inside a body, the innermost rail's scope. - return railsHere[0] - ? { - foldable: railsHere[0], - rail: false, - control: false, - collapsed: false, - } - : undefined; - } - - private foldablesOf(diff: StructuralTextDiff): StructuralFoldable[] { - let foldables = this.foldables.get(diff); - if (!foldables) { - foldables = structuralFoldables(diff[this.side]); - this.foldables.set(diff, foldables); - } - return foldables; - } - - private isCollapsed(path: string, foldable: StructuralFoldable): boolean { - return ( - this.session.isRegionCollapsed(path, foldable.foldStateId) ?? - foldable.collapsedByDefault - ); - } - - private isSummaryFolded(foldable: StructuralFoldable): boolean { - return ( - !!foldable.rail && - this.regions.get().some((region) => { - if ( - region.foldStateId !== foldable.foldStateId || - !bandDetail(region.readLabel(undefined) ?? "") - ) - return false; - return !( - this.side === "rhs" - ? region.getHiddenModifiedRange(undefined) - : region.getHiddenOriginalRange(undefined) - ).isEmpty; - }) - ); - } - - /** Whether the editor hides the scope's body with no band, so the header shows the fold. */ - private isFolded(foldable: StructuralFoldable): boolean { - return ( - !!foldable.inline && - this.regions.get().some((region) => { - if (region.band || region.foldStateId !== foldable.foldStateId) { - return false; - } - const hidden = - this.side === "rhs" - ? region.getHiddenModifiedRange(undefined) - : region.getHiddenOriginalRange(undefined); - return hidden.startLineNumber === foldable.line + 2 && !hidden.isEmpty; - }) - ); - } -} - -/** Scope rails share one clipped overlay, including the gaps occupied by view zones. */ -class ScopeRails implements IOverlayWidget { - readonly allowEditorOverflow = false; - private readonly node: HTMLElement; - private readonly targets = new WeakMap(); - private readonly segments = new Map(); - private added = false; - - constructor(private readonly editor: ICodeEditor) { - this.node = editor.getContainerDomNode().ownerDocument.createElement("div"); - this.node.className = "review-scope-rails"; - } - - getId(): string { - return "review.scopeRails"; - } - getDomNode(): HTMLElement { - return this.node; - } - targetOf(element: HTMLElement | null): StructuralFoldable | undefined { - const rail = element?.closest?.(".review-scope-rail"); - return rail ? this.targets.get(rail) : undefined; - } - - getPosition(): IOverlayWidgetPosition { - return { - preference: { top: 0, left: this.editor.getLayoutInfo().contentLeft }, - }; - } - - show( - model: ITextModel, - foldables: readonly StructuralFoldable[], - target: FoldTarget | undefined, - ): void { - const layout = this.editor.getLayoutInfo(); - const scrollTop = this.editor.getScrollTop(); - const scrollLeft = this.editor.getScrollLeft(); - const spaceWidth = this.editor.getOption(EditorOption.fontInfo).spaceWidth; - this.node.style.display = ""; - this.node.style.width = `${layout.contentWidth}px`; - this.node.style.height = `${layout.height}px`; - const segments: HTMLElement[] = []; - for (const foldable of foldables) { - // Start after the header, not at the first body line: it may be hidden - // behind a band, which Monaco maps back onto the header itself. - const top = Math.max( - 0, - this.editor.getBottomForLineNumber(foldable.line + 1) - scrollTop, - ); - const bottom = Math.min( - layout.height, - this.editor.getTopForLineNumber(foldable.rail!.end + 1) - scrollTop, - ); - const left = - Math.round(leadingWidth(model, foldable.line + 1) * spaceWidth) - - scrollLeft; - if ( - bottom <= top || - !Number.isFinite(left) || - left < 0 || - left >= layout.contentWidth - ) { - continue; - } - let segment = this.segments.get(foldable); - if (!segment) { - segment = this.node.ownerDocument.createElement("div"); - this.segments.set(foldable, segment); - } - const active = target?.foldable.foldStateId === foldable.foldStateId; - segment.className = `review-scope-rail${active ? " is-active" : ""}${active && (target.rail || target.control) ? " is-target" : ""}`; - this.targets.set(segment, foldable); - segment.style.top = `${top}px`; - segment.style.left = `${left}px`; - segment.style.height = `${bottom - top}px`; - segments.push(segment); - } - for (const [foldable, segment] of this.segments) { - if (!segments.includes(segment)) { - this.segments.delete(foldable); - } - } - // Keep the hovered element attached while its paint changes. - if ( - segments.length !== this.node.children.length || - segments.some((segment, i) => this.node.children[i] !== segment) - ) { - this.node.replaceChildren(...segments); - } - if (this.added) { - this.editor.layoutOverlayWidget(this); - } else { - this.added = true; - this.editor.addOverlayWidget(this); - } - } - - hide(): void { - // Model attachment can synchronously trigger layout while Monaco iterates - // its overlay registry. Keep the entry stable until this control is disposed. - this.node.style.display = "none"; - } - dispose(): void { - if (this.added) { - this.added = false; - this.editor.removeOverlayWidget(this); - } - } -} - -/** The visible width of a line's indent. A blank line has no limit, so rails continue through it. */ -function leadingWidth(model: ITextModel, lineNumber: number): number { - const column = model.getLineFirstNonWhitespaceColumn(lineNumber); - if (column === 0) { - return Number.POSITIVE_INFINITY; - } - return CursorColumns.visibleColumnFromColumn( - model.getLineContent(lineNumber), - column, - model.getOptions().tabSize, - ); -} - -/** Native buttons bypass Monaco's mouse-only press/release cycle on touch screens. */ -class TouchFoldButtons implements IOverlayWidget { - private readonly node = document.createElement("div"); - private readonly buttons = new Map(); - private added = false; - constructor( - private readonly editor: ICodeEditor, - private readonly toggle: (foldable: StructuralFoldable) => void, - ) { - this.node.className = "app-touch-folds"; - for (const event of ["pointerdown", "mousedown", "mouseup"]) - this.node.addEventListener(event, (e) => e.stopPropagation()); - } - getId(): string { - return "review.touchFolds"; - } - getDomNode(): HTMLElement { - return this.node; - } - getPosition(): IOverlayWidgetPosition { - return { - // Share the number column's hit area rather than reserving extra code width. - preference: { - top: 0, - left: Math.max(0, this.editor.getLayoutInfo().contentLeft - 44), - }, - }; - } - show( - foldables: readonly StructuralFoldable[], - collapsed: (foldable: StructuralFoldable) => boolean, - ): void { - if (!mobileViewport.matches) { - this.hide(); - return; - } - const layout = this.editor.getLayoutInfo(); - const width = Math.min(44, layout.contentLeft); - const lineHeight = this.editor.getOption(EditorOption.lineHeight); - const visible = new Set(); - for (const foldable of foldables) { - const top = - this.editor.getTopForLineNumber(foldable.line + 1) - - this.editor.getScrollTop(); - if (top < 0 || top >= layout.height) continue; - visible.add(foldable.line); - let button = this.buttons.get(foldable.line); - if (!button) { - button = document.createElement("button"); - button.type = "button"; - button.className = "app-touch-fold"; - this.buttons.set(foldable.line, button); - this.node.append(button); - } - const folded = collapsed(foldable); - button.setAttribute( - "aria-label", - `${folded ? "Expand" : "Fold"} scope at line ${foldable.line + 1}`, - ); - button.setAttribute("aria-expanded", String(!folded)); - button.style.top = `${top}px`; - button.style.width = `${width}px`; - button.style.height = `${lineHeight}px`; - button.onclick = (event) => { - event.stopPropagation(); - this.toggle(foldable); - }; - } - for (const [line, button] of this.buttons) - if (!visible.has(line)) { - button.remove(); - this.buttons.delete(line); - } - this.node.style.height = `${layout.height}px`; - this.node.style.width = `${width}px`; - this.node.style.display = ""; - if (this.added) this.editor.layoutOverlayWidget(this); - else { - this.added = true; - this.editor.addOverlayWidget(this); - } - } - hide(): void { - this.node.style.display = "none"; - } - dispose(): void { - if (this.added) { - this.editor.removeOverlayWidget(this); - this.added = false; - } - } -} diff --git a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts b/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts deleted file mode 100644 index e21fb6015..000000000 --- a/apps/diffr-web/src/review/services/reviewStructuralSyntax.ts +++ /dev/null @@ -1,106 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Review Desktop colours code with its TextMate grammars. The page has none: -// diffr already parsed each side, so its highlight captures paint the text, -// in the Whiteboard theme's roles (extensions/review-themes). -import type { Event } from "vs/base/common/event.js"; -import { Disposable } from "vs/base/common/lifecycle.js"; -import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; -import { MetadataConsts } from "vs/editor/common/encodedTokenAttributes.js"; -import type { ITextModel } from "vs/editor/common/model.js"; -import { SparseMultilineTokens } from "vs/editor/common/tokens/sparseMultilineTokens.js"; - -import { StandaloneServices } from "../../standalone/browser/standaloneServices.js"; -import { IStandaloneThemeService } from "../../standalone/common/standaloneTheme.js"; -import { - utf16Column, - type StructuralSyntaxSpan, -} from "../common/reviewStructuralDiff.js"; -import { syntaxRole, SYNTAX_SCOPES } from "../common/reviewSyntaxRoles.js"; -import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; - -/** 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); - private painted: readonly StructuralSyntaxSpan[] | undefined; - private model: ITextModel | undefined; - - constructor( - private readonly editor: ICodeEditor, - private readonly side: "lhs" | "rhs", - private readonly path: () => string | undefined, - private readonly session: StructuralDiffSession, - onDidChangePath: Event, - ) { - super(); - this._register({ dispose: () => this.clear() }); - this._register(editor.onDidChangeModel(() => this.render())); - this._register(onDidChangePath(() => this.render())); - this._register( - this.themes.onDidColorThemeChange(() => { - this.painted = undefined; - this.render(); - }), - ); - this._register( - session.onDidChange((change) => { - const path = this.path(); - if (path && change.files.has(path)) this.render(); - }), - ); - this.render(); - } - - private clear(): void { - if (this.model && !this.model.isDisposed()) - this.model.tokenization.setSemanticTokens(null, false); - this.model = undefined; - this.painted = undefined; - } - - private render(): void { - const model = this.editor.getModel(); - const path = this.path(); - const spans = path - ? this.session.getTextDiff(path)?.[this.side]?.syntax - : undefined; - if (model !== this.model) this.clear(); - if (!model || !spans) { - this.clear(); - return; - } - if (spans === this.painted) return; - this.model = model; - this.painted = spans; - const theme = this.themes.getColorTheme().tokenTheme; - const tokens: number[] = []; - // The protocol supplies sorted, non-overlapping spans, with inner captures taking precedence. - for (const span of spans) { - const kind = syntaxRole(span.capture); - if (!kind || span.line >= model.getLineCount()) continue; - const text = model.getLineContent(span.line + 1); - const metadata = theme.match(0, SYNTAX_SCOPES[kind]); - tokens.push( - span.line, - utf16Column(text, span.start_column) - 1, - utf16Column(text, span.end_column) - 1, - (metadata & - (MetadataConsts.FOREGROUND_MASK | MetadataConsts.FONT_STYLE_MASK)) | - MetadataConsts.SEMANTIC_USE_FOREGROUND | - MetadataConsts.SEMANTIC_USE_ITALIC | - MetadataConsts.SEMANTIC_USE_BOLD | - MetadataConsts.SEMANTIC_USE_UNDERLINE | - MetadataConsts.SEMANTIC_USE_STRIKETHROUGH, - ); - } - model.tokenization.setSemanticTokens( - tokens.length - ? [SparseMultilineTokens.create(1, new Uint32Array(tokens))] - : [], - true, - ); - } -} diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewed.ts b/apps/diffr-web/src/review/services/reviewStructuralViewed.ts deleted file mode 100644 index a71120ebb..000000000 --- a/apps/diffr-web/src/review/services/reviewStructuralViewed.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Emitter, type Event } from "vs/base/common/event.js"; -import { Disposable } from "vs/base/common/lifecycle.js"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) dev.fast. All rights reserved. - * Licensed under the MIT License. See LICENSE in the repository root for license information. - *--------------------------------------------------------------------------------------------*/ -import type { ICodeEditor } from "vs/editor/browser/editorBrowser.js"; - -import type { ReviewDiffProgress, ReviewDiffViewSpec } from "../../protocol.js"; -import { - structuralViewedProgress, - structuralViewedRanges, - structuralViewedScopes, -} from "../common/reviewStructuralViewed.js"; -import type { ReviewFilesEditorEntry } from "./reviewFilesDiffView.js"; -import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; -import type { FoldTarget } from "./reviewStructuralFolds.js"; -import type { ScopeViewedControl } from "./reviewStructuralViewedControl.js"; - -// Several peeks can mount on one comparison. Only its first coverage snapshot -// restores viewed folds; mounting another peek must not close a reopened fold. -const initializedScopes = new WeakMap>(); - -/** Viewed marks persist as line coverage; opening a fold only changes session fold state. */ -export class StructuralViewedState extends Disposable { - private readonly changed = this._register(new Emitter()); - readonly onDidChange = this.changed.event; - private readonly scopes = new Map< - string, - ReturnType - >(); - private readonly states = new Map(); - private pending = false; - private requestedFold: - | { path: string; id: number; viewed: boolean } - | undefined; - - constructor( - private readonly session: StructuralDiffSession, - private readonly entries: readonly ReviewFilesEditorEntry[], - private readonly progress: () => ReviewDiffProgress | undefined, - onProgress: Event, - private readonly onSetViewed: NonNullable< - ReviewDiffViewSpec["onSetViewed"] - >, - readonly createControl: ( - editor: ICodeEditor, - onToggle: (target: FoldTarget) => void, - ) => ScopeViewedControl, - ) { - super(); - this._register(onProgress(() => this.sync())); - this._register(session.onDidChange(() => this.sync())); - this.sync(); - } - - private getScopes(path: string) { - let scopes = this.scopes.get(path); - const diff = this.session.getTextDiff(path); - if (!scopes && diff) { - scopes = structuralViewedScopes( - diff, - path, - this.entries.find((e) => e.file.path === path)?.file.previousPath, - ); - this.scopes.set(path, scopes); - } - return scopes; - } - - get(path: string, id: number) { - return structuralViewedProgress( - this.getScopes(path)?.get(id) ?? [], - this.progress()?.files.find((f) => f.path === path), - ); - } - - getViewedRanges(path: string) { - return structuralViewedRanges( - this.getScopes(path), - this.progress()?.files.find((f) => f.path === path), - ); - } - - async toggle(path: string, id: number): Promise { - const ranges = this.getScopes(path)?.get(id); - if (!ranges || this.pending) return; - const viewed = this.get(path, id).state !== "viewed"; - this.pending = true; - this.requestedFold = { path, id, viewed }; - try { - await this.onSetViewed(ranges, viewed); - // Fold only the box the reader checked, not every newly covered - // descendant. Reopening should recover the body's previous folds. - this.applyRequestedFold(); - } catch (error) { - this.requestedFold = undefined; - throw error; - } finally { - this.pending = false; - } - } - - private sync(): void { - const initialized = - initializedScopes.get(this.session) ?? new Set(); - initializedScopes.set(this.session, initialized); - for (const entry of this.entries) { - const path = entry.file.path; - if (!this.progress()?.files.some((f) => f.path === path)) continue; - const scopes = this.getScopes(path); - const viewed = [...(scopes ?? [])].filter( - ([id]) => this.get(path, id).state === "viewed", - ); - for (const [id, ranges] of scopes ?? []) { - const state = this.get(path, id).state; - const key = `${path}:${id}`, - previous = this.states.get(key); - this.states.set(key, state); - // Only transitions (and initial load) fold a viewed scope. Reopening - // it must survive unrelated progress updates and layout changes. - const enclosed = viewed.some( - ([other, outer]) => - other !== id && - ranges.every((r) => - outer.some( - (o) => - r.side === o.side && - r.file === o.file && - o.fromLine <= r.fromLine && - o.toLine >= r.toLine, - ), - ) && - !outer.every((o) => - ranges.some( - (r) => - r.side === o.side && - r.file === o.file && - r.fromLine <= o.fromLine && - r.toLine >= o.toLine, - ), - ), - ); - if (state === "viewed" && !initialized.has(key) && !enclosed) - this.session.setRegionCollapsed(path, id, true); - else if (previous === "viewed" && state !== "viewed") - this.session.setRegionCollapsed(path, id, false); - initialized.add(key); - } - } - this.applyRequestedFold(); - this.changed.fire(); - } - - private applyRequestedFold(): void { - const request = this.requestedFold; - // React may deliver the new coverage after the mutation promise resolves. - // Wait for that coverage, rather than folding optimistically on a failed save. - if ( - request && - (this.get(request.path, request.id).state === "viewed") === request.viewed - ) { - this.requestedFold = undefined; - this.session.setRegionCollapsed(request.path, request.id, request.viewed); - } - } -} diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts b/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts deleted file mode 100644 index 7cbdd7dc6..000000000 --- a/apps/diffr-web/src/review/services/reviewStructuralViewedControl.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Disposable } from "vs/base/common/lifecycle.js"; -import type { - ICodeEditor, - IOverlayWidget, - IOverlayWidgetPosition, -} from "vs/editor/browser/editorBrowser.js"; -import { EditorOption } from "vs/editor/common/config/editorOptions.js"; - -import { ReviewViewedCheckbox } from "../browser/reviewTooltip.js"; -import type { FoldTarget } from "./reviewStructuralFolds.js"; -import type { StructuralViewedState } from "./reviewStructuralViewed.js"; - -/** A scope header action, persistent when viewed and otherwise shown on hover. */ -export class ScopeViewedControl extends Disposable implements IOverlayWidget { - private static nextId = 0; - private readonly id = `review.scopeViewed.${ScopeViewedControl.nextId++}`; - readonly allowEditorOverflow = false; - private readonly node: HTMLElement; - private readonly additions: HTMLElement; - private readonly deletions: HTMLElement; - private readonly status: HTMLElement; - private readonly check: ReviewViewedCheckbox; - private target: FoldTarget | undefined; - private added = false; - private top = 0; - - constructor( - private readonly editor: ICodeEditor, - onToggle: (target: FoldTarget) => void, - ) { - super(); - const document = editor.getContainerDomNode().ownerDocument; - this.node = document.createElement("div"); - this.node.className = "review-scope-viewed-control"; - this.additions = document.createElement("span"); - this.additions.className = "review-multidiff-additions"; - this.deletions = document.createElement("span"); - this.deletions.className = "review-multidiff-deletions"; - this.status = document.createElement("span"); - this.status.className = "review-scope-viewed-status"; - this.check = this._register( - new ReviewViewedCheckbox(document, () => { - if (this.target) onToggle(this.target); - }), - ); - this.node.append( - this.additions, - this.deletions, - this.status, - this.check.element, - ); - this._register({ dispose: () => this.hide() }); - } - - getId(): string { - return this.id; - } - getDomNode(): HTMLElement { - return this.node; - } - getPosition(): IOverlayWidgetPosition { - return { preference: this.place() }; - } - /** At the right of the code area, on the scope's header line. */ - private place(): { top: number; left: number } { - const layout = this.editor.getLayoutInfo(); - return { - top: this.top, - left: - layout.contentLeft + layout.contentWidth - this.node.offsetWidth - 12, - }; - } - targetOf(element: HTMLElement | null): FoldTarget | undefined { - return element && this.node.contains(element) ? this.target : undefined; - } - - show( - target: FoldTarget, - progress: ReturnType, - ): void { - // Unified diffs retain a narrow original editor for their gutter. - // It has no code area and must not render a second viewed checkbox. - if (this.editor.getLayoutInfo().width < 60) { - this.hide(); - return; - } - const line = target.foldable.line + 1; - const lineHeight = this.editor.getOption(EditorOption.lineHeight); - const height = Math.max(22, lineHeight); - this.top = - this.editor.getTopForLineNumber(line) - - this.editor.getScrollTop() - - (height - lineHeight) / 2; - if ( - this.top < 0 || - this.top + height > this.editor.getLayoutInfo().height - ) { - this.hide(); - return; - } - this.target = target; - this.node.style.height = `${height}px`; - this.additions.textContent = progress.remaining.additions - ? `+${progress.remaining.additions}` - : ""; - this.deletions.textContent = progress.remaining.deletions - ? `−${progress.remaining.deletions}` - : ""; - this.status.textContent = progress.state === "viewed" ? "Viewed" : ""; - const subject = - this.editor.getModel()?.getLineContent(line).trim() ?? "scope"; - this.check.update(progress.state, subject, false); - if (!this.added) { - this.added = true; - this.editor.addOverlayWidget(this); - } - this.editor.layoutOverlayWidget(this); - // The editor places overlay widgets when it next renders, which a hover alone may not cause - // for a while; until then a new control would sit in the editor's top left corner. - const { top, left } = this.place(); - this.node.style.top = `${top}px`; - this.node.style.left = `${left}px`; - } - - hide(): void { - this.target = undefined; - if (this.added) { - this.added = false; - this.editor.removeOverlayWidget(this); - } - } -} diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 32fb776cb..53d3a81d8 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -122,12 +122,6 @@ body { inset: 0; } -.app-overflow { - position: fixed; - inset: 0 auto auto 0; - z-index: 50; -} - .app-landing { max-width: 520px; margin: 18vh auto 0; @@ -724,90 +718,6 @@ body { font-size: 12px; } -/* Find in all files (find.ts): the editor's own find styles, which the page does not load. */ -.app .monaco-editor .findMatch { - background-color: var( - --vscode-editor-findMatchHighlightBackground, - rgba(234, 92, 0, 0.33) - ); -} - -.app .monaco-editor .currentFindMatch { - background-color: var( - --vscode-editor-findMatchBackground, - rgba(234, 92, 0, 0.6) - ); - border: 2px solid var(--vscode-editor-findMatchBorder, transparent); - box-sizing: border-box; -} - -/* - * A summary stands in for the code it folds, so it reads like code: the editor's own colour, upright, - * with an accent rule that marks it as written by a model. Click it for the code. - */ -.app - .monaco-editor - .diff-hidden-lines.summary-fold - .detail - pre.diff-hidden-lines-detail { - color: color-mix( - in srgb, - var(--vscode-editor-foreground) 85%, - transparent - ) !important; - font-style: normal; - padding-left: 10px; - border-left: 2px solid - color-mix(in srgb, var(--vscode-textLink-foreground) 60%, transparent); -} - -.app - .monaco-editor - .diff-hidden-lines.summary-fold - .detail:hover - pre.diff-hidden-lines-detail { - border-left-color: var(--vscode-textLink-foreground); -} - -/* Read-only diffs keep selection and copy, without an editing caret. */ -.app .monaco-editor .cursors-layer { - display: none; -} -.app-native-scroll, -.app-native-scroll-extent, -.app-diff-viewport { - height: 100%; - width: 100%; -} -.app-native-scroll { - overflow: hidden; -} -.app-native-scroll.is-native { - overflow-y: auto; - overscroll-behavior-y: contain; - overflow-anchor: none; -} -.is-native .app-diff-viewport { - position: sticky; - top: 0; - touch-action: pan-y; -} -.is-native .monaco-editor .view-lines, -.is-native .monaco-editor .view-line, -.is-native .monaco-editor .lines-content { - user-select: text !important; - -webkit-user-select: text !important; -} -.is-native .monaco-editor .inputarea { - pointer-events: none; -} -.is-native - .multiDiffEditor - > div - > .monaco-scrollable-element - > .scrollbar.vertical { - display: none !important; -} .app-mobile-files-host:not([hidden]) { display: contents; } @@ -953,51 +863,7 @@ body { font-size: 16px; } } -.is-native .monaco-editor .inputarea { - font-size: 16px !important; -} -.app-touch-folds { - position: absolute; - pointer-events: none; - z-index: 10; - overflow: hidden; -} -.app-touch-fold { - position: absolute; - left: 0; - pointer-events: auto; - border: 0; - background: transparent; - color: var(--vscode-descriptionForeground); - padding: 0; - touch-action: manipulation; -} -.app-touch-fold::before, -.app .monaco-editor .diff-fold-chevron.codicon::before, -.app .monaco-editor .diff-fold-chevron .codicon::before { - content: ""; - display: block; - width: 12px; - height: 12px; - background: currentColor; - mask: url("data:image/svg+xml;utf8,") - center / 12px 12px no-repeat; -} -.app-touch-fold::before { - position: absolute; - right: 6px; - top: calc(50% - 6px); -} -.app-touch-fold[aria-expanded="true"]::before { - transform: rotate(90deg); -} -.app-touch-fold:active { - background: var(--vscode-toolbar-hoverBackground); -} -.app-touch-fold:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -2px; -} + .app-mobile-fold-actions { display: flex; gap: 8px; @@ -1006,16 +872,6 @@ body { .app-mobile-fold-actions > button { flex: 1; } -@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { - .review-workbench .monaco-editor .review-fold-chevron { - visibility: hidden; - } - .app.review-workbench .monaco-editor .review-scope-viewed-control, - .app .review-multidiff-note, - .app .review-multidiff-open-container { - display: none; - } -} /* Mobile navigation uses the desktop tree's status marks and line-count colors. */ .app-mobile-files-heading .app-mobile-files-close:focus:not(:focus-visible) { @@ -1250,26 +1106,7 @@ body { color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground); } - .app .multiDiffEntry > .header > .header-content { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } - .app - .monaco-component.multiDiffEditor - .multiDiffEntry.active - > .header - .header-content { - border-top-color: var(--vscode-multiDiffEditor-border); - } - .app - .monaco-component.multiDiffEditor - .multiDiffEntry.active - > .editorParent { - border-bottom-color: var(--vscode-multiDiffEditor-border); - } - .app .review-multidiff-counts { - font-size: 11px; - gap: 5px; - } + .app-mobile-files { background: var(--vscode-editor-background); box-shadow: 0 -12px 48px rgb(0 0 0 / 0.12); @@ -1415,8 +1252,7 @@ body { .app-mobile-file-search:not([hidden]) { animation: app-control-enter 160ms ease-out; } - .app-mobile-folder-label::before, - .app-touch-fold::before { + .app-mobile-folder-label::before { transition: transform 160ms ease; } .app-icon-button, @@ -1444,20 +1280,3 @@ body { transform: translateY(0); } } - -.app .monaco-editor .diff-hidden-lines .detail pre.diff-hidden-lines-detail { - overflow-x: auto; - text-overflow: clip; - scrollbar-width: thin; - user-select: text; - -webkit-user-select: text; -} -.app .multiDiffEditor > div > .monaco-scrollable-element > .scrollbar { - z-index: 2000; -} - -/* Monaco translates this virtual content itself. A hidden overflow container can still be - scrolled by browser focus, which makes Safari shift the whole diff after a resize. */ -.is-native .multiDiffEditor .scrollContent > div:first-child { - overflow: clip !important; -} diff --git a/apps/diffr-web/src/viewport.ts b/apps/diffr-web/src/viewport.ts new file mode 100644 index 000000000..d1f68f329 --- /dev/null +++ b/apps/diffr-web/src/viewport.ts @@ -0,0 +1,4 @@ +/** A phone, or a phone turned sideways: diffr reads in one column of big touch targets. */ +export const mobileViewport = window.matchMedia( + "(max-width: 760px), (pointer: coarse) and (max-height: 500px)", +); From 35509bc5d7e40125fa043f4cca78d7ac1e8911ea Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Mon, 12 Oct 2026 13:18:54 -0500 Subject: [PATCH 02/19] Bring back diffr-web's scope rails, scope viewed marks and pinch to fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Monaco view had these and the plain rows did not yet. - On a desktop, the scope under the pointer lights its chevron, shows a rail down its indentation that folds it on click, and shows its changes left to read with a viewed box on its header. - Marking a scope viewed records its lines as viewed, folds it and dims them. A viewed scope keeps "Viewed" on its header, the outermost one only, and its pill reads ✓. Viewed scopes start folded, as before. StructuralViewedState comes back without its Monaco widget hook. - On a phone, pinching in over a scope folds it and pinching out over a fold opens it. The list sets touch-action: pan-x pan-y, so Safari never zooms the page under it, and the touch listeners stay passive. - Wrapped lines continue under their own indentation, and their number and chevron stay beside the first line. Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/comparison.ts | 12 + apps/diffr-web/src/diffView.css | 95 ++++ apps/diffr-web/src/diffView.ts | 469 +++++++++++++++++- .../review/common/reviewStructuralViewed.ts | 128 +++++ .../review/services/reviewStructuralViewed.ts | 163 ++++++ 5 files changed, 845 insertions(+), 22 deletions(-) create mode 100644 apps/diffr-web/src/review/common/reviewStructuralViewed.ts create mode 100644 apps/diffr-web/src/review/services/reviewStructuralViewed.ts diff --git a/apps/diffr-web/src/comparison.ts b/apps/diffr-web/src/comparison.ts index 275f20961..5f9521bd9 100644 --- a/apps/diffr-web/src/comparison.ts +++ b/apps/diffr-web/src/comparison.ts @@ -39,6 +39,7 @@ import { } from "./review/common/reviewStructuralDiff.js"; import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; import { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; +import { StructuralViewedState } from "./review/services/reviewStructuralViewed.js"; import { ViewedProgress } from "./viewed.js"; /** Files fetched or diffed at once. The engine spreads them over its workers. */ @@ -548,11 +549,22 @@ export class Comparison extends Disposable { new ViewedProgress(targetPath(this.target)), )); + const scopes = store.add( + new StructuralViewedState( + session, + entries, + () => viewed.progress, + viewed.onDidChange, + (ranges, isViewed) => viewed.setViewed(ranges, isViewed), + ), + ); + const view = store.add( new DiffView( this.container, session, (path) => void viewed.toggleFile(path), + scopes, this.wordWrap, this.layout, this.instantiation, diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index dadefd8a5..e8a5ba970 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -355,6 +355,27 @@ overflow-wrap: anywhere; } +/* diffView.ts sets --dv-hang to the line's indentation; the first line keeps its own. */ +.is-wrapped .dv-code { + padding-left: calc(8px + var(--dv-hang, 0ch)); + text-indent: calc(-1 * var(--dv-hang, 0ch)); +} + +/* An inline block takes the indent as its own first line's. */ +.is-wrapped .dv-code * { + text-indent: 0; +} + +/* A wrapped line's number and chevron stay beside its first line. */ +.is-wrapped .dv-gutter { + align-items: flex-start; +} + +.is-wrapped .dv-chevron { + bottom: auto; + height: var(--dv-line); +} + .dv-changed { border-radius: 2px; } @@ -711,3 +732,77 @@ .dv:not(.is-compact) .dv-pill { font-size: 11px; } + +/* A phone pinches to fold, so the page never zooms under the diff. */ +.dv.is-compact { + touch-action: pan-x pan-y; +} + +/* Rails are drawn in the block, pane or grid that holds their rows. */ +.dv-chunk, +.dv-pane-rows, +.dv-seg.is-grid { + position: relative; +} + +/* Viewed lines read as done. */ +.dv-row.is-viewed > .dv-code { + opacity: 0.55; +} + +.dv-pill.is-viewed { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +/* The scope under the pointer: its chevron, and a rail down its body that folds it. */ +.dv-row.is-scope-head .dv-chevron { + color: var(--vscode-textLink-foreground); +} + +.dv-rail { + position: absolute; + width: 9px; + margin-left: -4px; + cursor: pointer; +} + +.dv-rail::before { + content: ""; + position: absolute; + inset: 0 auto 0 4px; + width: 1px; + background: var(--vscode-textLink-foreground); + opacity: 0.6; +} + +.dv-rail:hover::before { + left: 3px; + width: 3px; + opacity: 1; +} + +/* A scope's changes left to read and its viewed box, at the row's trailing edge. */ +.dv-scope { + position: sticky; + right: 0; + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + padding: 0 8px 0 12px; + font: + 11px -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); +} + +.dv-scope-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.dv-scope-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index f2acad3d0..9357f4a63 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -55,6 +55,7 @@ import { } from "./review/common/reviewSyntaxRoles.js"; import type { ReviewDiffLayoutSetting } from "./review/services/reviewDiffLayout.js"; import type { StructuralDiffSession } from "./review/services/reviewStructuralDiffSession.js"; +import type { StructuralViewedState } from "./review/services/reviewStructuralViewed.js"; import { StandaloneServices } from "./standalone/browser/standaloneServices.js"; import { IStandaloneThemeService } from "./standalone/common/standaloneTheme.js"; import { element } from "./ui.js"; @@ -81,6 +82,19 @@ interface RowContext { readonly text: Record<"lhs" | "rhs", string[]>; readonly syntax: Record<"lhs" | "rhs", Map>; readonly changed: Record<"lhs" | "rhs", Map>; + readonly path: string; + /** Lines the reader has viewed, from 0; empty sets when none are. */ + readonly viewed: Record<"lhs" | "rhs", Set>; + /** Header lines of viewed scopes that keep their viewed control in view. */ + readonly viewedHeads: Record<"lhs" | "rhs", Set>; +} + +/** A scope on one side of a file: the fold a row heads, or the smallest open one around it. */ +interface Scope { + readonly path: string; + readonly side: "lhs" | "rhs"; + readonly foldable: StructuralFoldable; + readonly collapsed: boolean; } /** One find match, as find.ts counts: 1-based line and column. */ @@ -117,6 +131,8 @@ interface FileView { readonly scrolled: { lhs: number; rhs: number }; /** Its body's blocks of rows, while it is drawn. */ chunks: Chunk[]; + /** The viewed lines it was drawn with, to redraw when they change. */ + viewedKey?: string; } /** One changed file of the comparison. */ @@ -175,6 +191,7 @@ export class DiffView extends Disposable { container: HTMLElement, private readonly session: StructuralDiffSession, private readonly onToggleViewed: (path: string) => void, + private readonly scopes: StructuralViewedState, wordWrap: IObservable, private readonly layout: ReviewDiffLayoutSetting, instantiation: IInstantiationService, @@ -319,6 +336,7 @@ export class DiffView extends Disposable { { capture: true, passive: true }, ); this.root.addEventListener("click", (event) => this.onClick(event)); + this.listenForScopes(); this.root.addEventListener("keydown", (event) => { if ( (event.key === "Enter" || event.key === " ") && @@ -368,6 +386,7 @@ export class DiffView extends Disposable { }), ), ); + this._register(this.scopes.onDidChange(() => this.syncViewed())); } /** The theme's colour for each of diffr's syntax roles, as CSS variables. */ @@ -672,6 +691,8 @@ export class DiffView extends Disposable { file.built = true; file.body.style.height = ""; + + if (this.hover?.path === file.entry.file.path) this.clearScope(); file.body.replaceChildren( this.renderDiff(file.entry.file.path, diff, file), ); @@ -720,6 +741,8 @@ export class DiffView extends Disposable { }, syntax: { lhs: syntaxByLine(diff.lhs), rhs: syntaxByLine(diff.rhs) }, changed: changedByLine(diff), + path, + ...this.viewedLines(path, diff, file), }; const rows = element("div", this.split ? "dv-rows is-split" : "dv-rows"); @@ -1007,10 +1030,11 @@ export class DiffView extends Disposable { /** One line; `side`, in the split view, the side it is drawn on. */ private renderLine( line: UnifiedLine, - { text, syntax, changed }: RowContext, + context: RowContext, startsChange: boolean, side?: "lhs" | "rhs", ): HTMLElement { + const { text, syntax, changed } = context; const row = element("div", `dv-row is-${line.change}`); if (side) row.dataset.side = side; @@ -1061,6 +1085,19 @@ export class DiffView extends Disposable { else gutter.append(element("span", "dv-lineno", String(number + 1))); const code = row.appendChild(element("span", "dv-code")); const source = text[line.side][number] ?? ""; + + // Wrapped, a long line continues under its own indentation, as an editor wraps it. + if (this.wrapped) { + let columns = 0; + + for (const character of source) { + if (character === " ") columns++; + else if (character === "\t") columns += 8 - (columns % 8); + else break; + } + + if (columns) code.style.setProperty("--dv-hang", `${columns}ch`); + } code.append( renderCode( source, @@ -1069,9 +1106,19 @@ export class DiffView extends Disposable { ), ); + if (context.viewed[line.side].has(number)) row.classList.add("is-viewed"); + if (line.pill) { + const viewed = + context.viewed[line.side].size > 0 && + this.scopes.get(context.path, line.pill.foldStateId).state === "viewed"; + const pill = code.appendChild( - element("span", "dv-pill", `⋯ ${line.pill.label}`), + element( + "span", + viewed ? "dv-pill is-viewed" : "dv-pill", + `${viewed ? "✓" : "⋯"} ${line.pill.label}`, + ), ); pill.setAttribute("aria-hidden", "true"); @@ -1080,6 +1127,16 @@ export class DiffView extends Disposable { code.append(element("span", "dv-closer", ` ${line.pill.closer}`)); } + if (line.fold && context.viewedHeads[line.side].has(number)) { + const control = this.scopeControl( + context.path, + line.fold.foldStateId, + source.trim(), + ); + + if (control) row.append(control); + } + return row; } @@ -1127,6 +1184,28 @@ export class DiffView extends Disposable { const overSelection = this.pressedOverSelection; this.pressedOverSelection = false; + const scopeCheck = target.closest(".dv-scope > button"); + const rail = target.closest(".dv-rail"); + + const scopePath = (scopeCheck ?? rail)?.closest(".dv-file") + ?.dataset.path; + + if (scopeCheck && scopePath) { + void this.scopes.toggle(scopePath, Number(scopeCheck.dataset.scope)); + + return; + } + + if (rail && scopePath) { + this.session.setRegionCollapsed( + scopePath, + Number(rail.dataset.fold), + true, + ); + + return; + } + if (target.closest(".review-viewed-check, .dv-open")) return; const section = target.closest(".dv-file"); const file = section && this.files.get(section.dataset.path!); @@ -1460,22 +1539,8 @@ export class DiffView extends Disposable { */ foldCommand(command: string): void { const row = this.currentRow(); - const section = row?.closest(".dv-file"); - const path = section?.dataset.path; - const diff = path ? this.session.getTextDiff(path) : undefined; - - if (!row || !path || !diff) return; - - const side = - row.dataset.head !== undefined && row.dataset.side !== "lhs" - ? "rhs" - : "lhs"; - const line = Number(side === "rhs" ? row.dataset.head : row.dataset.base); - - const foldables = structuralFoldables( - side === "rhs" ? diff.rhs : diff.lhs, - ).filter((foldable) => foldable.chevron); + if (!row) return; if (command === "j" || command === "k") { const rows = [ @@ -1505,6 +1570,51 @@ export class DiffView extends Disposable { } const letter = command.toLowerCase(); + const scope = this.scopeAt(row); + const diff = scope && this.session.getTextDiff(scope.path); + + if (!scope || !diff || !"aoc".includes(letter)) return; + const collapse = letter === "a" ? !scope.collapsed : letter === "c"; + const id = scope.foldable.foldStateId; + + for (const each of command !== letter ? [id, ...nestedIds(diff, id)] : [id]) + this.session.setRegionCollapsed(scope.path, each, collapse); + } + + private readonly foldablesBySource = new WeakMap< + StructuralSource, + StructuralFoldable[] + >(); + + /** A side's foldables with chevrons, worked out once per diff. */ + private foldablesOf(source: StructuralSource | undefined) { + if (!source) return []; + let foldables = this.foldablesBySource.get(source); + + if (!foldables) { + foldables = structuralFoldables(source).filter( + (foldable) => foldable.chevron, + ); + this.foldablesBySource.set(source, foldables); + } + + return foldables; + } + + /** The fold `row` heads, else the smallest open scope around it, on the row's side. */ + private scopeAt(row: HTMLElement): Scope | undefined { + const path = row.closest(".dv-file")?.dataset.path; + const diff = path ? this.session.getTextDiff(path) : undefined; + + if (!path || !diff) return undefined; + + const side = + row.dataset.head !== undefined && row.dataset.side !== "lhs" + ? "rhs" + : "lhs"; + + const line = Number(side === "rhs" ? row.dataset.head : row.dataset.base); + const foldables = this.foldablesOf(side === "rhs" ? diff.rhs : diff.lhs); const collapsed = (foldable: StructuralFoldable) => this.session.isRegionCollapsed(path, foldable.foldStateId) ?? @@ -1524,12 +1634,327 @@ export class DiffView extends Disposable { (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), )[0]; - if (!foldable || !"aoc".includes(letter)) return; - const collapse = letter === "a" ? !collapsed(foldable) : letter === "c"; - const id = foldable.foldStateId; + return foldable && { path, side, foldable, collapsed: collapsed(foldable) }; + } - for (const each of command !== letter ? [id, ...nestedIds(diff, id)] : [id]) - this.session.setRegionCollapsed(path, each, collapse); + /** + * The lines the reader has viewed, and the viewed scopes whose control stays on their header: + * each outermost one, since it accounts for the scopes inside it. + */ + private viewedLines(path: string, diff: StructuralTextDiff, file: FileView) { + const ranges = this.scopes.getViewedRanges(path); + file.viewedKey = JSON.stringify(ranges); + const viewed = { lhs: new Set(), rhs: new Set() }; + const viewedHeads = { lhs: new Set(), rhs: new Set() }; + + for (const range of ranges) + for (let line = range.fromLine; line <= range.toLine; line++) + viewed[range.side === "head" ? "rhs" : "lhs"].add(line - 1); + + if (this.compact || !ranges.length) return { viewed, viewedHeads }; + + for (const side of ["lhs", "rhs"] as const) { + const done = this.foldablesOf(diff[side]).filter( + (foldable) => + foldable.rail && + viewed[side].has(foldable.line) && + this.scopes.get(path, foldable.foldStateId).state === "viewed", + ); + + for (const scope of done) + if ( + !done.some( + (outer) => + outer.line < scope.line && outer.rail!.end >= scope.rail!.end, + ) + ) + viewedHeads[side].add(scope.line); + } + + return { viewed, viewedHeads }; + } + + /** Redraw the files whose viewed lines changed. */ + private syncViewed(): void { + this.preserve(() => { + for (const file of this.order) + if ( + file.built && + file.viewedKey !== + JSON.stringify(this.scopes.getViewedRanges(file.entry.file.path)) + ) + this.renderBody(file); + }); + } + + /** A scope's changes left to read and its viewed box; none for a scope with no changes. */ + private scopeControl( + path: string, + foldStateId: number, + subject: string, + ): HTMLElement | undefined { + const progress = this.scopes.get(path, foldStateId); + + if (!progress.total.additions && !progress.total.deletions) + return undefined; + const control = element("span", "dv-scope"); + + if (progress.remaining.additions) + control.append( + element("span", "dv-scope-added", `+${progress.remaining.additions}`), + ); + + if (progress.remaining.deletions) + control.append( + element("span", "dv-scope-removed", `−${progress.remaining.deletions}`), + ); + const viewed = progress.state === "viewed"; + + if (viewed) control.append(element("span", "dv-scope-status", "Viewed")); + const check = control.appendChild(element("button", "review-viewed-check")); + check.type = "button"; + check.dataset.scope = String(foldStateId); + check.setAttribute("role", "checkbox"); + check.setAttribute( + "aria-checked", + progress.state === "partial" ? "mixed" : String(viewed), + ); + check.setAttribute( + "aria-label", + `${viewed ? "Mark unviewed" : "Mark viewed"}: ${subject}`, + ); + + return control; + } + + private hover: + | { key: string; path: string; marks: HTMLElement[] } + | undefined; + private hoverRow: HTMLElement | undefined; + + /** + * A desktop lights the scope under the pointer: its header, its viewed control and a rail down + * its body that folds it. A phone folds by pinching: in over a scope to fold it, out over a fold + * to open it. + */ + private listenForScopes(): void { + this.root.addEventListener( + "pointermove", + (event) => { + if (this.compact || event.pointerType !== "mouse") return; + const target = event.target instanceof Element ? event.target : null; + + // The pointer on a scope's rail or control is still in the scope. + if (target?.closest(".dv-rail, .dv-scope")) return; + const row = target?.closest(".dv-row") ?? undefined; + + if (row === this.hoverRow) return; + this.hoverRow = row; + const scope = row && this.scopeAt(row); + + const key = + scope && + `${scope.path}\0${scope.side}\0${scope.foldable.foldStateId}`; + + if (key === this.hover?.key) return; + this.clearScope(); + + if (scope && key) this.showScope(scope, key); + }, + { passive: true }, + ); + this.root.addEventListener("pointerleave", () => { + this.hoverRow = undefined; + this.clearScope(); + }); + + let pinch: + | { distance: number; x: number; y: number; done: boolean } + | undefined; + + const spread = (touches: TouchList) => + Math.hypot( + touches[0]!.clientX - touches[1]!.clientX, + touches[0]!.clientY - touches[1]!.clientY, + ); + + // The page never zooms here (touch-action in diffView.css), so these never need to cancel + // the browser's scrolling, and stay passive. + this.root.addEventListener( + "touchstart", + (event) => { + pinch = + this.compact && event.touches.length === 2 + ? { + distance: spread(event.touches), + x: (event.touches[0]!.clientX + event.touches[1]!.clientX) / 2, + y: (event.touches[0]!.clientY + event.touches[1]!.clientY) / 2, + done: false, + } + : undefined; + }, + { passive: true }, + ); + this.root.addEventListener( + "touchmove", + (event) => { + if (!pinch || pinch.done || event.touches.length !== 2) return; + const delta = spread(event.touches) - pinch.distance; + + if (Math.abs(delta) < Math.max(24, pinch.distance * 0.18)) return; + pinch.done = true; + this.pinchFold(pinch.x, pinch.y, delta < 0); + }, + { passive: true }, + ); + + for (const type of ["touchend", "touchcancel"]) + this.root.addEventListener(type, () => (pinch = undefined), { + passive: true, + }); + } + + private showScope(scope: Scope, key: string): void { + const file = this.files.get(scope.path); + + if (!file?.built) return; + const { side, foldable } = scope; + const marks: HTMLElement[] = []; + this.hover = { key, path: scope.path, marks }; + const cells = this.split ? `.dv-row[data-side="${side}"]` : ".dv-row"; + const attribute = side === "rhs" ? "head" : "base"; + + const header = + file.body.querySelector( + `${cells}.has-fold[data-${attribute}="${foldable.line}"]`, + ) ?? undefined; + + if (header) { + header.classList.add("is-scope-head"); + marks.push(header); + + if (!header.querySelector(".dv-scope")) { + const control = this.scopeControl( + scope.path, + foldable.foldStateId, + header.querySelector(".dv-code")?.textContent?.trim() ?? "scope", + ); + + if (control) { + header.append(control); + marks.push(control); + } + } + } + + if (scope.collapsed || !foldable.rail) return; + + // The body's rows on this side, by the block or pane that holds them: a rail is drawn in each. + const groups = new Map(); + + for (const row of file.body.querySelectorAll(cells)) { + const value = row.dataset[attribute]; + + if (value === undefined) continue; + const line = Number(value); + + if (line <= foldable.line || line >= foldable.rail.end) continue; + const parent = row.parentElement!; + const rows = groups.get(parent) ?? []; + rows.push(row); + groups.set(parent, rows); + } + + const first = groups.values().next().value?.[0]; + const code = first?.querySelector(".dv-code"); + + if (!code) return; + // The rail runs down the header's indentation, measured in the code's own font. + const source = this.session.getTextDiff(scope.path)?.[side]?.text ?? ""; + const headerText = sourceLines(source)[foldable.line] ?? ""; + + const measure = code.appendChild( + element("span", undefined, /^[\t ]*/.exec(headerText)![0]), + ); + + const indent = measure.getBoundingClientRect().width; + measure.remove(); + const padding = parseFloat(getComputedStyle(code).paddingLeft) || 0; + const lines = foldable.rail.end - foldable.rail.start; + + for (const [parent, rows] of groups) { + const bounds = parent.getBoundingClientRect(); + const top = rows[0]!.getBoundingClientRect().top; + const bottom = rows.at(-1)!.getBoundingClientRect().bottom; + + const left = rows[0]! + .querySelector(".dv-code")! + .getBoundingClientRect().left; + + const rail = parent.appendChild(element("div", "dv-rail")); + rail.dataset.fold = String(foldable.foldStateId); + rail.title = `Fold ${lines} line${lines === 1 ? "" : "s"}`; + rail.style.top = `${top - bounds.top}px`; + rail.style.height = `${bottom - top}px`; + rail.style.left = `${left - bounds.left + padding + indent}px`; + marks.push(rail); + } + } + + private clearScope(): void { + for (const mark of this.hover?.marks ?? []) + if (mark.classList.contains("dv-row")) + mark.classList.remove("is-scope-head"); + else mark.remove(); + + this.hover = undefined; + } + + /** Fold the open scope under a pinch, or open the fold nearest one. */ + private pinchFold(x: number, y: number, collapse: boolean): void { + const hit = document + .elementsFromPoint(x, y) + .find((node) => this.root.contains(node)); + + const row = hit?.closest(".dv-row"); + const scope = collapse && row ? this.scopeAt(row) : undefined; + + if (scope && !scope.collapsed) { + this.session.setRegionCollapsed( + scope.path, + scope.foldable.foldStateId, + true, + ); + + return; + } + + const folds = collapse + ? ".dv-row.has-fold:not(.is-folded)" + : ".dv-band, .dv-row.is-folded"; + + // Fingers are wide: the fold under the pinch, else the nearest within a tap target's reach. + let nearest = hit?.closest(folds) ?? undefined; + let best = 44; + + if (!nearest) + for (const node of this.root.querySelectorAll(folds)) { + const bounds = node.getBoundingClientRect(); + const distance = Math.abs((bounds.top + bounds.bottom) / 2 - y); + + if (distance > best) continue; + best = distance; + nearest = node; + } + + const path = nearest?.closest(".dv-file")?.dataset.path; + + if (nearest && path) + this.session.setRegionCollapsed( + path, + Number(nearest.dataset.fold), + collapse, + ); } private caret: { path: string; selector: string } | undefined; diff --git a/apps/diffr-web/src/review/common/reviewStructuralViewed.ts b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts new file mode 100644 index 000000000..5d6a980ff --- /dev/null +++ b/apps/diffr-web/src/review/common/reviewStructuralViewed.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { ReviewDiffLens, ReviewDiffProgressFile } from "../../protocol.js"; +import { + regionLines, + type StructuralRegion, + type StructuralTextDiff, +} from "./reviewStructuralDiff.js"; + +type Sources = ReviewDiffLens["ranges"]; +type Source = Sources[number]; + +/** All members of a fold state, including a bundled doc comment and both revisions. */ +export function structuralViewedScopes( + diff: StructuralTextDiff, + path: string, + previousPath = path, +): ReadonlyMap { + const result = new Map(); + const scopes = new Set(); + for (const [side, source, file] of [ + ["base", diff.lhs, previousPath], + ["head", diff.rhs, path], + ] as const) { + const visit = (region: StructuralRegion) => { + if (region.kind !== "fold") return; + if (region.syntax) scopes.add(region.fold_state_id); + const span = regionLines(region); + const fromLine = (region.syntax?.start.line ?? span.start) + 1; + const toLine = region.syntax ? region.syntax.end.line + 1 : span.end; + if (toLine >= fromLine) { + const ranges = result.get(region.fold_state_id) ?? []; + ranges.push({ side, file, fromLine, toLine }); + result.set(region.fold_state_id, ranges); + } + region.children.forEach(visit); + }; + // The file header owns the root's viewed control. + if (source?.root.kind === "fold") source.root.children.forEach(visit); + } + for (const [id, ranges] of result) { + if (scopes.has(id)) result.set(id, mergeSources(ranges)); + else result.delete(id); + } + return result; +} + +/** Viewed state depends on changed-line coverage, never on whether a fold is open. */ +export function structuralViewedProgress( + sources: Sources, + file: ReviewDiffProgressFile | undefined, +) { + const changes = intersectSources(file?.changedRanges ?? [], sources); + const viewed = intersectSources(changes, file?.viewedRanges ?? []); + const count = (ranges: Sources, side: Source["side"]) => + ranges.reduce( + (sum, r) => sum + (r.side === side ? r.toLine - r.fromLine + 1 : 0), + 0, + ); + const total = { + additions: count(changes, "head"), + deletions: count(changes, "base"), + }; + const remaining = { + additions: total.additions - count(viewed, "head"), + deletions: total.deletions - count(viewed, "base"), + }; + const size = total.additions + total.deletions, + unread = remaining.additions + remaining.deletions; + const state = + size > 0 && unread === 0 ? "viewed" : unread < size ? "partial" : "unread"; + return { state, total, remaining } as const; +} + +/** Lens marks can cover individual changes without covering any complete syntax scope. */ +export function structuralViewedRanges( + scopes: ReadonlyMap | undefined, + file: ReviewDiffProgressFile | undefined, +): Sources { + const ranges = intersectSources( + file?.changedRanges ?? [], + file?.viewedRanges ?? [], + ); + for (const sources of scopes?.values() ?? []) { + if (structuralViewedProgress(sources, file).state === "viewed") + ranges.push(...sources); + } + return mergeSources(ranges); +} + +function intersectSources(a: Sources, b: Sources): Source[] { + return mergeSources( + a.flatMap((left) => + b.flatMap((right) => { + if (left.side !== right.side || left.file !== right.file) return []; + const fromLine = Math.max(left.fromLine, right.fromLine), + toLine = Math.min(left.toLine, right.toLine); + return fromLine <= toLine ? [{ ...left, fromLine, toLine }] : []; + }), + ), + ); +} + +function mergeSources(ranges: Sources): Source[] { + const result: Source[] = []; + for (const range of [...ranges].sort( + (a, b) => + a.side.localeCompare(b.side) || + a.file.localeCompare(b.file) || + a.fromLine - b.fromLine, + )) { + const last = result[result.length - 1]; + if ( + last && + last.side === range.side && + last.file === range.file && + range.fromLine <= last.toLine + 1 + ) { + result[result.length - 1] = { + ...last, + toLine: Math.max(last.toLine, range.toLine), + }; + } else result.push(range); + } + return result; +} diff --git a/apps/diffr-web/src/review/services/reviewStructuralViewed.ts b/apps/diffr-web/src/review/services/reviewStructuralViewed.ts new file mode 100644 index 000000000..abfd9fa45 --- /dev/null +++ b/apps/diffr-web/src/review/services/reviewStructuralViewed.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Emitter, type Event } from "vs/base/common/event.js"; +import { Disposable } from "vs/base/common/lifecycle.js"; + +import type { + ReviewDiffFileWire, + ReviewDiffProgress, + ReviewDiffViewSpec, +} from "../../protocol.js"; +import { + structuralViewedProgress, + structuralViewedRanges, + structuralViewedScopes, +} from "../common/reviewStructuralViewed.js"; +import type { StructuralDiffSession } from "./reviewStructuralDiffSession.js"; + +// Several peeks can mount on one comparison. Only its first coverage snapshot +// restores viewed folds; mounting another peek must not close a reopened fold. +const initializedScopes = new WeakMap>(); + +/** Viewed marks persist as line coverage; opening a fold only changes session fold state. */ +export class StructuralViewedState extends Disposable { + private readonly changed = this._register(new Emitter()); + readonly onDidChange = this.changed.event; + private readonly scopes = new Map< + string, + ReturnType + >(); + private readonly states = new Map(); + private pending = false; + private requestedFold: + | { path: string; id: number; viewed: boolean } + | undefined; + + constructor( + private readonly session: StructuralDiffSession, + private readonly entries: readonly { readonly file: ReviewDiffFileWire }[], + private readonly progress: () => ReviewDiffProgress | undefined, + onProgress: Event, + private readonly onSetViewed: NonNullable< + ReviewDiffViewSpec["onSetViewed"] + >, + ) { + super(); + this._register(onProgress(() => this.sync())); + this._register(session.onDidChange(() => this.sync())); + this.sync(); + } + + private getScopes(path: string) { + let scopes = this.scopes.get(path); + const diff = this.session.getTextDiff(path); + if (!scopes && diff) { + scopes = structuralViewedScopes( + diff, + path, + this.entries.find((e) => e.file.path === path)?.file.previousPath, + ); + this.scopes.set(path, scopes); + } + return scopes; + } + + get(path: string, id: number) { + return structuralViewedProgress( + this.getScopes(path)?.get(id) ?? [], + this.progress()?.files.find((f) => f.path === path), + ); + } + + getViewedRanges(path: string) { + return structuralViewedRanges( + this.getScopes(path), + this.progress()?.files.find((f) => f.path === path), + ); + } + + async toggle(path: string, id: number): Promise { + const ranges = this.getScopes(path)?.get(id); + if (!ranges || this.pending) return; + const viewed = this.get(path, id).state !== "viewed"; + this.pending = true; + this.requestedFold = { path, id, viewed }; + try { + await this.onSetViewed(ranges, viewed); + // Fold only the box the reader checked, not every newly covered + // descendant. Reopening should recover the body's previous folds. + this.applyRequestedFold(); + } catch (error) { + this.requestedFold = undefined; + throw error; + } finally { + this.pending = false; + } + } + + private sync(): void { + const initialized = + initializedScopes.get(this.session) ?? new Set(); + initializedScopes.set(this.session, initialized); + for (const entry of this.entries) { + const path = entry.file.path; + if (!this.progress()?.files.some((f) => f.path === path)) continue; + const scopes = this.getScopes(path); + const viewed = [...(scopes ?? [])].filter( + ([id]) => this.get(path, id).state === "viewed", + ); + for (const [id, ranges] of scopes ?? []) { + const state = this.get(path, id).state; + const key = `${path}:${id}`, + previous = this.states.get(key); + this.states.set(key, state); + // Only transitions (and initial load) fold a viewed scope. Reopening + // it must survive unrelated progress updates and layout changes. + const enclosed = viewed.some( + ([other, outer]) => + other !== id && + ranges.every((r) => + outer.some( + (o) => + r.side === o.side && + r.file === o.file && + o.fromLine <= r.fromLine && + o.toLine >= r.toLine, + ), + ) && + !outer.every((o) => + ranges.some( + (r) => + r.side === o.side && + r.file === o.file && + r.fromLine <= o.fromLine && + r.toLine >= o.toLine, + ), + ), + ); + if (state === "viewed" && !initialized.has(key) && !enclosed) + this.session.setRegionCollapsed(path, id, true); + else if (previous === "viewed" && state !== "viewed") + this.session.setRegionCollapsed(path, id, false); + initialized.add(key); + } + } + this.applyRequestedFold(); + this.changed.fire(); + } + + private applyRequestedFold(): void { + const request = this.requestedFold; + // React may deliver the new coverage after the mutation promise resolves. + // Wait for that coverage, rather than folding optimistically on a failed save. + if ( + request && + (this.get(request.path, request.id).state === "viewed") === request.viewed + ) { + this.requestedFold = undefined; + this.session.setRegionCollapsed(request.path, request.id, request.viewed); + } + } +} From 59b7212f0a68cf132eb1f7c4d96ee761ca3d0187 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Mon, 12 Oct 2026 13:23:17 -0500 Subject: [PATCH 03/19] Light the braces of the scope under the pointer in diffr-web As the Monaco view did, with the CSS Custom Highlight API that find already uses, so hovering adds no DOM. Brace columns are one-based. Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/diffView.css | 5 +++++ apps/diffr-web/src/diffView.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index e8a5ba970..5f61e25bb 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -559,6 +559,11 @@ background-color: var(--vscode-editor-findMatchHighlightBackground); } +::highlight(diffr-scope-brace) { + color: var(--vscode-textLink-foreground); + background-color: var(--vscode-editor-wordHighlightBackground); +} + ::highlight(diffr-find-current) { background-color: var(--vscode-editor-findMatchBackground); } diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index 9357f4a63..e18519472 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -1098,6 +1098,7 @@ export class DiffView extends Disposable { if (columns) code.style.setProperty("--dv-hang", `${columns}ch`); } + code.append( renderCode( source, @@ -1849,6 +1850,23 @@ export class DiffView extends Disposable { if (scope.collapsed || !foldable.rail) return; + // Its braces light up too, wherever they are drawn. + const braces: Range[] = []; + + for (const brace of foldable.braces + ? [foldable.braces.opener, foldable.braces.closer] + : []) { + const code = file.body.querySelector( + `${cells}[data-${attribute}="${brace.line - 1}"] .dv-code`, + ); + + const range = code && textRange(code, brace.column - 1, 1); + + if (range) braces.push(range); + } + + CSS.highlights?.set("diffr-scope-brace", new Highlight(...braces)); + // The body's rows on this side, by the block or pane that holds them: a rail is drawn in each. const groups = new Map(); @@ -1902,6 +1920,8 @@ export class DiffView extends Disposable { } private clearScope(): void { + CSS.highlights?.delete("diffr-scope-brace"); + for (const mark of this.hover?.marks ?? []) if (mark.classList.contains("dv-row")) mark.classList.remove("is-scope-head"); From e3056e4d17cca5b04bd2f0c994577253da575728 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 09:57:15 -0500 Subject: [PATCH 04/19] Say on diffr-web's landing page that diffs and secrets stay in the browser Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/main.ts | 13 +++++++------ apps/diffr-web/src/styles.css | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index 97fa20cd6..e95b47612 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -424,12 +424,13 @@ function togglePanel(panel: "stats" | "engine"): void { function renderLanding(): void { const landing = body.appendChild(element("form", "app-landing")); landing.appendChild(element("h1", undefined, "diffr")); - landing.appendChild( - element( - "p", - undefined, - "Structural diffs of GitHub pull requests, worked out in your browser.", - ), + const blurb = landing.appendChild(element("p")); + const repo = element("a", undefined, "diffr"); + repo.href = "https://github.com/devdotfast/whiteboard"; + blurb.append( + "Structural diffs in your browser powered by ", + repo, + ". All diffs are computed in your browser with WASM. All secrets stay local.", ); const input = landing.appendChild(element("input")); input.placeholder = "github.com/owner/repo/pull/123"; diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 53d3a81d8..0517ff8a8 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -140,6 +140,10 @@ body { color: var(--vscode-descriptionForeground); } +.app-landing p a { + color: inherit; +} + .app-landing input, .app-dialog input, .app-dialog textarea { From e278abb10392a057ee04ec87dbb79a33360a3e85 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:00:17 -0500 Subject: [PATCH 05/19] Tighten diffr-web's landing page text Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index e95b47612..e9c154ea3 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -428,9 +428,9 @@ function renderLanding(): void { const repo = element("a", undefined, "diffr"); repo.href = "https://github.com/devdotfast/whiteboard"; blurb.append( - "Structural diffs in your browser powered by ", + "Structural diffs powered by ", repo, - ". All diffs are computed in your browser with WASM. All secrets stay local.", + ", computed in your browser with WASM. Your GitHub token stays on this device and goes only to GitHub.", ); const input = landing.appendChild(element("input")); input.placeholder = "github.com/owner/repo/pull/123"; From b58aa065d4f4a61578a32293682469b83266c3db Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:01:45 -0500 Subject: [PATCH 06/19] Drop the token line from diffr-web's landing page Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index e9c154ea3..fa5642015 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -430,7 +430,7 @@ function renderLanding(): void { blurb.append( "Structural diffs powered by ", repo, - ", computed in your browser with WASM. Your GitHub token stays on this device and goes only to GitHub.", + ", computed in your browser with WASM.", ); const input = landing.appendChild(element("input")); input.placeholder = "github.com/owner/repo/pull/123"; From 0e66be07abba584dd52fe621fda29e972b3be9fc Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:06:06 -0500 Subject: [PATCH 07/19] Accept github.com URLs without a scheme on diffr-web's landing page Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/diffr-web/src/github.ts b/apps/diffr-web/src/github.ts index bde0ac7a7..af7fa3eee 100644 --- a/apps/diffr-web/src/github.ts +++ b/apps/diffr-web/src/github.ts @@ -65,7 +65,7 @@ export function setToken(value: string | null) { export function parseTarget(input: string): Target | null { const path = input .trim() - .replace(/^https?:\/\/(www\.)?github\.com\//, "") + .replace(/^(https?:\/\/)?(www\.)?github\.com\//, "") .replace(/^\/+/, "") .replace(/[?#].*$/, ""); From 8d02e2cdbb4e1e0b9a1c67890794ab8c9fe888de Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:08:51 -0500 Subject: [PATCH 08/19] Centre diffr-web's open-file icon at the desktop button size Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/diffView.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index 5f61e25bb..a7bb2f11c 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -570,12 +570,13 @@ /* The header's link to the file on GitHub. */ /* The codicon stylesheet makes it a block, so the glyph is centred by its line. */ -.dv-open.codicon { +a.dv-open.codicon { + display: inline-flex; flex: none; + align-items: center; + justify-content: center; width: 36px; height: 36px; - line-height: 36px; - text-align: center; border-radius: 6px; color: var(--vscode-descriptionForeground); text-decoration: none; From 3bf5207f9a1439c1016b0247073cf22c8e140607 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:15:31 -0500 Subject: [PATCH 09/19] Draw diffr-web's indent guides down every open scope and hint the rail's fold on the header Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/diffView.css | 14 +++++++ apps/diffr-web/src/diffView.ts | 71 +++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index a7bb2f11c..6461a60c7 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -338,8 +338,15 @@ } .dv-code { + --dv-guide: linear-gradient( + var(--vscode-editorIndentGuide-background1, rgba(128, 128, 128, 0.3)), + var(--vscode-editorIndentGuide-background1, rgba(128, 128, 128, 0.3)) + ); flex: 1; padding: 0 16px 0 8px; + /* diffView.ts lays one --dv-guide per open scope the line sits in. */ + background-repeat: no-repeat; + background-size: 1px 100%; } .is-wrapped .dv-body { @@ -787,6 +794,13 @@ a.dv-open.codicon { opacity: 1; } +.dv-rail-hint { + margin-left: 2ch; + color: var(--vscode-textLink-foreground); + pointer-events: none; + user-select: none; +} + /* A scope's changes left to read and its viewed box, at the row's trailing edge. */ .dv-scope { position: sticky; diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index e18519472..02cee9524 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -87,6 +87,28 @@ interface RowContext { readonly viewed: Record<"lhs" | "rhs", Set>; /** Header lines of viewed scopes that keep their viewed control in view. */ readonly viewedHeads: Record<"lhs" | "rhs", Set>; + /** Each side's scopes, by header line, that draw an indent guide down their body. */ + readonly guides: Record<"lhs" | "rhs", Guide[]>; +} + +/** A scope's indent guide: the rows after `line` and before `end`, at its header's indentation. */ +interface Guide { + readonly line: number; + readonly end: number; + readonly columns: number; +} + +/** A line's indentation in columns, with tabs stopping every 8. */ +function indentColumns(source: string): number { + let columns = 0; + + for (const character of source) { + if (character === " ") columns++; + else if (character === "\t") columns += 8 - (columns % 8); + else break; + } + + return columns; } /** A scope on one side of a file: the fold a row heads, or the smallest open one around it. */ @@ -743,8 +765,21 @@ export class DiffView extends Disposable { changed: changedByLine(diff), path, ...this.viewedLines(path, diff, file), + guides: { lhs: [], rhs: [] }, }; + for (const side of ["lhs", "rhs"] as const) { + for (const foldable of this.foldablesOf(diff[side])) + if (foldable.rail) + context.guides[side].push({ + line: foldable.line, + end: foldable.rail.end, + columns: indentColumns(context.text[side][foldable.line] ?? ""), + }); + + context.guides[side].sort((a, b) => a.line - b.line); + } + const rows = element("div", this.split ? "dv-rows is-split" : "dv-rows"); rows.style.setProperty( "--dv-digits", @@ -1088,17 +1123,25 @@ export class DiffView extends Disposable { // Wrapped, a long line continues under its own indentation, as an editor wraps it. if (this.wrapped) { - let columns = 0; - - for (const character of source) { - if (character === " ") columns++; - else if (character === "\t") columns += 8 - (columns % 8); - else break; - } + const columns = indentColumns(source); if (columns) code.style.setProperty("--dv-hang", `${columns}ch`); } + // A gray guide down each open scope the line sits in, at its header's indentation. + const guides: string[] = []; + + for (const guide of context.guides[line.side]) { + if (guide.line >= number) break; + + if (number < guide.end) guides.push(`calc(8px + ${guide.columns}ch) 0`); + } + + if (guides.length) { + code.style.backgroundImage = guides.map(() => "var(--dv-guide)").join(); + code.style.backgroundPosition = guides.join(); + } + code.append( renderCode( source, @@ -1899,6 +1942,12 @@ export class DiffView extends Disposable { measure.remove(); const padding = parseFloat(getComputedStyle(code).paddingLeft) || 0; const lines = foldable.rail.end - foldable.rail.start; + const label = `fold ${lines} line${lines === 1 ? "" : "s"}`; + + // On the rail, the header says what a click does, as the editor did. + const hint = element("span", "dv-rail-hint", `click · ${label}`); + const headerCode = header?.querySelector(".dv-code"); + marks.push(hint); for (const [parent, rows] of groups) { const bounds = parent.getBoundingClientRect(); @@ -1911,7 +1960,13 @@ export class DiffView extends Disposable { const rail = parent.appendChild(element("div", "dv-rail")); rail.dataset.fold = String(foldable.foldStateId); - rail.title = `Fold ${lines} line${lines === 1 ? "" : "s"}`; + rail.setAttribute("aria-label", label); + + // A header drawn in a block away from the screen can't carry the hint. + if (headerCode) { + rail.addEventListener("pointerenter", () => headerCode.append(hint)); + rail.addEventListener("pointerleave", () => hint.remove()); + } else rail.title = `Click to ${label}`; rail.style.top = `${top - bounds.top}px`; rail.style.height = `${bottom - top}px`; rail.style.left = `${left - bounds.left + padding + indent}px`; From 209ecced84f6157f023e6a1b8125279cf3ea4af3 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:22:03 -0500 Subject: [PATCH 10/19] Pill a folded scope's header on each side of diffr-web's split view Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/review/common/reviewDiffRows.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/diffr-web/src/review/common/reviewDiffRows.ts b/apps/diffr-web/src/review/common/reviewDiffRows.ts index 19138b8ed..a9f24ff58 100644 --- a/apps/diffr-web/src/review/common/reviewDiffRows.ts +++ b/apps/diffr-web/src/review/common/reviewDiffRows.ts @@ -151,13 +151,17 @@ function foldLayout( const baseFoldables = structuralFoldables(diff.lhs); const headFoldables = structuralFoldables(diff.rhs); - // A fold folded into its header line puts its pill on that side's header. + // A fold folded into its header line puts its pill on the header of each side it hides lines on. const pills = (side: "base" | "head") => { const byLine = new Map(); const foldables = side === "base" ? baseFoldables : headFoldables; for (const gap of gaps) { - if (gap.band || (side === "head") !== (gap.owner !== "base")) continue; + if ( + gap.band || + !(side === "base" ? gap.originalCount : gap.modifiedCount) + ) + continue; const foldable = foldables.find( (candidate) => candidate.foldStateId === gap.foldStateId && candidate.inline, From 15adf1c864420acf7c349440088dc51541697bd7 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 10:29:14 -0500 Subject: [PATCH 11/19] Put diffr-web's file note after the counts on a desktop header Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/diffView.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index 6461a60c7..3d105b558 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -679,6 +679,20 @@ a.dv-open.codicon { padding: 2px 4px 2px 12px; } +/* A desktop has the width for one line: the path, its counts, then the note. */ +.dv:not(.is-compact) .dv-header-title { + display: contents; +} + +.dv:not(.is-compact) .dv-header-path { + flex: 1; +} + +.dv:not(.is-compact) .dv-header-note, +.dv:not(.is-compact) .dv-header > :is(.dv-open, .review-viewed-check) { + order: 1; +} + .dv:not(.is-compact) .dv-header .review-viewed-check { margin: 0 10px 0 4px; } From 6d001ab26f72cfdda4c1562afa816d2591243ac3 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 11:47:02 -0500 Subject: [PATCH 12/19] Band each column of diffr-web's split view, and give a fold one chevron Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 --- apps/diffr-web/src/diffView.css | 15 ++++++++++++++ apps/diffr-web/src/diffView.ts | 35 +++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index 3d105b558..d48f3f89c 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -634,6 +634,21 @@ a.dv-open.codicon { vertical-align: top; } +/* A band spans its own column and stays in view as the column scrolls sideways. */ +.dv-pane { + container-type: inline-size; +} + +.dv-pane-rows > .dv-band { + width: 100cqw; +} + +.dv-seg.is-grid > .dv-band { + position: static; + width: auto; + min-width: 0; +} + .dv-seg.is-grid > .dv-row { min-width: 0; white-space: pre-wrap; diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index 02cee9524..13d1937cf 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -89,6 +89,8 @@ interface RowContext { readonly viewedHeads: Record<"lhs" | "rhs", Set>; /** Each side's scopes, by header line, that draw an indent guide down their body. */ readonly guides: Record<"lhs" | "rhs", Guide[]>; + /** Folds whose header line has a chevron, so their band needs none of its own. */ + readonly headed: Set; } /** A scope's indent guide: the rows after `line` and before `end`, at its header's indentation. */ @@ -766,6 +768,7 @@ export class DiffView extends Disposable { path, ...this.viewedLines(path, diff, file), guides: { lhs: [], rhs: [] }, + headed: new Set(), }; for (const side of ["lhs", "rhs"] as const) { @@ -793,6 +796,11 @@ export class DiffView extends Disposable { const items = splitItems(diff, collapsed); let inChange = false; + for (const item of items) + if (item.kind === "pair") + for (const cell of [item.left, item.right]) + if (cell?.fold) context.headed.add(cell.fold.foldStateId); + const starts = items.map((item) => { if (item.kind === "band") { inChange = false; @@ -824,6 +832,10 @@ export class DiffView extends Disposable { } else { const items = unifiedItems(diff, collapsed); + for (const item of items) + if (item.kind === "line" && item.fold) + context.headed.add(item.fold.foldStateId); + const starts = items.map((item, index) => { const previous = items[index - 1]; @@ -844,7 +856,7 @@ export class DiffView extends Disposable { slice.forEach((item, index) => into.append( item.kind === "band" - ? this.renderBand(item, sliceStarts[index]!) + ? this.renderBand(item, sliceStarts[index]!, context) : this.renderLine(item, context, sliceStarts[index]!), ), ), @@ -1047,14 +1059,18 @@ export class DiffView extends Disposable { }; items.forEach((item, index) => { + segment ??= open(); + + // A band sits in each column, as the editor folded each side, and keeps them level. if (item.kind === "band") { - segment = undefined; - into.append(this.renderBand(item, starts[index]!)); + segment( + this.renderBand(item, starts[index]!, context), + this.renderBand(item, false, context), + ); return; } - segment ??= open(); segment( cell(item.left, "lhs", starts[index]!), cell(item.right, "rhs", false), @@ -1184,7 +1200,11 @@ export class DiffView extends Disposable { return row; } - private renderBand(band: UnifiedBand, startsChange: boolean): HTMLElement { + private renderBand( + band: UnifiedBand, + startsChange: boolean, + context: RowContext, + ): HTMLElement { const node = element("div", `dv-band kind-${band.change}`); if (startsChange) node.classList.add("is-change-start"); @@ -1198,7 +1218,10 @@ export class DiffView extends Disposable { `Expand ${band.hidden} hidden line${band.hidden === 1 ? "" : "s"}${summary ? ", summarized" : `: ${band.title}`}`, ); const title = node.appendChild(element("span", "dv-band-title")); - title.append(element("span", "dv-band-chevron")); + + // A scope's header chevron already opens it; one control per fold. + if (!context.headed.has(band.foldStateId)) + title.append(element("span", "dv-band-chevron")); if (summary) title.append(element("span", "dv-band-caption", "Pseudocode")); title.append(element("span", "dv-band-label", band.title)); From 3faee76a334f0dfc7eb922685a2a5b7b2e1a3ab5 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 12:38:07 -0500 Subject: [PATCH 13/19] Redraw diffr-web's rows when the screen crosses between desktop and phone Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 01a1362c-3e21-7c22-9c5c-8f440a884b30 --- apps/diffr-web/src/diffView.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index 13d1937cf..0a454711a 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -242,10 +242,11 @@ export class DiffView extends Disposable { this.onDidScroll(() => this.sidebar.setActiveFile(this.activePath)), ); + // Rows are drawn for their screen: a phone's gutter, row heights and tap targets differ. const screen = () => { this.compact = mobileViewport.matches; this.applyScreen(); - this.relayout(); + this.relayout(true); }; mobileViewport.addEventListener("change", screen); @@ -443,13 +444,16 @@ export class DiffView extends Disposable { this.sidebar.hidden = this.compact || this.sidebarHidden; } - /** Draw the drawn files again, for a new layout, keeping the reader's row in place. */ - private relayout(): void { + /** + * Draw the drawn files again, for a new layout or, with `screen`, a new screen, keeping the + * reader's row in place. + */ + private relayout(screen = false): void { const split = this.wantsSplit(); const changed = split !== this.split; this.split = split; - if (!changed && !split) return; + if (!changed && !split && !screen) return; this.preserve(() => { for (const file of this.order) if (file.built) this.renderBody(file); }); From 4e317fb0567657eaa436930cad6b38eac1830e73 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 13:08:17 -0500 Subject: [PATCH 14/19] Hatch the side of a split band that has none of its lines Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 01a1362c-3e21-7c22-9c5c-8f440a884b30 --- apps/diffr-web/src/diffView.css | 14 ++++++++++++++ apps/diffr-web/src/diffView.ts | 21 +++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index d48f3f89c..e8e0b0b84 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -643,6 +643,20 @@ a.dv-open.codicon { width: 100cqw; } +.dv-band.is-absent { + pointer-events: none; + box-shadow: none; + background: repeating-linear-gradient( + -45deg, + transparent 0 5px, + var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px + ); +} + +.dv-band.is-absent > * { + visibility: hidden; +} + .dv-seg.is-grid > .dv-band { position: static; width: auto; diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index 0a454711a..111aba41f 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -1067,10 +1067,23 @@ export class DiffView extends Disposable { // A band sits in each column, as the editor folded each side, and keeps them level. if (item.kind === "band") { - segment( - this.renderBand(item, starts[index]!, context), - this.renderBand(item, false, context), - ); + const left = this.renderBand(item, starts[index]!, context); + const right = this.renderBand(item, false, context); + + // A side with none of the band's lines is hatched, as a missing line is, at its height. + const absent = + item.change === "inserted" + ? left + : item.change === "removed" + ? right + : undefined; + + if (absent) { + absent.classList.add("is-absent"); + absent.tabIndex = -1; + absent.setAttribute("aria-hidden", "true"); + } + segment(left, right); return; } From d49be21e4b1685ed11582ba57d3713111402163b Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 13:14:46 -0500 Subject: [PATCH 15/19] Give diffr-web's phone file sheet 44px targets, darken line numbers and keep file extensions Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 01a1362c-3e21-7c22-9c5c-8f440a884b30 --- apps/diffr-web/src/diffView.css | 9 +++++-- .../src/review/browser/media/review.css | 16 +++++++++++ .../review/browser/reviewChangedFilesTree.ts | 27 ++++++++++++++++--- apps/diffr-web/src/styles.css | 18 +++++++------ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index e8e0b0b84..b9388d155 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -258,7 +258,12 @@ width: calc(var(--dv-digits, 3) * 1ch + 36px); padding-right: 8px; background: var(--vscode-editor-background); - color: var(--vscode-editorLineNumber-foreground); + /* The editor's own numbers are too faint to read on a tinted row; these keep about 4.5:1. */ + color: color-mix( + in srgb, + var(--vscode-editorLineNumber-activeForeground) 80%, + var(--vscode-editorLineNumber-foreground) + ); user-select: none; -webkit-user-select: none; } @@ -758,7 +763,7 @@ a.dv-open.codicon { } .dv:not(.is-compact) .dv-row.has-fold .dv-gutter:hover { - color: var(--vscode-editorLineNumber-activeForeground); + color: var(--vscode-editor-foreground); } .dv:not(.is-compact) .dv-row.has-fold:active { diff --git a/apps/diffr-web/src/review/browser/media/review.css b/apps/diffr-web/src/review/browser/media/review.css index 1d4c312ad..32772b669 100644 --- a/apps/diffr-web/src/review/browser/media/review.css +++ b/apps/diffr-web/src/review/browser/media/review.css @@ -805,6 +805,22 @@ body font-weight: 600; } +/* A file's name gives way before its extension. */ +.review-changed-files-row:not(.review-changed-files-folder) + .review-changed-files-label { + display: flex; +} + +.review-changed-files-stem { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.review-changed-files-extension { + flex: none; +} + .review-canvas-part > .review-canvas-container { display: grid; grid-template-rows: minmax(0, 1fr); diff --git a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts index 0907979ed..b5c6e6bca 100644 --- a/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts +++ b/apps/diffr-web/src/review/browser/reviewChangedFilesTree.ts @@ -253,12 +253,31 @@ export class ChangedFilesTreeRenderer implements ICompressibleTreeRenderer< 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"}`; + // A cut-off name keeps its extension, and its full path shows on hover. template.row.title = - state?.message ?? (state?.status === "loading" ? "Loading diff…" : ""); + state?.message ?? + (state?.status === "loading" + ? "Loading diff…" + : isFile + ? element.file.path + : ""); template.row.setAttribute("aria-busy", String(state?.status === "loading")); - template.label.textContent = isFile - ? element.name - : elements.map((item) => item.name).join("/"); + + if (isFile) { + const dot = element.name.lastIndexOf("."); + const stem = $("span.review-changed-files-stem"); + stem.textContent = dot > 0 ? element.name.slice(0, dot) : element.name; + template.label.replaceChildren(stem); + + if (dot > 0) { + const extension = append( + template.label, + $("span.review-changed-files-extension"), + ); + extension.textContent = element.name.slice(dot); + } + } else + template.label.textContent = elements.map((item) => item.name).join("/"); } } diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index 0517ff8a8..f3f7afb38 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -875,6 +875,7 @@ body { } .app-mobile-fold-actions > button { flex: 1; + min-height: 44px; } /* Mobile navigation uses the desktop tree's status marks and line-count colors. */ @@ -884,7 +885,8 @@ body { .app-mobile-files-heading .app-mobile-files-close { border: 0; background: transparent; - width: 40px; + width: 44px; + height: 44px; padding: 0; font: 24px/1 sans-serif; } @@ -916,8 +918,8 @@ body { .app-mobile-files-heading .app-mobile-tree-tool { display: grid; place-items: center; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border: 0; border-radius: 5px; background: transparent; @@ -966,7 +968,7 @@ body { display: flex; align-items: center; gap: 6px; - min-height: 32px; + min-height: 44px; padding: 0 4px; white-space: nowrap; overflow: hidden; @@ -996,8 +998,8 @@ body { } .app-mobile-file-row { width: 100%; - min-height: 28px; - height: 28px; + min-height: 44px; + height: 44px; padding: 0 4px; border: 0; border-radius: 4px; @@ -1128,7 +1130,7 @@ body { .app-mobile-folder-label { color: var(--vscode-foreground); font-weight: 400; - min-height: 28px; + min-height: 44px; } .app-mobile-file-row { gap: 4px; @@ -1156,7 +1158,7 @@ body { gap: 4px; } .app-mobile-fold-actions .app-button { - min-height: 32px; + min-height: 44px; border: 0; font-size: 12px; color: var(--vscode-descriptionForeground); From 3ab8628ac535078a6ced20d64c979c3d502352c5 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 13:34:00 -0500 Subject: [PATCH 16/19] Stop diffr-web's scope rail at a band and resume it below Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 01a1362c-3e21-7c22-9c5c-8f440a884b30 --- apps/diffr-web/src/diffView.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index 111aba41f..c22dc6ec3 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -1989,7 +1989,28 @@ export class DiffView extends Disposable { const headerCode = header?.querySelector(".dv-code"); marks.push(hint); + // A band between two of the scope's rows hides lines, so the rail stops at it and resumes. + const runs: [HTMLElement, HTMLElement[]][] = []; + for (const [parent, rows] of groups) { + let run: HTMLElement[] = []; + + for (const row of rows) { + let between = run.at(-1)?.nextElementSibling; + + while (between && between !== row && !between.matches(".dv-band")) + between = between.nextElementSibling; + + if (run.length && between !== row) { + runs.push([parent, run]); + run = []; + } + run.push(row); + } + runs.push([parent, run]); + } + + for (const [parent, rows] of runs) { const bounds = parent.getBoundingClientRect(); const top = rows[0]!.getBoundingClientRect().top; const bottom = rows.at(-1)!.getBoundingClientRect().bottom; From e1ff5d63d875d249eec3641cce6a036358043463 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 13:45:08 -0500 Subject: [PATCH 17/19] Pick diffr-web's scope by the pointer's column, fold from its indentation, and list a line's scopes from its number Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 01a1362c-3e21-7c22-9c5c-8f440a884b30 --- apps/diffr-web/src/diffView.css | 140 +++++++++++++ apps/diffr-web/src/diffView.ts | 357 +++++++++++++++++++++++++++++--- 2 files changed, 471 insertions(+), 26 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index b9388d155..99fdf9894 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -874,3 +874,143 @@ a.dv-open.codicon { .dv-scope-removed { color: var(--vscode-gitDecoration-deletedResourceForeground); } + +/* The lit scope's indentation folds it on click. */ +.dv.is-lane .dv-code { + cursor: pointer; +} + +/* The scopes around a line, to pick one to fold: a menu by its number, or a phone's sheet. */ +.dv-scopes { + position: fixed; + z-index: 30; + display: flex; + flex-direction: column; + min-width: 280px; + max-width: min(440px, calc(100vw - 16px)); + padding: 4px 0; + border: 1px solid + var(--vscode-editorWidget-border, var(--vscode-panel-border)); + border-radius: 8px; + background: var( + --vscode-editorWidget-background, + var(--vscode-editor-background) + ); + box-shadow: 0 8px 24px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.16)); + color: var(--vscode-foreground); + font: + 13px -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.dv-scopes.is-sheet { + left: 0; + right: 0; + bottom: 0; + max-width: none; + padding: 10px 0 calc(12px + env(safe-area-inset-bottom)); + border-width: 1px 0 0; + border-radius: 16px 16px 0 0; +} + +.dv-scopes.is-sheet::before { + content: ""; + align-self: center; + width: 36px; + height: 5px; + margin-bottom: 6px; + border-radius: 3px; + background: var(--vscode-panel-border); +} + +.dv-scopes-scrim { + position: fixed; + inset: 0; + z-index: 29; + background: rgba(0, 0, 0, 0.28); +} + +.dv-scopes-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 6px 12px 8px; +} + +.is-sheet .dv-scopes-heading { + padding: 6px 20px 12px; +} + +.dv-scopes-title { + font-weight: 600; +} + +.is-sheet .dv-scopes-title { + font-size: 17px; +} + +.dv-scopes-line, +.dv-scopes-meta, +.dv-scopes-count { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.dv-scopes-item { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 4px 12px; + border: 0; + border-top: 1px solid var(--vscode-panel-border); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.is-sheet .dv-scopes-item { + min-height: 56px; + padding: 4px 20px; +} + +.dv-scopes-item:hover, +.dv-scopes-item:focus-visible { + outline: none; + background: var( + --vscode-list-hoverBackground, + var(--vscode-toolbar-hoverBackground) + ); +} + +.dv-scopes-swatch { + flex: none; + width: 4px; + height: 24px; + border-radius: 2px; + background: var(--vscode-textLink-foreground); +} + +.dv-scopes-text { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.dv-scopes-label { + overflow: hidden; + font-family: "Geist Mono", ui-monospace, Menlo, monospace; + white-space: nowrap; + text-overflow: ellipsis; +} + +.dv-scopes-count { + flex: none; +} diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index c22dc6ec3..c42401b25 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -440,6 +440,7 @@ export class DiffView extends Disposable { } private applyScreen(): void { + this.ch = undefined; this.root.classList.toggle("is-compact", this.compact); this.sidebar.hidden = this.compact || this.sidebarHidden; } @@ -1083,6 +1084,7 @@ export class DiffView extends Disposable { absent.tabIndex = -1; absent.setAttribute("aria-hidden", "true"); } + segment(left, right); return; @@ -1320,6 +1322,33 @@ export class DiffView extends Disposable { if (line) this.focusRow(line, false); + // A desktop folds the lit scope from its lane, the indentation beside its rail. + if ( + !this.compact && + this.lane && + this.hover && + target.closest(".dv-code") + ) { + this.session.setRegionCollapsed(this.hover.path, this.hover.fold, true); + + return; + } + + // A line inside a scope lists, from its number, every scope around it to fold. + if ( + line && + !line.classList.contains("has-fold") && + target.closest(".dv-gutter") + ) { + const scopes = this.scopesAt(line).filter((scope) => !scope.collapsed); + + if (scopes.length) { + this.openScopeList(line, scopes); + + return; + } + } + // A phone folds from anywhere on the row; a desktop from its gutter, leaving the code to // select and click. const row = target @@ -1687,10 +1716,18 @@ export class DiffView extends Disposable { /** The fold `row` heads, else the smallest open scope around it, on the row's side. */ private scopeAt(row: HTMLElement): Scope | undefined { + return this.scopesAt(row)[0]; + } + + /** + * The scopes around `row` on its side, innermost first: the fold it heads, if any, then each + * open scope it sits in. + */ + private scopesAt(row: HTMLElement): Scope[] { const path = row.closest(".dv-file")?.dataset.path; const diff = path ? this.session.getTextDiff(path) : undefined; - if (!path || !diff) return undefined; + if (!path || !diff) return []; const side = row.dataset.head !== undefined && row.dataset.side !== "lhs" @@ -1704,21 +1741,95 @@ export class DiffView extends Disposable { this.session.isRegionCollapsed(path, foldable.foldStateId) ?? foldable.collapsedByDefault; - const foldable = - foldables.find((candidate) => candidate.line === line) ?? - foldables - .filter( - (candidate) => - candidate.rail && - !collapsed(candidate) && - candidate.rail.start <= line && - line <= candidate.rail.end, - ) - .sort( - (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), - )[0]; + const own = foldables.find((candidate) => candidate.line === line); + + const around = foldables + .filter( + (candidate) => + candidate !== own && + candidate.rail && + !collapsed(candidate) && + candidate.rail.start <= line && + line <= candidate.rail.end, + ) + .sort( + (a, b) => a.rail!.end - a.rail!.start - (b.rail!.end - b.rail!.start), + ); + + return (own ? [own, ...around] : around).map((foldable) => ({ + path, + side, + foldable, + collapsed: collapsed(foldable), + })); + } + + private readonly linesBySource = new WeakMap(); + + /** A side's source lines, split once per diff. */ + private linesOf(path: string, side: "lhs" | "rhs"): string[] { + const source = this.session.getTextDiff(path)?.[side]; + + if (!source) return []; + let lines = this.linesBySource.get(source); + + if (!lines) { + lines = sourceLines(source.text); + this.linesBySource.set(source, lines); + } - return foldable && { path, side, foldable, collapsed: collapsed(foldable) }; + return lines; + } + + /** The code font's column width, measured once per screen. */ + private ch: number | undefined; + + /** + * Of `scopes`, the one the pointer's column picks: the innermost whose guide is left of it, so + * moving into the indentation steps out a scope at each guide. `lane` is whether the pointer is + * in the line's indentation, where a click folds that scope. + */ + private pickScope( + row: HTMLElement, + scopes: readonly Scope[], + clientX: number, + ): { scope: Scope; lane: boolean } | undefined { + const code = row.querySelector(".dv-code"); + const first = scopes[0]; + + if (!code || !first) return undefined; + + if (this.ch === undefined) { + const probe = code.appendChild( + element("span", undefined, "0".repeat(10)), + ); + + this.ch = probe.getBoundingClientRect().width / 10 || 8; + probe.remove(); + } + + // Guides sit 8px into the code, as diffView.css lays them. + const column = (clientX - code.getBoundingClientRect().left - 8) / this.ch; + const lines = this.linesOf(first.path, first.side); + + const scope = + scopes.find( + (candidate) => + column > indentColumns(lines[candidate.foldable.line] ?? ""), + ) ?? scopes.at(-1)!; + + const line = Number( + first.side === "rhs" ? row.dataset.head : row.dataset.base, + ); + + const source = lines[line] ?? ""; + + return { + scope, + lane: + !scope.collapsed && + (source.trim() === "" || column < indentColumns(source)), + }; } /** @@ -1812,9 +1923,18 @@ export class DiffView extends Disposable { } private hover: - | { key: string; path: string; marks: HTMLElement[] } + | { + key: string; + path: string; + fold: number; + marks: HTMLElement[]; + hint?: { hint: HTMLElement; code: Element }; + } | undefined; private hoverRow: HTMLElement | undefined; + private hoverScopes: Scope[] = []; + /** Whether the pointer is in the lit scope's indentation, where a click folds it. */ + private lane = false; /** * A desktop lights the scope under the pointer: its header, its viewed control and a rail down @@ -1832,23 +1952,37 @@ export class DiffView extends Disposable { if (target?.closest(".dv-rail, .dv-scope")) return; const row = target?.closest(".dv-row") ?? undefined; - if (row === this.hoverRow) return; - this.hoverRow = row; - const scope = row && this.scopeAt(row); + // While the scope list is open, its rows choose what is lit. + if (this.scopeList) return; + + if (row !== this.hoverRow) { + this.hoverRow = row; + this.hoverScopes = row ? this.scopesAt(row) : []; + } + + const pick = + row && this.pickScope(row, this.hoverScopes, event.clientX); + + const scope = pick?.scope; const key = scope && `${scope.path}\0${scope.side}\0${scope.foldable.foldStateId}`; - if (key === this.hover?.key) return; - this.clearScope(); + if (key !== this.hover?.key) { + this.clearScope(); - if (scope && key) this.showScope(scope, key); + if (scope && key) this.showScope(scope, key); + } + + this.setLane(!!pick?.lane && !!this.hover); }, { passive: true }, ); this.root.addEventListener("pointerleave", () => { + if (this.scopeList) return; this.hoverRow = undefined; + this.hoverScopes = []; this.clearScope(); }); @@ -1904,7 +2038,7 @@ export class DiffView extends Disposable { if (!file?.built) return; const { side, foldable } = scope; const marks: HTMLElement[] = []; - this.hover = { key, path: scope.path, marks }; + this.hover = { key, path: scope.path, fold: foldable.foldStateId, marks }; const cells = this.split ? `.dv-row[data-side="${side}"]` : ".dv-row"; const attribute = side === "rhs" ? "head" : "base"; @@ -1917,7 +2051,8 @@ export class DiffView extends Disposable { header.classList.add("is-scope-head"); marks.push(header); - if (!header.querySelector(".dv-scope")) { + // A phone marks scopes viewed from the file, not from a control on each header. + if (!this.compact && !header.querySelector(".dv-scope")) { const control = this.scopeControl( scope.path, foldable.foldStateId, @@ -1989,6 +2124,8 @@ export class DiffView extends Disposable { const headerCode = header?.querySelector(".dv-code"); marks.push(hint); + if (headerCode) this.hover.hint = { hint, code: headerCode }; + // A band between two of the scope's rows hides lines, so the rail stops at it and resumes. const runs: [HTMLElement, HTMLElement[]][] = []; @@ -2005,8 +2142,10 @@ export class DiffView extends Disposable { runs.push([parent, run]); run = []; } + run.push(row); } + runs.push([parent, run]); } @@ -2025,8 +2164,8 @@ export class DiffView extends Disposable { // A header drawn in a block away from the screen can't carry the hint. if (headerCode) { - rail.addEventListener("pointerenter", () => headerCode.append(hint)); - rail.addEventListener("pointerleave", () => hint.remove()); + rail.addEventListener("pointerenter", () => this.showHint(true)); + rail.addEventListener("pointerleave", () => this.showHint(this.lane)); } else rail.title = `Click to ${label}`; rail.style.top = `${top - bounds.top}px`; rail.style.height = `${bottom - top}px`; @@ -2035,7 +2174,25 @@ export class DiffView extends Disposable { } } + /** Whether the pointer is in the lit scope's lane: a click there folds it, and says so. */ + private setLane(lane: boolean): void { + this.lane = lane; + this.root.classList.toggle("is-lane", lane); + this.showHint(lane); + } + + private showHint(show: boolean): void { + const hint = this.hover?.hint; + + if (!hint) return; + + if (show) hint.code.append(hint.hint); + else hint.hint.remove(); + } + private clearScope(): void { + this.lane = false; + this.root.classList.remove("is-lane"); CSS.highlights?.delete("diffr-scope-brace"); for (const mark of this.hover?.marks ?? []) @@ -2046,6 +2203,154 @@ export class DiffView extends Disposable { this.hover = undefined; } + private scopeList: + | { list: HTMLElement; scrim?: HTMLElement; dispose: () => void } + | undefined; + + /** + * Every scope around `row`, innermost first, each a row that folds it: a sheet on a phone, a + * menu beside the number on a desktop. The scope a row would fold is lit behind it. + */ + private openScopeList(row: HTMLElement, scopes: readonly Scope[]): void { + this.closeScopeList(); + const sheet = this.compact; + const list = element("div", sheet ? "dv-scopes is-sheet" : "dv-scopes"); + list.setAttribute("role", "menu"); + + const line = + Number( + row.dataset.side === "lhs" || row.dataset.head === undefined + ? row.dataset.base + : row.dataset.head, + ) + 1; + + list.setAttribute("aria-label", `Fold a scope around line ${line}`); + const heading = list.appendChild(element("div", "dv-scopes-heading")); + heading.append( + element("span", "dv-scopes-title", "Fold"), + element("span", "dv-scopes-line", `around line ${line}`), + ); + const lines = this.linesOf(scopes[0]!.path, scopes[0]!.side); + + const light = (scope: Scope) => { + const key = `${scope.path}\0${scope.side}\0${scope.foldable.foldStateId}`; + + if (key === this.hover?.key) return; + this.clearScope(); + this.showScope(scope, key); + }; + + scopes.forEach((scope, index) => { + const { foldable } = scope; + const end = foldable.rail?.end ?? foldable.line; + const count = foldable.rail ? foldable.rail.end - foldable.rail.start : 0; + const item = list.appendChild(element("button", "dv-scopes-item")); + item.type = "button"; + item.setAttribute("role", "menuitem"); + const swatch = item.appendChild(element("span", "dv-scopes-swatch")); + swatch.style.opacity = String(Math.max(0.25, 1 - index * 0.35)); + const text = item.appendChild(element("span", "dv-scopes-text")); + let header = (lines[foldable.line] ?? "").trim(); + + // A bare bracket says little; its first line says which one it is. + if (header.length <= 2) { + const next = lines + .slice(foldable.line + 1, end) + .find((text) => text.trim()) + ?.trim(); + + if (next) header = `${header} ${next}`; + } + + text.append( + element( + "span", + "dv-scopes-label", + header.length > 44 ? `${header.slice(0, 43)}…` : header, + ), + element( + "span", + "dv-scopes-meta", + `${index === 0 ? "innermost · " : ""}lines ${foldable.line + 1}–${end + 1}`, + ), + ); + item.append( + element( + "span", + "dv-scopes-count", + `${count} line${count === 1 ? "" : "s"}`, + ), + ); + item.addEventListener("pointerenter", () => light(scope)); + item.addEventListener("focus", () => light(scope)); + item.addEventListener("click", () => { + this.closeScopeList(); + this.session.setRegionCollapsed(scope.path, foldable.foldStateId, true); + }); + }); + + const scrim = sheet ? element("div", "dv-scopes-scrim") : undefined; + const close = () => this.closeScopeList(); + scrim?.addEventListener("click", close); + + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + + const onPointer = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !list.contains(event.target)) + close(); + }; + + document.addEventListener("keydown", onKey, true); + // A press outside closes a menu; a sheet's scrim takes its own. + + if (!sheet) document.addEventListener("pointerdown", onPointer, true); + this.root.addEventListener("scroll", close, { passive: true }); + + this.scopeList = { + list, + scrim, + dispose: () => { + document.removeEventListener("keydown", onKey, true); + document.removeEventListener("pointerdown", onPointer, true); + this.root.removeEventListener("scroll", close); + }, + }; + + // Inside the app, where the theme's colors apply. + const host = this.root.closest(".monaco-component") ?? document.body; + + if (scrim) host.append(scrim); + host.append(list); + + if (!sheet) { + const at = row.querySelector(".dv-gutter")!.getBoundingClientRect(); + const bounds = list.getBoundingClientRect(); + list.style.left = `${Math.min(at.right + 4, innerWidth - bounds.width - 8)}px`; + list.style.top = `${Math.min(at.bottom + 2, innerHeight - bounds.height - 8)}px`; + } + + light(scopes[0]!); + list + .querySelector(".dv-scopes-item") + ?.focus({ preventScroll: true }); + } + + private closeScopeList(): void { + const open = this.scopeList; + + if (!open) return; + this.scopeList = undefined; + open.dispose(); + open.list.remove(); + open.scrim?.remove(); + this.clearScope(); + } + /** Fold the open scope under a pinch, or open the fold nearest one. */ private pinchFold(x: number, y: number, collapse: boolean): void { const hit = document From a863e99d631e8092bb731acf1c509ec3ed983b22 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 15:52:19 -0700 Subject: [PATCH 18/19] Scroll diffr-web's page on phones, and draw a fold band's chevron in the gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS drew the inner list's scroll bar under the sticky file headers. A phone now scrolls the page: the app header sticks, the file headers stick below it, and the observers that draw and free rows watch the viewport. A band's chevron sits in the gutter in line with the rows' own, before a hatched code column reading "⋯ N unchanged lines", as Monaco drew it. Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e --- apps/diffr-web/src/diffView.css | 75 +++++++--- apps/diffr-web/src/diffView.ts | 236 +++++++++++++++++++++----------- apps/diffr-web/src/main.ts | 5 + apps/diffr-web/src/styles.css | 25 +++- 4 files changed, 242 insertions(+), 99 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index 99fdf9894..6714ce657 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -465,17 +465,11 @@ box-sizing: border-box; width: var(--dv-width, 100vw); min-height: 44px; - padding: 0 12px 0 0; + display: flex; 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) - ); + background: var(--vscode-editor-background); white-space: normal; cursor: pointer; -webkit-tap-highlight-color: transparent; @@ -491,21 +485,48 @@ background: var(--vscode-toolbar-hoverBackground); } +/* As wide as the rows' gutter, in their font, so its chevron lines up with theirs. */ +.dv-band-gutter { + flex: none; + display: flex; + align-items: center; + box-sizing: border-box; + width: calc(var(--dv-digits, 3) * 1ch + 36px); + min-height: inherit; + padding-left: 8px; + border-right: 1px solid var(--vscode-panel-border); +} + +.no-numbers .dv-band-gutter { + width: 28px; +} + +.dv-band-main { + flex: 1; + min-width: 0; + padding-right: 12px; + background: repeating-linear-gradient( + -45deg, + transparent 0 5px, + var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px + ); +} + .dv-band:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } -.dv-band.kind-inserted { +.dv-band.kind-inserted > .dv-band-gutter { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-addedResourceForeground); } -.dv-band.kind-removed { +.dv-band.kind-removed > .dv-band-gutter { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-deletedResourceForeground); } -.dv-band.kind-modified { +.dv-band.kind-modified > .dv-band-gutter { box-shadow: inset 3px 0 0 var(--vscode-gitDecoration-modifiedResourceForeground); } @@ -515,12 +536,12 @@ align-items: center; gap: 8px; min-height: 44px; - padding-left: calc(8px + min(var(--dv-indent, 0), 8) * 0.5ch); + padding-left: 8px; } .dv-band-chevron { flex: none; - width: 28px; + width: 12px; height: 12px; background: currentColor; mask: url("data:image/svg+xml;utf8,") @@ -544,10 +565,14 @@ overflow-wrap: anywhere; } +.dv-band-label::before { + content: "⋯ "; +} + /* 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. */ .dv-pseudocode { - margin: 0 0 12px calc(44px + min(var(--dv-indent, 0), 8) * 0.5ch); + margin: 0 0 12px calc(8px + min(var(--dv-indent, 0), 8) * 0.5ch); padding: 8px 12px; border-left: 2px solid var(--dv-comment, var(--vscode-descriptionForeground)); border-radius: 0 6px 6px 0; @@ -775,8 +800,8 @@ a.dv-open.codicon { min-height: 28px; } -.dv:not(.is-compact) .dv-band { - font-size: 12px; +.dv:not(.is-compact) .dv-rows:not(.is-split) .dv-band-gutter { + width: calc(var(--dv-digits, 3) * 2ch + 44px); } .dv:not(.is-compact) .dv-band:hover { @@ -784,7 +809,7 @@ a.dv-open.codicon { } .dv:not(.is-compact) .dv-pseudocode { - margin: 0 0 8px calc(40px + min(var(--dv-indent, 0), 8) * 0.5ch); + margin: 0 0 8px calc(8px + min(var(--dv-indent, 0), 8) * 0.5ch); padding: 6px 12px; font-size: 12px; line-height: 1.5; @@ -794,11 +819,25 @@ a.dv-open.codicon { font-size: 11px; } -/* A phone pinches to fold, so the page never zooms under the diff. */ +/* A phone pinches to fold, so the page never zooms under the diff. It scrolls the page, not the + list: iOS draws an inner scroller's bar under the sticky file headers. */ .dv.is-compact { + height: auto; + overflow: visible; + overflow-x: clip; + overscroll-behavior-y: auto; touch-action: pan-x pan-y; } +.dv.is-compact .dv-header { + top: var(--dv-top, 0px); +} + +.dv-host:has(> .dv.is-compact) { + display: block; + height: auto; +} + /* Rails are drawn in the block, pane or grid that holds their rows. */ .dv-chunk, .dv-pane-rows, diff --git a/apps/diffr-web/src/diffView.ts b/apps/diffr-web/src/diffView.ts index c42401b25..cd0918db1 100644 --- a/apps/diffr-web/src/diffView.ts +++ b/apps/diffr-web/src/diffView.ts @@ -199,12 +199,13 @@ export class DiffView extends Disposable { private readonly bySection = new WeakMap(); private order: FileView[] = []; private progress: ReviewDiffProgress | undefined; - private readonly build: IntersectionObserver; - private readonly release: IntersectionObserver; - private readonly chunkBuild: IntersectionObserver; - private readonly chunkRelease: IntersectionObserver; + // Made again by watch() when a phone's page, not the list, becomes what scrolls. + private build!: IntersectionObserver; + private release!: IntersectionObserver; + private chunkBuild!: IntersectionObserver; + private chunkRelease!: IntersectionObserver; private readonly chunkOf = new WeakMap(); - private readonly request: IntersectionObserver; + private request!: IntersectionObserver; private scrollFrame = 0; /** A selection when the press began: the tap ends it, and must not fold anything. */ private pressedOverSelection = false; @@ -244,8 +245,11 @@ export class DiffView extends Disposable { // Rows are drawn for their screen: a phone's gutter, row heights and tap targets differ. const screen = () => { + const scroller = this.compact !== mobileViewport.matches; this.compact = mobileViewport.matches; this.applyScreen(); + + if (scroller) this.watch(); this.relayout(true); }; @@ -255,46 +259,7 @@ export class DiffView extends Disposable { ); this._register(layout.onDidChange(() => this.relayout())); - 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.chunkBuild = new IntersectionObserver( - (entries) => - this.preserve(() => { - for (const entry of entries) { - const chunk = this.chunkOf.get(entry.target); - - if (entry.isIntersecting && chunk) this.fillChunk(chunk); - } - }), - { root: this.root, rootMargin: BUILD_MARGIN }, - ); - this.chunkRelease = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - const chunk = this.chunkOf.get(entry.target); - - if (!entry.isIntersecting && chunk) this.emptyChunk(chunk); - } - }, - { 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.watch(); this._register( toDisposable(() => { this.build.disconnect(); @@ -311,17 +276,18 @@ export class DiffView extends Disposable { }), ); - this.root.addEventListener( - "scroll", - () => { - if (this.scrollFrame) return; - this.scrollFrame = requestAnimationFrame(() => { - this.scrollFrame = 0; - this._onDidScroll.fire(); - }); - }, - { passive: true }, - ); + const scrolled = () => { + if (this.scrollFrame) return; + this.scrollFrame = requestAnimationFrame(() => { + this.scrollFrame = 0; + this._onDidScroll.fire(); + }); + }; + + this.root.addEventListener("scroll", scrolled, { passive: true }); + // A phone scrolls the page instead. + addEventListener("scroll", scrolled, { passive: true }); + this._register(toDisposable(() => removeEventListener("scroll", scrolled))); this.root.addEventListener( "pointerdown", (event) => { @@ -414,6 +380,75 @@ export class DiffView extends Disposable { this._register(this.scopes.onDidChange(() => this.syncViewed())); } + /** Watch files and their blocks come near the screen, to draw them, and leave it, to free them. */ + private watch(): void { + const old = this.build && [ + this.build, + this.release, + this.chunkBuild, + this.chunkRelease, + this.request, + ]; + + for (const observer of old || []) observer.disconnect(); + // A phone scrolls the page, not the list, so iOS draws its scroll bar over the file headers. + const root = this.compact ? null : this.root; + + this.build = new IntersectionObserver( + (entries) => this.preserve(() => this.onBuild(entries)), + { root, rootMargin: BUILD_MARGIN }, + ); + this.release = new IntersectionObserver( + (entries) => this.onRelease(entries), + { root, rootMargin: RELEASE_MARGIN }, + ); + this.chunkBuild = new IntersectionObserver( + (entries) => + this.preserve(() => { + for (const entry of entries) { + const chunk = this.chunkOf.get(entry.target); + + if (entry.isIntersecting && chunk) this.fillChunk(chunk); + } + }), + { root, rootMargin: BUILD_MARGIN }, + ); + this.chunkRelease = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const chunk = this.chunkOf.get(entry.target); + + if (!entry.isIntersecting && chunk) this.emptyChunk(chunk); + } + }, + { 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 }, + ); + + if (!old) return; + + for (const file of this.order) { + this.build.observe(file.section); + this.release.observe(file.section); + this.request.observe(file.section); + + for (const chunk of file.chunks) { + this.chunkBuild.observe(chunk.element); + this.chunkRelease.observe(chunk.element); + } + } + } + /** The theme's colour for each of diffr's syntax roles, as CSS variables. */ private paintSyntax( themes: ReturnType>, @@ -961,8 +996,8 @@ export class DiffView extends Disposable { /** Draw a file's blocks that are on or near the screen now, so it never shows empty. */ private fillNear(file: FileView): void { - const view = this.root.getBoundingClientRect(); - const margin = this.root.clientHeight * 1.5; + const view = this.view(); + const margin = view.height * 1.5; for (const chunk of file.chunks) { const bounds = chunk.element.getBoundingClientRect(); @@ -1236,17 +1271,21 @@ export class DiffView extends Disposable { "aria-label", `Expand ${band.hidden} hidden line${band.hidden === 1 ? "" : "s"}${summary ? ", summarized" : `: ${band.title}`}`, ); - const title = node.appendChild(element("span", "dv-band-title")); + // Its chevron sits in the gutter, in line with the rows' own fold chevrons. + const gutter = node.appendChild(element("span", "dv-band-gutter")); // A scope's header chevron already opens it; one control per fold. if (!context.headed.has(band.foldStateId)) - title.append(element("span", "dv-band-chevron")); + gutter.append(element("span", "dv-band-chevron")); + + const main = node.appendChild(element("span", "dv-band-main")); + const title = main.appendChild(element("span", "dv-band-title")); if (summary) title.append(element("span", "dv-band-caption", "Pseudocode")); title.append(element("span", "dv-band-label", band.title)); if (summary) { - const detail = node.appendChild(element("div", "dv-pseudocode")); + const detail = main.appendChild(element("div", "dv-pseudocode")); for (const text of band.detail) { const line = detail.appendChild( @@ -1365,12 +1404,46 @@ export class DiffView extends Disposable { ); } + /** The part of the screen the list shows: its own box, or on a phone the window's share of it. */ + private view() { + const bounds = this.root.getBoundingClientRect(); + + if (!this.compact) return bounds; + + // The page's header stays over the list's top as it scrolls. + const header = + parseFloat(getComputedStyle(this.root).getPropertyValue("--dv-top")) || 0; + + const top = Math.max(bounds.top, header); + const bottom = Math.min(bounds.bottom, innerHeight); + + return { + left: bounds.left, + width: bounds.width, + top, + bottom, + height: bottom - top, + }; + } + + /** How far the list is scrolled: its own box, or on a phone the page past its top. */ + private get scrolledTo(): number { + return this.compact + ? this.view().top - this.root.getBoundingClientRect().top + : this.root.scrollTop; + } + + private scrollBy(delta: number): void { + if (this.compact) window.scrollBy(0, delta); + else this.root.scrollTop += delta; + } + /** * 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 top = this.view().top; const anchor = this.anchor(top); change(); @@ -1380,11 +1453,11 @@ export class DiffView extends Disposable { if (!node) return; const delta = node.getBoundingClientRect().top - top - anchor.offset; - if (Math.abs(delta) >= 1) this.root.scrollTop += delta; + if (Math.abs(delta) >= 1) this.scrollBy(delta); } private anchor(top: number) { - const bounds = this.root.getBoundingClientRect(); + const bounds = this.view(); if (!bounds.height) return undefined; @@ -1427,7 +1500,7 @@ export class DiffView extends Disposable { /** The topmost file on screen. */ get activePath(): string | undefined { - const top = this.root.scrollTop; + const top = this.scrolledTo; const visible = this.order.filter((file) => !file.section.hidden); let low = 0, @@ -1455,7 +1528,7 @@ export class DiffView extends Disposable { if (open) this.setFileCollapsed(path, false); this.settle(() => { - this.root.scrollTop = file.section.offsetTop; + this.scrollBy(file.section.offsetTop - this.scrolledTo); return true; }); @@ -1476,12 +1549,12 @@ export class DiffView extends Disposable { if (!row) return false; + const view = this.view(); + const delta = - row.getBoundingClientRect().top - - this.root.getBoundingClientRect().top - - this.root.clientHeight / 3; + row.getBoundingClientRect().top - view.top - view.height / 3; - if (Math.abs(delta) > 1) this.root.scrollTop += delta; + if (Math.abs(delta) > 1) this.scrollBy(delta); row.classList.remove("is-revealed"); void row.offsetWidth; row.classList.add("is-revealed"); @@ -1532,8 +1605,8 @@ export class DiffView extends Disposable { /** 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 view = this.view(); + const line = view.top + view.height / 3; const active = this.order.findIndex( (file) => file.entry.file.path === this.activePath, @@ -1578,7 +1651,7 @@ export class DiffView extends Disposable { .find((node) => node.getBoundingClientRect().top - line < -8); if (target) { - this.root.scrollTop += target.getBoundingClientRect().top - line; + this.scrollBy(target.getBoundingClientRect().top - line); return; } @@ -1589,9 +1662,10 @@ export class DiffView extends Disposable { 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; + // A phone's page goes back above the list, to its header. + if (to === "top") this.scrollBy(this.compact ? -scrollY : -this.scrolledTo); + else if (to === "end") this.scrollBy(this.root.scrollHeight); + else this.scrollBy(to); } focus(): void { @@ -2310,6 +2384,7 @@ export class DiffView extends Disposable { if (!sheet) document.addEventListener("pointerdown", onPointer, true); this.root.addEventListener("scroll", close, { passive: true }); + addEventListener("scroll", close, { passive: true }); this.scopeList = { list, @@ -2318,6 +2393,7 @@ export class DiffView extends Disposable { document.removeEventListener("keydown", onKey, true); document.removeEventListener("pointerdown", onPointer, true); this.root.removeEventListener("scroll", close); + removeEventListener("scroll", close); }, }; @@ -2408,13 +2484,13 @@ export class DiffView extends Disposable { ?.body.querySelector(this.caret.selector); const bounds = row?.getBoundingClientRect(); - const view = this.root.getBoundingClientRect(); + const view = this.view(); if (row && bounds && bounds.bottom > view.top && bounds.top < view.bottom) return row; } - const view = this.root.getBoundingClientRect(); + const view = this.view(); return ( document @@ -2448,10 +2524,10 @@ export class DiffView extends Disposable { if (!scroll) return; row.classList.add("is-caret"); const bounds = row.getBoundingClientRect(); - const view = this.root.getBoundingClientRect(); + const view = this.view(); if (bounds.top < view.top + 40 || bounds.bottom > view.bottom - 40) - this.root.scrollTop += bounds.top - view.top - this.root.clientHeight / 3; + this.scrollBy(bounds.top - view.top - view.height / 3); } // Review Desktop's surface for find in its editors: these rows are no editor. diff --git a/apps/diffr-web/src/main.ts b/apps/diffr-web/src/main.ts index fa5642015..ad5d8c7b3 100644 --- a/apps/diffr-web/src/main.ts +++ b/apps/diffr-web/src/main.ts @@ -53,6 +53,11 @@ const setTheme = applyTheme(root); const header = root.appendChild(element("header", "app-header")); +// A phone's file headers stick below the page's own, which stays as the page scrolls. +new ResizeObserver(() => + root.style.setProperty("--dv-top", `${header.offsetHeight}px`), +).observe(header); + let headerUpdatePending = false; const status = root.appendChild(element("div", "app-status")); diff --git a/apps/diffr-web/src/styles.css b/apps/diffr-web/src/styles.css index f3f7afb38..351b0182d 100644 --- a/apps/diffr-web/src/styles.css +++ b/apps/diffr-web/src/styles.css @@ -803,8 +803,31 @@ body { outline: 1px solid var(--vscode-focusBorder); } @media (max-width: 760px), (pointer: coarse) and (max-height: 500px) { + /* A phone scrolls the page, header and diff together (diffView.css). */ + html, + body { + height: auto; + } + body { + overflow: visible; + } .app { - height: 100dvh; + position: static; + min-height: 100dvh; + } + .app-body { + flex: 1 0 auto; + } + .app-header { + position: sticky; + top: 0; + z-index: 3; + } + .app-comparison { + position: static; + } + .app-find { + position: fixed; } .app-header { display: grid; From 9668dca3084afaebdfe6474f644e4c81667d8ee9 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 13 Oct 2026 16:09:40 -0700 Subject: [PATCH 19/19] Draw diffr-web's fold bands as Monaco did: 24px, borderless, ghost label on its diagonal fill Agent-Session: 5eb4067a-b2f6-4508-a6dd-0471ff2c14d1 Agent-Session: 429958d8-68f3-4f68-bd3b-a8371e7feb8e Agent-Session: dcdf8861-de36-4127-b7a5-52bfb5448d3d --- apps/diffr-web/src/diffView.css | 44 +++++++++++++++------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/apps/diffr-web/src/diffView.css b/apps/diffr-web/src/diffView.css index 6714ce657..ce776ef91 100644 --- a/apps/diffr-web/src/diffView.css +++ b/apps/diffr-web/src/diffView.css @@ -4,6 +4,18 @@ .dv { --dv-line: 22px; --dv-font: "Geist Mono", ui-monospace, Menlo, monospace; + /* The diff editor's diagonal fill, for folds and the side of a split with no line. */ + --dv-hatch: linear-gradient( + -45deg, + var(--vscode-diffEditor-diagonalFill) 12.5%, + #0000 12.5%, + #0000 50%, + var(--vscode-diffEditor-diagonalFill) 50%, + var(--vscode-diffEditor-diagonalFill) 62.5%, + #0000 62.5%, + #0000 100% + ) + 0 0 / 8px 8px; position: relative; height: 100%; overflow-y: auto; @@ -458,7 +470,8 @@ -webkit-user-select: none; } -/* What a collapsed region leaves: as wide as the screen, whatever the code's width. */ +/* What a collapsed region leaves, as Monaco drew it: as wide as the screen, whatever the code's + width, with no border, and its lines hatched. */ .dv-band { position: sticky; left: 0; @@ -466,7 +479,6 @@ width: var(--dv-width, 100vw); min-height: 44px; display: flex; - border-block: 1px solid var(--vscode-panel-border); line-height: 1.4; color: var(--vscode-descriptionForeground); background: var(--vscode-editor-background); @@ -476,11 +488,6 @@ touch-action: manipulation; } -.dv-band + .dv-band, -.dv-chunk + .dv-chunk > .dv-band:first-child { - border-top: 0; -} - .dv-band:active { background: var(--vscode-toolbar-hoverBackground); } @@ -494,7 +501,6 @@ width: calc(var(--dv-digits, 3) * 1ch + 36px); min-height: inherit; padding-left: 8px; - border-right: 1px solid var(--vscode-panel-border); } .no-numbers .dv-band-gutter { @@ -505,11 +511,7 @@ flex: 1; min-width: 0; padding-right: 12px; - background: repeating-linear-gradient( - -45deg, - transparent 0 5px, - var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px - ); + background: var(--dv-hatch); } .dv-band:focus-visible { @@ -543,6 +545,7 @@ flex: none; width: 12px; height: 12px; + opacity: 0.7; background: currentColor; mask: url("data:image/svg+xml;utf8,") center / 12px 12px no-repeat; @@ -563,6 +566,7 @@ .dv-band-label { min-width: 0; overflow-wrap: anywhere; + font-size: 11px; } .dv-band-label::before { @@ -676,11 +680,7 @@ a.dv-open.codicon { .dv-band.is-absent { pointer-events: none; box-shadow: none; - background: repeating-linear-gradient( - -45deg, - transparent 0 5px, - var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px - ); + background: var(--dv-hatch); } .dv-band.is-absent > * { @@ -702,11 +702,7 @@ a.dv-open.codicon { /* Where one side has no line beside the other's: hatched, as the diff editor draws it. */ .dv-filler { min-height: var(--dv-line); - background: repeating-linear-gradient( - -45deg, - transparent 0 5px, - var(--vscode-diffEditor-diagonalFill, rgba(128, 128, 128, 0.18)) 5px 6px - ); + background: var(--dv-hatch); } /* A selection stays on the side it began on. */ @@ -797,7 +793,7 @@ a.dv-open.codicon { .dv:not(.is-compact) .dv-band, .dv:not(.is-compact) .dv-band-title { - min-height: 28px; + min-height: 24px; } .dv:not(.is-compact) .dv-rows:not(.is-split) .dv-band-gutter {