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);
|