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,