Skip to content

Instantly share code, notes, and snippets.

@zspotter
Last active July 27, 2026 18:56
Show Gist options
  • Select an option

  • Save zspotter/8eff45303b724d987d074b5d7aca69e1 to your computer and use it in GitHub Desktop.

Select an option

Save zspotter/8eff45303b724d987d074b5d7aca69e1 to your computer and use it in GitHub Desktop.
Tampermonkey: fade + strike files you've marked Viewed in the GitHub PR sidebar, and collapse fully-viewed folders
// ==UserScript==
// @name GitHub PR: viewed files
// @namespace zspotter
// @version 1.2
// @description Fades + strikes through files you've marked "Viewed" in the PR file-tree sidebar.
// @match https://github.com/*/pull/*
// @updateURL https://gist.githubusercontent.com/zspotter/8eff45303b724d987d074b5d7aca69e1/raw/gh-mark-viewed.user.js
// @downloadURL https://gist.githubusercontent.com/zspotter/8eff45303b724d987d074b5d7aca69e1/raw/gh-mark-viewed.user.js
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
// Everything is read out of the DOM and re-read on every tick. GitHub's PR tabs are client-side,
// so this script is injected once and then has to survive every soft navigation on its own —
// anything derived once at startup silently no-ops when you arrive at the diff without a
// document load. Re-reading also means state can never be permanently wrong: a bad transient
// read corrects itself on the next tick.
//
// Known ceiling: a file's viewed state is only knowable once GitHub has mounted its diff header,
// and it mounts those progressively (once mounted they stay). So on a large PR the sidebar
// crosses itself off over a few seconds instead of at once. The page's embedded JSON payload
// would give the whole list up front, but it only exists on a document load — depending on it
// is exactly what made this break on soft navigation.
let state = new Map(); // digest -> viewed. Retains files whose diff isn't currently mounted.
let files = new Map(); // digest -> path. Retains rows unmounted inside a collapsed folder.
let autoCollapsed = new Set(); // folders seen collapsed while fully viewed
let route = null; // pathname the above was built for
const style = document.createElement('style');
// Only fade/strike the row's own label (its first child), never its nested child <ul>,
// so a crossed-off folder doesn't compound opacity or double-strike its (already faded) children.
style.textContent = `
li[role="treeitem"].ghmv-viewed > :first-child {
opacity: 0.45 !important;
}
li[role="treeitem"].ghmv-viewed > :first-child :is(a, span) {
text-decoration: line-through !important;
}
`;
document.head.appendChild(style);
// Digests are hashes of the file path, so `src/index.ts` has the same digest in every PR:
// carrying state across a soft nav would apply the old PR's viewed flags to the new one.
function resetIfNavigated() {
if (location.pathname === route) return;
route = location.pathname;
state = new Map();
files = new Map();
autoCollapsed = new Set();
}
// Only folder rows carry aria-expanded; file rows don't.
const isFolder = (row) => row.hasAttribute('aria-expanded');
const digestOf = (anchor) => anchor.getAttribute('href').slice('#diff-'.length);
// The sidebar tree is the file list: every row carries its full path as `id`, and file rows
// carry this file's #diff-<digest> anchor.
function readTree() {
for (const row of document.querySelectorAll('li[role="treeitem"]')) {
if (isFolder(row)) continue;
const anchor = row.querySelector('a[href^="#diff-"]');
if (anchor && row.id) files.set(digestOf(anchor), row.id);
}
}
// Diff-header toggles are the only source of viewed state. Whatever they currently say is the
// truth; digests with no mounted button keep their last known value. Covers clicks and "v".
function readButtons() {
for (const btn of document.querySelectorAll(
'button[aria-label="Viewed"], button[aria-label="Not Viewed"]'
)) {
const digest = digestForButton(btn);
if (digest) state.set(digest, btn.getAttribute('aria-pressed') === 'true');
}
}
// Walk up from the button to the nearest ancestor holding this file's #diff-<digest> anchor.
function digestForButton(btn) {
let node = btn;
for (let i = 0; i < 12 && node; i++, node = node.parentElement) {
const anchor = node.querySelector('a[href^="#diff-"]');
if (anchor) return digestOf(anchor);
}
return null;
}
// Repaint sidebar rows. A file row keys off its own #diff anchor; a folder row's id is its path,
// so its files are every known path under `id + "/"` — derived from `files` rather than from
// child rows, so it stays right once a collapsed folder has unmounted them. A folder crosses off
// only when every file under it is viewed, and is left alone while any of them is still unknown.
function repaint() {
const known = [...files]; // hoisted: every folder row scans the whole file list
for (const row of document.querySelectorAll('li[role="treeitem"]')) {
const folder = isFolder(row);
let digests;
if (folder) {
digests = known.filter(([, path]) => path.startsWith(row.id + '/')).map(([d]) => d);
} else {
const anchor = row.querySelector('a[href^="#diff-"]');
digests = anchor ? [digestOf(anchor)] : [];
}
if (digests.length === 0) continue; // no files under this row (yet)
if (!digests.every((d) => state.has(d))) continue; // unknown → leave untouched
const allViewed = digests.every((d) => state.get(d));
row.classList.toggle('ghmv-viewed', allViewed);
if (!folder) continue;
const collapsed = row.getAttribute('aria-expanded') === 'false';
if (allViewed) {
// Record what we OBSERVE, not what we intended. A collapse click can be swallowed — the
// row may already be detached (querySelectorAll returns a static list, and collapsing a
// parent unmounts every child row later in it), or React may not have attached the handler
// yet. Marking the folder done on intent means never retrying it; keying off aria-expanded
// means we retry until it takes, then stop — so re-expanding it by hand still sticks.
if (collapsed) autoCollapsed.add(row.id);
else if (!autoCollapsed.has(row.id)) row.firstElementChild?.click();
} else if (autoCollapsed.delete(row.id) && collapsed) {
row.firstElementChild?.click(); // no longer fully viewed — undo our auto-collapse
}
}
}
function tick() {
resetIfNavigated();
readTree();
readButtons();
repaint();
}
tick();
// Catch clicks, the "v" shortcut, soft navigations, and GitHub's own re-renders. Observes
// documentElement rather than body so it survives a wholesale <body> swap.
let pending = false;
const obs = new MutationObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
tick();
});
});
obs.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['aria-pressed', 'aria-label'],
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment