0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-22 04:35:44 +01:00

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 <noreply@anthropic.com>
This commit is contained in:
silverwind 2026-02-17 21:51:33 +01:00
parent 318cb85037
commit faa7680555
No known key found for this signature in database
GPG Key ID: 2E62B41C93869443
6 changed files with 124 additions and 7 deletions

View File

@ -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",

View File

@ -228,7 +228,7 @@ func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobE
link += fmt.Sprintf("&pull_issue_index=%d", data.PullIssueIndex)
}
return htmlutil.HTMLFormat(
`<button class="code-expander-button" hx-target="closest tr" hx-get="%s" data-hidden-comment-ids=",%s,">%s</button>`,
`<button class="code-expander-button" data-url="%s" data-hidden-comment-ids=",%s,">%s</button>`,
link, dataHiddenCommentIDs, svg.RenderHTML(svgName),
)
}

View File

@ -98,6 +98,9 @@
</a>
</div>
<button class="btn interact-fg tw-p-2 tw-shrink-0" data-clipboard-text="{{$file.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button>
{{if and $.DiffBlobExcerptData (not $file.IsSubmodule) (not $file.IsBin) (not $file.IsIncomplete)}}
<button class="btn interact-fg tw-p-2 tw-shrink-0 diff-expand-all" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.expand_all"}}" data-expand-tooltip="{{ctx.Locale.Tr "repo.diff.expand_all"}}" data-collapse-tooltip="{{ctx.Locale.Tr "repo.diff.collapse_expanded"}}">{{svg "octicon-unfold" 14}}</button>
{{end}}
{{if $file.IsLFSFile}}
<span class="ui label">LFS</span>
{{end}}

View File

@ -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/"))
}
})

View File

@ -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<HTMLElement>(`.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<HTMLElement, HTMLElement>();
async function fetchBlobExcerpt(tr: Element, url: string): Promise<void> {
const resp = await GET(url);
const text = await resp.text();
// Parse <tr> 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 <tr> 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 <tr> (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<Element>();
const expanders: {tr: Element, url: string}[] = [];
for (const btn of container.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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);

View File

@ -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,