From faa76805556852a1bd215a5de00c194106b5620f Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 21:51:33 +0100 Subject: [PATCH] Add "Expand all" / "Collapse expanded" toggle button in diff file boxes Add a per-file toggle button that expands all collapsed sections at once and can revert to the original collapsed state. The expand mechanism uses direct JS fetch instead of htmx, replacing the hx-get/hx-target attributes on code expander buttons with data-url. Individual expand buttons are also handled via JS delegated click handlers. The expand-all uses direction=full to make the backend expand entire gaps in a single response instead of chunk-by-chunk, significantly reducing the number of network round-trips for large files. Co-Authored-By: Claude Opus 4.6 --- options/locale/locale_en-US.json | 2 + services/gitdiff/gitdiff.go | 2 +- templates/repo/diff/box.tmpl | 3 + tests/integration/compare_test.go | 4 +- web_src/js/features/repo-diff.ts | 116 ++++++++++++++++++++++++++++-- web_src/js/svg.ts | 4 ++ 6 files changed, 124 insertions(+), 7 deletions(-) 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,