From 1ec71e877cde1a2fdea23d60f2a7ef5a995d9b09 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 20 Nov 2025 00:08:38 -0800 Subject: [PATCH] Add pull request files line selections --- templates/repo/diff/blob_excerpt.tmpl | 8 +- templates/repo/diff/section_split.tmpl | 8 +- templates/repo/diff/section_unified.tmpl | 4 +- web_src/css/repo.css | 28 +++++ web_src/js/features/repo-diff.ts | 125 +++++++++++++++++++++++ 5 files changed, 163 insertions(+), 10 deletions(-) diff --git a/templates/repo/diff/blob_excerpt.tmpl b/templates/repo/diff/blob_excerpt.tmpl index c9aac6d61d..a9323d4778 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 ab23b1b934..da5390978a 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 908b14656e..ddd48b1d84 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 9b70e0e6db..1d7fcdbe13 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,26 @@ 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 { + content: ""; + position: absolute; + left: 0; + width: 2px; + height: 100%; + background: var(--color-highlight-fg); +} + .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.ts b/web_src/js/features/repo-diff.ts index 6f5cb2f63b..c688c1826e 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -12,6 +12,130 @@ import {invertFileFolding} from './file-fold.ts'; import {parseDom, sleep} from '../utils.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.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}; + +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, fragment: string, side: DiffAnchorSide, startLine: number, endLine: number, options?: {updateHash?: boolean}): boolean { + const minLine = Math.min(startLine, endLine); + const maxLine = Math.max(startLine, endLine); + const selector = `.code-diff td.lines-num span[id^="${CSS.escape(fragment)}"]`; + const spans = Array.from(container.querySelectorAll(selector)); + const matches = spans.filter((span) => { + const info = parseDiffAnchor(span.id); + if (!info || info.side !== side) return false; + return info.line >= minLine && info.line <= maxLine; + }); + if (!matches.length) return false; + + for (const tr of document.querySelectorAll('.code-diff tr.active')) { + tr.classList.remove('active'); + } + for (const span of matches) { + span.closest('tr')?.classList.add('active'); + } + + if (options?.updateHash !== false) { + const startAnchor = `${fragment}${side}${minLine}`; + const endAnchor = `${fragment}${side}${maxLine}`; + const hashValue = minLine === maxLine ? startAnchor : `${startAnchor}-${endAnchor}`; + changeHash(`#${hashValue}`); + } + return true; +} + +type DiffHashRange = {fragment: string, side: DiffAnchorSide, startLine: number, endLine: number}; + +function parseDiffHashRange(hashValue: string): DiffHashRange | 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 endLine = startInfo.line; + if (match[3]) { + const endInfo = parseDiffAnchor(`${match[1]}${match[3]}`); + if (!endInfo || endInfo.side !== startInfo.side) { + return {fragment: startInfo.fragment, side: startInfo.side, startLine: startInfo.line, endLine: startInfo.line}; + } + endLine = endInfo.line; + } + return { + fragment: startInfo.fragment, + side: startInfo.side, + startLine: startInfo.line, + endLine, + }; +} + +function highlightDiffSelectionFromHash() { + const {hash} = window.location; + if (!hash || !hash.startsWith('#diff-')) return; + const range = parseDiffHashRange(hash.substring(1)); + if (!range) return; + const targetId = `${range.fragment}${range.side}${range.startLine}`; + const target = document.querySelector(`#${CSS.escape(targetId)}`); + if (!target) return; + const container = target.closest('.diff-file-box'); + if (!container) return; + applyDiffLineSelection(container, range.fragment, range.side, range.startLine, range.endLine, {updateHash: false}); + diffSelectionStart = null; +} + +function handleDiffLineNumberClick(cell: HTMLElement, e: MouseEvent) { + const span = cell.querySelector('span[id^="diff-"]'); + const info = parseDiffAnchor(span?.id ?? null); + if (!info) return; + const container = cell.closest('.diff-file-box'); + if (!container) return; + + let rangeStart: DiffAnchorInfo = info; + if (e.shiftKey && diffSelectionStart && + diffSelectionStart.container === container && + diffSelectionStart.fragment === info.fragment && + diffSelectionStart.side === info.side) { + rangeStart = diffSelectionStart; + } + + if (applyDiffLineSelection(container, rangeStart.fragment, rangeStart.side, rangeStart.line, info.line)) { + diffSelectionStart = {...info, container}; + window.getSelection().removeAllRanges(); + } +} + +function initDiffLineSelection() { + addDelegatedEventListener(document, 'click', diffLineNumberCellSelector, (cell, e) => { + handleDiffLineNumberClick(cell, e); + }); + window.addEventListener('hashchange', highlightDiffSelectionFromHash); + highlightDiffSelectionFromHash(); +} + function initRepoDiffFileBox(el: HTMLElement) { // switch between "rendered" and "source", for image and CSV files queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => { @@ -283,6 +407,7 @@ export function initRepoDiffView() { initDiffHeaderPopup(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); + initDiffLineSelection(); initRepoDiffHashChangeListener(); registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);