Last active
July 27, 2026 18:56
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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