From 4b284473c3d2b05c5ee433cf61e9c86bd43825d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=98n=20Ustin=C3=98ff?= Date: Fri, 23 Oct 2026 16:58:01 +0400 Subject: [PATCH 1/2] feat: add change-block navigation to diff viewers --- CHANGELOG.md | 1 + README.md | 2 + docs/ARCHITECTURE.md | 5 +- docs/FEATURES.md | 2 + src/content/sidebar.scss | 34 +++ src/content/viewer/file-changes.js | 88 ++++++ src/content/viewer/file-content.js | 22 ++ tests/file-tabs.test.mjs | 260 ++++++++++++++++- tests/viewer-changes.test.mjs | 439 +++++++++++++++++++++++++++++ 9 files changed, 851 insertions(+), 2 deletions(-) create mode 100644 src/content/viewer/file-changes.js create mode 100644 tests/viewer-changes.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ac50905..fb3f927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **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 - **ADDED**: Ctrl/Cmd+F literal search in open files and full-file diffs, with match highlights, counts and previous/next navigation across virtualized rows +- **ADDED**: previous/next change-block navigation with a scroll-aware counter in file tabs and View full, for Unified and Side-by-side diffs - **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 d8a117e..0a0ccc6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Switching branches keeps Code's open paths and active file while loading the sel Use **Ctrl/Cmd+F** or the viewer's search button to find literal text without case sensitivity in the open file or diff. The search covers the complete loaded text, including virtualized rows. **Enter / Shift+Enter** and the arrow buttons cycle through matches; **Escape** closes search before closing View full. Diff search includes old and new lines, counting shared context once. Tabs and View full have separate searches, cleared when their file changes; queries stay in page memory. At most 10,000 matches are listed, with a `+` count when more exist. Native text editors keep their normal find shortcut. +In diffs, the **Changes** counter and its arrow buttons cycle through blocks of additions/removals separated by unchanged lines. They work in file tabs and **View full**, in both layouts, revealing off-screen blocks without reloading the file. The counter follows scrolling and keeps the selected block current while it remains visible. Diffs without changed lines show `0 / 0` with disabled arrows. + Workspace metadata belongs to the current browser tab, selected account, host and repository. It resets on extension/browser restart and is excluded from Sync. Limits are 32 open files, 256 saved positions and 50 workspaces, with 64 KiB per entry; file text is never stored with this metadata. In-page tabs are new beta behavior; installed-browser smoke verification is pending. | Shortcut | Action | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index deb2be4..65fb8f9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -91,6 +91,8 @@ In-page file tabs use `FILE_TABS` with `scope: 'code'|'review'` and an optional `viewer/file-search.js` owns the current file's find controls and capture-phase keyboard listener. It scans the complete validated row list with escaped, case-insensitive Unicode matching, retaining at most 10,000 source-offset ranges. Debounced scans yield between chunks and reject replies after query changes or disposal. Ranges are keyed by the original row objects, so split context is counted once and painted in both panes. `file-content.js` splits syntax text nodes at match boundaries and translates result navigation through the existing unified-coordinate viewport adapter. Queries and match metadata stay in the owned viewer, with no broker, storage or Sync changes. +`viewer/file-changes.js` groups contiguous non-context rows into change blocks once per diff render. Owned SVG icon buttons navigate cyclically through unified source boundaries; `file-content.js` maps them to the active layout, reveals virtualized rows and reuses tab-position persistence without fetching again. The counter follows the viewport, retaining a selected block while visible even when scrolling clamps at the file end. Scroll updates find nearby block boundaries by binary search. The controller adds no shortcuts, preferences or storage fields and is disposed with its viewer content. + 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. @@ -113,7 +115,8 @@ See [Checks and releases](RELEASING.md) for CI and publication. - `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. - `viewer-search.test.mjs`: file-search controls and shortcuts, literal/Unicode ranges across syntax tokens, complete virtualized-file navigation, result limits and stale-scan/disposal checks through the production viewer. -- `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. +- `viewer-changes.test.mjs`: change-block counts, cyclic navigation, virtual row reveal, manual scrolling, split projection, clamped positions and owned controls through the production View full dialog. +- `file-tabs.test.mjs`: file navigation and session restoration, including Split diff positions and change-block counters across file/review switches, Refresh and page reload, plus independent inline/dialog scrolling and navigation 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 e39b2f7..d4e4664 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -32,6 +32,8 @@ The **Unified / Side-by-side** switch is available in file tabs and **View full* **Ctrl/Cmd+F** or the search button opens literal, case-insensitive search in the active file; an open View full dialog takes priority over inline tabs. Enter/Shift+Enter and previous/next buttons wrap through matches and reveal off-screen source rows horizontally and vertically. Search includes both revisions, counts unchanged context once and survives switching Unified/Side-by-side. Highlighting preserves syntax tokens and original Unicode offsets. Each viewer keeps its own query only while its current file is open; closing search hides highlights and retains the query for reopening. Native input fields keep browser/editor find behavior. Scanning yields between chunks, cancels stale queries and lists the first 10,000 matches with an explicit `+` count when more exist. +The **Changes** group provides previous/next change-block buttons and a counter in file tabs and **View full**, in both layouts. A block is a contiguous run of additions/removals, separated from other blocks by unchanged context. Navigation wraps and reveals virtualized rows without fetching revisions again; tab positions use the existing restoration behavior. The counter follows scrolling and retains the selected block while visible, including when the last block cannot reach the top of the viewport. Before the first block enters view it shows `0 / N`; diffs with no changed lines show `0 / 0` and disabled arrows. These controls add no shortcuts or preferences. + 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 58fea01..0c58f3d 100644 --- a/src/content/sidebar.scss +++ b/src/content/sidebar.scss @@ -969,6 +969,40 @@ button:disabled { background: var(--selected); color: var(--accent); } +.diff-changes { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--muted-bg); +} +.diff-changes .icon-button { + width: 24px; + height: 24px; +} +.diff-changes-label { + padding-left: 6px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} +.diff-changes-count { + min-width: 38px; + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: center; + white-space: nowrap; +} +.diff-changes-previous .icon { + transform: rotate(-90deg); +} +.diff-changes-next .icon { + transform: rotate(90deg); +} .viewer-find { display: flex; flex-wrap: wrap; diff --git a/src/content/viewer/file-changes.js b/src/content/viewer/file-changes.js new file mode 100644 index 0000000..23c2280 --- /dev/null +++ b/src/content/viewer/file-changes.js @@ -0,0 +1,88 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * Owned change-block navigation over validated, virtualized diff rows. + * Unified source boundaries also identify blocks in the paired split layout. + */ + +import {button, el} from '../dom.js'; +import {onCleanup} from '../reactive.js'; + +/** + * Adds cyclic previous/next navigation for contiguous additions and removals. + * A selected block stays current while visible, including when scrolling is + * clamped near the end of a file or several blocks fit in one viewport. + * + * @param {object} shell The owned diff shell. + * @param {Array} rows Complete, validated unified diff rows. + * @param {{range: Function, project: Function, reveal: Function}} viewport Layout-aware row adapters. + * @param {Function} current Whether this render still owns the shell. + * @returns {{update: Function}} A viewport refresh callback. + */ +export function createFileChanges(shell, rows, viewport, current) { + const blocks = []; + for (let index = 0; index < rows.length; index++) { + if (rows[index].type === 'context') continue; + const start = index; + while (index + 1 < rows.length && rows[index + 1].type !== 'context') index++; + blocks.push({start, end: index + 1}); + } + let active = -1; + let live = true; + const count = el('span', {class: 'diff-changes-count', role: 'status', 'aria-live': 'polite'}); + const previous = button('chevron', 'Previous change block', () => select(-1), 'diff-changes-previous'); + const next = button('chevron', 'Next change block', () => select(1), 'diff-changes-next'); + const controls = el('div', {class: 'diff-changes', role: 'group', 'aria-label': 'Change blocks'}, [ + el('span', {class: 'diff-changes-label', text: 'Changes'}), + count, + previous, + next, + ]); + shell.actions.prepend(controls); + previous.disabled = next.disabled = !blocks.length; + + /** Tests a block's exclusive boundaries in the current visual row coordinates. */ + function visible(index, range) { + const block = blocks[index]; + return block && viewport.project(block.start) < range.end && viewport.project(block.end) > range.start; + } + + /** Follows scrolling without scanning every change block on each animation frame. */ + function update() { + if (!live || !current()) return; + const range = viewport.range(); + if (!visible(active, range)) { + let low = 0; + let high = blocks.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (viewport.project(blocks[middle].start) <= range.start) low = middle + 1; + else high = middle; + } + active = low - 1; + if (!visible(active, range) && visible(low, range)) active = low; + } + const text = `${active + 1} / ${blocks.length}`; + if (count.textContent !== text) { + count.textContent = text; + count.setAttribute('aria-label', `Change block ${active + 1} of ${blocks.length}`); + } + } + + /** Reveals the selected source block through the renderer, including off-screen rows. */ + function select(direction) { + if (!live || !current() || !blocks.length) return; + update(); + active = + active < 0 ? (direction > 0 ? 0 : blocks.length - 1) : (active + direction + blocks.length) % blocks.length; + viewport.reveal(blocks[active].start); + } + + onCleanup(() => { + live = false; + controls.remove(); + }); + return {update}; +} diff --git a/src/content/viewer/file-content.js b/src/content/viewer/file-content.js index 1217220..b645c57 100644 --- a/src/content/viewer/file-content.js +++ b/src/content/viewer/file-content.js @@ -17,6 +17,7 @@ import {createRenderEffect, on, onCleanup, runWithOwner} from '../reactive.js'; import {listen, requestFrame} from '../reactive-dom.js'; import {tokenize} from './syntax.js'; import {createFileSearch} from './file-search.js'; +import {createFileChanges} from './file-changes.js'; /** * Loads and renders both exact revisions of a changed file. @@ -181,6 +182,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals let splitAnchor; let frame = 0; let search; + let changes; const code = el('div', {class: 'code-spacer'}); const oldCode = el('div', {class: 'code-spacer'}); const newCode = el('div', {class: 'code-spacer'}); @@ -246,6 +248,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals oldCode.style.width = `${overflow + oldPane.clientWidth}px`; newCode.style.width = `${overflow + newPane.clientWidth}px`; } + changes?.update(); } /** Keeps paired panes equally wide so horizontal and vertical scroll ranges match. */ @@ -320,6 +323,25 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals ); } if (!diff) return; + changes = createFileChanges( + shell, + rows, + { + range: () => { + const viewport = split ? oldPane : shell.body; + return { + start: viewport.scrollTop / rowHeight, + end: (viewport.scrollTop + viewport.clientHeight) / rowHeight, + }; + }, + project: index => (split ? (projection.unifiedToSplit[index] ?? projection.pairs.length) : index), + reveal: row => { + restorePosition({top: row * rowHeight, left: readPosition().left}); + shell.onScroll?.(); + }, + }, + current, + ); listen(window, 'resize', relayout); createRenderEffect(on(() => app.state.preferences.fontSize, relayout)); const controls = el('div', {class: 'diff-layout', role: 'group', 'aria-label': 'Diff layout'}); diff --git a/tests/file-tabs.test.mjs b/tests/file-tabs.test.mjs index 79bb3da..a8eca55 100644 --- a/tests/file-tabs.test.mjs +++ b/tests/file-tabs.test.mjs @@ -102,6 +102,7 @@ function fixture(t, provider, reviewDiff, {search = false} = {}) { }; if (tag === 'div') { let top = 0; + const boundedTop = Object.getOwnPropertyDescriptor(node, 'scrollTop'); // Only the workspace viewport models CSS-dependent geometry. Without its // linked stylesheet the block has auto height, so scroll restoration is // clamped to zero; with CSS it is a bounded viewport. This reproduces the @@ -118,12 +119,14 @@ function fixture(t, provider, reviewDiff, {search = false} = {}) { Object.defineProperty(node, 'scrollTop', { configurable: true, get() { + if (!this.classList.contains('file-tab-body')) return boundedTop?.get?.call(this) ?? top; if (this.classList.contains('file-tab-body') && !this.isConnected) return 0; return top; }, set(value) { if (!this.classList.contains('file-tab-body')) { - top = value; + if (boundedTop?.set) boundedTop.set.call(this, value); + else top = value; return; } const height = Number.parseFloat(this.querySelector('.code-spacer')?.style.height) || 0; @@ -707,6 +710,261 @@ function panePositions(scope) { return diffPanes(scope).map(pane => ({top: Math.round(pane.scrollTop * 1e6) / 1e6, left: pane.scrollLeft})); } +/** One complete patch contains four distinct runs, including one-sided blocks and a clamped final block. */ +function changeRevision() { + const before = Array.from({length: 260}, (_, index) => `export const source${index + 1} = ${index + 1};`); + before[100] = '// ' + 'wide source '.repeat(90); + const edits = [ + {start: 20, remove: 2, add: ['export const firstReplacement = true;']}, + {start: 79, remove: 0, add: ['export const secondAddedA = true;', 'export const secondAddedB = false;']}, + {start: 159, remove: 3, add: []}, + { + start: 258, + remove: 1, + add: ['export const finalAddedA = true;', 'export const finalAddedB = false;', 'export const finalAddedC = 3;'], + }, + ]; + const after = []; + const patch = []; + let cursor = 0; + for (const edit of edits) { + const context = before.slice(cursor, edit.start); + after.push(...context, ...edit.add); + patch.push(...context.map(line => ' ' + line)); + patch.push(...before.slice(edit.start, edit.start + edit.remove).map(line => '-' + line)); + patch.push(...edit.add.map(line => '+' + line)); + cursor = edit.start + edit.remove; + } + after.push(...before.slice(cursor)); + patch.push(...before.slice(cursor).map(line => ' ' + line)); + return { + before: before.join('\n'), + after: after.join('\n'), + file: { + status: 'modified', + additions: 6, + deletions: 6, + patch: [`@@ -1,${before.length} +1,${after.length} @@`, ...patch].join('\n'), + }, + }; +} + +/** Reads the visible, accessible feature so the same tests fail against the pre-feature production bundle. */ +function changeControls(scope) { + const group = scope.querySelector('.diff-changes'); + assert.ok(group, 'A loaded diff exposes change-block navigation.'); + assert.equal(group.getAttribute('role'), 'group'); + assert.equal(group.getAttribute('aria-label'), 'Change blocks'); + const control = label => { + const node = group.querySelectorAll('button').find(button => button.getAttribute('aria-label') === label); + assert.ok(node, `Expected ${label} control`); + assert.ok(node.classList.contains('icon-button'), 'Change navigation uses the shared icon-button component.'); + assert.ok(node.querySelector('svg'), 'Change navigation uses an original SVG icon.'); + return node; + }; + const count = group.querySelector('[role="status"]'); + assert.ok(count, 'The current block and total are exposed as a status.'); + return { + group, + count, + previous: control('Previous change block'), + next: control('Next change block'), + }; +} + +/** Clicks an owned change navigation button and finishes its scheduled viewport update. */ +async function changeStep(ui, controls, direction = 'next') { + await ui.settle(controls[direction].fire('click')); + await ui.drain(1); +} + +for (const provider of ['github', 'gitlab']) { + for (const layout of ['Unified', 'Side-by-side']) { + test(`${provider} ${layout} change-block navigation counts runs, reveals offscreen rows and cycles`, async t => { + const ui = fixture(t, provider, changeRevision()); + await ui.navigate(ui.review(41)); + const controls = changeControls(ui.workspace()); + await diffLayout(ui, ui.workspace(), layout); + assert.equal(controls.count.textContent, '0 / 4', 'No change block is initially visible.'); + assert.equal(controls.previous.disabled, false); + assert.equal(controls.next.disabled, false); + assert.doesNotMatch(ui.body().textContent, /secondAddedA|finalAddedA/); + const requests = ui.files.length; + const split = layout === 'Side-by-side'; + const starts = split ? [20, 79, 161, 260] : [20, 80, 162, 261]; + const end = (split ? 264 : 266) * 22 - 290; + const markers = [/firstReplacement/, /secondAddedA/, /source160/, /finalAddedA/]; + for (let index = 0; index < starts.length; index++) { + await changeStep(ui, controls); + assert.equal(controls.count.textContent, `${index + 1} / 4`); + assert.equal(controls.count.getAttribute('aria-label'), `Change block ${index + 1} of 4`); + const expectedTop = Math.min(starts[index] * 22, end); + if (split) + assert.deepEqual(panePositions(ui.body()), [ + {top: expectedTop, left: 0}, + {top: expectedTop, left: 0}, + ]); + else assert.equal(ui.body().scrollTop, expectedTop); + assert.match(ui.body().textContent, markers[index], 'The selected source block enters the virtual DOM.'); + const viewports = split ? diffPanes(ui.body()) : [ui.body()]; + for (const viewport of viewports) assert.ok(viewport.querySelectorAll('.code-row').length < 50); + } + await changeStep(ui, controls); + assert.equal(controls.count.textContent, '1 / 4', 'Next wraps from the last block to the first.'); + await changeStep(ui, controls, 'previous'); + assert.equal(controls.count.textContent, '4 / 4', 'Previous wraps to the final block despite scroll clamping.'); + if (split) await paneScroll(ui, diffPanes(ui.body())[1], 100 * 22, 85); + else { + await ui.scroll(100 * 22, 85); + await ui.drain(1); + } + assert.equal(controls.count.textContent, '2 / 4', 'Manual scrolling follows the preceding change block.'); + await changeStep(ui, controls); + assert.equal(controls.count.textContent, '3 / 4'); + if (split) + assert.deepEqual(panePositions(ui.body()), [ + {top: 161 * 22, left: 85}, + {top: 161 * 22, left: 85}, + ]); + else assert.deepEqual({top: ui.body().scrollTop, left: ui.body().scrollLeft}, {top: 162 * 22, left: 85}); + assert.equal(ui.files.length, requests, 'Block navigation reuses the loaded revisions.'); + assert.deepEqual(ui.errors, []); + }); + } + + test(`${provider} change-block counters follow saved source positions across tabs, layouts and PR/MR workspaces`, async t => { + const ui = fixture(t, provider, changeRevision()); + await ui.navigate(ui.review(41)); + let controls = changeControls(ui.workspace()); + await changeStep(ui, controls); + await changeStep(ui, controls); + assert.equal(controls.count.textContent, '2 / 4'); + await ui.open(paths[1]); + controls = changeControls(ui.workspace()); + assert.equal(controls.count.textContent, '0 / 4', 'A newly opened file starts at its own source position.'); + for (let index = 0; index < 3; index++) await changeStep(ui, controls); + assert.equal(controls.count.textContent, '3 / 4'); + await ui.open(paths[0]); + assert.equal(changeControls(ui.workspace()).count.textContent, '2 / 4'); + assert.equal(ui.body().scrollTop, 80 * 22); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + assert.equal(changeControls(ui.workspace()).count.textContent, '2 / 4'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 79 * 22, left: 0}, + {top: 79 * 22, left: 0}, + ]); + await ui.navigate(ui.review(42)); + controls = changeControls(ui.workspace()); + assert.equal(controls.count.textContent, '0 / 4', 'A separate PR/MR has an independent saved source position.'); + for (let index = 0; index < 3; index++) await changeStep(ui, controls); + await ui.navigate(ui.review(41)); + assert.equal(changeControls(ui.workspace()).count.textContent, '2 / 4'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 79 * 22, left: 0}, + {top: 79 * 22, left: 0}, + ]); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal(changeControls(ui.workspace()).count.textContent, '2 / 4'); + assert.equal(ui.body().scrollTop, 80 * 22); + await ui.open(paths[1]); + assert.equal(changeControls(ui.workspace()).count.textContent, '3 / 4'); + assert.equal(ui.body().scrollTop, 162 * 22); + await ui.navigate(ui.review(42)); + assert.equal(changeControls(ui.workspace()).count.textContent, '3 / 4'); + assert.equal(ui.body().scrollTop, 162 * 22); + await ui.branch('main'); + await ui.open(paths[0]); + assert.equal( + ui.workspace().querySelector('.diff-changes'), + null, + 'Plain Code files have no change-block controls.', + ); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} change-block counters and positions stay independent in inline tabs and actual View full`, async t => { + const ui = fixture(t, provider, changeRevision()); + await ui.navigate(ui.review(41)); + const inline = changeControls(ui.workspace()); + await changeStep(ui, inline); + await changeStep(ui, inline); + 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 full = changeControls(modal); + assert.equal(full.count.textContent, '0 / 4'); + for (let index = 0; index < 3; index++) await changeStep(ui, full); + assert.equal(full.count.textContent, '3 / 4'); + assert.equal(inline.count.textContent, '2 / 4'); + assert.equal(ui.body().scrollTop, 80 * 22); + const requests = ui.files.length; + await diffLayout(ui, modal, 'Side-by-side'); + assert.equal(full.count.textContent, '3 / 4'); + assert.equal(inline.count.textContent, '2 / 4'); + assert.deepEqual(panePositions(modal), [ + {top: 161 * 22, left: 0}, + {top: 161 * 22, left: 0}, + ]); + assert.deepEqual(panePositions(ui.body()), [ + {top: 79 * 22, left: 0}, + {top: 79 * 22, left: 0}, + ]); + await changeStep(ui, inline, 'previous'); + assert.equal(inline.count.textContent, '1 / 4'); + assert.equal(full.count.textContent, '3 / 4', 'An inline jump does not change the modal counter.'); + await changeStep(ui, full); + assert.equal(full.count.textContent, '4 / 4'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 20 * 22, left: 0}, + {top: 20 * 22, left: 0}, + ]); + assert.equal(ui.files.length, requests); + ui.app.closeViewer(); + await ui.open(paths[1]); + await ui.open(paths[0]); + assert.equal(changeControls(ui.workspace()).count.textContent, '1 / 4'); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal(ui.body().scrollTop, 20 * 22, 'View full cannot overwrite the inline navigation position.'); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} change-block controls disposed by tab or PR/MR changes cannot move another file`, async t => { + const ui = fixture(t, provider, changeRevision()); + await ui.navigate(ui.review(41)); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + const old = changeControls(ui.workspace()); + const oldPane = diffPanes(ui.body())[0]; + await changeStep(ui, old); + await ui.open(paths[1]); + const current = changeControls(ui.workspace()); + await changeStep(ui, current); + await changeStep(ui, current); + await ui.app.rememberFileTabs(); + const stored = structuredClone([...ui.metadata]); + assert.equal(old.group.isConnected, false); + await changeStep(ui, old); + await changeStep(ui, old, 'previous'); + await paneScroll(ui, oldPane, 200 * 22, 95); + assert.equal(current.count.textContent, '2 / 4'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 79 * 22, left: 0}, + {top: 79 * 22, left: 0}, + ]); + assert.deepEqual([...ui.metadata], stored, 'Detached controls cannot save another active file position.'); + await ui.navigate(ui.review(42)); + const review = changeControls(ui.workspace()); + assert.equal(review.count.textContent, '0 / 4'); + await changeStep(ui, current); + await changeStep(ui, current, 'previous'); + assert.equal(review.count.textContent, '0 / 4'); + assert.deepEqual(panePositions(ui.body()), [ + {top: 0, left: 0}, + {top: 0, left: 0}, + ]); + assert.deepEqual(ui.errors, []); + }); +} + 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()); diff --git a/tests/viewer-changes.test.mjs b/tests/viewer-changes.test.mjs new file mode 100644 index 0000000..2a0e51a --- /dev/null +++ b/tests/viewer-changes.test.mjs @@ -0,0 +1,439 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * Change-block navigation regressions through the production View full modal. + * Synthetic viewport geometry verifies source mapping and clamping, without + * claiming installed-browser layout or authenticated provider 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-viewer-changes-')); +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; + +/** One validated patch hunk containing four separate change blocks and unequal replacements. */ +function sample() { + const prefix = Array.from({length: 45}, (_, index) => `prefix ${index}: ${'x'.repeat(80)}`); + const firstGap = Array.from({length: 80}, (_, index) => `first gap ${index}`); + const secondGap = Array.from({length: 70}, (_, index) => `second gap ${index}`); + const thirdGap = Array.from({length: 50}, (_, index) => `third gap ${index}`); + const before = [ + ...prefix, + 'old first A', + 'old first B', + ...firstGap, + ...secondGap, + 'removed only A', + 'removed only B', + 'removed only C', + ...thirdGap, + 'old final A', + 'old final B', + 'old final C', + 'last context', + ]; + const after = [ + ...prefix, + 'new first A', + 'new first B', + 'new first C', + ...firstGap, + 'added only A', + 'added only B', + ...secondGap, + ...thirdGap, + 'new final A', + 'last context', + ]; + const patch = [ + `@@ -1,${before.length} +1,${after.length} @@`, + ...prefix.map(line => ` ${line}`), + '-old first A', + '-old first B', + '+new first A', + '+new first B', + '+new first C', + ...firstGap.map(line => ` ${line}`), + '+added only A', + '+added only B', + ...secondGap.map(line => ` ${line}`), + '-removed only A', + '-removed only B', + '-removed only C', + ...thirdGap.map(line => ` ${line}`), + '-old final A', + '-old final B', + '-old final C', + '+new final A', + ' last context', + ].join('\n'); + return { + before: before.join('\n') + '\n', + after: after.join('\n') + '\n', + node: {path: 'source.txt', type: 'blob', status: 'modified', adds: 6, dels: 8, patch}, + }; +} + +/** Creates the actual modal with exact revision replies and deterministic scroll bounds. */ +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}; +} + +/** Opens the public View full action and requires its accessible block-navigation UI. */ +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); + assert.ok(ui.viewer.querySelector('[aria-label="Change blocks"]'), 'View full exposes change-block navigation'); + return body; +} + +/** Checks the displayed count and its accessible status text. */ +function count(ui, active, total = 4) { + const status = ui.viewer.querySelector('.diff-changes-count'); + assert.ok(status, 'change counter is present'); + assert.equal(status.textContent, `${active} / ${total}`); + assert.equal(status.getAttribute('aria-label'), `Change block ${active} of ${total}`); + assert.equal(status.getAttribute('role'), 'status'); +} + +/** Activates one public navigation button and flushes any queued viewport rendering. */ +async function navigate(ui, direction) { + const control = ui.viewer.querySelector(`[aria-label="${direction} change block"]`); + assert.ok(control, `${direction} change block is present`); + assert.equal(control.disabled, false); + await control.fire('click'); + ui.clock.flush(); + return control; +} + +/** Switches through the same visible layout controls as the user. */ +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'); +} + +/** Finds the two independently scrollable revision panes. */ +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}; +} + +/** Delivers one manual scroll, allowing the production renderer to synchronize and count it. */ +async function scroll(ui, viewport, top, left = 0) { + viewport.scrollTop = top; + viewport.scrollLeft = left; + await viewport.fire('scroll'); + ui.clock.flush(); +} + +test('View full Unified change-block navigation counts contiguous changes rather than patch hunks and reveals virtual rows', async t => { + const ui = fixture(t); + const body = await open(ui); + count(ui, 0); + assert.equal(body.textContent.includes('added only A'), false, 'distant change is initially virtualized away'); + await navigate(ui, 'Next'); + assert.equal(body.scrollTop, 45 * rowHeight); + count(ui, 1); + await navigate(ui, 'Next'); + assert.equal(body.scrollTop, 130 * rowHeight); + count(ui, 2); + assert.ok(body.textContent.includes('added only A'), 'navigation renders an off-screen addition-only block'); + assert.ok(body.querySelectorAll('.code-row').length < 40, 'navigation retains bounded virtual rendering'); + await navigate(ui, 'Previous'); + assert.equal(body.scrollTop, 45 * rowHeight); + count(ui, 1); + await navigate(ui, 'Previous'); + count(ui, 4); + assert.equal(body.scrollTop, body.scrollHeight - body.clientHeight, 'last block clamps at the scroll boundary'); + assert.ok(body.textContent.includes('old final A')); + await navigate(ui, 'Next'); + assert.equal(body.scrollTop, 45 * rowHeight); + count(ui, 1); + assert.deepEqual( + ui.requests.map(request => request.source.sha), + [baseSHA, headSHA], + ); +}); + +test('View full Side-by-side change-block navigation reveals additions and removals and synchronizes both panes', async t => { + const ui = fixture(t); + await open(ui); + await layout(ui, 'Side-by-side'); + const {old, next} = panes(ui); + await scroll(ui, next, 0, 91); + count(ui, 0); + await navigate(ui, 'Previous'); + count(ui, 4); + assert.equal(old.scrollTop, old.scrollHeight - old.clientHeight); + assert.deepEqual([next.scrollTop, old.scrollLeft, next.scrollLeft], [old.scrollTop, 91, 91]); + await navigate(ui, 'Previous'); + count(ui, 3); + assert.deepEqual([old.scrollTop, next.scrollTop], [200 * rowHeight, 200 * rowHeight]); + assert.ok(old.textContent.includes('removed only A')); + assert.equal(next.textContent.includes('removed only A'), false); + await navigate(ui, 'Previous'); + count(ui, 2); + assert.deepEqual([old.scrollTop, next.scrollTop], [128 * rowHeight, 128 * rowHeight]); + assert.ok(next.textContent.includes('added only A')); + assert.equal(old.textContent.includes('added only A'), false); + await navigate(ui, 'Next'); + count(ui, 3); + assert.equal(old.scrollTop, next.scrollTop); + assert.equal(ui.requests.length, 2); +}); + +test('View full change-block counter follows manual scrolling in Unified and either split revision pane', async t => { + const ui = fixture(t); + const body = await open(ui); + await scroll(ui, body, 60 * rowHeight); + count(ui, 1); + await scroll(ui, body, 125 * rowHeight); + count(ui, 2); + await layout(ui, 'Side-by-side'); + count(ui, 2); + const {old, next} = panes(ui); + await scroll(ui, next, 180 * rowHeight, 57); + count(ui, 2); + assert.deepEqual([old.scrollTop, old.scrollLeft], [next.scrollTop, 57]); + await scroll(ui, old, 197 * rowHeight, 33); + count(ui, 3); + assert.deepEqual([next.scrollTop, next.scrollLeft], [old.scrollTop, 33]); + await scroll(ui, next, 0); + count(ui, 0); + await layout(ui, 'Unified'); + count(ui, 0); +}); + +test('View full change-block navigation retains selection through unequal-row layout roundtrips', async t => { + const ui = fixture(t); + const body = await open(ui); + await navigate(ui, 'Next'); + await navigate(ui, 'Next'); + assert.equal(body.scrollTop, 130 * rowHeight); + await layout(ui, 'Side-by-side'); + count(ui, 2); + const {old, next} = panes(ui); + assert.deepEqual([old.scrollTop, next.scrollTop], [128 * rowHeight, 128 * rowHeight]); + await navigate(ui, 'Next'); + count(ui, 3); + await layout(ui, 'Unified'); + assert.equal(body.scrollTop, 202 * rowHeight); + count(ui, 3); + await navigate(ui, 'Next'); + count(ui, 4); + await layout(ui, 'Side-by-side'); + count(ui, 4); + await layout(ui, 'Unified'); + count(ui, 4); + assert.equal(ui.requests.length, 2); +}); + +test('View full change-block counter is zero with disabled navigation for unchanged files in both layouts', async t => { + const text = 'unchanged line\n'; + const ui = fixture(t, { + before: text, + after: text, + node: {path: 'same.txt', type: 'blob', status: 'renamed', adds: 0, dels: 0, patch: ''}, + }); + await open(ui); + for (const label of ['Unified', 'Side-by-side']) { + await layout(ui, label); + count(ui, 0, 0); + assert.equal(ui.viewer.querySelector('[aria-label="Previous change block"]').disabled, true); + assert.equal(ui.viewer.querySelector('[aria-label="Next change block"]').disabled, true); + } + assert.equal(ui.requests.length, 2); +}); + +test('View full change-block selection changes between nearby final blocks at the same clamped scroll position', async t => { + const prefix = Array.from({length: 60}, (_, index) => `prefix ${index}`); + const ui = fixture(t, { + before: [...prefix, 'old A', 'separator', 'old B', 'last context'].join('\n') + '\n', + after: [...prefix, 'new A', 'separator', 'new B', 'last context'].join('\n') + '\n', + node: { + path: 'nearby.txt', + type: 'blob', + status: 'modified', + adds: 2, + dels: 2, + patch: [ + '@@ -1,64 +1,64 @@', + ...prefix.map(line => ` ${line}`), + '-old A', + '+new A', + ' separator', + '-old B', + '+new B', + ' last context', + ].join('\n'), + }, + }); + const body = await open(ui); + count(ui, 0, 2); + await navigate(ui, 'Next'); + count(ui, 1, 2); + const unifiedTop = body.scrollTop; + assert.equal(unifiedTop, body.scrollHeight - body.clientHeight); + await navigate(ui, 'Next'); + count(ui, 2, 2); + assert.equal(body.scrollTop, unifiedTop, 'the second target clamps to the same Unified position'); + await navigate(ui, 'Previous'); + count(ui, 1, 2); + assert.equal(body.scrollTop, unifiedTop); + await navigate(ui, 'Next'); + count(ui, 2, 2); + await layout(ui, 'Side-by-side'); + count(ui, 2, 2); + const {old, next} = panes(ui); + const splitTop = old.scrollTop; + assert.equal(splitTop, old.scrollHeight - old.clientHeight); + await navigate(ui, 'Previous'); + count(ui, 1, 2); + assert.deepEqual([old.scrollTop, next.scrollTop], [splitTop, splitTop]); + await navigate(ui, 'Next'); + count(ui, 2, 2); + assert.deepEqual([old.scrollTop, next.scrollTop], [splitTop, splitTop]); + await layout(ui, 'Unified'); + count(ui, 2, 2); + await navigate(ui, 'Previous'); + count(ui, 1, 2); + await layout(ui, 'Side-by-side'); + count(ui, 1, 2); + assert.equal(ui.requests.length, 2); +}); + +for (const status of ['added', 'removed']) { + test(`View full change-block counter remains stable for one whole-file ${status} block in both layouts`, async t => { + const text = 'source A\nsource B\n'; + const ui = fixture(t, { + before: status === 'removed' ? text : '', + after: status === 'added' ? text : '', + node: { + path: `${status}.txt`, + type: 'blob', + status, + adds: status === 'added' ? 2 : 0, + dels: status === 'removed' ? 2 : 0, + patch: '', + }, + }); + const body = await open(ui); + count(ui, 1, 1); + await navigate(ui, 'Next'); + await navigate(ui, 'Previous'); + assert.equal(body.scrollTop, 0); + count(ui, 1, 1); + await layout(ui, 'Side-by-side'); + await navigate(ui, 'Previous'); + await navigate(ui, 'Next'); + count(ui, 1, 1); + const {old, next} = panes(ui); + assert.deepEqual([old.scrollTop, next.scrollTop], [0, 0]); + const blank = status === 'added' ? old : next; + const source = status === 'added' ? next : old; + assert.equal(blank.querySelectorAll('.line-text').length, 0, 'the absent revision contains only aligned gaps'); + assert.ok(source.textContent.includes('source A')); + assert.equal(ui.requests.length, 1, 'the missing revision of a whole-file change is not fetched'); + assert.equal(ui.requests[0].source.sha, status === 'added' ? headSHA : baseSHA); + }); +} + +test('View full change-block controls become inert after closing and reopening resets the counter', async t => { + const ui = fixture(t); + await open(ui); + const stale = await navigate(ui, 'Next'); + const staleCount = ui.viewer.querySelector('.diff-changes-count'); + count(ui, 1); + ui.app.closeViewer(); + assert.equal(stale.isConnected, false); + const body = await open(ui); + count(ui, 0); + assert.equal(ui.viewer.querySelectorAll('.diff-changes').length, 1); + await stale.fire('click'); + count(ui, 0); + assert.equal(staleCount.textContent, '1 / 4'); + assert.equal(body.scrollTop, 0); + assert.equal(ui.requests.length, 4); +}); From 63b6643c0782aae7d82a2ddecc41096c9a157916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=98n=20Ustin=C3=98ff?= Date: Fri, 23 Oct 2026 23:34:58 +0400 Subject: [PATCH 2/2] fix: compact and anchor the sidebar handle --- CHANGELOG.md | 1 + src/content/app.js | 1 - src/content/native/header-buttons.js | 1 - src/content/sidebar.scss | 24 ++++++++++----------- src/content/sidebar/layout.js | 32 +++------------------------- 5 files changed, 16 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3f927..d93148d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - **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 +- **FIXED**: collapsed sidebar handle stays at the window edge and sits lower, independently of GitHub's native navigation ## 0.4.1 diff --git a/src/content/app.js b/src/content/app.js index 199b65f..3876173 100644 --- a/src/content/app.js +++ b/src/content/app.js @@ -173,7 +173,6 @@ export function mount() { window, 'resize', () => { - app.positionHandle(); app.requestTreeRender(); app.updateFileTabTheme(); }, diff --git a/src/content/native/header-buttons.js b/src/content/native/header-buttons.js index 1c43b1d..0dfa533 100644 --- a/src/content/native/header-buttons.js +++ b/src/content/native/header-buttons.js @@ -182,7 +182,6 @@ export function createHeaderButtons(app) { if (!headerFrame) headerFrame = requestFrame(() => { headerFrame = null; - app.positionHandle(); if (['pull', 'commit'].includes(state.context?.kind)) injectHeaderButtons(); }); }); diff --git a/src/content/sidebar.scss b/src/content/sidebar.scss index 0c58f3d..552bd2e 100644 --- a/src/content/sidebar.scss +++ b/src/content/sidebar.scss @@ -796,34 +796,34 @@ button:disabled { } .handle { position: fixed; - top: 35%; + bottom: 17%; pointer-events: auto; display: flex; flex-direction: column; align-items: center; - gap: 13px; - width: 34px; - padding: 12px 6px; + gap: 8px; + width: 26px; + padding: 8px 4px; background: var(--muted-bg); color: var(--text); border: 0; cursor: pointer; - font-size: 16px; + font-size: 12px; font-weight: 650; line-height: 1; box-shadow: 1px 1px 8px #0001; &[data-dock='left'] { - left: var(--handle-offset, 0px); - border-radius: 0 9px 9px 0; + left: 0; + border-radius: 0 7px 7px 0; } &[data-dock='right'] { right: 0; - border-radius: 9px 0 0 9px; + border-radius: 7px 0 0 7px; } & .icon { - width: 18px; - height: 18px; + width: 14px; + height: 14px; color: var(--muted); } &:hover { @@ -1306,8 +1306,8 @@ button:disabled { .handle-grip { display: flex; flex-direction: column; - gap: 3px; - width: 14px; + gap: 2px; + width: 10px; color: var(--muted); } .handle-grip::before, diff --git a/src/content/sidebar/layout.js b/src/content/sidebar/layout.js index cee7c13..c0e12e5 100644 --- a/src/content/sidebar/layout.js +++ b/src/content/sidebar/layout.js @@ -9,7 +9,7 @@ import pageStyles from 'virtual:page-styles'; import {fontFamilies, preferences} from '../../shared/preferences.js'; import {isDark} from '../page.js'; -import {createRenderEffect, on, onCleanup} from '../reactive.js'; +import {createRenderEffect, on} from '../reactive.js'; import {listen} from '../reactive-dom.js'; /** Native Rouge token classes in GitLab's active code-theme stylesheet. */ @@ -30,13 +30,10 @@ const gitlabTokens = { punctuation: 'p', }; -/** Creates the layout feature: `layout`, `updateTheme`, `positionHandle`, `setPreferences` and `updateCodeColors`. */ +/** Creates the layout feature: `layout`, `updateTheme`, `setPreferences` and `updateCodeColors`. */ export function createLayout(app) { const {state} = app; const {host, pageStyle, panel, handle, resize, toastBox, pinButton, closeButton, searchHint} = app.view; - let nativeSidebar = null; - const nativeSidebarObserver = new ResizeObserver(positionHandle); - onCleanup(() => nativeSidebarObserver.disconnect()); listen( document, 'load', @@ -70,7 +67,6 @@ export function createLayout(app) { panel.hidden = !available || !prefs.open; handle.hidden = !available || prefs.open; resize.hidden = !available || !prefs.open; - positionHandle(); pinButton.classList.toggle('active', prefs.pinned); pinButton.setAttribute('aria-pressed', String(prefs.pinned)); const closeLabel = `Close sidebar${prefs.toggleShortcut ? ` ยท ${prefs.toggleShortcut}` : ''}`; @@ -155,28 +151,6 @@ export function createLayout(app) { } } - /** Moves the collapsed edge handle beside GitHub's left navigation drawer when it is open. */ - function positionHandle() { - const sidebar = - state.context?.provider === 'github' ? document.querySelector('[aria-label="Issues sidebar navigation"]') : null; - if (sidebar !== nativeSidebar) { - if (nativeSidebar) nativeSidebarObserver.unobserve(nativeSidebar); - nativeSidebar = sidebar; - if (sidebar) nativeSidebarObserver.observe(sidebar); - } - const rect = sidebar?.getBoundingClientRect(); - const offset = - state.preferences.dock === 'left' && - rect?.width > 0 && - rect.height > 0 && - rect.left >= -1 && - rect.left < Math.max(1, handle.getBoundingClientRect().width) && - rect.right < innerWidth / 2 - ? Math.ceil(Math.max(0, rect.right)) - : 0; - host.style.setProperty('--handle-offset', `${offset}px`); - } - /** * Applies preferences immediately and saves them in the service worker. * @@ -194,5 +168,5 @@ export function createLayout(app) { undefined, {name: 'native code colors'}, ); - return {layout, positionHandle, setPreferences, updateTheme, updateCodeColors}; + return {layout, setPreferences, updateTheme, updateCodeColors}; }