diff --git a/templates/repo/diff/blob_excerpt.tmpl b/templates/repo/diff/blob_excerpt.tmpl index c9aac6d61d84c..424231a50539e 100644 --- a/templates/repo/diff/blob_excerpt.tmpl +++ b/templates/repo/diff/blob_excerpt.tmpl @@ -11,7 +11,8 @@ {{else}} {{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}} - + {{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $.FileNameHash $line.LeftIdx) "" -}} + {{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.LeftIdx}}{{end}} @@ -27,7 +28,8 @@ {{- end -}} - + {{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $.FileNameHash $line.RightIdx) "" -}} + {{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.RightIdx}}{{end}} @@ -65,8 +67,10 @@ {{if eq .GetType 4}} {{$line.RenderBlobExcerptButtons $.FileNameHash $diffBlobExcerptData}} {{else}} - - + {{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $.FileNameHash $line.LeftIdx) "" -}} + + {{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $.FileNameHash $line.RightIdx) "" -}} + {{end}} {{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}} {{if $inlineDiff.EscapeStatus.Escaped}}{{end}} diff --git a/templates/repo/diff/section_split.tmpl b/templates/repo/diff/section_split.tmpl index ab23b1b934b7b..0cc8303d5b8b6 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -24,7 +24,8 @@ {{$match := index $section.Lines $line.Match}} {{- $leftDiff := ""}}{{if $line.LeftIdx}}{{$leftDiff = $section.GetComputedInlineDiffFor $line ctx.Locale}}{{end}} {{- $rightDiff := ""}}{{if $match.RightIdx}}{{$rightDiff = $section.GetComputedInlineDiffFor $match ctx.Locale}}{{end}} - + {{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}} + {{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} @@ -39,7 +40,8 @@ {{- end -}} - + {{- $matchRightAnchor := Iif $match.RightIdx (printf "diff-%sR%d" $file.NameHash $match.RightIdx) "" -}} + {{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $match.RightIdx}}{{end}} @@ -56,7 +58,8 @@ {{else}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale}} - + {{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}} + {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.LeftIdx}}{{end}} @@ -71,7 +74,8 @@ {{- end -}} - + {{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $file.NameHash $line.RightIdx) "" -}} + {{if $line.RightIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.RightIdx}}{{end}} diff --git a/templates/repo/diff/section_unified.tmpl b/templates/repo/diff/section_unified.tmpl index 6776198b7573e..a5f001303fc6d 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -19,8 +19,10 @@ {{end}} {{else}} - - + {{- $leftAnchor := Iif $line.LeftIdx (printf "diff-%sL%d" $file.NameHash $line.LeftIdx) "" -}} + + {{- $rightAnchor := Iif $line.RightIdx (printf "diff-%sR%d" $file.NameHash $line.RightIdx) "" -}} + {{end}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale -}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 0bf37ca083807..73043a0670ba4 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -986,6 +986,14 @@ td .commit-summary { text-align: right; } +.repository .diff-file-box .code-diff .lines-num[data-line-num] { + cursor: pointer; +} + +.repository .diff-file-box .code-diff .lines-num[data-line-num]:hover { + color: var(--color-text-dark); +} + .repository .diff-file-box .code-diff tbody tr .lines-type-marker { width: 10px; min-width: 10px; @@ -997,6 +1005,32 @@ td .commit-summary { display: inline-block; } +.repository .diff-file-box .code-diff tr.active .lines-num, +.repository .diff-file-box .code-diff tr.active .lines-escape, +.repository .diff-file-box .code-diff tr.active .lines-type-marker, +.repository .diff-file-box .code-diff tr.active .lines-code { + background: var(--color-highlight-bg); +} + +.repository .diff-file-box .code-diff tr.active .lines-num { + position: relative; +} + +.repository .diff-file-box .code-diff tr.active .lines-num::after { + display: none; +} + +.repository .diff-file-box .code-diff tr.active .lines-num:first-of-type::after { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-highlight-fg); + display: block; +} + .repository .diff-file-box .code-diff-split .tag-code .lines-code code.code-inner { padding-left: 10px !important; } diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts index 74b36c0096c8b..fc68f9031fe22 100644 --- a/web_src/js/features/file-fold.ts +++ b/web_src/js/features/file-fold.ts @@ -1,19 +1,60 @@ import {svg} from '../svg.ts'; +function parseTransitionValue(value: string): number { + let max = 0; + for (const current of value.split(',')) { + const trimmed = current.trim(); + if (!trimmed) continue; + const isMs = trimmed.endsWith('ms'); + const numericPortion = Number.parseFloat(trimmed.replace(/ms|s$/u, '')); + if (Number.isNaN(numericPortion)) continue; + const duration = numericPortion * (isMs ? 1 : 1000); + max = Math.max(max, duration); + } + return max; +} + +function waitForTransitionEnd(element: Element): Promise { + if (!(element instanceof HTMLElement)) return Promise.resolve(); + const transitionTarget = element.querySelector('.diff-file-body') ?? element; + const styles = window.getComputedStyle(transitionTarget); + const transitionDuration = parseTransitionValue(styles.transitionDuration); + const transitionDelay = parseTransitionValue(styles.transitionDelay); + const total = transitionDuration + transitionDelay; + if (total === 0) return Promise.resolve(); + + return new Promise((resolve) => { + let resolved = false; + function cleanup() { + if (resolved) return; + resolved = true; + transitionTarget.removeEventListener('transitionend', onTransitionEnd); + resolve(); + } + function onTransitionEnd(event: TransitionEvent) { + if (event.target !== transitionTarget) return; + cleanup(); + } + transitionTarget.addEventListener('transitionend', onTransitionEnd); + window.setTimeout(cleanup, total + 50); + }); +} + // Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS. // // The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class. // The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class. // -export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) { +export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean): Promise { foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18); fileContentBox.setAttribute('data-folded', String(newFold)); if (newFold && fileContentBox.getBoundingClientRect().top < 0) { fileContentBox.scrollIntoView(); } + return waitForTransitionEnd(fileContentBox); } // Like `setFileFolding`, except that it automatically inverts the current file folding state. -export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) { - setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true'); +export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement): Promise { + return setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true'); } diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts index c2dc86cea50a0..803ed90a07df5 100644 --- a/web_src/js/features/repo-code.ts +++ b/web_src/js/features/repo-code.ts @@ -3,14 +3,6 @@ import {createTippy} from '../modules/tippy.ts'; import {toAbsoluteUrl} from '../utils.ts'; import {addDelegatedEventListener} from '../utils/dom.ts'; -function changeHash(hash: string) { - if (window.history.pushState) { - window.history.pushState(null, '', hash); - } else { - window.location.hash = hash; - } -} - // it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line) function selectRange(range: string): Element | null { for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active'); @@ -65,7 +57,7 @@ function selectRange(range: string): Element | null { for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) { elLineNums[i].closest('tr')!.classList.add('active'); } - changeHash(`#${range}`); + window.history.replaceState(null, '', `#${range}`); updateIssueHref(range); updateViewGitBlameFragment(range); updateCopyPermalinkUrl(range); diff --git a/web_src/js/features/repo-diff-selection.test.ts b/web_src/js/features/repo-diff-selection.test.ts new file mode 100644 index 0000000000000..c2b035e978efe --- /dev/null +++ b/web_src/js/features/repo-diff-selection.test.ts @@ -0,0 +1,86 @@ +import {applyDiffLineSelection} from './repo-diff-selection.ts'; + +function createDiffRow(tbody: HTMLTableSectionElement, options: {id?: string, lineType?: string} = {}) { + const tr = document.createElement('tr'); + if (options.lineType) tr.setAttribute('data-line-type', options.lineType); + + const numberCell = document.createElement('td'); + numberCell.classList.add('lines-num'); + const span = document.createElement('span'); + if (options.id) span.id = options.id; + numberCell.append(span); + tr.append(numberCell); + + tr.append(document.createElement('td')); + tbody.append(tr); + return tr; +} + +describe('applyDiffLineSelection', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('selects contiguous diff rows, skips expansion rows, and clears previous selection', () => { + const fragment = 'diff-selection'; + + const otherBox = document.createElement('div'); + const otherTable = document.createElement('table'); + otherTable.classList.add('code-diff'); + const otherTbody = document.createElement('tbody'); + const staleActiveRow = document.createElement('tr'); + staleActiveRow.classList.add('active'); + otherTbody.append(staleActiveRow); + otherTable.append(otherTbody); + otherBox.append(otherTable); + + const container = document.createElement('div'); + container.classList.add('diff-file-box'); + const table = document.createElement('table'); + table.classList.add('code-diff'); + const tbody = document.createElement('tbody'); + table.append(tbody); + container.append(table); + + const rows = [ + createDiffRow(tbody, {id: `${fragment}L1`}), + createDiffRow(tbody), + createDiffRow(tbody, {lineType: '4'}), + createDiffRow(tbody), + createDiffRow(tbody, {id: `${fragment}R5`}), + createDiffRow(tbody), + ]; + + document.body.append(otherBox, container); + + const range = {fragment, startSide: 'L' as const, startLine: 1, endSide: 'R' as const, endLine: 5}; + const applied = applyDiffLineSelection(container, range); + + expect(applied).toBe(true); + expect(rows[0].classList.contains('active')).toBe(true); + expect(rows[1].classList.contains('active')).toBe(true); + expect(rows[2].classList.contains('active')).toBe(false); + expect(rows[3].classList.contains('active')).toBe(true); + expect(rows[4].classList.contains('active')).toBe(true); + expect(rows[5].classList.contains('active')).toBe(false); + expect(staleActiveRow.classList.contains('active')).toBe(false); + }); + + test('returns false when either anchor is missing', () => { + const fragment = 'diff-missing'; + const container = document.createElement('div'); + container.classList.add('diff-file-box'); + const table = document.createElement('table'); + table.classList.add('code-diff'); + const tbody = document.createElement('tbody'); + table.append(tbody); + container.append(table); + document.body.append(container); + + createDiffRow(tbody, {id: `${fragment}L1`}); + + const applied = applyDiffLineSelection(container, {fragment, startSide: 'L' as const, startLine: 1, endSide: 'R' as const, endLine: 2}); + expect(applied).toBe(false); + expect(container.querySelectorAll('tr.active').length).toBe(0); + }); +}); diff --git a/web_src/js/features/repo-diff-selection.ts b/web_src/js/features/repo-diff-selection.ts new file mode 100644 index 0000000000000..bc00f04fdfbea --- /dev/null +++ b/web_src/js/features/repo-diff-selection.ts @@ -0,0 +1,261 @@ +import {addDelegatedEventListener} from '../utils/dom.ts'; +import {setFileFolding} from './file-fold.ts'; + +const diffLineNumberCellSelector = '#diff-file-boxes .code-diff td.lines-num[data-line-num]'; +const diffAnchorSuffixRegex = /([LR])(\d+)$/; +const diffHashRangeRegex = /^(diff-[0-9a-f]+)([LR]\d+)(?:-([LR]\d+))?$/i; +export const diffAutoScrollAttr = 'data-auto-scroll-running'; + +type DiffAnchorSide = 'L' | 'R'; +type DiffAnchorInfo = {anchor: string, fragment: string, side: DiffAnchorSide, line: number}; +type DiffSelectionState = DiffAnchorInfo & {container: HTMLElement}; +type DiffSelectionRange = {fragment: string, startSide: DiffAnchorSide, startLine: number, endSide: DiffAnchorSide, endLine: number}; + +let diffSelectionStart: DiffSelectionState | null = null; + +function scrollDiffAnchorIntoView(targetElement: HTMLElement, currentHash: string) { + targetElement.scrollIntoView(); + document.body.setAttribute(diffAutoScrollAttr, 'true'); + window.location.hash = ''; + window.location.hash = currentHash; + setTimeout(() => document.body.removeAttribute(diffAutoScrollAttr), 0); +} + +function isDiffAnchorId(id: string | null): boolean { + return id !== null && id.startsWith('diff-'); +} + +function parseDiffAnchor(anchor: string): DiffAnchorInfo | null { + if (!isDiffAnchorId(anchor)) return null; + const suffixMatch = diffAnchorSuffixRegex.exec(anchor); + if (!suffixMatch) return null; + const line = Number.parseInt(suffixMatch[2]); + if (Number.isNaN(line)) return null; + const fragment = anchor.slice(0, -suffixMatch[0].length); + const side = suffixMatch[1] as DiffAnchorSide; + return {anchor, fragment, side, line}; +} + +export function applyDiffLineSelection(container: HTMLElement, range: DiffSelectionRange): boolean { + // Find the start and end anchor elements + const startId = `${range.fragment}${range.startSide}${range.startLine}`; + const endId = `${range.fragment}${range.endSide}${range.endLine}`; + const startSpan = container.querySelector(`#${CSS.escape(startId)}`); + const endSpan = container.querySelector(`#${CSS.escape(endId)}`); + + if (!startSpan || !endSpan) return false; + + const startTr = startSpan.closest('tr'); + const endTr = endSpan.closest('tr'); + if (!startTr || !endTr) return false; + + // Clear previous selection + for (const tr of document.querySelectorAll('.code-diff tr.active')) { + tr.classList.remove('active'); + } + + // gather rows from the actual table that contains the selection to avoid missing hunks + const codeDiffTable = startSpan.closest('.code-diff'); + if (!codeDiffTable || !codeDiffTable.contains(endSpan)) return false; + const allRows = Array.from(codeDiffTable.querySelectorAll('tbody tr')); + const startIndex = allRows.indexOf(startTr); + const endIndex = allRows.indexOf(endTr); + + if (startIndex === -1 || endIndex === -1) return false; + + // Select all rows between start and end (inclusive) + const minIndex = Math.min(startIndex, endIndex); + const maxIndex = Math.max(startIndex, endIndex); + + for (let i = minIndex; i <= maxIndex; i++) { + const row = allRows[i]; + // Only select rows that are actual diff lines (not comment rows, expansion buttons, etc.) + // Skip rows with data-line-type="4" which are code expansion buttons + if (row.querySelector('td.lines-num') && row.getAttribute('data-line-type') !== '4') { + row.classList.add('active'); + } + } + + return true; +} + +function buildDiffHash(range: DiffSelectionRange): string { + const startAnchor = `${range.fragment}${range.startSide}${range.startLine}`; + if (range.startSide === range.endSide && range.startLine === range.endLine) { + return startAnchor; + } + return `${startAnchor}-${range.endSide}${range.endLine}`; +} + +function updateDiffHash(range: DiffSelectionRange) { + const hashValue = `#${buildDiffHash(range)}`; + if (window.location.hash === hashValue) return; + window.history.replaceState(null, '', hashValue); +} + +export function parseDiffHashRange(hashValue: string): DiffSelectionRange | null { + if (!isDiffAnchorId(hashValue)) return null; + const match = diffHashRangeRegex.exec(hashValue); + if (!match) return null; + const startInfo = parseDiffAnchor(`${match[1]}${match[2]}`); + if (!startInfo) return null; + let endSide = startInfo.side; + let endLine = startInfo.line; + if (match[3]) { + const endInfo = parseDiffAnchor(`${match[1]}${match[3]}`); + if (!endInfo) { + return {fragment: startInfo.fragment, startSide: startInfo.side, startLine: startInfo.line, endSide: startInfo.side, endLine: startInfo.line}; + } + endSide = endInfo.side; + endLine = endInfo.line; + } + return { + fragment: startInfo.fragment, + startSide: startInfo.side, + startLine: startInfo.line, + endSide, + endLine, + }; +} + +async function waitNextAnimationFrame() { + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); +} + +export async function highlightDiffSelectionFromHash(): Promise { + const {hash} = window.location; + if (!hash || !hash.startsWith('#diff-')) return false; + const hashValue = hash.substring(1); + const range = parseDiffHashRange(hashValue); + if (!range) { + if (document.body.hasAttribute(diffAutoScrollAttr)) return false; + // eslint-disable-next-line unicorn/prefer-query-selector + const targetElement = document.getElementById(hashValue); + if (!targetElement) return false; + scrollDiffAnchorIntoView(targetElement, hash); + return true; + } + const targetId = `${range.fragment}${range.startSide}${range.startLine}`; + + // Wait for the target element to be available (in case it needs to be loaded) + let targetSpan = document.querySelector(`#${CSS.escape(targetId)}`); + if (!targetSpan) { + // Flush pending DOM mutations (htmx, folding animations, etc.) before giving up + await waitNextAnimationFrame(); + targetSpan = document.querySelector(`#${CSS.escape(targetId)}`); + if (!targetSpan) { + // Target not found - it might need to be loaded via "show more files" + // Return false to let onLocationHashChange handle the loading + return false; + } + } + + const container = targetSpan.closest('.diff-file-box'); + if (!container) return false; + + // Check if the file is collapsed and expand it if needed + if (container.getAttribute('data-folded') === 'true') { + const foldBtn = container.querySelector('.fold-file'); + if (foldBtn) { + // Expand the file and wait for any transition to finish before selecting lines + await setFileFolding(container, foldBtn, false); + } + } + + if (!applyDiffLineSelection(container, range)) return false; + updateDiffHash(range); + diffSelectionStart = { + anchor: targetId, + fragment: range.fragment, + side: range.startSide, + line: range.startLine, + container, + }; + + // Scroll to the first selected line (scroll to the tr element, not the span) + // The span is an inline element inside td, we need to scroll to the tr for better visibility + await waitNextAnimationFrame(); + const targetTr = targetSpan.closest('tr'); + if (targetTr) { + targetTr.scrollIntoView({block: 'center'}); + } + return true; +} + +function handleDiffLineNumberClick(cell: HTMLElement, e: MouseEvent) { + let span = cell.querySelector('span[id^="diff-"]'); + let info = parseDiffAnchor(span?.id ?? ''); + + // If clicked cell has no line number (e.g., clicking on the empty side of a deletion/addition), + // try to find the line number from the sibling cell on the same row + if (!info) { + const row = cell.closest('tr'); + if (!row) return; + // Find the other line number cell in the same row + const siblingCell = cell.classList.contains('lines-num-old') ? + row.querySelector('td.lines-num-new') : + row.querySelector('td.lines-num-old'); + if (siblingCell) { + span = siblingCell.querySelector('span[id^="diff-"]'); + info = parseDiffAnchor(span?.id ?? ''); + } + if (!info) return; + } + + const container = cell.closest('.diff-file-box'); + if (!container) return; + + e.preventDefault(); + + // Check if clicking on a single already-selected line without shift key - deselect it + if (!e.shiftKey) { + const clickedRow = cell.closest('tr'); + if (clickedRow?.classList.contains('active')) { + // Check if this is a single-line selection by checking if it's the only selected line + const selectedRows = container.querySelectorAll('.code-diff tr.active'); + if (selectedRows.length === 1) { + // This is a single selected line, deselect it + clickedRow.classList.remove('active'); + diffSelectionStart = null; + // Remove hash from URL completely + window.history.replaceState(null, '', window.location.pathname + window.location.search); + window.getSelection()?.removeAllRanges(); + return; + } + } + } + + let rangeStart: DiffAnchorInfo = info; + if (e.shiftKey && diffSelectionStart && + diffSelectionStart.container === container && + diffSelectionStart.fragment === info.fragment) { + rangeStart = diffSelectionStart; + } + + const range: DiffSelectionRange = { + fragment: info.fragment, + startSide: rangeStart.side, + startLine: rangeStart.line, + endSide: info.side, + endLine: info.line, + }; + + if (applyDiffLineSelection(container, range)) { + updateDiffHash(range); + if (!e.shiftKey || !diffSelectionStart || diffSelectionStart.container !== container || diffSelectionStart.fragment !== info.fragment) { + diffSelectionStart = {...info, container}; + } + window.getSelection()?.removeAllRanges(); + } +} + +export function initDiffLineSelection() { + addDelegatedEventListener(document, 'click', diffLineNumberCellSelector, (cell, e) => { + if (e.defaultPrevented) return; + handleDiffLineNumberClick(cell, e); + }); + window.addEventListener('hashchange', () => { + highlightDiffSelectionFromHash(); + }); + highlightDiffSelectionFromHash(); +} diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 20f27f31bc3a8..af3ee2ba94a8a 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -11,6 +11,7 @@ import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; import {parseDom, sleep} from '../utils.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {parseDiffHashRange, highlightDiffSelectionFromHash, initDiffLineSelection, diffAutoScrollAttr} from './repo-diff-selection.ts'; function initRepoDiffFileBox(el: HTMLElement) { // switch between "rendered" and "source", for image and CSV files @@ -149,7 +150,7 @@ function initDiffHeaderPopup() { } // Will be called when the show more (files) button has been pressed -function onShowMoreFiles() { +async function onShowMoreFiles() { // TODO: replace these calls with the "observer.ts" methods initRepoIssueContentHistory(); initViewedCheckboxListenerFor(); @@ -169,9 +170,11 @@ async function loadMoreFiles(btn: Element): Promise { const resp = await response.text(); const respDoc = parseDom(resp, 'text/html'); const respFileBoxes = respDoc.querySelector('#diff-file-boxes')!; + const respFileBoxesChildren = Array.from(respFileBoxes.children); // respFileBoxes.children will be empty after replaceWith // the response is a full HTML page, we need to extract the relevant contents: // * append the newly loaded file list items to the existing list - document.querySelector('#diff-incomplete')!.replaceWith(...Array.from(respFileBoxes.children)); + document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren); + for (const el of respFileBoxesChildren) window.htmx.process(el); onShowMoreFiles(); return true; } catch (error) { @@ -219,30 +222,51 @@ function initRepoDiffShowMore() { async function onLocationHashChange() { // try to scroll to the target element by the current hash const currentHash = window.location.hash; - if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return; + const issueCommentPrefix = '#issuecomment-'; + const isDiffHash = currentHash.startsWith('#diff-'); + const isIssueCommentHash = currentHash.startsWith(issueCommentPrefix); + if (!isDiffHash && !isIssueCommentHash) return; // avoid reentrance when we are changing the hash to scroll and trigger ":target" selection - const attrAutoScrollRunning = 'data-auto-scroll-running'; - if (document.body.hasAttribute(attrAutoScrollRunning)) return; + if (document.body.hasAttribute(diffAutoScrollAttr)) return; - const targetElementId = currentHash.substring(1); - while (currentHash === window.location.hash) { - // use getElementById to avoid querySelector throws an error when the hash is invalid - // eslint-disable-next-line unicorn/prefer-query-selector - const targetElement = document.getElementById(targetElementId); - if (targetElement) { - // need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it - targetElement.scrollIntoView(); - document.body.setAttribute(attrAutoScrollRunning, 'true'); - window.location.hash = ''; - window.location.hash = currentHash; - setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0); + const hashValue = currentHash.substring(1); + let targetElementId = hashValue; + + if (isDiffHash) { + const success = await highlightDiffSelectionFromHash(); + if (success) { + // Successfully highlighted and scrolled, we're done return; } + const range = parseDiffHashRange(hashValue); + if (range) { + targetElementId = `${range.fragment}${range.startSide}${range.startLine}`; + } + } + + while (currentHash === window.location.hash) { + if (isDiffHash) { + // eslint-disable-next-line unicorn/prefer-query-selector + const targetElement = document.getElementById(targetElementId); + if (targetElement) { + // Try again to highlight and scroll now that the element is loaded + const success = await highlightDiffSelectionFromHash(); + if (success) return; + } + } else if (isIssueCommentHash) { + // eslint-disable-next-line unicorn/prefer-query-selector + const commentElement = document.getElementById(hashValue); + if (commentElement) { + commentElement.scrollIntoView({behavior: 'instant'}); + window.location.hash = ''; + window.location.hash = currentHash; + return; + } + } // If looking for a hidden comment, try to expand the section that contains it - const issueCommentPrefix = '#issuecomment-'; - if (currentHash.startsWith(issueCommentPrefix)) { + if (isIssueCommentHash) { const commentId = currentHash.substring(issueCommentPrefix.length); const expandButton = document.querySelector(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`); if (expandButton) { @@ -284,6 +308,7 @@ export function initRepoDiffView() { initDiffHeaderPopup(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); + initDiffLineSelection(); initRepoDiffHashChangeListener(); registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);