tarekziade's picture
tarekziade HF Staff
Show independent loading progress for diff and comments
37bc184 verified
Raw History Blame Contribute Delete
25.6 kB
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PR diff</title>
<style>
:root{color-scheme:dark;--bg:#111217;--card:#181b1f;--fg:#d8d9da;--muted:#9fa3a9;--border:#34363c;--add:#173326;--del:#3d2227;--hunk:#192c42;--link:#8ab8ff}
:root.light{color-scheme:light;--bg:#fff;--card:#f6f8fa;--fg:#24292f;--muted:#6e7781;--border:#d0d7de;--add:#e6ffec;--del:#ffebe9;--hunk:#ddf4ff;--link:#0969da}
*{box-sizing:border-box}body{margin:0;padding:12px;background:var(--bg);color:var(--fg);font:13px/1.5 Inter,system-ui,sans-serif}
.back-to-top{position:fixed;bottom:16px;left:16px;z-index:10;box-shadow:0 2px 8px #0004}body{padding-bottom:64px}
.line-comment-button{border:0;background:transparent;padding:0;color:var(--muted);width:100%;border-radius:0;font:inherit;text-align:right}.line-comment-button:hover,.line-comment-button:focus-visible{color:var(--link);background:var(--hunk)}
.comment-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}#add-comment{margin-top:10px}.comment-dialog{width:min(520px,calc(100vw - 32px));padding:20px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--fg);box-shadow:0 12px 48px #0008}.comment-dialog::backdrop{background:#0008}.comment-dialog h2{font-size:16px;margin:0 0 12px}.comment-dialog label{display:block;margin:12px 0 6px}.comment-dialog input,.comment-dialog textarea{box-sizing:border-box;width:100%;font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:8px}.comment-dialog textarea{min-height:130px;resize:vertical}.comment-dialog-context{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.comment-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.comment-dialog-note{font-size:11px;color:var(--muted)}#comment-submit{background:var(--hunk);border-color:var(--link)}#comment-save-error{color:#f2495c;font-size:12px}
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
header{display:flex;gap:12px;align-items:center;flex-wrap:wrap}#source{font-weight:600}button{font:inherit;background:var(--card);color:var(--fg);border:1px solid var(--border);border-radius:4px;padding:4px 10px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}#message{color:var(--muted);padding:14px 0}#message.error{color:#f2495c}
.patch-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:12px 0;flex-wrap:wrap;color:var(--muted)}
.patch-file{border:1px solid var(--border);border-radius:4px;margin-bottom:12px;overflow:hidden}
.patch-file-header{padding:8px 12px;background:var(--card);cursor:pointer;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere}
.patch-counts{float:right;margin-left:16px;color:var(--muted)}.patch-scroll{overflow-x:auto}
.patch-table{border-collapse:collapse;width:100%;font:12px/21px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.patch-number{min-width:48px;width:48px;text-align:right;padding:0 10px;color:var(--muted);border-right:1px solid var(--border);user-select:none}
.patch-code{padding:0 12px;white-space:pre;tab-size:4}.patch-line.add{background:var(--add)}.patch-line.del{background:var(--del)}.patch-line.hunk{background:var(--hunk);color:var(--link)}.patch-line.meta{color:var(--muted);background:var(--card)}
.patch-raw summary{cursor:pointer;color:var(--muted)}.patch-raw pre{overflow:auto;font-size:12px}
.file-filters{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px;color:var(--muted);font-size:12px}.filter-check{display:inline-flex;align-items:center;gap:6px;cursor:pointer}.filter-check input{accent-color:var(--link)}.bot-filter{font-size:11px;color:var(--muted);margin-top:10px}
.patch-line.line-highlight{outline:1px solid #ffb357;outline-offset:-1px;background:#6b4b2040}.comment-context{margin:12px;border:1px solid var(--link);padding:10px;border-radius:4px}.comment-context p{margin-top:0;color:var(--link);overflow-wrap:anywhere}.comment-navigation-status{font-size:11px;color:var(--muted);margin-top:6px}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start;margin-top:12px}.diff-column{min-width:0}
.comments-sidebar{position:sticky;top:12px;border:1px solid var(--border);border-radius:5px;background:var(--card);max-height:calc(100vh - 24px);display:flex;flex-direction:column;min-height:180px}
.comments-heading{padding:12px;border-bottom:1px solid var(--border)}.comments-heading h2{font-size:14px;margin:0 0 10px}.comment-filters{display:flex;gap:6px;flex-wrap:wrap}.comment-filters button{font-size:11px;padding:3px 8px}.comment-filters button[aria-pressed=true]{border-color:var(--link);color:var(--link);background:var(--hunk)}
#comments-list{overflow-y:auto;padding:12px;min-height:0}.comments-status{color:var(--muted);padding:12px}.comments-status.error{color:#f2495c}
.comment-card{border:1px solid var(--border);border-radius:4px;padding:10px;margin-bottom:10px;background:var(--bg)}.comment-card:last-child{margin-bottom:0}
.comment-card.review-approved{background:var(--add);border-color:#3d8656}.comment-card.review-changes-requested{background:var(--del);border-color:#b7656e}
.comment-thread{border:1px solid var(--border);border-radius:4px;margin-bottom:12px;background:var(--bg)}.comment-thread>summary{padding:9px 10px;font-weight:600;font-size:12px;cursor:pointer;background:var(--card)}.comment-thread .comment-card{border:0;border-top:1px solid var(--border);border-radius:0;margin:0}.comment-thread .comment-reply{border-left:2px solid var(--border);margin-left:10px}.thread-filter-note{font-size:10px;color:var(--muted);margin:8px 0 0}.comment-context-label{font-size:10px;color:var(--muted);margin-top:4px}
.comment-meta{display:flex;align-items:baseline;gap:8px;justify-content:space-between;flex-wrap:wrap}.comment-author{font-weight:600}.comment-time{font-size:10px;color:var(--muted)}.comment-kind{font-size:10px;color:var(--muted);margin-top:4px}.comment-location{font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere;margin:8px 0;color:var(--link)}.comment-body{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:8px 0;max-height:300px;overflow:auto}.comment-link{font-size:11px}
@media(max-width:850px){.layout{grid-template-columns:minmax(0,1fr)}.comments-sidebar{position:static;max-height:600px}}
.loading-progress{display:block;width:calc(100% - 24px);height:8px;margin:0 12px 12px;accent-color:var(--link)}.loading-progress[hidden]{display:none}
</style></head><body>
<header><a id="source" target="_blank" rel="noopener"></a><button id="reload">Refresh</button><button id="collapse">Collapse all</button></header>
<div class="file-filters" role="group" aria-label="Filter changed files">
<label class="filter-check"><input type="checkbox" id="hide-tests" checked>Hide test changes</label>
<label class="filter-check"><input type="checkbox" id="only-python" checked>Only .py changes</label>
<label class="filter-check"><input type="checkbox" id="only-models" checked>Only model changes</label>
<label class="filter-check"><input type="checkbox" id="hide-generated" checked>Hide generated files</label>
<label class="filter-check"><input type="checkbox" id="hide-comment-lines" checked>Hide comment lines</label>
<label class="filter-check"><input type="checkbox" id="hide-module-headers" checked>Hide module headers</label>
</div>
<main class="layout">
<section class="diff-column" aria-label="Unified diff"><div id="message" role="status">Loading diff…</div><progress id="diff-progress" class="loading-progress" aria-label="Loading diff"></progress><div id="patch-view"></div></section>
<aside class="comments-sidebar" aria-label="PR comments">
<div class="comments-heading"><h2>Comments <span id="comment-count"></span></h2>
<div class="comment-filters" role="group" aria-label="Filter comments">
<button data-filter="all" aria-pressed="false">All</button><button data-filter="discussion" aria-pressed="false">Discussion</button><button data-filter="review" aria-pressed="true">Reviews</button><button data-filter="inline" aria-pressed="false">Inline</button>
</div>
<label class="filter-check bot-filter"><input type="checkbox" id="hide-bots" checked>Hide bot comments</label>
<label class="filter-check bot-filter"><input type="checkbox" id="hide-resolved" checked disabled>Hide resolved</label>
<label class="filter-check bot-filter"><input type="checkbox" id="hide-commands" checked>Hide command comments</label>
<label class="filter-check bot-filter"><input type="checkbox" id="maintainers-only">Maintainers only</label>
<p class="thread-filter-note">Filters select threads. Replies stay visible for context.</p>
<button id="add-comment" type="button">Add comment</button>
</div>
<div id="comments-status" class="comments-status" role="status">Loading comments…</div><progress id="comments-progress" class="loading-progress" aria-label="Loading comments"></progress><div id="comments-list"></div>
</aside>
</main>
<button id="back-to-top" class="back-to-top" aria-label="Back to top">↑ Back to top</button>
<dialog id="comment-dialog" class="comment-dialog" aria-labelledby="comment-dialog-title">
<form id="comment-form">
<h2 id="comment-dialog-title">Add comment</h2>
<div id="comment-dialog-context" class="comment-dialog-context"></div>
<label for="comment-author-input">Display name</label><input id="comment-author-input" maxlength="80" autocomplete="nickname" value="Visitor" required>
<label for="comment-body-input">Comment</label><textarea id="comment-body-input" maxlength="10000" required placeholder="Write your comment…"></textarea>
<p id="comment-save-note" class="comment-dialog-note">Demo only. Comments disappear on refresh and are not posted to GitHub.</p>
<div id="comment-save-error" role="alert"></div>
<div class="comment-dialog-actions"><button id="comment-cancel" type="button">Cancel</button><button id="comment-submit" type="submit">Add comment</button></div>
</form>
</dialog>
<script src="/patch-view/script.js"></script>
<script>
(() => {
document.getElementById('back-to-top').onclick = () => {
window.scrollTo({top:0, behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'});
};
const params = new URLSearchParams(location.search);
const pr = params.get('pr') || '43451', repository = params.get('repository') || 'huggingface/transformers';
try {
if (params.get('theme') === 'light' || (parent.grafanaBootData && parent.grafanaBootData.user.lightTheme)) document.documentElement.classList.add('light');
} catch (_) {}
const source = document.getElementById('source'), message = document.getElementById('message'), reload = document.getElementById('reload');
const diffProgress = document.getElementById('diff-progress'), commentsProgress = document.getElementById('comments-progress');
const paintLoading = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
let currentPatch = '';
const hideTests = document.getElementById('hide-tests'), onlyPython = document.getElementById('only-python'), hideBots = document.getElementById('hide-bots'), onlyModels = document.getElementById('only-models'), hideResolved = document.getElementById('hide-resolved'), hideGenerated = document.getElementById('hide-generated'), hideCommands = document.getElementById('hide-commands'), maintainersOnly = document.getElementById('maintainers-only'), hideCommentLines = document.getElementById('hide-comment-lines'), hideModuleHeaders = document.getElementById('hide-module-headers');
function renderDiff() {
if (!currentPatch) return;
TCIPatch.render(document.getElementById('patch-view'), currentPatch, {hideTests:hideTests.checked, onlyPython:onlyPython.checked, onlyModels:onlyModels.checked, hideGenerated:hideGenerated.checked, hideComments:hideCommentLines.checked, hideModuleHeaders:hideModuleHeaders.checked});
document.getElementById('collapse').textContent = 'Collapse all';
}
onlyPython.onchange = () => {
if (!onlyPython.checked) onlyModels.checked = false;
renderDiff();
};
hideTests.onchange = renderDiff; onlyModels.onchange = renderDiff; hideGenerated.onchange = renderDiff; hideCommentLines.onchange = renderDiff; hideModuleHeaders.onchange = renderDiff;
source.textContent = repository + ' · PR #' + pr;
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) && /^[1-9][0-9]*$/.test(pr)) source.href = 'https://github.com/' + repository + '/pull/' + pr + '/files';
async function loadDiff() {
currentPatch = '';
diffProgress.hidden = false; diffProgress.removeAttribute('value');
message.hidden = false; message.className = ''; message.textContent = 'Loading diff…';
document.getElementById('patch-view').replaceChildren();
try {
const text = await TCIPatch.fetchText('/patch-view/data?' + new URLSearchParams({pr, repository}), diffProgress, message, 'diff');
if (!text.trim()) { message.textContent = 'This PR has no changes.'; return; }
message.textContent = 'Rendering diff…'; diffProgress.removeAttribute('value');
await paintLoading();
currentPatch = text; renderDiff();
message.hidden = true;
document.getElementById('collapse').textContent = 'Collapse all';
} catch (e) { message.className = 'error'; message.textContent = e.message; }
finally { diffProgress.hidden = true; }
}
let comments = [], demoComments = [], filter = 'review';
let nextDemoId = -1, composerTarget = null;
const dialog = document.getElementById('comment-dialog');
const authorInput = document.getElementById('comment-author-input');
const bodyInput = document.getElementById('comment-body-input');
function openComposer(target = {}) {
composerTarget = target;
const reply = target.reply;
document.getElementById('comment-dialog-title').textContent = reply ? 'Reply to ' + reply.author : target.path ? 'Add inline comment' : 'Add comment';
document.getElementById('comment-dialog-context').textContent = reply ? (reply.path ? reply.path + ':' + (reply.line || reply.original_line || '') + ' · ' : '') + reply.body.slice(0, 220) : target.path ? target.path + ':' + target.line + (target.side === 'LEFT' ? ' · Old version' : ' · New version') : 'Discussion on PR #' + pr;
document.getElementById('comment-submit').textContent = reply ? 'Add reply' : 'Add comment';
bodyInput.value = '';
document.getElementById('comment-save-error').textContent = '';
dialog.showModal(); bodyInput.focus();
}
document.getElementById('add-comment').onclick = () => openComposer();
document.getElementById('patch-view').addEventListener('patch-comment', e => openComposer(e.detail));
document.getElementById('comment-cancel').onclick = () => dialog.close();
document.getElementById('comment-form').onsubmit = e => {
e.preventDefault();
const author = authorInput.value.trim(), body = bodyInput.value.trim();
if (!author || !body) { document.getElementById('comment-save-error').textContent = 'Enter a name and comment.'; return; }
const target = composerTarget || {}, parent = target.reply;
const comment = {id:nextDemoId--, author, body, created_at:new Date().toISOString(), kind:parent ? parent.kind : target.path ? 'inline' : 'discussion', prototype:true, is_maintainer:false, is_bot:false};
if (parent) {
comment.reply_to = parent.id;
comment.thread_id = parent.thread_id || (parent.kind === 'inline' ? String(parent.id) : parent.kind + ':' + parent.id);
for (const key of ['path','line','start_line','original_line','original_start_line','side','diff_hunk','outdated','resolved']) if (key in parent) comment[key] = parent[key];
} else if (target.path) {
Object.assign(comment, target, {thread_id:'demo:' + comment.id});
}
demoComments.push(comment); comments.push(comment);
filter = comment.kind;
document.querySelectorAll('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
maintainersOnly.checked = false;
if (comment.resolved) hideResolved.checked = false;
if (body.toLowerCase().startsWith('run-slow:') || body.toLowerCase().startsWith('@askserge')) hideCommands.checked = false;
if (author.toLowerCase().endsWith('[bot]') || author.toLowerCase() === 'huggingfacedocbuilderdev') hideBots.checked = false;
dialog.close(); renderComments();
const card = commentList.querySelector('[data-comment-id="' + comment.id + '"]');
if (card) card.scrollIntoView({block:'nearest'});
};
const commentStatus = document.getElementById('comments-status'), commentList = document.getElementById('comments-list');
function node(tag, cls, text) {
const el = document.createElement(tag); el.className = cls; el.textContent = text; return el;
}
function isMaintainer(comment) {
return comment.is_maintainer === true || ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(comment.author_association);
}
function matchesComment(c) {
const body = (c.body || '').trimStart().toLowerCase();
const author = (c.author || '').toLowerCase();
const bot = c.is_bot || author.endsWith('[bot]') || author === 'huggingfacedocbuilderdev';
return (filter === 'all' || c.kind === filter) && (!hideBots.checked || !bot) && (!hideResolved.checked || c.resolved !== true) && (!hideCommands.checked || !(body.startsWith('run-slow:') || body.startsWith('@askserge'))) && (!maintainersOnly.checked || isMaintainer(c));
}
function groupComments() {
const byId = new Map(comments.filter(c => c.kind === 'inline').map(c => [c.id, c]));
const groups = new Map();
for (const c of comments) {
let root = c;
const seen = new Set();
while (root.kind === 'inline' && byId.has(root.reply_to) && !seen.has(root.id)) {
seen.add(root.id); root = byId.get(root.reply_to);
}
const key = c.kind === 'inline' ? 'inline:' + (c.thread_id || root.thread_id || root.reply_to || root.id) : c.thread_id || c.kind + ':' + c.id;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(c);
}
const ascending = (a, b) => (a.created_at || '').localeCompare(b.created_at || '') || a.id - b.id;
return [...groups.values()].map(group => group.sort(ascending)).sort((a, b) => ascending(b[b.length-1], a[a.length-1]));
}
function renderComments() {
commentList.replaceChildren();
const visible = groupComments().filter(group => group.some(matchesComment));
const count = visible.reduce((n, group) => n + group.length, 0);
document.getElementById('comment-count').textContent = '(' + count + (count !== comments.length ? '/' + comments.length : '') + ')';
if (!visible.length) commentList.append(node('p', 'comments-status', comments.length ? 'No comments match these filters.' : 'No comments yet.'));
for (const group of visible) {
if (group.length === 1) { commentList.append(buildCommentCard(group[0])); continue; }
const thread = node('details', 'comment-thread', ''); thread.open = true;
thread.dataset.threadId = group[0].thread_id || group[0].id;
thread.append(node('summary', '', 'Thread · ' + group.length + ' comments'));
group.forEach((c, i) => thread.append(buildCommentCard(c, {reply:i > 0, context:!matchesComment(c)})));
commentList.append(thread);
}
}
function buildCommentCard(c, options = {}) {
const card = node('article', 'comment-card', '');
if (c.kind === 'review' && c.state === 'APPROVED') card.classList.add('review-approved');
if (c.kind === 'review' && c.state === 'CHANGES_REQUESTED') card.classList.add('review-changes-requested');
card.dataset.commentId = c.id;
if (options.reply) card.classList.add('comment-reply');
const meta = node('div', 'comment-meta', '');
const date = new Date(c.created_at);
const time = node('time', 'comment-time', isNaN(date) ? '' : date.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}));
time.dateTime = c.created_at; time.title = c.created_at;
const author = node('span', 'comment-author', c.author);
if (isMaintainer(c)) {
const crown = node('span', 'maintainer-crown', ' 👑'); crown.title = 'Maintainer · ' + (c.author_association || 'repository access').toLowerCase(); crown.setAttribute('aria-label', 'Maintainer'); author.append(crown);
}
meta.append(author, time);
card.append(meta, node('div', 'comment-kind', ({discussion:'Discussion', review:'Review', inline:'Inline comment'})[c.kind] + (c.state ? ' · ' + c.state.toLowerCase().replaceAll('_', ' ') : '') + (c.reply_to ? ' · Reply' : '')));
if (options.context) card.append(node('div', 'comment-context-label', 'Included for thread context'));
if (c.path) card.append(node('div', 'comment-location', c.path + (c.line ? ':' + (c.start_line && c.start_line !== c.line ? c.start_line + '–' : '') + c.line : '') + (c.outdated ? ' · Outdated' : '') + (c.resolved === true ? ' · Resolved' : '')));
card.append(node('div', 'comment-body', c.body));
if (c.prototype) card.append(node('div', 'comment-context-label', 'Demo comment · disappears on refresh'));
const actions = node('div', 'comment-actions', '');
const reply = node('button', 'comment-link', 'Reply');
reply.type = 'button'; reply.onclick = () => openComposer({reply:c});
actions.append(reply); card.append(actions);
if (c.path && c.line) {
const show = node('button', 'comment-link', 'Show the line(s)');
const status = node('div', 'comment-navigation-status', ''); status.setAttribute('role','status');
show.onclick = () => {
const container = document.getElementById('patch-view');
const exists = [...container.querySelectorAll('.patch-file')].some(file => file.dataset.path === c.path);
let adjusted = false;
if (!exists && currentPatch) {
// Reveal a comment's file when a file filter currently excludes it.
const files = TCIPatch.parse(currentPatch), file = files.find(f => f.name === c.path);
if (file) {
if (hideTests.checked && TCIPatch.isTestFile(c.path)) { hideTests.checked = false; adjusted = true; }
if (onlyPython.checked && !c.path.endsWith('.py')) { onlyPython.checked = false; adjusted = true; }
if (onlyModels.checked && (!c.path.startsWith('src/transformers/models/') || TCIPatch.isGeneratedFile(file, files))) { onlyModels.checked = false; adjusted = true; }
if (hideGenerated.checked && TCIPatch.isGeneratedFile(file, files)) { hideGenerated.checked = false; adjusted = true; }
if (adjusted) renderDiff();
}
}
status.textContent = TCIPatch.showLines(container, c) ? (adjusted ? 'File filters adjusted to show this comment.' : '') : 'These lines are not available in the saved diff context.';
};
actions.append(show); card.append(status);
}
return card;
}
hideBots.onchange = renderComments;
hideResolved.onchange = renderComments;
hideCommands.onchange = renderComments;
maintainersOnly.onchange = renderComments;
async function loadComments() {
commentsProgress.hidden = false; commentsProgress.removeAttribute('value');
commentStatus.hidden = false; commentStatus.className = 'comments-status'; commentStatus.textContent = 'Loading comments…'; commentList.replaceChildren();
try {
const text = await TCIPatch.fetchText('/patch-view/comments?' + new URLSearchParams({pr, repository}), commentsProgress, commentStatus, 'comments');
commentStatus.textContent = 'Preparing comments…'; commentsProgress.removeAttribute('value');
await paintLoading();
const data = JSON.parse(text); comments = [...data.comments, ...demoComments].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id);
hideResolved.disabled = !data.resolution_available;
hideResolved.title = data.resolution_available ? '' : 'GitHub review-thread status is unavailable.';
if (hideResolved.disabled) hideResolved.checked = false;
commentStatus.hidden = !data.truncated;
commentStatus.textContent = 'Showing the first 500 entries in each category. View the full conversation on GitHub.';
renderComments();
} catch(e) { comments = []; document.getElementById('comment-count').textContent = ''; commentStatus.hidden = false; commentStatus.className = 'comments-status error'; commentStatus.textContent = 'Could not load comments: ' + e.message; }
finally { commentsProgress.hidden = true; }
}
document.querySelectorAll('[data-filter]').forEach(button => { button.onclick = () => {
filter = button.dataset.filter;
document.querySelectorAll('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b === button)));
renderComments();
}; });
async function load() {
reload.disabled = true;
await Promise.all([loadDiff(), loadComments()]);
reload.disabled = false;
}
reload.onclick = load;
document.getElementById('collapse').onclick = e => {
const open = e.target.textContent === 'Expand all';
document.querySelectorAll('.patch-file').forEach(file => { file.open = open; });
e.target.textContent = open ? 'Collapse all' : 'Expand all';
};
load();
})();
</script></body></html>