diff --git a/CHANGELOG.md b/CHANGELOG.md index 7633853..ac50905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - **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 +- **ADDED**: Ctrl/Cmd+F literal search in open files and full-file diffs, with match highlights, counts and previous/next navigation across virtualized rows - **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 a70c696..d8a117e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Click a tree file to open it in Codetree's in-page workspace; clicking it again 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. +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. + 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 b7b5fe7..deb2be4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,6 +89,8 @@ In-page file tabs use `FILE_TABS` with `scope: 'code'|'review'` and an optional `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. +`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. + 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. @@ -110,6 +112,7 @@ 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. - `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. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 2ab6029..e39b2f7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -30,6 +30,8 @@ The native button is inserted into a changed file's header. A rerendered header 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. +**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. + 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 9cf7808..58fea01 100644 --- a/src/content/sidebar.scss +++ b/src/content/sidebar.scss @@ -969,6 +969,54 @@ button:disabled { background: var(--selected); color: var(--accent); } +.viewer-find { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + max-width: 100%; + min-width: 0; +} +.viewer-find[hidden], +.viewer-find-toggle[hidden] { + display: none; +} +.viewer-find-field { + margin: 0; + width: clamp(180px, 24vw, 320px); + flex: 1 1 220px; + min-width: 120px; + max-width: 100%; +} +.viewer-find-field:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} +.viewer-find-count { + flex-shrink: 0; + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.viewer-find-previous .icon { + transform: rotate(-90deg); +} +.viewer-find-next .icon { + transform: rotate(90deg); +} +.file-match { + padding: 0; + border-radius: 2px; + background: var(--selected); + box-shadow: inset 0 -1px var(--accent); + color: inherit; +} +.file-match.active { + background: var(--accent); + color: var(--bg); + outline: 1px solid var(--accent); +} .viewer[aria-label='Full-file diff'] .viewer-body { background: var(--code-bg); color: var(--code-text); diff --git a/src/content/viewer/file-content.js b/src/content/viewer/file-content.js index 24cf9f8..1217220 100644 --- a/src/content/viewer/file-content.js +++ b/src/content/viewer/file-content.js @@ -16,6 +16,7 @@ import {providerName} from '../page.js'; 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'; /** * Loads and renders both exact revisions of a changed file. @@ -128,20 +129,42 @@ function splitRows(rows) { } /** Creates one source row or an empty alignment gap, rendering syntax as text nodes only. */ -function codeRow(row, side, oldSyntax, newSyntax) { +function codeRow(row, side, oldSyntax, newSyntax, search) { 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]; + const ranges = search.ranges(row); + let rangeIndex = 0; + /** Preserves token spans while splitting only their text at original match boundaries. */ + function append(parent, start, end) { + while (start < end) { + while (rangeIndex < ranges.length && ranges[rangeIndex].end <= start) rangeIndex++; + const range = ranges[rangeIndex]; + if (!range || range.start >= end) { + parent.append(document.createTextNode(row.text.slice(start, end))); + return; + } + if (range.start > start) parent.append(document.createTextNode(row.text.slice(start, range.start))); + const finish = Math.min(end, range.end); + parent.append( + el('mark', { + class: `file-match${range === search.active() ? ' active' : ''}`, + text: row.text.slice(Math.max(start, range.start), finish), + }), + ); + start = finish; + } + } 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)}), - ); + append(text, position, token.start); + const syntax = el('span', {class: `syntax-${token.type}`}); + append(syntax, token.start, token.end); + text.append(syntax); position = token.end; } - text.append(document.createTextNode(row.text.slice(position))); + append(text, position, row.text.length); 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 || ''})), @@ -157,6 +180,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals let projection; let splitAnchor; let frame = 0; + let search; const code = el('div', {class: 'code-spacer'}); const oldCode = el('div', {class: 'code-spacer'}); const newCode = el('div', {class: 'code-spacer'}); @@ -210,7 +234,7 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals 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); + const element = codeRow(row, side, oldSyntax, newSyntax, search); element.style.top = `${index * rowHeight}px`; fragment.append(element); } @@ -246,11 +270,38 @@ function renderRows(app, shell, rows, oldSyntax, newSyntax, current, diff = fals restorePosition({top: line * rowHeight, left: position.left}); } + /** Reveals a search hit even when its row is not rendered, keeping split panes and tab metadata in sync. */ + function reveal(match) { + restorePosition({top: match.row * rowHeight, left: readPosition().left}); + const viewport = split ? (rows[match.row].type === 'removed' ? oldPane : newPane) : shell.body; + const marks = Array.from(viewport.querySelectorAll('.file-match.active')); + if (marks.length) { + // One match can span several syntax tokens; reveal their combined range. + const rects = marks.map(mark => mark.getBoundingClientRect()); + const leftEdge = Math.min(...rects.map(rect => rect.left)); + const rightEdge = Math.max(...rects.map(rect => rect.right)); + const bounds = viewport.getBoundingClientRect(); + if (leftEdge < bounds.left || rightEdge > bounds.right) { + const margin = Math.max(0, (viewport.clientWidth - (rightEdge - leftEdge)) / 2); + const left = Math.max(0, viewport.scrollLeft + leftEdge - bounds.left - margin); + for (const pane of split ? [oldPane, newPane] : [shell.body]) pane.scrollLeft = left; + } + } + shell.onScroll?.(); + } + shell.readPosition = readPosition; shell.restorePosition = restorePosition; shell.relayout = relayout; shell.body.replaceChildren(code); runWithOwner(shell.owner, () => { + search = createFileSearch( + app, + shell, + rows, + {draw, reveal, row: () => Math.floor(readPosition().top / rowHeight)}, + current, + ); for (const viewport of [shell.body, oldPane, newPane]) { listen( viewport, diff --git a/src/content/viewer/file-search.js b/src/content/viewer/file-search.js new file mode 100644 index 0000000..5cffb5b --- /dev/null +++ b/src/content/viewer/file-search.js @@ -0,0 +1,211 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * Owned literal search over complete viewer rows, independent of DOM virtualization. + * Matches use original UTF-16 offsets, retain syntax spans and never leave the viewer. + */ + +import {button, el} from '../dom.js'; +import {listen} from '../reactive-dom.js'; +import {onCleanup, withOwner} from '../reactive.js'; +import {icon} from '../../shared/icons.js'; + +/** Bounds retained match metadata even when a short query occurs throughout a large file. */ +const maxMatches = 10000; + +/** + * Adds a find bar to one loaded file. The modal takes shortcut priority over + * the inline workspace; native editors keep their own Ctrl/Cmd+F behavior. + * Context rows are searched once and share their ranges in both split panes. + * + * @param {object} app The content feature context. + * @param {object} shell The owned viewer shell. + * @param {Array} rows Validated unified rows or complete plain-file rows. + * @param {{draw: Function, reveal: Function, row: Function}} viewport Virtual viewport adapters. + * @param {Function} current Whether this shell still owns its contents. + * @returns {{ranges: Function, active: Function}} Visible-row highlight accessors. + */ +export function createFileSearch(app, shell, rows, viewport, current) { + const input = el('input', { + type: 'search', + placeholder: 'Find in file', + 'aria-label': 'Find in file', + maxlength: '1024', + autocomplete: 'off', + spellcheck: 'false', + }); + const count = el('span', {class: 'viewer-find-count', role: 'status', 'aria-live': 'polite'}); + const previous = button('chevron', 'Previous match', () => select(active - 1), 'viewer-find-previous'); + const next = button('chevron', 'Next match', () => select(active + 1), 'viewer-find-next'); + previous.title = 'Previous match (Shift+Enter)'; + next.title = 'Next match (Enter)'; + const close = button('close', 'Close file search', closeSearch); + const field = el('div', {class: 'searchbar viewer-find-field'}, [icon('search'), input, count]); + const bar = el('div', {class: 'viewer-find', role: 'search', hidden: ''}, [field, previous, next, close]); + const toggle = button('search', 'Find in file (Ctrl/Cmd+F)', openSearch, 'viewer-find-toggle'); + shell.actions.append(toggle, bar); + shell.body.setAttribute('tabindex', '0'); + let ranges = new Map(); + let matches = []; + let active = -1; + let limited = false; + let searching = false; + let generation = 0; + let timer; + let live = true; + + /** Updates the count without replacing the focused input or its selection. */ + function update() { + count.textContent = searching + ? 'Searching…' + : input.value + ? `${active + 1} / ${matches.length}${limited ? '+' : ''}` + : ''; + count.title = limited ? `Only the first ${maxMatches.toLocaleString('en-US')} matches are listed.` : ''; + previous.disabled = next.disabled = !matches.length || searching; + input.setAttribute('aria-busy', String(searching)); + } + + /** Cancels unpublished work and drops row ranges without changing source text. */ + function reset() { + generation++; + clearTimeout(timer); + ranges = new Map(); + matches = []; + active = -1; + limited = searching = false; + } + + /** Opens this viewer's bar and selects the existing local query for replacement. */ + function openSearch() { + if (!live || !current()) return; + const wasHidden = bar.hidden; + bar.hidden = false; + toggle.hidden = true; + input.focus(); + input.select(); + if (wasHidden) schedule(); + } + + /** Closes search before a modal Escape can close the underlying diff dialog. */ + function closeSearch() { + if (!live || !current()) return; + bar.hidden = true; + toggle.hidden = false; + reset(); + update(); + viewport.draw(); + shell.body.focus(); + } + + /** Selects one retained match and lets the renderer reveal its virtual source row. */ + function select(index) { + if (!live || !current() || !matches.length || searching) return; + active = (index + matches.length) % matches.length; + update(); + viewport.reveal(matches[active]); + } + + /** Scans literal, case-insensitive matches in bounded chunks; stale queries cannot publish results. */ + async function scan(query, request) { + const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu'); + const found = []; + const indexed = new Map(); + let clipped = false; + let scanned = 0; + scanRows: for (let index = 0; index < rows.length; index++) { + const row = rows[index]; + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(row.text))) { + if (found.length === maxMatches) { + clipped = true; + break scanRows; + } + const range = {row: index, start: match.index, end: match.index + match[0].length}; + found.push(range); + if (!indexed.has(row)) indexed.set(row, []); + indexed.get(row).push(range); + } + scanned += row.text.length; + if (scanned >= 32768 || index % 256 === 255) { + scanned = 0; + await new Promise(resolve => setTimeout(resolve, 0)); + if (!live || !current() || request !== generation) return; + } + } + if (!live || !current() || request !== generation) return; + matches = found; + ranges = indexed; + limited = clipped; + searching = false; + const top = viewport.row(); + const index = matches.findIndex(match => match.row >= top); + if (matches.length) select(index < 0 ? 0 : index); + else { + update(); + viewport.draw(); + } + } + + /** Debounces input and invalidates old highlights immediately, including while composing text. */ + function schedule(event) { + if (!live || !current()) return; + reset(); + const query = input.value.slice(0, 1024); + searching = Boolean(query); + update(); + viewport.draw(); + if (!query || event?.isComposing) return; + const request = generation; + timer = setTimeout( + withOwner(() => scan(query, request)), + 120, + ); + } + + /** Routes find/Enter/Escape through the active viewer, respecting closed-shadow input focus. */ + function keydown(event) { + if (!live || !current() || event.defaultPrevented || event.isComposing || event.getModifierState('AltGraph')) + return; + const modal = shell.body.closest('dialog'); + if (app.view.viewer.open ? modal !== app.view.viewer : modal) return; + const root = shell.body.getRootNode(); + const focused = root.activeElement; + const editing = [focused, app.view.shadow?.activeElement, ...event.composedPath()].some( + node => node !== input && (node?.matches?.('input,textarea,select') || node?.isContentEditable), + ); + if (editing) return; + const find = event.key.toLowerCase() === 'f' && event.ctrlKey !== event.metaKey && !event.altKey && !event.shiftKey; + if (find) { + if (event.repeat) return; + event.preventDefault(); + event.stopPropagation(); + openSearch(); + } else if (!bar.hidden && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeSearch(); + } else if (event.key === 'Enter' && focused === input) { + event.preventDefault(); + event.stopPropagation(); + select(active + (event.shiftKey ? -1 : 1)); + } + } + } + + listen(input, 'input', schedule); + listen(input, 'compositionend', schedule); + listen(window, 'keydown', keydown, true); + onCleanup(() => { + live = false; + reset(); + bar.remove(); + toggle.remove(); + }); + update(); + return {ranges: row => ranges.get(row) || [], active: () => matches[active]}; +} diff --git a/tests/file-tabs.test.mjs b/tests/file-tabs.test.mjs index b069c16..79bb3da 100644 --- a/tests/file-tabs.test.mjs +++ b/tests/file-tabs.test.mjs @@ -18,6 +18,7 @@ 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'; +import {installViewerSearchDOM, keyEvent} from './helpers/viewer-search.mjs'; const directory = await mkdtemp(join(tmpdir(), 'codetree-file-tabs-')); const modulePath = join(directory, 'content.mjs'); @@ -54,9 +55,10 @@ async function turn() { } /** Composes real navigation, tree, branch and workspace factories around a metadata-only RPC fixture. */ -function fixture(t, provider, reviewDiff) { +function fixture(t, provider, reviewDiff, {search = false} = {}) { const {document, window} = dom(); - if (reviewDiff) installDiffViewport(document); + if (reviewDiff || search) installDiffViewport(document); + if (search) installViewerSearchDOM(document); const clock = frames(); const gitlab = provider === 'gitlab'; const root = gitlab ? 'https://gitlab.com/team/nested/repo' : 'https://github.com/sample/repo'; @@ -75,7 +77,7 @@ function fixture(t, provider, reviewDiff) { const createElement = document.createElement.bind(document); document.createElement = tag => { const node = createElement(tag); - if (reviewDiff) { + if (reviewDiff || search) { Object.defineProperty(node, 'firstChild', {get: () => node.children[0] || null}); if (tag === 'dialog') { node.showModal = () => { @@ -245,7 +247,7 @@ function fixture(t, provider, reviewDiff) { content.createTree, content.createBranches, content.createNavigation, - ...(reviewDiff ? [content.createViewer] : []), + ...(reviewDiff || search ? [content.createViewer] : []), content.createFileTabs, ].filter(Boolean)) Object.assign(app, untrackActions(factory(app))); @@ -398,6 +400,8 @@ function fixture(t, provider, reviewDiff) { return app; }, root, + document, + window, clock, files, errors, @@ -875,3 +879,240 @@ for (const provider of ['github', 'gitlab']) { assert.deepEqual(ui.errors, []); }); } + +/** Returns visible find controls through their accessible labels and status text. */ +function findControls(scope) { + const bar = scope.querySelector('.viewer-find'); + assert.ok(bar, 'A loaded file exposes its owned search controls.'); + const input = bar.querySelector('input'); + assert.equal(input?.getAttribute('aria-label'), 'Find in file'); + const control = label => { + const button = bar.querySelectorAll('button').find(node => node.getAttribute('aria-label') === label); + assert.ok(button, `Expected ${label} search control`); + return button; + }; + return { + bar, + input, + count: bar.querySelector('[role="status"]'), + previous: control('Previous match'), + next: control('Next match'), + close: control('Close file search'), + }; +} + +/** Opens find using the production window capture handler rather than invoking search internals. */ +async function openFind(ui, scope, body, modifiers = {ctrlKey: true}) { + body.focus(); + const event = keyEvent(body, 'f', modifiers); + await ui.window.fire('keydown', event); + assert.equal(event.defaultPrevented, true, 'The active viewer handles its find shortcut.'); + const controls = findControls(scope); + assert.equal(controls.bar.hidden, false); + assert.equal(ui.document.activeElement, controls.input); + return controls; +} + +/** Advances only the input debounce, after file loading and stylesheet work have completed. */ +async function findText(t, ui, controls, query) { + t.mock.timers.enable({apis: ['setTimeout']}); + try { + controls.input.value = query; + await controls.input.fire('input'); + t.mock.timers.tick(120); + await Promise.resolve(); + ui.clock.flush(); + } finally { + t.mock.timers.reset(); + } + assert.equal(controls.input.getAttribute('aria-busy'), 'false', 'The completed query publishes its visible count.'); +} + +/** Dispatches Enter and Escape through the same active-viewer shortcut handler. */ +async function findKey(ui, controls, key, modifiers) { + const event = keyEvent(controls.input, key, modifiers); + await ui.window.fire('keydown', event); + ui.clock.flush(); + assert.equal(event.defaultPrevented, true); +} + +/** Counts removed, added and distant context hits without counting shared context twice. */ +function searchRevision() { + const before = Array.from({length: 220}, (_, index) => `export const source${index + 1} = ${index + 1};`); + before[20] = "const oldOnly = 'search beacon';"; + before[21] = "const oldPair = 'old branch';"; + before[22] = "const removedOnly = 'gone';"; + before[180] = '// search beacon in shared context'; + const added = ["const newOnly = 'search beacon';", "const newPair = 'new branch';"]; + return { + before: before.join('\n'), + after: [...before.slice(0, 20), ...added, ...before.slice(23)].join('\n'), + file: { + status: 'modified', + additions: 2, + deletions: 3, + patch: [ + '@@ -21,3 +21,2 @@', + ...before.slice(20, 23).map(line => '-' + line), + ...added.map(line => '+' + line), + ].join('\n'), + }, + }; +} + +for (const provider of ['github', 'gitlab']) { + test(`${provider} file search finds literal offscreen text and retains its revealed position across tabs`, async t => { + const ui = fixture(t, provider, undefined, {search: true}); + const lines = longText.split('\n'); + lines[5] = '// needle without literal punctuation'; + lines[180] = '// ' + 'wide prefix '.repeat(15) + '[Needle].*'; + lines[195] = '// [needle].*'; + ui.app.fileReply = message => (message.path === paths[0] ? lines.join('\n') : longText); + await ui.navigate(ui.root); + await ui.open(paths[0]); + assert.equal(ui.body().querySelectorAll('.file-match').length, 0); + assert.doesNotMatch(ui.body().textContent, /\[Needle\]/, 'The distant source row starts outside the virtual DOM.'); + const controls = await openFind(ui, ui.workspace(), ui.body()); + const requests = ui.files.length; + await findText(t, ui, controls, '[needle].*'); + assert.equal(controls.count.textContent, '1 / 2', 'Punctuation is literal and matching is case insensitive.'); + assert.equal( + ui + .body() + .querySelectorAll('.file-match.active') + .map(node => node.textContent) + .join(''), + '[Needle].*', + ); + assert.equal(ui.body().scrollTop, 180 * 22); + assert.ok(ui.body().scrollLeft > 0, 'A match beyond the visible columns is revealed horizontally.'); + await findKey(ui, controls, 'Enter'); + assert.equal(controls.count.textContent, '2 / 2'); + assert.equal(ui.body().scrollTop, 195 * 22); + await findKey(ui, controls, 'Enter', {shiftKey: true}); + assert.equal(controls.count.textContent, '1 / 2'); + const revealed = {top: ui.body().scrollTop, left: ui.body().scrollLeft}; + assert.equal(ui.files.length, requests, 'Searching the complete source does not refetch the file.'); + await ui.open(paths[1]); + assert.equal( + findControls(ui.workspace()).bar.hidden, + true, + 'A newly loaded file starts with its own closed search.', + ); + await ui.open(paths[0]); + assert.deepEqual({top: ui.body().scrollTop, left: ui.body().scrollLeft}, revealed); + assert.doesNotMatch( + JSON.stringify([...ui.metadata]), + /needle/i, + 'Session metadata retains positions, not search text.', + ); + assert.ok(ui.body().querySelectorAll('.code-row').length < 50); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} file search retains its diff query and active source across Unified and Side-by-side`, async t => { + const ui = fixture(t, provider, searchRevision(), {search: true}); + await ui.navigate(ui.review(41)); + const controls = await openFind(ui, ui.workspace(), ui.body(), {metaKey: true}); + const requests = ui.files.length; + await findText(t, ui, controls, 'search beacon'); + assert.equal(controls.count.textContent, '1 / 3'); + assert.match(ui.body().querySelector('.file-match.active').closest('.code-row').textContent, /oldOnly/); + await diffLayout(ui, ui.workspace(), 'Side-by-side'); + assert.equal(controls.input.value, 'search beacon'); + assert.equal(controls.count.textContent, '1 / 3'); + const [oldPane, newPane] = diffPanes(ui.body()); + assert.match(oldPane.querySelector('.file-match.active').closest('.code-row').textContent, /oldOnly/); + assert.equal(newPane.querySelectorAll('.file-match.active').length, 0); + await findKey(ui, controls, 'Enter'); + assert.equal(controls.count.textContent, '2 / 3'); + assert.match(newPane.querySelector('.file-match.active').closest('.code-row').textContent, /newOnly/); + assert.equal(oldPane.querySelectorAll('.file-match.active').length, 0); + await findKey(ui, controls, 'Enter'); + assert.equal( + controls.count.textContent, + '3 / 3', + 'Shared context contributes one result, despite two rendered panes.', + ); + for (const pane of [oldPane, newPane]) { + assert.match(pane.querySelector('.file-match.active').closest('.code-row').textContent, /shared context/); + assert.ok(pane.scrollTop > 3000); + } + assert.equal(oldPane.scrollTop, newPane.scrollTop); + await diffLayout(ui, ui.workspace(), 'Unified'); + assert.equal(controls.count.textContent, '3 / 3'); + assert.match(ui.body().querySelector('.file-match.active').closest('.code-row').textContent, /shared context/); + assert.equal(ui.files.length, requests); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} file search gives actual View full shortcut priority and keeps its query separate from inline`, async t => { + const ui = fixture(t, provider, searchRevision(), {search: true}); + await ui.navigate(ui.review(41)); + const inline = await openFind(ui, ui.workspace(), ui.body()); + await findText(t, ui, inline, 'source110'); + assert.equal(inline.count.textContent, '1 / 1'); + const inlineTop = ui.body().scrollTop; + 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'); + const full = await openFind(ui, modal, modalBody, {metaKey: true}); + assert.equal(full.input.value, '', 'View full starts its own query.'); + assert.equal(inline.input.value, 'source110'); + await findText(t, ui, full, 'newOnly'); + assert.equal(full.count.textContent, '1 / 1'); + assert.equal(inline.count.textContent, '1 / 1'); + assert.equal(ui.body().scrollTop, inlineTop, 'A modal match does not move the inline file.'); + await findKey(ui, full, 'Escape'); + assert.equal(full.bar.hidden, true); + assert.equal(modal.open, true, 'The first Escape closes find before the underlying diff dialog.'); + assert.equal(ui.document.activeElement, modalBody); + ui.app.closeViewer(); + const reopened = await openFind(ui, ui.workspace(), ui.body(), {metaKey: true}); + assert.equal(reopened.input, inline.input); + assert.equal(reopened.input.value, 'source110'); + assert.equal(reopened.input.selectionStart, 0); + assert.equal(reopened.input.selectionEnd, 'source110'.length); + assert.equal(ui.body().scrollTop, inlineTop); + assert.deepEqual(ui.errors, []); + }); + + test(`${provider} file search drops pending queries and detached controls when files or reviews change`, async t => { + const ui = fixture(t, provider, searchRevision(), {search: true}); + await ui.navigate(ui.review(41)); + const old = await openFind(ui, ui.workspace(), ui.body()); + const listenerCount = ui.window.listeners.get('keydown').length; + old.input.value = 'source220'; + await old.input.fire('input'); // Leave a real debounce pending while a different file disposes this search. + await ui.open(paths[1]); + const current = await openFind(ui, ui.workspace(), ui.body(), {metaKey: true}); + await findText(t, ui, current, 'newOnly'); + await new Promise(resolve => setTimeout(resolve, 140)); + ui.clock.flush(); + assert.equal(current.input.value, 'newOnly'); + assert.equal(current.count.textContent, '1 / 1'); + assert.equal(old.bar.isConnected, false); + await ui.app.rememberFileTabs(); + const stored = structuredClone([...ui.metadata]); + const top = ui.body().scrollTop; + await old.next.fire('click'); + await old.close.fire('click'); + old.input.value = 'source180'; + await old.input.fire('input'); + assert.equal(current.bar.hidden, false); + assert.equal(ui.body().scrollTop, top); + assert.deepEqual([...ui.metadata], stored, 'Detached controls cannot save another file position.'); + await ui.navigate(ui.review(42)); + const nextReview = await openFind(ui, ui.workspace(), ui.body()); + assert.equal(nextReview.input.value, '', 'A different PR/MR has an independent active file search.'); + assert.equal(nextReview.count.textContent, ''); + assert.equal(ui.body().querySelectorAll('.file-match').length, 0); + assert.equal( + ui.window.listeners.get('keydown').length, + listenerCount, + 'Replacing views removes their key listeners.', + ); + assert.deepEqual(ui.errors, []); + }); +} diff --git a/tests/helpers/viewer-search.mjs b/tests/helpers/viewer-search.mjs new file mode 100644 index 0000000..25b66b2 --- /dev/null +++ b/tests/helpers/viewer-search.mjs @@ -0,0 +1,113 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * Search-specific focus, keyboard and source-text geometry for content fixtures. + */ + +/** Counts visible columns from rendered source text, with the viewer's four-column tabs. */ +function columns(text) { + let width = 0; + for (const character of text) width += character === '\t' ? 4 - (width % 4) : 1; + return width; +} + +/** Collects the rendered text before a descendant without using the search query or match metadata. */ +function precedingText(root, target) { + let text = ''; + let found = false; + /** Visits source leaves in DOM order, stopping at the requested rendered mark. */ + function visit(node) { + if (node === target) found = true; + if (found) return; + if (!node.children.length) text += node.textContent; + else for (const child of node.children) visit(child); + } + visit(root); + return text; +} + +/** + * Extends only this document's fixture elements with the browser APIs used by + * file search. Geometry comes from actual rendered text and scroll positions; + * it deliberately does not model fonts, browser layout or native selection. + * Install after installDiffViewport so its bounded scroll dimensions remain in use. + */ +export function installViewerSearchDOM(document) { + const createElement = document.createElement.bind(document); + /** Adds search APIs to one element, including elements already created by dom(). */ + function extend(node) { + Object.defineProperty(node, 'firstChild', {configurable: true, get: () => node.children[0] || null}); + node.getRootNode = () => { + let root = node; + while (root.parent) root = root.parent; + return root.host ? root : document; + }; + node.select = () => { + node.selectionStart = 0; + node.selectionEnd = node.value.length; + }; + node.matches = selector => + selector.split(',').some(part => { + const tokens = part.trim().match(/\[[^\]]+\]|[.#][\w-]+|^[\w-]+/g) || []; + return ( + tokens.length > 0 && + tokens.every(token => { + if (token.startsWith('.')) return node.classList.contains(token.slice(1)); + if (token.startsWith('#')) return node.id === token.slice(1); + const attribute = token.match(/^\[([^=]+)="(.*)"\]$/); + return attribute ? node.getAttribute(attribute[1]) === attribute[2] : node.tagName === token.toUpperCase(); + }) + ); + }); + node.getBoundingClientRect = () => { + const viewport = node.closest('.split-diff-pane') || node.closest('.viewer-body') || node; + const origin = viewport.getAttribute('aria-label') === 'New revision' ? 360 : 0; + const line = node.closest('.line-text'); + const prefix = line ? precedingText(line, node) : ''; + const left = origin + (line ? 100 + columns(prefix) * 10 - viewport.scrollLeft : 0); + const width = line ? (columns(prefix + node.textContent) - columns(prefix)) * 10 : viewport.clientWidth; + const row = node.closest('.code-row'); + const top = row ? Number.parseFloat(row.style.top) - viewport.scrollTop : 0; + return {left, right: left + width, top, bottom: top + 22, width, height: 22}; + }; + return node; + } + document.createElement = tag => extend(createElement(tag)); + extend(document.body); +} + +/** + * Creates a keyboard event delivered through window.fire('keydown', event). + * Its observable cancellation fields survive the base fixture's event copy. + */ +export function keyEvent(target, key, modifiers = {}) { + const event = { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + defaultPrevented: false, + propagationStopped: false, + ...modifiers, + composedPath() { + const path = []; + for (let node = target; node; node = node.parent || node.host) path.push(node); + return path; + }, + getModifierState(name) { + return name === 'AltGraph' && Boolean(modifiers.altGraph); + }, + preventDefault() { + event.defaultPrevented = true; + }, + stopPropagation() { + event.propagationStopped = true; + }, + }; + return event; +} diff --git a/tests/viewer-search.test.mjs b/tests/viewer-search.test.mjs new file mode 100644 index 0000000..4f7f35c --- /dev/null +++ b/tests/viewer-search.test.mjs @@ -0,0 +1,524 @@ +/* + * https://github.com/ziqq/Codetree + * Copyright (C) 2026 Anton Ustinoff + * https://github.com/ziqq/Codetree/blob/main/LICENSE + * + * File search regressions through the production View full factory. + * Synthetic source-text geometry proves virtual reveal behavior, not browser layout. + */ + +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'; +import {installViewerSearchDOM, keyEvent} from './helpers/viewer-search.mjs'; + +const directory = await mkdtemp(join(tmpdir(), 'codetree-viewer-search-')); +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); + +/** Builds a complete, valid replacement patch without depending on search or diff projection internals. */ +function revision({ + prefix = ['const prefix = 1;'], + removed = ['old beacon'], + added = ['new beacon'], + suffix = ['shared beacon'], + path = 'source.txt', +} = {}) { + const before = [...prefix, ...removed, ...suffix]; + const after = [...prefix, ...added, ...suffix]; + return { + before: before.join('\n') + '\n', + after: after.join('\n') + '\n', + node: { + path, + type: 'blob', + status: 'modified', + adds: added.length, + dels: removed.length, + patch: [ + `@@ -1,${before.length} +1,${after.length} @@`, + ...prefix.map(line => ` ${line}`), + ...removed.map(line => `-${line}`), + ...added.map(line => `+${line}`), + ...suffix.map(line => ` ${line}`), + ].join('\n'), + }, + }; +} + +/** Installs the real modal feature and controls frame scheduling and revision replies. */ +function fixture(t, data = revision()) { + const {document, window} = dom(); + installDiffViewport(document); + installViewerSearchDOM(document); + 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'); + 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, document, viewer, window, clock, requests, t}; +} + +/** Opens View full before mocking timers, so syntax-tokenizer yielding uses its normal clock. */ +async function open(ui) { + await ui.app.showDiff(ui.data.node); + const body = ui.viewer.querySelector('.viewer-body'); + assert.ok(body?.querySelector('.code-spacer'), body?.textContent); + ui.t.mock.timers.enable({apis: ['setTimeout']}); + return body; +} + +/** Returns publicly labelled find controls; the baseline must fail on the missing UI assertion. */ +function controls(ui) { + const bar = ui.viewer.querySelector('.viewer-find'); + assert.ok(bar, 'View full exposes file search'); + return { + bar, + input: bar.querySelector('input'), + count: bar.querySelector('[role="status"]'), + previous: bar.querySelector('[aria-label="Previous match"]'), + next: bar.querySelector('[aria-label="Next match"]'), + close: bar.querySelector('[aria-label="Close file search"]'), + toggle: ui.viewer.querySelector('[aria-label="Find in file (Ctrl/Cmd+F)"]'), + }; +} + +/** Delivers one window keyboard event and exposes its browser-default cancellation. */ +async function key(ui, target, value, modifiers) { + const event = keyEvent(target, value, modifiers); + await ui.window.fire('keydown', event); + return event; +} + +/** Advances debounce and bounded asynchronous scan chunks without real-time sleeps. */ +async function finish(ui, advance = 120) { + ui.t.mock.timers.tick(advance); + for (let turn = 0; turn < 200; turn++) { + await Promise.resolve(); + ui.t.mock.timers.tick(0); + ui.clock.flush(); + if (controls(ui).input.getAttribute('aria-busy') === 'false') return; + } + assert.fail('File search finishes its bounded scan'); +} + +/** Enters a query through the actual input handler and waits for its result count. */ +async function search(ui, query) { + const {input} = controls(ui); + input.value = query; + await input.fire('input'); + await finish(ui); +} + +/** Clicks a public diff-layout control and flushes its viewport frame. */ +async function layout(ui, label) { + const button = ui.viewer.querySelectorAll('button').find(node => node.textContent === label); + assert.ok(button, `View full exposes ${label}`); + await button.fire('click'); + ui.clock.flush(); +} + +/** Returns the rendered active source fragments in DOM order, across syntax spans. */ +function activeText(root) { + return root + .querySelectorAll('.file-match.active') + .map(mark => mark.textContent) + .join(''); +} + +test('View full file search opens with Ctrl/Cmd+F and selects its existing local query', async t => { + const ui = fixture(t); + const body = await open(ui); + const {bar, input, toggle} = controls(ui); + assert.equal(bar.hidden, true); + assert.equal(toggle.classList.contains('icon-button'), true); + assert.equal(toggle.querySelector('svg')?.tagName, 'SVG'); + const first = await key(ui, body, 'f', {ctrlKey: true}); + assert.equal(first.defaultPrevented, true); + assert.equal(first.propagationStopped, true); + assert.equal(bar.hidden, false); + assert.equal(toggle.hidden, true); + assert.equal(ui.document.activeElement, input); + assert.equal(input.getAttribute('aria-label'), 'Find in file'); + assert.equal(input.parentElement.classList.contains('searchbar'), true); + await search(ui, 'beacon'); + input.selectionStart = input.selectionEnd = 2; + const second = await key(ui, input, 'F', {metaKey: true}); + assert.equal(second.defaultPrevented, true); + assert.deepEqual([input.selectionStart, input.selectionEnd], [0, 6]); + assert.equal(controls(ui).count.textContent, '1 / 3'); + assert.equal(ui.requests.length, 2); +}); + +test('View full file search navigates Enter and Shift+Enter with wrap and SVG controls', async t => { + const ui = fixture(t); + const body = await open(ui); + await key(ui, body, 'f', {metaKey: true}); + await search(ui, 'beacon'); + const {input, count, previous, next} = controls(ui); + for (const control of [previous, next]) { + assert.equal(control.classList.contains('icon-button'), true); + assert.ok(control.querySelector('svg')); + assert.equal(control.textContent, ''); + } + assert.equal(activeText(body), 'beacon'); + for (const expected of ['2 / 3', '3 / 3', '1 / 3']) { + const event = await key(ui, input, 'Enter'); + assert.equal(event.defaultPrevented, true); + assert.equal(count.textContent, expected); + } + await key(ui, input, 'Enter', {shiftKey: true}); + assert.equal(count.textContent, '3 / 3'); + await next.fire('click'); + assert.equal(count.textContent, '1 / 3'); + await previous.fire('click'); + assert.equal(count.textContent, '3 / 3'); +}); + +test('View full file search consumes Escape before modal dismissal and clears only search highlights', async t => { + const ui = fixture(t); + const body = await open(ui); + const source = body.querySelectorAll('.line-text').map(node => node.textContent); + await controls(ui).toggle.fire('click'); + await search(ui, 'beacon'); + const {input, bar, toggle} = controls(ui); + const first = await key(ui, input, 'Escape'); + assert.equal(first.defaultPrevented, true); + assert.equal(first.propagationStopped, true); + assert.equal(ui.viewer.open, true); + assert.equal(bar.hidden, true); + assert.equal(toggle.hidden, false); + assert.equal(ui.document.activeElement, body); + assert.equal(body.querySelectorAll('.file-match').length, 0); + assert.deepEqual( + body.querySelectorAll('.line-text').map(node => node.textContent), + source, + ); + const second = await key(ui, body, 'Escape'); + assert.equal(second.defaultPrevented, false, 'a closed find bar leaves native dialog Escape available'); + assert.equal(second.propagationStopped, false); +}); + +test('View full file search preserves native editor shortcuts and excludes composing or modified find events', async t => { + const ui = fixture(t); + const body = await open(ui); + const {bar} = controls(ui); + for (const tag of ['input', 'textarea', 'select', 'div']) { + const editor = ui.document.createElement(tag); + if (tag === 'div') editor.isContentEditable = true; + ui.document.body.append(editor); + editor.focus(); + const event = await key(ui, editor, 'f', {ctrlKey: true}); + assert.equal(event.defaultPrevented, false, `${tag} retains its native find shortcut`); + assert.equal(bar.hidden, true); + } + body.focus(); + for (const modifiers of [ + {ctrlKey: true, isComposing: true}, + {ctrlKey: true, altGraph: true}, + {ctrlKey: true, altKey: true}, + {ctrlKey: true, metaKey: true}, + {ctrlKey: true, shiftKey: true}, + {ctrlKey: true, repeat: true}, + {ctrlKey: true, defaultPrevented: true}, + {}, + ]) { + const event = await key(ui, body, 'f', modifiers); + assert.equal(bar.hidden, true, JSON.stringify(modifiers)); + assert.equal(event.propagationStopped, false); + } + const hiddenInput = ui.document.createElement('input'); + const shadow = ui.document.createElement('div').attachShadow(); + shadow.append(hiddenInput); + hiddenInput.focus(); + ui.app.view.shadow = shadow; + const event = await key(ui, body, 'f', {metaKey: true}); + assert.equal(event.defaultPrevented, false, 'closed-shadow editor focus remains authoritative'); + assert.equal(bar.hidden, true); +}); + +test('View full file search treats special characters literally and keeps original Unicode offsets across syntax spans', async t => { + const line = 'const İ = "x"; const value = "a.b[0]"; const astral = "𐐨";'; + const ui = fixture(t, revision({prefix: [line], removed: ['old'], added: ['new'], suffix: [], path: 'source.js'})); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + await search(ui, 'A.B[0]'); + assert.equal(controls(ui).count.textContent, '1 / 1'); + assert.equal(activeText(body), 'a.b[0]'); + await search(ui, 'value = "a.b[0]"'); + assert.equal(controls(ui).count.textContent, '1 / 1'); + assert.equal(activeText(body), 'value = "a.b[0]"'); + assert.ok(body.querySelectorAll('.file-match.active').length > 1, 'one literal range crosses preserved token spans'); + assert.ok(body.querySelector('.syntax-string')); + assert.equal(body.querySelector('.line-text').textContent, line); + await search(ui, '𐐀'); + assert.equal(controls(ui).count.textContent, '1 / 1'); + assert.equal(activeText(body), '𐐨'); + assert.equal(body.querySelector('.line-text').textContent, line); +}); + +test('View full file search counts removed, added and shared context once across Unified and Side-by-side', async t => { + const ui = fixture(t); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + await search(ui, 'beacon'); + const {count, input} = controls(ui); + assert.equal(count.textContent, '1 / 3'); + await layout(ui, 'Side-by-side'); + const old = body.querySelector('[aria-label="Old revision"]'); + const next = body.querySelector('[aria-label="New revision"]'); + assert.equal(count.textContent, '1 / 3'); + assert.equal(activeText(old), 'beacon'); + assert.equal(activeText(next), ''); + await key(ui, input, 'Enter'); + assert.equal(count.textContent, '2 / 3'); + assert.equal(activeText(old), ''); + assert.equal(activeText(next), 'beacon'); + await key(ui, input, 'Enter'); + assert.equal(count.textContent, '3 / 3'); + assert.equal(activeText(old), 'beacon'); + assert.equal(activeText(next), 'beacon'); + await layout(ui, 'Unified'); + assert.equal(input.value, 'beacon'); + assert.equal(count.textContent, '3 / 3'); + assert.equal(activeText(body), 'beacon'); + assert.equal(ui.requests.length, 2, 'mode changes reuse loaded revisions and scan state'); +}); + +test('View full file search reveals offscreen matches on both axes in Unified and synchronizes split panes', async t => { + const wide = '\t'.repeat(80) + 'const farAway = "beacon";'; + const ui = fixture( + t, + revision({ + prefix: Array.from({length: 320}, (_, index) => `source ${index}`), + removed: [wide], + added: [wide.replace('farAway', 'nextFarAway')], + suffix: Array.from({length: 40}, (_, index) => `tail ${index}`), + path: 'source.js', + }), + ); + const body = await open(ui); + assert.equal( + body.querySelectorAll('.line-text').some(line => line.textContent.includes('beacon')), + false, + ); + await controls(ui).toggle.fire('click'); + await search(ui, 'BEACON'); + assert.equal(controls(ui).count.textContent, '1 / 2'); + assert.equal(body.scrollTop, 320 * 22); + assert.ok(body.scrollLeft > 2000, 'rendered tab expansion places the match well beyond the viewport'); + const mark = body.querySelector('.file-match.active'); + const bounds = body.getBoundingClientRect(); + assert.ok(mark.getBoundingClientRect().left >= bounds.left); + assert.ok(mark.getBoundingClientRect().right <= bounds.right); + assert.ok(body.querySelectorAll('.code-row').length < 50, 'reveal retains row virtualization'); + await layout(ui, 'Side-by-side'); + await key(ui, controls(ui).input, 'Enter'); + const old = body.querySelector('[aria-label="Old revision"]'); + const next = body.querySelector('[aria-label="New revision"]'); + assert.equal(activeText(next), 'beacon'); + assert.equal(activeText(old), ''); + assert.equal(old.scrollTop, 320 * 22); + assert.equal(next.scrollTop, old.scrollTop); + assert.equal(next.scrollLeft, old.scrollLeft); + const nextMark = next.querySelector('.file-match.active').getBoundingClientRect(); + const nextBounds = next.getBoundingClientRect(); + assert.ok(nextMark.left >= nextBounds.left); + assert.ok(nextMark.right <= nextBounds.right); +}); + +test('View full file search reports empty and missing queries and debounces replacement without stale marks', async t => { + const ui = fixture(t); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + const {input, count, previous, next} = controls(ui); + input.value = 'old'; + await input.fire('input'); + t.mock.timers.tick(119); + assert.equal(count.textContent, 'Searching…'); + assert.equal(previous.disabled, true); + assert.equal(next.disabled, true); + assert.equal(body.querySelectorAll('.file-match').length, 0); + input.value = 'new'; + await input.fire('input'); + t.mock.timers.tick(1); + assert.equal(count.textContent, 'Searching…', 'the cancelled first debounce cannot publish'); + await finish(ui, 119); + assert.equal(count.textContent, '1 / 1'); + assert.equal(activeText(body), 'new'); + await search(ui, 'absent'); + assert.equal(count.textContent, '0 / 0'); + assert.equal(body.querySelectorAll('.file-match').length, 0); + assert.equal(previous.disabled, true); + assert.equal(next.disabled, true); + await key(ui, input, 'Enter'); + assert.equal(count.textContent, '0 / 0'); + await search(ui, ''); + assert.equal(count.textContent, ''); + assert.equal(input.getAttribute('aria-busy'), 'false'); +}); + +test('View full file search waits for compositionend before scanning an IME query', async t => { + const ui = fixture(t); + await open(ui); + await controls(ui).toggle.fire('click'); + const {input, count} = controls(ui); + input.value = 'beacon'; + await input.fire('input', {isComposing: true}); + t.mock.timers.tick(1000); + await Promise.resolve(); + assert.equal(count.textContent, 'Searching…'); + assert.equal(input.getAttribute('aria-busy'), 'true'); + await input.fire('compositionend'); + await finish(ui); + assert.equal(count.textContent, '1 / 3'); + const event = await key(ui, input, 'Enter', {isComposing: true}); + assert.equal(event.defaultPrevented, false); + assert.equal(count.textContent, '1 / 3'); +}); + +test('View full file search bounds retained matches and reports the 10000+ cap without expanding virtual rows', async t => { + const ui = fixture( + t, + revision({prefix: Array.from({length: 5001}, () => 'a a'), removed: ['old'], added: ['new'], suffix: []}), + ); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + await search(ui, 'a'); + const {count, input} = controls(ui); + assert.equal(count.textContent, '1 / 10000+'); + assert.equal(count.title, 'Only the first 10,000 matches are listed.'); + assert.ok(body.querySelectorAll('.code-row').length < 50); + assert.ok(body.querySelectorAll('.file-match').length < 100); + await key(ui, input, 'Enter', {shiftKey: true}); + assert.equal(count.textContent, '10000 / 10000+'); + assert.equal(body.scrollTop, Math.min(4999 * 22, body.scrollHeight - body.clientHeight)); + assert.equal( + body.querySelector('.file-match.active').closest('.code-row').querySelector('.line-number').textContent, + '5000', + ); + await key(ui, input, 'Enter'); + assert.equal(count.textContent, '1 / 10000+'); +}); + +test('View full file search cancels a yielding scan when another query replaces it', async t => { + const ui = fixture( + t, + revision({ + prefix: Array.from({length: 600}, (_, index) => `old ${index}`), + removed: ['old'], + added: ['new'], + suffix: ['replacement needle'], + }), + ); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + const {input, count} = controls(ui); + input.value = 'old'; + await input.fire('input'); + t.mock.timers.tick(120); + assert.equal(count.textContent, 'Searching…', 'the scan yields before publishing a partial result'); + input.value = 'needle'; + await input.fire('input'); + await finish(ui); + assert.equal(count.textContent, '1 / 1'); + assert.equal(activeText(body), 'needle'); + assert.equal( + body.querySelectorAll('.file-match').every(mark => mark.textContent === 'needle'), + true, + ); + await finish(ui, 1000); + assert.equal(count.textContent, '1 / 1', 'the old scan cannot replace the completed query'); +}); + +test('View full file search cancels debounce and yielding work on close and disposes stale controls', async t => { + const ui = fixture( + t, + revision({prefix: Array.from({length: 600}, () => 'beacon'), removed: ['old'], added: ['new'], suffix: []}), + ); + const body = await open(ui); + await controls(ui).toggle.fire('click'); + const stale = controls(ui); + stale.input.value = 'beacon'; + await stale.input.fire('input'); + await stale.close.fire('click'); + await finish(ui, 1000); + assert.equal(stale.bar.hidden, true); + assert.equal(body.querySelectorAll('.file-match').length, 0); + await stale.toggle.fire('click'); + t.mock.timers.tick(120); + assert.equal(stale.count.textContent, 'Searching…'); + ui.app.closeViewer(); + await Promise.resolve(); + t.mock.timers.tick(1000); + await Promise.resolve(); + ui.clock.flush(); + assert.equal(ui.viewer.open, false); + assert.equal(ui.viewer.querySelector('.viewer-find'), null); + assert.equal(ui.window.listeners.get('keydown')?.length, 0); + assert.equal(stale.input.listeners.get('input')?.length, 0); + const staleCount = stale.count.textContent; + await stale.next.fire('click'); + await stale.toggle.fire('click'); + assert.equal(stale.count.textContent, staleCount, 'detached icon controls cannot publish or reopen a disposed view'); + assert.equal(ui.viewer.querySelector('.viewer-find'), null); + assert.equal(ui.clock.pending.size, 0); + const event = await key(ui, body, 'f', {ctrlKey: true}); + assert.equal(event.defaultPrevented, false); +});