diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index 93ac046612..5b36c92a53 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -2562,6 +2562,8 @@
"repo.diff.load": "Load Diff",
"repo.diff.generated": "Generated",
"repo.diff.vendored": "Vendored",
+ "repo.diff.expand_all": "Expand all",
+ "repo.diff.collapse_expanded": "Collapse expanded",
"repo.diff.comment.add_line_comment": "Add line comment",
"repo.diff.comment.placeholder": "Leave a comment",
"repo.diff.comment.add_single_comment": "Add single comment",
diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go
index 7777cf4a1c..aacddf3856 100644
--- a/services/gitdiff/gitdiff.go
+++ b/services/gitdiff/gitdiff.go
@@ -228,7 +228,7 @@ func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobE
link += fmt.Sprintf("&pull_issue_index=%d", data.PullIssueIndex)
}
return htmlutil.HTMLFormat(
- ``,
+ ``,
link, dataHiddenCommentIDs, svg.RenderHTML(svgName),
)
}
diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index 41a8268cb3..2a69fcbc99 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -98,6 +98,9 @@
+ {{if and $.DiffBlobExcerptData (not $file.IsSubmodule) (not $file.IsBin) (not $file.IsIncomplete)}}
+
+ {{end}}
{{if $file.IsLFSFile}}
LFS
{{end}}
diff --git a/tests/integration/compare_test.go b/tests/integration/compare_test.go
index a3cb538d5b..f70ab4473b 100644
--- a/tests/integration/compare_test.go
+++ b/tests/integration/compare_test.go
@@ -147,12 +147,12 @@ func TestCompareCodeExpand(t *testing.T) {
req := NewRequest(t, "GET", "/user1/test_blob_excerpt/compare/main...user2/test_blob_excerpt-fork:forked-branch")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
- els := htmlDoc.Find(`button.code-expander-button[hx-get]`)
+ els := htmlDoc.Find(`button.code-expander-button[data-url]`)
// all the links in the comparison should be to the forked repo&branch
assert.NotZero(t, els.Length())
for i := 0; i < els.Length(); i++ {
- link := els.Eq(i).AttrOr("hx-get", "")
+ link := els.Eq(i).AttrOr("data-url", "")
assert.True(t, strings.HasPrefix(link, "/user2/test_blob_excerpt-fork/blob_excerpt/"))
}
})
diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts
index 0c8b1357b0..fe1325c6ac 100644
--- a/web_src/js/features/repo-diff.ts
+++ b/web_src/js/features/repo-diff.ts
@@ -9,8 +9,9 @@ import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
-import {parseDom, sleep} from '../utils.ts';
+import {parseDom} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
+import {svg} from '../svg.ts';
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
@@ -24,6 +25,12 @@ function initRepoDiffFileBox(el: HTMLElement) {
hideElem(queryElemSiblings(target));
showElem(target);
}));
+
+ // Hide "expand all" button when there are no expandable sections
+ const expandAllBtn = el.querySelector('.diff-expand-all');
+ if (expandAllBtn && !el.querySelector('.code-expander-button')) {
+ hideElem(expandAllBtn);
+ }
}
function initRepoDiffConversationForm() {
@@ -247,12 +254,13 @@ async function onLocationHashChange() {
const commentId = currentHash.substring(issueCommentPrefix.length);
const expandButton = document.querySelector(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
if (expandButton) {
- // avoid infinite loop, do not re-click the button if already clicked
+ // avoid infinite loop, do not re-expand the same button
const attrAutoLoadClicked = 'data-auto-load-clicked';
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
expandButton.setAttribute(attrAutoLoadClicked, 'true');
- expandButton.click();
- await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
+ const tr = expandButton.closest('tr');
+ const url = expandButton.getAttribute('data-url');
+ if (tr && url) await fetchBlobExcerpt(tr, url);
continue; // Try again to find the element
}
}
@@ -274,6 +282,105 @@ function initRepoDiffHashChangeListener() {
onLocationHashChange();
}
+const expandAllSavedState = new WeakMap();
+
+async function fetchBlobExcerpt(tr: Element, url: string): Promise {
+ const resp = await GET(url);
+ const text = await resp.text();
+ // Parse elements in proper table context
+ const tempTbody = document.createElement('tbody');
+ tempTbody.innerHTML = text;
+ const nodes = Array.from(tempTbody.children);
+ tr.replaceWith(...nodes);
+}
+
+async function expandAllLines(btn: HTMLElement, fileBox: HTMLElement) {
+ const fileBody = fileBox.querySelector('.diff-file-body');
+ if (!fileBody) return;
+
+ // Save original state for later collapse
+ const tbody = fileBody.querySelector('table.chroma tbody');
+ if (!tbody) return;
+ expandAllSavedState.set(fileBox, tbody.cloneNode(true) as HTMLElement);
+
+ btn.classList.add('disabled');
+ try {
+ // Loop: expand all collapsed sections until none remain.
+ // Each round fetches all current expander URLs in parallel, replaces their
+ // target
rows with the response rows, then rescans for new expanders
+ // that may have appeared in the inserted content.
+ while (true) {
+ const expanders = collectExpanderButtons(fileBody, true);
+ if (expanders.length === 0) break;
+ await Promise.all(expanders.map(({tr, url}) => fetchBlobExcerpt(tr, url)));
+ }
+ } finally {
+ btn.classList.remove('disabled');
+ }
+
+ // Update button to "collapse" state
+ btn.innerHTML = svg('octicon-fold', 14);
+ btn.setAttribute('data-tooltip-content', btn.getAttribute('data-collapse-tooltip') ?? '');
+}
+
+function collapseExpandedLines(btn: HTMLElement, fileBox: HTMLElement) {
+ const savedTbody = expandAllSavedState.get(fileBox);
+ if (!savedTbody) return;
+
+ const tbody = fileBox.querySelector('.diff-file-body table.chroma tbody');
+ if (tbody) {
+ tbody.replaceWith(savedTbody.cloneNode(true));
+ }
+
+ expandAllSavedState.delete(fileBox);
+
+ // Update button to "expand" state
+ btn.innerHTML = svg('octicon-unfold', 14);
+ btn.setAttribute('data-tooltip-content', btn.getAttribute('data-expand-tooltip') ?? '');
+}
+
+// Collect one expander button per
(skip duplicates from "updown" rows
+// that have both up/down buttons targeting the same row).
+// When fullExpand is true, the direction parameter is rewritten so the backend
+// expands the entire gap in a single response instead of chunk-by-chunk.
+function collectExpanderButtons(container: Element, fullExpand?: boolean): {tr: Element, url: string}[] {
+ const seen = new Set();
+ const expanders: {tr: Element, url: string}[] = [];
+ for (const btn of container.querySelectorAll('.code-expander-button')) {
+ const tr = btn.closest('tr');
+ let url = btn.getAttribute('data-url');
+ if (tr && url && !seen.has(tr)) {
+ seen.add(tr);
+ if (fullExpand) url = url.replace(/direction=[^&]*/, 'direction=full');
+ expanders.push({tr, url});
+ }
+ }
+ return expanders;
+}
+
+function initDiffExpandAllLines() {
+ addDelegatedEventListener(document, 'click', '.diff-expand-all', (btn, e) => {
+ e.preventDefault();
+ if (btn.classList.contains('disabled')) return;
+ const fileBox = btn.closest('.diff-file-box');
+ if (!fileBox) return;
+
+ if (expandAllSavedState.has(fileBox)) {
+ collapseExpandedLines(btn, fileBox);
+ } else {
+ expandAllLines(btn, fileBox);
+ }
+ });
+
+ // Handle individual expand button clicks
+ addDelegatedEventListener(document, 'click', '.code-expander-button', (btn, e) => {
+ e.preventDefault();
+ const tr = btn.closest('tr');
+ const url = btn.getAttribute('data-url');
+ if (tr && url) fetchBlobExcerpt(tr, url);
+ });
+}
+
export function initRepoDiffView() {
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
@@ -285,6 +392,7 @@ export function initRepoDiffView() {
initDiffHeaderPopup();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
+ initDiffExpandAllLines();
initRepoDiffHashChangeListener();
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index cd19938c99..ee9b6e6dcd 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -34,6 +34,7 @@ import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-fi
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg';
import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg';
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
+import octiconFold from '../../public/assets/img/svg/octicon-fold.svg';
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg';
@@ -78,6 +79,7 @@ import octiconTable from '../../public/assets/img/svg/octicon-table.svg';
import octiconTag from '../../public/assets/img/svg/octicon-tag.svg';
import octiconTrash from '../../public/assets/img/svg/octicon-trash.svg';
import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg';
+import octiconUnfold from '../../public/assets/img/svg/octicon-unfold.svg';
import octiconX from '../../public/assets/img/svg/octicon-x.svg';
import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg';
import octiconZoomIn from '../../public/assets/img/svg/octicon-zoom-in.svg';
@@ -117,6 +119,7 @@ const svgs = {
'octicon-file-submodule': octiconFileSubmodule,
'octicon-file-symlink-file': octiconFileSymlinkFile,
'octicon-filter': octiconFilter,
+ 'octicon-fold': octiconFold,
'octicon-gear': octiconGear,
'octicon-git-branch': octiconGitBranch,
'octicon-git-commit': octiconGitCommit,
@@ -161,6 +164,7 @@ const svgs = {
'octicon-tag': octiconTag,
'octicon-trash': octiconTrash,
'octicon-triangle-down': octiconTriangleDown,
+ 'octicon-unfold': octiconUnfold,
'octicon-x': octiconX,
'octicon-x-circle-fill': octiconXCircleFill,
'octicon-zoom-in': octiconZoomIn,