diff --git a/templates/repo/diff/blob_excerpt.tmpl b/templates/repo/diff/blob_excerpt.tmpl index c9aac6d61d84c..a9323d4778193 100644 --- a/templates/repo/diff/blob_excerpt.tmpl +++ b/templates/repo/diff/blob_excerpt.tmpl @@ -11,7 +11,7 @@ {{else}} {{$inlineDiff := $.section.GetComputedInlineDiffFor $line ctx.Locale}} - + {{if and $line.LeftIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.LeftIdx}}{{end}} @@ -27,7 +27,7 @@ {{- end -}} - + {{if and $line.RightIdx $inlineDiff.EscapeStatus.Escaped}}{{end}} {{if $line.RightIdx}}{{end}} @@ -65,8 +65,8 @@ {{if eq .GetType 4}} {{$line.RenderBlobExcerptButtons $.FileNameHash $diffBlobExcerptData}} {{else}} - - + + {{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..da5390978aa26 100644 --- a/templates/repo/diff/section_split.tmpl +++ b/templates/repo/diff/section_split.tmpl @@ -24,7 +24,7 @@ {{$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}} - + {{if $line.LeftIdx}}{{if $leftDiff.EscapeStatus.Escaped}}{{end}}{{end}} @@ -39,7 +39,7 @@ {{- end -}} - + {{if $match.RightIdx}}{{if $rightDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $match.RightIdx}}{{end}} @@ -56,7 +56,7 @@ {{else}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale}} - + {{if $line.LeftIdx}}{{if $inlineDiff.EscapeStatus.Escaped}}{{end}}{{end}} {{if $line.LeftIdx}}{{end}} @@ -71,7 +71,7 @@ {{- end -}} - + {{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 908b14656e36e..ddd48b1d84e65 100644 --- a/templates/repo/diff/section_unified.tmpl +++ b/templates/repo/diff/section_unified.tmpl @@ -19,8 +19,8 @@ {{end}} {{else}} - - + + {{end}} {{$inlineDiff := $section.GetComputedInlineDiffFor $line ctx.Locale -}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 9b70e0e6dbaa1..2b2dd8460d08e 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -985,6 +985,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; @@ -996,6 +1004,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/repo-diff-selection.ts b/web_src/js/features/repo-diff-selection.ts new file mode 100644 index 0000000000000..906bff3a02a9d --- /dev/null +++ b/web_src/js/features/repo-diff-selection.ts @@ -0,0 +1,241 @@ +import {addDelegatedEventListener} from '../utils/dom.ts'; +import {sleep} from '../utils.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; + +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 changeHash(hash: string) { + if (window.history.pushState) { + window.history.pushState(null, null, hash); + } else { + window.location.hash = hash; + } +} + +function parseDiffAnchor(anchor: string | null): DiffAnchorInfo | null { + if (!anchor || !anchor.startsWith('diff-')) 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}; +} + +function applyDiffLineSelection(container: HTMLElement, range: DiffSelectionRange, options?: {updateHash?: boolean}): 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'); + } + + // Get all rows in the diff section + const allRows = Array.from(container.querySelectorAll('.code-diff 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'); + } + } + + if (options?.updateHash !== false) { + const startAnchor = `${range.fragment}${range.startSide}${range.startLine}`; + const hashValue = (range.startSide === range.endSide && range.startLine === range.endLine) ? + startAnchor : + `${startAnchor}-${range.endSide}${range.endLine}`; + changeHash(`#${hashValue}`); + } + return true; +} + +export function parseDiffHashRange(hashValue: string): DiffSelectionRange | null { + if (!hashValue.startsWith('diff-')) 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, + }; +} + +export async function highlightDiffSelectionFromHash(): Promise { + const {hash} = window.location; + if (!hash || !hash.startsWith('#diff-')) return false; + const range = parseDiffHashRange(hash.substring(1)); + if (!range) return false; + const targetId = `${range.fragment}${range.startSide}${range.startLine}`; + + // Wait for the target element to be available (in case it needs to be loaded) + const 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 using the setFileFolding utility + setFileFolding(container, foldBtn, false); + // Wait a bit for the expansion animation + await sleep(100); + } + } + + if (!applyDiffLineSelection(container, range, {updateHash: false})) return false; + 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 sleep(10); + const targetTr = targetSpan.closest('tr'); + if (targetTr) { + targetTr.scrollIntoView({behavior: 'smooth', block: 'center'}); + } + return true; +} + +function handleDiffLineNumberClick(cell: HTMLElement, e: MouseEvent) { + let span = cell.querySelector('span[id^="diff-"]'); + let info = parseDiffAnchor(span?.id ?? null); + + // 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 ?? null); + } + 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 + if (window.history.pushState) { + window.history.pushState(null, null, window.location.pathname + window.location.search); + } else { + window.location.hash = ''; + } + 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)) { + 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; + // Ignore clicks on or inside code-expander-buttons + const target = e.target as HTMLElement; + if (target.closest('.code-expander-button') || target.closest('.code-expander-buttons') || + target.closest('button, a, input, select, textarea, summary, [role="button"]')) { + 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 6f5cb2f63ba1a..73c77dc795432 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} from './repo-diff-selection.ts'; function initRepoDiffFileBox(el: HTMLElement) { // switch between "rendered" and "source", for image and CSV files @@ -148,12 +149,14 @@ 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(); initImageDiff(); initDiffHeaderPopup(); + // Re-apply hash selection in case the target was just loaded + await highlightDiffSelectionFromHash(); } async function loadMoreFiles(btn: Element): Promise { @@ -224,20 +227,46 @@ async function onLocationHashChange() { const attrAutoScrollRunning = 'data-auto-scroll-running'; if (document.body.hasAttribute(attrAutoScrollRunning)) 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); + // Check if this is a diff line selection hash (e.g., #diff-xxxL10 or #diff-xxxL10-R20) + const hashValue = currentHash.substring(1); + const range = parseDiffHashRange(hashValue); + if (range) { + // This is a line selection hash, try to highlight it first + const success = await highlightDiffSelectionFromHash(); + if (success) { + // Successfully highlighted and scrolled, we're done return; } + // If not successful, fall through to load more files + } + + const targetElementId = hashValue; + while (currentHash === window.location.hash) { + // For line selections, check the range-based target + let targetElement; + if (range) { + const targetId = `${range.fragment}${range.startSide}${range.startLine}`; + // eslint-disable-next-line unicorn/prefer-query-selector + targetElement = document.getElementById(targetId); + if (targetElement) { + // Try again to highlight and scroll now that the element is loaded + await highlightDiffSelectionFromHash(); + return; + } + } else { + // use getElementById to avoid querySelector throws an error when the hash is invalid + // eslint-disable-next-line unicorn/prefer-query-selector + 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); + return; + } + } // If looking for a hidden comment, try to expand the section that contains it const issueCommentPrefix = '#issuecomment-'; @@ -283,6 +312,7 @@ export function initRepoDiffView() { initDiffHeaderPopup(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); + initDiffLineSelection(); initRepoDiffHashChangeListener(); registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);