diff --git a/CHANGELOG.md b/CHANGELOG.md index c917423..7633853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **ADDED**: optional regular-expression search over file paths, with invalid-pattern feedback - **ADDED**: `regex:` URL hide rules alongside existing wildcards, with validation before saving - **ADDED**: in-page file tabs with branch-specific scroll positions, separate PR/MR/commit workspaces and trusted session-only metadata +- **ADDED**: Unified / Side-by-side full-file diffs in file tabs and View full, with synchronized panes and preserved source-line positions - **CHANGED**: switching branches from a file page preserves its resolved file path instead of opening the branch root - **CHANGED**: enable GitHub device-flow and GitLab PKCE sign-in with Codetree's registered public client IDs - **CHANGED**: minimum Chrome version to 140 for trusted-only local and Sync storage access diff --git a/README.md b/README.md index 70d4f9f..a70c696 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The edge tab opens the sidebar. Use **Files**, **Pull requests / Merge requests* In **Files**, toggle `.*` to search full paths with a case-insensitive regular expression, for example `\.(dart|swift)$` or `^src/`. Enter the pattern without `/` delimiters or flags. Invalid patterns show an error. Search includes loaded entries and combines with **Only unviewed**; the mode resets when navigating or switching tabs. -Click a tree file to open it in Codetree's in-page workspace; clicking it again activates the existing tab. Changed files show full-file diffs. Close tabs with their close buttons, or use Left/Right and Home/End while a file-tab button has focus. Ctrl/Cmd-click still opens the file or native diff in a new browser tab; submodules retain their native navigation. Native comments and review controls remain available below the workspace. **View full** in a native header or the tree's full-diff action still opens the separate dialog, whose previous/next controls cover all changed files independently of sidebar search. +Click a tree file to open it in Codetree's in-page workspace; clicking it again activates the existing tab. Changed files show full-file diffs with a **Unified / Side-by-side** switch, also available in **View full**. Side-by-side aligns old/new lines and synchronizes scrolling; switching keeps your source position. The layout is shared within the page and resets to Unified on reload. Close tabs with their close buttons, or use Left/Right and Home/End while a file-tab button has focus. Ctrl/Cmd-click still opens the file or native diff in a new browser tab; submodules retain their native navigation. Native comments and review controls remain available below the workspace. **View full** in a native header or the tree's full-diff action still opens the separate dialog, whose previous/next controls cover all changed files independently of sidebar search. Switching branches keeps Code's open paths and active file while loading the selected branch; each branch has separate scroll positions. A missing file stays open with Retry and, when available, an action to open it on the default branch. Each PR, MR and commit has a separate workspace; returning restores its tabs and positions. Selecting a branch from a review exits to Code and restores Code's workspace. Reloading the same native route preserves the selected inline tab; navigating to another native file selects that file. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d88a23..b7b5fe7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,6 +87,8 @@ Every asynchronous reply is applied only if the generations captured at its star In-page file tabs use `FILE_TABS` with `scope: 'code'|'review'` and an optional complete metadata `value`. Reads return paths, active path, positions, opaque page identity and a broker-derived `accountID`; saves echo that account ID and reject mismatches, including account changes while queued. Storage keys derive the sender's browser tab ID and selected account, provider, origin, owner/repository and scope. Code shares paths across branches; review adds its validated PR/MR number or commit SHA. Positions are keyed by branch or exact review revisions. `src/shared/file-tabs.js` shares the 32-file/256-position limits; the broker also bounds storage to 50 workspaces and 64 KiB per entry, validates saved metadata on read and never persists text. Session operations do not touch other keys. New workspace behavior still requires installed-browser verification. +`file-content.js` projects validated unified diff rows into paired old/new rows without re-fetching revisions or re-running highlighting. Both split panes virtualize the same row range and synchronize scrolling. The page-local `diffLayout` signal updates tabs and dialogs together; it is not a stored preference. Viewport adapters translate saved tab positions to/from unified row coordinates, keeping the existing `FILE_TABS` schema. + View fields in `state.js` are signal-backed accessors, so factories keep the `state.x = value` syntax. `expanded` is replaced with a new Set and compared by contents. Generations, tree entries, the tree model and keyboard focus stay plain fields. `updateTree()` invalidates one tree derivation after structural changes; expansion and the debounced search invalidate it directly. Multi-field updates use `batch`. Files status is stored once; the visible `loading`/`error` accessors choose between Files and list status without copying on tab switches. The app root owns sidebar bindings and global listeners. Each navigation disposes its page root, closes its viewer and branch popover, removes native buttons and clears pending folder requests. Page liveness checks complement request generations. Each viewer shell owns its scroll listener and frames, and releases them on close or replacement. `catchError` on the app root reports computation errors as a toast; feature actions run untracked. Settings uses the same primitives with separate preference, account, OAuth and Sync bindings and listens for stored preference changes. @@ -107,6 +109,8 @@ See [Checks and releases](RELEASING.md) for CI and publication. `npm test` runs Node.js regressions without a browser: - `core.test.mjs`: full-file diff validation. +- `split-diff.test.mjs`: View full layout controls, aligned old/new rows, synchronized/clamped scrolling, source-line positions, font/viewport changes and disposal through the production viewer with synthetic geometry. +- `file-tabs.test.mjs`: file navigation and session restoration, including Split diff positions across file/review switches, Refresh and page reload, plus independent inline/dialog scrolling with a shared layout. - `hosts.test.mjs`: request contexts built like the content script's, and rejected branch/path segments. - `hide-rules.test.mjs`: regex and wildcard URL exclusions, validation, persisted rules, URL query/fragment boundaries and code-only scope. - `viewed.test.mjs`: Viewed marks through the bundled service worker in a VM with mocked APIs. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e3cea55..2ab6029 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -28,6 +28,8 @@ Both providers require a token for review-state filters. GitLab reviewer/approva The native button is inserted into a changed file's header. A rerendered header gets one button; a route change clears the old bindings. The sidebar can stay closed. Headers with a native file path receive the button even when API access fails; clicking offers account connection/retry guidance. Website sign-in and extension API authentication are separate. Text previews include all unchanged context, additions and removals, old/new line numbers and previous/next file navigation. +The **Unified / Side-by-side** switch is available in file tabs and **View full**. Unified is the default; the selected layout is shared by both viewers until the page reloads. Side-by-side shows the old revision on the left and the new revision on the right, with aligned change blocks, empty gaps for unmatched additions/removals and synchronized scrolling. Switching reuses the loaded revisions and preserves the top source line. Saved tab positions use unified coordinates so reopening a file in either layout restores the same source location. Ordinary repository text and comments do not show this switch. + An unchanged rename loads the previous path at the base revision and the new path at the head revision. Added/deleted files use an empty opposite revision. Known binary extensions have disabled native buttons; encoding, binary content and size checks also run in the broker. Changed text must have a complete patch that matches both source revisions. Missing/truncated or incompatible patches show an error with a native diff link. There is no fallback that presents an incomplete file diff as complete. diff --git a/src/content/sidebar.scss b/src/content/sidebar.scss index ac5f785..9cf7808 100644 --- a/src/content/sidebar.scss +++ b/src/content/sidebar.scss @@ -909,11 +909,12 @@ button:disabled { padding: 13px 16px; border-bottom: 1px solid var(--border); display: flex; + flex-wrap: wrap; gap: 10px; align-items: center; } .viewer-heading { - flex: 1; + flex: 1 1 160px; min-width: 0; } .viewer-heading strong { @@ -931,13 +932,43 @@ button:disabled { } .viewer-actions { display: flex; + flex-wrap: wrap; align-items: center; gap: 5px; + max-width: 100%; } .viewer-actions a { font-size: 11px; margin-right: 9px; } +.diff-layout { + display: inline-flex; + flex: 0 0 auto; + gap: 2px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--muted-bg); +} +.diff-layout button { + min-height: 24px; + padding: 4px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} +.diff-layout button:hover { + background: var(--hover); + color: var(--text); +} +.diff-layout button[aria-pressed='true'] { + background: var(--selected); + color: var(--accent); +} .viewer[aria-label='Full-file diff'] .viewer-body { background: var(--code-bg); color: var(--code-text); @@ -949,6 +980,45 @@ button:disabled { position: relative; scrollbar-width: thin; } +.viewer-body.split-diff { + display: flex; + flex-direction: column; + overflow: hidden; +} +.split-diff-labels { + display: grid; + grid-template-columns: 1fr 1fr; + flex-shrink: 0; + background: var(--muted-bg); + color: var(--muted); + border-bottom: 1px solid var(--border); + font-size: 11px; +} +.split-diff-labels span { + padding: 5px 10px; +} +.split-diff-labels span + span, +.split-diff-pane + .split-diff-pane { + border-left: 1px solid var(--border); +} +.split-diff-panes { + display: flex; + flex: 1; + min-height: 0; +} +.split-diff-pane { + flex: 1; + min-width: 0; + overflow: auto; + position: relative; + background: var(--code-bg); + color: var(--code-text); + scrollbar-width: thin; +} +.split-diff-pane:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} // Loading and error states fill the dialog and sit in its middle. .viewer-body > .empty:only-child { min-height: 100%; @@ -984,6 +1054,9 @@ button:disabled { .code-row.removed { background: var(--diff-delete-bg); } +.code-row.diff-gap { + background: var(--muted-bg); +} .code-row .line-number { display: inline-block; text-align: right; @@ -1119,9 +1192,6 @@ button:disabled { .file-workspace { margin: 8px; } - .file-workspace .viewer-header { - flex-wrap: wrap; - } } @media (prefers-reduced-motion: reduce) { * { diff --git a/src/content/state.js b/src/content/state.js index c6b24d0..a967e9d 100644 --- a/src/content/state.js +++ b/src/content/state.js @@ -43,6 +43,7 @@ const viewFields = [ 'viewLoading', 'viewError', 'diff', + 'diffLayout', 'pulls', 'loadingAll', 'selected', @@ -83,6 +84,7 @@ export function createState() { viewLoading: false, viewError: '', diff: null, + diffLayout: 'unified', branches: null, pulls: [], loadingAll: false, diff --git a/src/content/viewer/file-content.js b/src/content/viewer/file-content.js index 114f076..24cf9f8 100644 --- a/src/content/viewer/file-content.js +++ b/src/content/viewer/file-content.js @@ -13,7 +13,7 @@ import {lines, fullDiff} from '../../shared/diff.js'; import {el} from '../dom.js'; import {providerName} from '../page.js'; -import {runWithOwner} from '../reactive.js'; +import {createRenderEffect, on, onCleanup, runWithOwner} from '../reactive.js'; import {listen, requestFrame} from '../reactive-dom.js'; import {tokenize} from './syntax.js'; @@ -25,7 +25,7 @@ import {tokenize} from './syntax.js'; * when either exceeds the token budget. * * @param {object} app The content feature context. - * @param {{body: Element, bottom: Element, owner: object}} shell Owned viewport and footer. + * @param {{actions: Element, body: Element, bottom: Element, owner: object}} shell Owned viewport and footer. * @param {object} node A changed file in tree-node form. * @param {object} diff The request's exact base and head revisions. * @param {() => boolean} current Whether this render still owns the shell. @@ -52,7 +52,7 @@ export async function renderDiffFile(app, shell, node, diff, current) { oldSyntax.lines = []; newSyntax.lines = []; } - renderRows(app, shell, rows, oldSyntax, newSyntax, current); + renderRows(app, shell, rows, oldSyntax, newSyntax, current, true); shell.bottom.prepend( el('span', {class: 'adds', text: `+${node.adds}`}), el('span', {class: 'dels', text: `โˆ’${node.dels}`}), @@ -92,69 +92,216 @@ export async function renderTextFile(app, shell, path, text, current) { if (syntax.limited) shell.bottom.append(el('span', {text: 'Syntax limit reached ยท plain text'})); } -/** Installs an owned, virtualized viewport for validated diff or plain-text rows. */ -function renderRows(app, shell, rows, oldSyntax, newSyntax, current) { +/** + * Pairs each contiguous change block without crossing unchanged context. + * Rows remain references to the validated unified diff; empty sides are gaps, + * not invented source lines. The index maps keep scroll metadata in unified coordinates. + */ +function splitRows(rows) { + const pairs = []; + const unifiedToSplit = []; + const splitToUnified = []; + let index = 0; + while (index < rows.length) { + if (rows[index].type === 'context') { + unifiedToSplit[index] = pairs.length; + splitToUnified.push(index); + pairs.push([rows[index], rows[index]]); + index++; + continue; + } + const removed = []; + const added = []; + while (index < rows.length && rows[index].type !== 'context') { + (rows[index].type === 'removed' ? removed : added).push(index++); + } + for (let offset = 0; offset < Math.max(removed.length, added.length); offset++) { + const oldIndex = removed[offset]; + const newIndex = added[offset]; + if (oldIndex !== undefined) unifiedToSplit[oldIndex] = pairs.length; + if (newIndex !== undefined) unifiedToSplit[newIndex] = pairs.length; + splitToUnified.push(oldIndex ?? newIndex); + pairs.push([rows[oldIndex], rows[newIndex]]); + } + } + return {pairs, unifiedToSplit, splitToUnified}; +} + +/** Creates one source row or an empty alignment gap, rendering syntax as text nodes only. */ +function codeRow(row, side, oldSyntax, newSyntax) { + if (!row) return el('div', {class: 'code-row diff-gap', 'aria-hidden': 'true'}); + const text = el('span', {class: 'line-text'}); + const old = side === 'old' || (side === 'unified' && row.type === 'removed'); + const tokens = old ? oldSyntax.lines[row.oldLine - 1] : newSyntax.lines[row.newLine - 1]; + let position = 0; + for (const token of tokens || []) { + text.append( + document.createTextNode(row.text.slice(position, token.start)), + el('span', {class: `syntax-${token.type}`, text: row.text.slice(token.start, token.end)}), + ); + position = token.end; + } + text.append(document.createTextNode(row.text.slice(position))); + const numbers = side === 'unified' ? [row.oldLine, row.newLine] : [old ? row.oldLine : row.newLine]; + return el('div', {class: `code-row ${row.type}`}, [ + ...numbers.map(number => el('span', {class: 'line-number', text: number || ''})), + el('span', {class: 'line-sign', text: row.type === 'added' ? '+' : row.type === 'removed' ? 'โˆ’' : ' '}), + text, + ]); +} + +/** Installs owned virtualized viewports; switching layout reuses validated rows and syntax without fetching. */ +function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = false) { let rowHeight = Math.max(22, app.state.preferences.fontSize + 8); + let split = false; + let projection; + let splitAnchor; + let frame = 0; const code = el('div', {class: 'code-spacer'}); - code.style.height = `${rows.length * rowHeight}px`; + const oldCode = el('div', {class: 'code-spacer'}); + const newCode = el('div', {class: 'code-spacer'}); + const oldPane = el('div', {class: 'split-diff-pane', tabindex: '0', 'aria-label': 'Old revision'}, [oldCode]); + const newPane = el('div', {class: 'split-diff-pane', tabindex: '0', 'aria-label': 'New revision'}, [newCode]); + const labels = el('div', {class: 'split-diff-labels'}, [el('span', {text: 'Old'}), el('span', {text: 'New'})]); + const panes = el('div', {class: 'split-diff-panes'}, [oldPane, newPane]); const maxLength = rows.reduce((max, row) => Math.max(max, row.text.length), 0); - code.style.width = `${Math.max(600, 128 + Math.min(4000, maxLength) * app.state.preferences.fontSize * 0.65)}px`; - let frame = 0; - /** Replaces only rows around the visible scroll position while this shell is live. */ + /** Reads the visible position in canonical unified coordinates, independent of the current layout. */ + function readPosition() { + const viewport = split ? oldPane : shell.body; + const line = viewport.scrollTop / rowHeight; + const index = Math.floor(line); + const unified = split + ? splitAnchor?.index === index + ? splitAnchor.unified + : (projection.splitToUnified[index] ?? 0) + : index; + return {top: (unified + line - index) * rowHeight, left: viewport.scrollLeft}; + } + + /** Restores a canonical position, synchronizing both revisions in split mode. */ + function restorePosition(position) { + const line = position.top / rowHeight; + const index = Math.floor(line); + const target = split ? (projection.unifiedToSplit[Math.min(index, rows.length - 1)] ?? 0) : index; + // Keep the exact source side when two unified rows share a split row, until scrolling to another row. + splitAnchor = split ? {index: target, unified: index} : null; + for (const viewport of split ? [oldPane, newPane] : [shell.body]) { + viewport.scrollTop = (target + line - index) * rowHeight; + } + draw(); + for (const viewport of split ? [oldPane, newPane] : [shell.body]) viewport.scrollLeft = position.left; + } + + /** Replaces only rows around the active scroll position while this shell is live. */ function draw() { frame = 0; if (!current()) return; - const start = Math.max(0, Math.floor(shell.body.scrollTop / rowHeight) - 6); - const end = Math.min(rows.length, start + Math.ceil(shell.body.clientHeight / rowHeight) + 14); - const fragment = document.createDocumentFragment(); - for (let index = start; index < end; index++) { - const row = rows[index]; - const text = el('span', {class: 'line-text'}); - let position = 0; - const tokens = row.type === 'removed' ? oldSyntax.lines[row.oldLine - 1] : newSyntax.lines[row.newLine - 1]; - for (const token of tokens || []) { - text.append( - document.createTextNode(row.text.slice(position, token.start)), - el('span', {class: `syntax-${token.type}`, text: row.text.slice(token.start, token.end)}), - ); - position = token.end; + const viewport = split ? oldPane : shell.body; + const count = split ? projection.pairs.length : rows.length; + const start = Math.max(0, Math.floor(viewport.scrollTop / rowHeight) - 6); + const end = Math.min(count, start + Math.ceil(viewport.clientHeight / rowHeight) + 14); + for (const [container, side] of split + ? [ + [oldCode, 'old'], + [newCode, 'new'], + ] + : [[code, 'unified']]) { + const fragment = document.createDocumentFragment(); + for (let index = start; index < end; index++) { + const row = split ? projection.pairs[index][side === 'old' ? 0 : 1] : rows[index]; + const element = codeRow(row, side, oldSyntax, newSyntax); + element.style.top = `${index * rowHeight}px`; + fragment.append(element); } - text.append(document.createTextNode(row.text.slice(position))); - const element = el('div', {class: `code-row ${row.type}`}, [ - el('span', {class: 'line-number', text: row.oldLine || ''}), - el('span', {class: 'line-number', text: row.newLine || ''}), - el('span', {class: 'line-sign', text: row.type === 'added' ? '+' : row.type === 'removed' ? 'โˆ’' : ' '}), - text, - ]); - element.style.top = `${index * rowHeight}px`; - fragment.append(element); + container.replaceChildren(fragment); + } + if (split) { + // Tabs and wide glyphs can exceed the estimate. Equalize actual overflow before either pane scrolls. + const overflow = Math.max(oldCode.scrollWidth - oldPane.clientWidth, newCode.scrollWidth - newPane.clientWidth); + oldCode.style.width = `${overflow + oldPane.clientWidth}px`; + newCode.style.width = `${overflow + newPane.clientWidth}px`; + } + } + + /** Keeps paired panes equally wide so horizontal and vertical scroll ranges match. */ + function sizeViewports() { + const textWidth = Math.min(4000, maxLength) * app.state.preferences.fontSize * 0.65; + shell.body.style.setProperty('--diff-row-height', `${rowHeight}px`); + code.style.height = `${rows.length * rowHeight}px`; + code.style.width = `${Math.max(600, 128 + textWidth)}px`; + for (const container of [oldCode, newCode]) { + container.style.height = `${(projection?.pairs.length || 0) * rowHeight}px`; + container.style.width = `${Math.max(300, 100 + textWidth)}px`; } - code.replaceChildren(fragment); } - /** Applies live font metrics without moving the logical line at the top of the viewport. */ + /** Applies live font metrics without moving the source line at the top of the viewport. */ function relayout() { if (!current()) return; - const line = shell.body.scrollTop / rowHeight; + const position = readPosition(); + const line = position.top / rowHeight; rowHeight = Math.max(22, app.state.preferences.fontSize + 8); - code.style.height = `${rows.length * rowHeight}px`; - code.style.width = `${Math.max(600, 128 + Math.min(4000, maxLength) * app.state.preferences.fontSize * 0.65)}px`; - shell.body.scrollTop = line * rowHeight; - draw(); + sizeViewports(); + restorePosition({top: line * rowHeight, left: position.left}); } + shell.readPosition = readPosition; + shell.restorePosition = restorePosition; shell.relayout = relayout; shell.body.replaceChildren(code); - runWithOwner(shell.owner, () => - listen( - shell.body, - 'scroll', - () => { - if (!frame) frame = requestFrame(draw); - }, - {passive: true}, - ), - ); - draw(); + runWithOwner(shell.owner, () => { + for (const viewport of [shell.body, oldPane, newPane]) { + listen( + viewport, + 'scroll', + () => { + if (!current() || (split ? viewport === shell.body : viewport !== shell.body)) return; + if (split) { + const other = viewport === oldPane ? newPane : oldPane; + if (other.scrollTop !== viewport.scrollTop) other.scrollTop = viewport.scrollTop; + if (other.scrollLeft !== viewport.scrollLeft) other.scrollLeft = viewport.scrollLeft; + shell.onScroll?.(); + } + if (!frame) frame = requestFrame(draw); + }, + {passive: true}, + ); + } + if (!diff) return; + listen(window, 'resize', relayout); + createRenderEffect(on(() => app.state.preferences.fontSize, relayout)); + const controls = el('div', {class: 'diff-layout', role: 'group', 'aria-label': 'Diff layout'}); + for (const [value, label] of [ + ['unified', 'Unified'], + ['split', 'Side-by-side'], + ]) { + const control = el('button', {type: 'button', text: label, 'aria-pressed': 'false'}); + listen(control, 'click', () => { + if (current()) app.state.diffLayout = value; + }); + controls.append(control); + } + shell.actions.prepend(controls); + onCleanup(() => controls.remove()); + createRenderEffect( + on( + () => app.state.diffLayout, + value => { + if (!current()) return; + const position = readPosition(); + split = value === 'split'; + if (split) projection ??= splitRows(rows); + shell.body.classList.toggle('split-diff', split); + shell.body.replaceChildren(...(split ? [labels, panes] : [code])); + for (const [index, control] of Array.from(controls.children).entries()) + control.setAttribute('aria-pressed', String(split === (index === 1))); + sizeViewports(); + restorePosition(position); + shell.onScroll?.(); + }, + ), + ); + }); + relayout(); } diff --git a/src/content/viewer/file-tabs.js b/src/content/viewer/file-tabs.js index baed8ec..b12bd21 100644 --- a/src/content/viewer/file-tabs.js +++ b/src/content/viewer/file-tabs.js @@ -28,12 +28,10 @@ export function createFileTabs(app) { const heading = el('strong'); const revision = el('small'); const native = el('a', {target: '_blank', rel: 'noopener noreferrer'}); + const actions = el('div', {class: 'viewer-actions'}, [native]); const frame = el('section', {class: 'file-workspace', 'aria-label': 'Codetree file workspace'}, [ tabs, - el('div', {class: 'viewer-header'}, [ - el('div', {class: 'viewer-heading'}, [heading, revision]), - el('div', {class: 'viewer-actions'}, [native]), - ]), + el('div', {class: 'viewer-header'}, [el('div', {class: 'viewer-heading'}, [heading, revision]), actions]), ]); shadow.append(frame); let sheet; @@ -119,9 +117,10 @@ export function createFileTabs(app) { ) return; const {positions} = snapshot.value; + const position = shell.readPosition?.() || {top: shell.body.scrollTop, left: shell.body.scrollLeft}; snapshot.value.positions = positions .filter(position => position.path !== renderedPath || position.ref !== positionRef) - .concat({ref: positionRef, path: renderedPath, top: shell.body.scrollTop, left: shell.body.scrollLeft}) + .concat({ref: positionRef, path: renderedPath, ...position}) .slice(-maxFileTabPositions); } @@ -358,7 +357,7 @@ export function createFileTabs(app) { tabindex: '0', }); const bottom = el('div', {class: 'viewer-bottom'}); - return {body, bottom, owner: getOwner()}; + return {actions, body, bottom, owner: getOwner()}; }), ); for (const node of Array.from(frame.children)) @@ -402,28 +401,29 @@ export function createFileTabs(app) { const position = snapshot.value.positions.find( position => position.path === path && position.ref === positionRef, ); - shell.body.scrollTop = position?.top || 0; - shell.body.scrollLeft = position?.left || 0; + const savedPosition = {top: position?.top || 0, left: position?.left || 0}; + if (shell.restorePosition) shell.restorePosition(savedPosition); + else { + shell.body.scrollTop = savedPosition.top; + shell.body.scrollLeft = savedPosition.left; + } shell.relayout?.(); - runWithOwner(shell.owner, () => - listen( - shell.body, - 'scroll', - () => { - capturePosition(); - clearTimeout(saveTimer); - saveTimer = setTimeout( - withOwner(() => - rememberFileTabs().catch(error => { - if (current()) app.toast(error.message); - }), - ), - 150, - ); - }, - {passive: true}, - ), + shell.onScroll = runWithOwner(shell.owner, () => + withOwner(() => { + if (!current()) return; + capturePosition(); + clearTimeout(saveTimer); + saveTimer = setTimeout( + withOwner(() => + rememberFileTabs().catch(error => { + if (current()) app.toast(error.message); + }), + ), + 150, + ); + }), ); + runWithOwner(shell.owner, () => listen(shell.body, 'scroll', shell.onScroll, {passive: true})); renderTabs(); } catch (error) { if (!current()) return; diff --git a/tests/file-tabs.test.mjs b/tests/file-tabs.test.mjs index 9f3191a..b069c16 100644 --- a/tests/file-tabs.test.mjs +++ b/tests/file-tabs.test.mjs @@ -17,6 +17,7 @@ import {join} from 'node:path'; import {pathToFileURL} from 'node:url'; import {bundle} from './helpers/content.mjs'; import {dom, frames, install} from './helpers/dom.mjs'; +import {installDiffViewport} from './helpers/diff-viewport.mjs'; const directory = await mkdtemp(join(tmpdir(), 'codetree-file-tabs-')); const modulePath = join(directory, 'content.mjs'); @@ -47,14 +48,15 @@ function deferred() { return {promise, resolve, reject}; } -/** Advances promise continuations without consuming the fixture's explicit stylesheet events. */ +/** Gives asynchronous viewer work a timer/I/O turn without consuming explicit stylesheet events. */ async function turn() { - await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setTimeout(resolve, 0)); } /** Composes real navigation, tree, branch and workspace factories around a metadata-only RPC fixture. */ -function fixture(t, provider) { +function fixture(t, provider, reviewDiff) { const {document, window} = dom(); + if (reviewDiff) installDiffViewport(document); const clock = frames(); const gitlab = provider === 'gitlab'; const root = gitlab ? 'https://gitlab.com/team/nested/repo' : 'https://github.com/sample/repo'; @@ -73,6 +75,18 @@ function fixture(t, provider) { const createElement = document.createElement.bind(document); document.createElement = tag => { const node = createElement(tag); + if (reviewDiff) { + Object.defineProperty(node, 'firstChild', {get: () => node.children[0] || null}); + if (tag === 'dialog') { + node.showModal = () => { + node.open = true; + }; + node.close = () => { + node.open = false; + return node.fire('close'); + }; + } + } const attachShadow = node.attachShadow.bind(node); node.attachShadow = () => { const shadow = attachShadow(); @@ -162,9 +176,10 @@ function fixture(t, provider) { return { base: source(baseSHA), head: source(reviewSHA), - files: paths - .slice(0, 2) - .map(filename => ({filename, status: 'added', additions: 220, deletions: 0, patch: ''})), + files: paths.slice(0, 2).map(filename => ({ + filename, + ...(reviewDiff?.file || {status: 'added', additions: 220, deletions: 0, patch: ''}), + })), comments: [], viewed: {}, viewedMode: 'none', @@ -192,6 +207,8 @@ function fixture(t, provider) { error.status = 404; throw error; } + if (reviewDiff && context.kind === 'pull') + return message.source.sha === baseSHA ? reviewDiff.before : reviewDiff.after; return longText; } throw new Error(`Unexpected request: ${type}`); @@ -228,6 +245,7 @@ function fixture(t, provider) { content.createTree, content.createBranches, content.createNavigation, + ...(reviewDiff ? [content.createViewer] : []), content.createFileTabs, ].filter(Boolean)) Object.assign(app, untrackActions(factory(app))); @@ -257,6 +275,8 @@ function fixture(t, provider) { for (let index = 0; index < count; index++) { if (styles) await loadStyles(); clock.flush(); + // An immediate-only loop can exhaust its turns before highlighting timers + // or native URL hashing finish under parallel load, in either viewer layout. await turn(); } } @@ -378,6 +398,7 @@ function fixture(t, provider) { return app; }, root, + clock, files, errors, metadata, @@ -623,3 +644,234 @@ for (const provider of ['github', 'gitlab']) { assert.deepEqual(ui.errors, []); }); } + +/** Unequal replacement makes post-change unified and split coordinates observably different. */ +function splitRevision() { + const before = Array.from({length: 220}, (_, index) => `export const source${index + 1} = ${index + 1};`); + before[100] = '// ' + 'wide source '.repeat(90); + const added = ['export const replacementA = true;', 'export const replacementB = false;']; + return { + before: before.join('\n'), + after: [...before.slice(0, 4), ...added, ...before.slice(9)].join('\n'), + file: { + status: 'modified', + additions: 2, + deletions: 5, + patch: ['@@ -5,5 +5,2 @@', ...before.slice(4, 9).map(line => '-' + line), ...added.map(line => '+' + line)].join( + '\n', + ), + }, + }; +} + +/** Clicks the visible layout control, requiring the feature rather than assigning internal state. */ +async function diffLayout(ui, scope, label) { + const controls = scope.querySelector('.diff-layout'); + assert.ok(controls, 'Changed files expose the Unified / Side-by-side layout control.'); + const target = controls.querySelectorAll('button').find(button => button.textContent === label); + assert.ok(target, `Expected ${label} layout control`); + await ui.settle(target.fire('click')); + assert.equal(target.getAttribute('aria-pressed'), 'true'); + assert.equal( + controls.querySelectorAll('button').filter(button => button.getAttribute('aria-pressed') === 'true').length, + 1, + ); +} + +/** Returns the two visible revision viewports in their Old/New order. */ +function diffPanes(scope) { + const panes = scope.querySelectorAll('.split-diff-pane'); + assert.equal(panes.length, 2, 'Side-by-side displays both source revisions.'); + assert.deepEqual( + panes.map(pane => pane.getAttribute('aria-label')), + ['Old revision', 'New revision'], + ); + return panes; +} + +/** Delivers a real pane scroll event and drains the renderer's owned frame. */ +async function paneScroll(ui, pane, top, left) { + pane.scrollTop = top; + pane.scrollLeft = left; + await pane.fire('scroll'); + await ui.drain(); +} + +/** Visible coordinates of both panes, without reading renderer or projection state. */ +function panePositions(scope) { + // Fractional row-coordinate conversions may differ by a few floating-point ULPs. + return diffPanes(scope).map(pane => ({top: Math.round(pane.scrollTop * 1e6) / 1e6, left: pane.scrollLeft})); +} + +for (const provider of ['github', 'gitlab']) { + test(`${provider} Split diff switches inline layout without refetching and keeps post-change position`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + const initialControls = ui.workspace().querySelector('.diff-layout'); + assert.ok(initialControls, 'Changed files expose their layout controls.'); + assert.equal(initialControls.querySelectorAll('button')[0].getAttribute('aria-pressed'), 'true'); + await ui.scroll(1107, 75); // unified row 50 + 7px; split removes two added-only rows above it. + const requests = ui.files.length; + for (let repeat = 0; repeat < 2; repeat++) { + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 75}, + {top: 1063, left: 75}, + ]); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal(ui.body().scrollTop, 1107); + assert.equal(ui.body().scrollLeft, 75); + } + assert.equal(ui.files.length, requests, 'Layout changes reuse both already-loaded revisions.'); + assert.equal(ui.workspace().querySelectorAll('.viewer-bottom').length, 1); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} Split diff preserves the added source line through pairing and a fresh page reload`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + await ui.scroll(227, 41); // second added row: unified index 10, split pair index 5, fractional offset 7. + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 117, left: 41}, + {top: 117, left: 41}, + ]); + await ui.open(paths[1]); + await ui.open(paths[0]); + assert.deepEqual(panePositions(ui.body()), [ + {top: 117, left: 41}, + {top: 117, left: 41}, + ]); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal( + Math.round(ui.body().scrollTop * 1e6) / 1e6, + 227, + 'An added line does not silently become the paired removed line.', + ); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + await ui.reload(); + assert.equal(ui.body().querySelectorAll('.split-diff-pane').length, 0, 'A fresh page defaults to Unified.'); + assert.equal( + Math.round(ui.body().scrollTop * 1e6) / 1e6, + 227, + 'Session metadata stores the source position, not split pixels.', + ); + assert.equal(ui.body().scrollLeft, 41); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} Split diff restores each file after pane scrolling, Refresh and reload`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + await paneScroll(ui, diffPanes(ui.body())[1], 1063, 125); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 125}, + {top: 1063, left: 125}, + ]); + await ui.open(paths[1]); + await paneScroll(ui, diffPanes(ui.body())[0], 1503, 85); + await ui.open(paths[0]); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 125}, + {top: 1063, left: 125}, + ]); + await ui.settle(ui.app.refresh()); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 125}, + {top: 1063, left: 125}, + ]); + await ui.reload(); + assert.equal(ui.body().scrollTop, 1107); + assert.equal(ui.body().scrollLeft, 125); + await ui.open(paths[1]); + assert.equal(ui.body().scrollTop, 1547); + assert.equal(ui.body().scrollLeft, 85); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} Split diff keeps independent inline and View full positions while sharing the layout`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + await ui.scroll(1107, 75); + const file = ui.app.state.diff.files[0]; + await ui.settle(ui.app.showDiff({...file, path: file.filename, adds: file.additions, dels: file.deletions})); + const modal = ui.app.view.viewer; + const modalBody = modal.querySelector('.viewer-body'); + modalBody.scrollTop = 1547; + modalBody.scrollLeft = 155; + await modalBody.fire('scroll'); + const requests = ui.files.length; + await diffLayout(ui, modal, 'Side-by-side'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 75}, + {top: 1063, left: 75}, + ]); + assert.deepEqual(panePositions(modal), [ + {top: 1503, left: 155}, + {top: 1503, left: 155}, + ]); + await paneScroll(ui, diffPanes(modal)[1], 1943, 195); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal(modalBody.scrollTop, 1987); + assert.equal(modalBody.scrollLeft, 195); + assert.equal(ui.body().scrollTop, 1107, 'Scrolling View full does not move the inline workspace.'); + assert.equal(ui.files.length, requests); + ui.app.closeViewer(); + await ui.open(paths[1]); + await ui.open(paths[0]); + assert.equal(ui.body().scrollTop, 1107, 'Closing View full cannot overwrite the inline saved position.'); + assert.equal(ui.body().scrollLeft, 75); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} Split diff review positions remain isolated across PR/MR workspaces and Code`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + await paneScroll(ui, diffPanes(ui.body())[0], 1063, 125); + await ui.navigate(ui.review(42)); + await paneScroll(ui, diffPanes(ui.body())[1], 1503, 85); + await ui.branch('main'); + await ui.open(paths[0]); + assert.equal(ui.workspace().querySelectorAll('.diff-layout').length, 0, 'Plain Code files have no diff layout.'); + await ui.scroll(220, 0); + await ui.navigate(ui.review(41)); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1063, left: 125}, + {top: 1063, left: 125}, + ]); + await diffLayout(ui, ui.workspace(), 'Unified'); + await ui.navigate(ui.review(42)); + assert.equal(ui.body().scrollTop, 1547); + assert.equal(ui.body().scrollLeft, 85); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} Split diff disposed file controls preserve the active position and metadata`, async t => { + const ui = fixture(t, provider, splitRevision()); + await ui.navigate(ui.review(41)); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + const oldPanes = diffPanes(ui.body()); + const oldUnified = ui.workspace().querySelector('.diff-layout').querySelectorAll('button')[0]; + await paneScroll(ui, oldPanes[0], 1063, 125); + const opening = ui.app.openFileTab({path: paths[1]}); + await oldPanes[0].fire('scroll'); // queue a frame while replacement is waiting for its metadata save. + await ui.settle(opening); + for (const pane of oldPanes) assert.equal(pane.listeners.get('scroll').length, 0); + assert.equal(oldUnified.listeners.get('click').length, 0); + await paneScroll(ui, diffPanes(ui.body())[0], 1503, 85); + await ui.app.rememberFileTabs(); // Flush the active file's own pending save before probing stale callbacks. + const stored = structuredClone([...ui.metadata]); + await oldUnified.fire('click'); + await paneScroll(ui, oldPanes[1], 2200, 250); + assert.deepEqual(panePositions(ui.body()), [ + {top: 1503, left: 85}, + {top: 1503, left: 85}, + ]); + assert.deepEqual([...ui.metadata], stored, 'Detached controls preserve the current workspace metadata.'); + assert.equal(ui.selection().active, paths[1]); + assert.ok(diffPanes(ui.body()).every(pane => pane.querySelectorAll('.code-row').length < 50)); + assert.deepEqual(ui.errors, []); + }); +} diff --git a/tests/helpers/diff-viewport.mjs b/tests/helpers/diff-viewport.mjs new file mode 100644 index 0000000..ababf69 --- /dev/null +++ b/tests/helpers/diff-viewport.mjs @@ -0,0 +1,96 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * Deterministic geometry for diff scrolling tests, not a browser layout engine. + */ + +/** + * Adds bounded viewport geometry to the DOM fixture. Spacer dimensions come + * from rendered styles, while visible text contributes ten pixels per column + * with four-column tabs, matching the stylesheet's tab size. Unequal split + * widths expose synchronization that incorrectly assumes identical viewports. + * All descriptors remain configurable for stylesheet-loading fixtures. + */ +export function installDiffViewport(document) { + const createElement = document.createElement.bind(document); + document.createElement = tag => { + const node = createElement(tag); + let top = 0; + let left = 0; + let width; + let height = 290; + Object.defineProperties(node, { + clientWidth: { + configurable: true, + get() { + return ( + width ?? + (this.getAttribute('aria-label') === 'Old revision' + ? 360 + : this.getAttribute('aria-label') === 'New revision' + ? 400 + : 800) + ); + }, + set(value) { + width = value; + }, + }, + clientHeight: { + configurable: true, + get() { + return height; + }, + set(value) { + height = value; + }, + }, + scrollWidth: { + configurable: true, + get() { + if (this.classList.contains('code-spacer')) { + const textWidth = Math.max( + 0, + ...this.querySelectorAll('.line-text').map(text => { + let columns = 0; + for (const character of text.textContent) columns += character === '\t' ? 4 - (columns % 4) : 1; + return columns * 10 + 100; + }), + ); + return Math.max(Number.parseFloat(this.style.width) || 0, this.parent?.clientWidth || 0, textWidth); + } + const spacer = this.children.find(child => child.classList.contains('code-spacer')); + return Math.max(this.clientWidth, spacer?.scrollWidth || 0); + }, + }, + scrollHeight: { + configurable: true, + get() { + const spacer = this.children.find(child => child.classList.contains('code-spacer')); + return Math.max(this.clientHeight, Number.parseFloat(spacer?.style.height) || 0); + }, + }, + scrollTop: { + configurable: true, + get() { + return Math.min(top, Math.max(0, this.scrollHeight - this.clientHeight)); + }, + set(value) { + top = Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight)); + }, + }, + scrollLeft: { + configurable: true, + get() { + return Math.min(left, Math.max(0, this.scrollWidth - this.clientWidth)); + }, + set(value) { + left = Math.max(0, Math.min(value, this.scrollWidth - this.clientWidth)); + }, + }, + }); + return node; + }; +} diff --git a/tests/split-diff.test.mjs b/tests/split-diff.test.mjs new file mode 100644 index 0000000..062e18e --- /dev/null +++ b/tests/split-diff.test.mjs @@ -0,0 +1,327 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * View full layout and scrolling regressions through the production viewer. + * Synthetic geometry makes overflow and clamping deterministic; these checks + * do not establish installed-browser layout or authenticated API behavior. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {bundle} from './helpers/content.mjs'; +import {dom, frames, install} from './helpers/dom.mjs'; +import {installDiffViewport} from './helpers/diff-viewport.mjs'; + +const directory = await mkdtemp(join(tmpdir(), 'codetree-split-diff-')); +const modulePath = join(directory, 'content.mjs'); +await writeFile(modulePath, await bundle()); +const content = await import(pathToFileURL(modulePath)); +await rm(directory, {recursive: true, force: true}); +const {createRoot, createState, getOwner, untrackActions, withOwner} = content; +const baseSHA = 'a'.repeat(40); +const headSHA = 'b'.repeat(40); +const rowHeight = 22; + +/** Two unequal replacement blocks separated by context, followed by enough text to scroll. */ +function sample({tabs = false} = {}) { + const prefix = Array.from({length: 20}, (_, index) => `prefix ${index + 1}: ${'x'.repeat(80)}`); + const suffix = Array.from({length: 160}, (_, index) => `tail ${index + 1}: ${'y'.repeat(80)}`); + const oldFirst = tabs ? '\t'.repeat(40) + 'wide old line' : 'old A'; + const before = [...prefix, oldFirst, 'old B', 'separator', 'old C', 'old D', 'old E', ...suffix]; + const after = [...prefix, 'new A', 'new B', 'new C', 'separator', 'new D', ...suffix]; + const patch = [ + `@@ -1,${before.length} +1,${after.length} @@`, + ...prefix.map(line => ` ${line}`), + `-${oldFirst}`, + '-old B', + '+new A', + '+new B', + '+new C', + ' separator', + '-old C', + '-old D', + '-old E', + '+new D', + ...suffix.map(line => ` ${line}`), + ].join('\n'); + return { + before: before.join('\n') + '\n', + after: after.join('\n') + '\n', + node: {path: 'source.txt', type: 'blob', status: 'modified', adds: 4, dels: 5, patch}, + }; +} + +/** Creates the actual modal factory with controlled source replies and browser frame scheduling. */ +function fixture(t, data = sample()) { + const {document, window} = dom(); + installDiffViewport(document); + const createElement = document.createElement.bind(document); + document.createElement = tag => { + const node = createElement(tag); + Object.defineProperty(node, 'firstChild', {get: () => node.children[0] || null}); + return node; + }; + const clock = frames(); + install(t, { + document, + window, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }); + const viewer = document.createElement('dialog'); + viewer.showModal = () => { + viewer.open = true; + }; + viewer.close = () => { + viewer.open = false; + void viewer.fire('close'); + }; + document.body.append(viewer); + const requests = []; + const app = createRoot(dispose => { + const state = createState(); + state.context = { + origin: 'https://github.com', + provider: 'github', + owner: 'sample', + repo: 'repo', + kind: 'pull', + number: 1, + }; + const source = sha => ({provider: 'github', origin: 'https://github.com', owner: 'sample', repo: 'repo', sha}); + state.diff = { + base: source(baseSHA), + head: source(headSHA), + files: [{...data.node, filename: data.node.path, additions: data.node.adds, deletions: data.node.dels}], + }; + const app = { + owner: getOwner(), + state, + dispose, + view: {viewer}, + run: callback => withOwner(callback), + diffURL: async () => 'https://github.com/sample/repo/pull/1/files', + async rpc(type, message) { + assert.equal(type, 'FILE'); + assert.equal(message.path, data.node.path); + requests.push(message); + return message.source.sha === baseSHA ? data.before : data.after; + }, + }; + Object.assign(app, untrackActions(content.createViewer(app))); + return app; + }); + t.after(app.dispose); + return {app, data, viewer, requests, clock, window}; +} + +/** Opens the public View full action and checks loading produced a usable viewport. */ +async function open(ui) { + await ui.app.showDiff(ui.data.node); + assert.equal(ui.viewer.open, true); + const body = ui.viewer.querySelector('.viewer-body'); + assert.ok(body.querySelector('.code-spacer'), body.textContent); + return body; +} + +/** Clicks the visible mode control, asserting behavior rather than importing a new implementation symbol. */ +async function layout(ui, label) { + const control = ui.viewer.querySelectorAll('button').find(node => node.textContent === label); + assert.ok(control, `View full exposes ${label}`); + await control.fire('click'); + assert.equal(control.getAttribute('aria-pressed'), 'true'); + return control; +} + +/** Returns both accessible revision viewports. */ +function panes(ui) { + const old = ui.viewer.querySelector('[aria-label="Old revision"]'); + const next = ui.viewer.querySelector('[aria-label="New revision"]'); + assert.ok(old && next, 'both revision panes are present'); + return {old, next}; +} + +/** Extracts source text, line numbers and gaps from rendered public DOM. */ +function rows(viewport) { + return viewport.querySelectorAll('.code-row').map(row => ({ + text: row.querySelector('.line-text')?.textContent ?? '', + numbers: row.querySelectorAll('.line-number').map(number => number.textContent), + gap: row.classList.contains('diff-gap'), + })); +} + +/** Delivers a user scroll followed by the scheduled viewport draw. */ +async function scroll(ui, viewport, top, left) { + viewport.scrollTop = top; + viewport.scrollLeft = left; + await viewport.fire('scroll'); + ui.clock.flush(); +} + +test('View full exposes Unified and Side-by-side and switches validated content without fetching again', async t => { + const ui = fixture(t); + const body = await open(ui); + const initial = rows(body); + assert.deepEqual( + ui.requests.map(request => request.source.sha), + [baseSHA, headSHA], + ); + assert.equal(ui.viewer.querySelector('.diff-layout')?.getAttribute('aria-label'), 'Diff layout'); + assert.equal( + ui.viewer + .querySelectorAll('button') + .find(node => node.textContent === 'Unified') + ?.getAttribute('aria-pressed'), + 'true', + ); + assert.deepEqual(initial[20], {text: 'old A', numbers: ['21', ''], gap: false}); + await layout(ui, 'Side-by-side'); + assert.equal(body.classList.contains('split-diff'), true); + assert.equal( + ui.viewer + .querySelectorAll('button') + .find(node => node.textContent === 'Unified') + .getAttribute('aria-pressed'), + 'false', + ); + await layout(ui, 'Unified'); + assert.equal(body.classList.contains('split-diff'), false); + assert.deepEqual(rows(body), initial); + assert.equal(ui.requests.length, 2); +}); + +test('View full aligns unequal replacement blocks without crossing context or numbering gaps', async t => { + const ui = fixture(t); + await open(ui); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + assert.deepEqual(rows(old).slice(20, 27), [ + {text: 'old A', numbers: ['21'], gap: false}, + {text: 'old B', numbers: ['22'], gap: false}, + {text: '', numbers: [], gap: true}, + {text: 'separator', numbers: ['23'], gap: false}, + {text: 'old C', numbers: ['24'], gap: false}, + {text: 'old D', numbers: ['25'], gap: false}, + {text: 'old E', numbers: ['26'], gap: false}, + ]); + assert.deepEqual(rows(next).slice(20, 27), [ + {text: 'new A', numbers: ['21'], gap: false}, + {text: 'new B', numbers: ['22'], gap: false}, + {text: 'new C', numbers: ['23'], gap: false}, + {text: 'separator', numbers: ['24'], gap: false}, + {text: 'new D', numbers: ['25'], gap: false}, + {text: '', numbers: [], gap: true}, + {text: '', numbers: [], gap: true}, + ]); + for (const gap of ui.viewer.querySelectorAll('.diff-gap')) assert.equal(gap.getAttribute('aria-hidden'), 'true'); +}); + +test('View full synchronizes both axes from either revision and respects actual scroll bounds', async t => { + const ui = fixture(t); + await open(ui); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + assert.notEqual(old.clientWidth, next.clientWidth); + await scroll(ui, old, 31 * rowHeight + 5.5, 117); + assert.deepEqual([next.scrollTop, next.scrollLeft], [old.scrollTop, 117]); + await scroll(ui, next, 54 * rowHeight + 7.25, 209); + assert.deepEqual([old.scrollTop, old.scrollLeft], [next.scrollTop, 209]); + await scroll(ui, old, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + assert.equal(old.scrollTop, old.scrollHeight - old.clientHeight); + assert.equal(old.scrollLeft, old.scrollWidth - old.clientWidth); + assert.deepEqual([next.scrollTop, next.scrollLeft], [old.scrollTop, old.scrollLeft]); + await scroll(ui, next, -100, -100); + assert.deepEqual([old.scrollTop, next.scrollTop, old.scrollLeft, next.scrollLeft], [0, 0, 0, 0]); +}); + +test('View full preserves an added-side source anchor and fractional offset across a layout roundtrip', async t => { + const ui = fixture(t); + const body = await open(ui); + const top = 22 * rowHeight + 5.5; + await scroll(ui, body, top, 107); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + assert.deepEqual([old.scrollTop, next.scrollTop], [20 * rowHeight + 5.5, 20 * rowHeight + 5.5]); + await scroll(ui, next, next.scrollTop, 179); + await layout(ui, 'Unified'); + assert.equal(body.scrollTop, top, 'paired old row must not replace the exact added source row'); + assert.equal(body.scrollLeft, 179); + assert.equal(ui.requests.length, 2); +}); + +test('View full derives a new canonical source position after vertical scrolling in split mode', async t => { + const ui = fixture(t); + const body = await open(ui); + await scroll(ui, body, 22 * rowHeight + 5.5, 111); + await layout(ui, 'Side-by-side'); + const {next} = panes(ui); + await scroll(ui, next, 24 * rowHeight + 7.25, 155); + await layout(ui, 'Unified'); + assert.equal(body.scrollTop, 26 * rowHeight + 7.25, 'new split row identifies old C in unified source coordinates'); + assert.equal(body.scrollLeft, 155); +}); + +test('View full retains the source line and fractional offset when font metrics or viewport width change', async t => { + const ui = fixture(t); + const body = await open(ui); + await scroll(ui, body, 22.25 * rowHeight, 101); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + ui.app.state.preferences = {...ui.app.state.preferences, fontSize: 22}; + assert.deepEqual([old.scrollTop, next.scrollTop], [20.25 * 30, 20.25 * 30]); + old.clientWidth = 410; + next.clientWidth = 450; + await ui.window.fire('resize'); + assert.deepEqual([old.scrollLeft, next.scrollLeft], [101, 101]); + await layout(ui, 'Unified'); + assert.equal(body.scrollTop, 22.25 * 30); + assert.equal(body.scrollLeft, 101); + assert.equal(ui.requests.length, 2); +}); + +test('View full equalizes real tab-expanded overflow before either pane reaches the horizontal edge', async t => { + const ui = fixture(t, sample({tabs: true})); + await open(ui); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + const oldCode = old.querySelector('.code-spacer'); + const newCode = next.querySelector('.code-spacer'); + assert.ok(oldCode.scrollWidth > 1700, 'four-column tabs overflow the character-count width estimate'); + assert.equal(oldCode.scrollWidth - old.clientWidth, newCode.scrollWidth - next.clientWidth); + await scroll(ui, old, 0, Number.MAX_SAFE_INTEGER); + assert.equal(next.scrollLeft, old.scrollLeft); + assert.equal(old.scrollLeft, old.scrollWidth - old.clientWidth); + await scroll(ui, next, 0, 37); + assert.equal(old.scrollLeft, 37); + assert.equal(ui.requests.length, 2); +}); + +test('closing View full cancels queued split draws and removes stale scroll and layout controls', async t => { + const ui = fixture(t); + await open(ui); + const staleControl = await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + const oldCode = old.querySelector('.code-spacer'); + const originalRows = [...oldCode.children]; + old.scrollTop = 70 * rowHeight; + await old.fire('scroll'); + assert.equal(ui.clock.pending.size, 1); + ui.app.closeViewer(); + assert.equal(ui.clock.pending.size, 0); + assert.equal(old.listeners.get('scroll').length, 0); + assert.equal(next.listeners.get('scroll').length, 0); + ui.clock.flush(); + assert.deepEqual(oldCode.children, originalRows); + ui.app.state.diffLayout = 'unified'; + await staleControl.fire('click'); + assert.equal(ui.app.state.diffLayout, 'unified'); + await open(ui); + assert.equal(ui.viewer.querySelectorAll('.diff-layout').length, 1); + assert.equal(ui.requests.length, 4); +});