From 7a7b6fb2d055a372468c15c79f9a90855ebb1db4 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:02:50 +0200 Subject: [PATCH 01/30] `dim-viewed-files` - Dim viewed files in the PR file tree Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build/__snapshots__/features-meta.json | 6 ++ build/__snapshots__/imported-features.json | 1 + readme.md | 1 + .../features/batch-mark-files-as-viewed.tsx | 6 +- source/features/dim-viewed-files.css | 3 + source/features/dim-viewed-files.tsx | 96 +++++++++++++++++++ source/features/keyboard-navigation.tsx | 2 +- source/helpers/viewed-file-selectors.ts | 4 + source/refined-github.ts | 1 + test/dim-viewed-files.test.ts | 95 ++++++++++++++++++ 10 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 source/features/dim-viewed-files.css create mode 100644 source/features/dim-viewed-files.tsx create mode 100644 source/helpers/viewed-file-selectors.ts create mode 100644 test/dim-viewed-files.test.ts diff --git a/build/__snapshots__/features-meta.json b/build/__snapshots__/features-meta.json index ca16227345ce..0f6117470eab 100644 --- a/build/__snapshots__/features-meta.json +++ b/build/__snapshots__/features-meta.json @@ -229,6 +229,12 @@ "css": true, "screenshot": "https://user-images.githubusercontent.com/1402241/220607557-f8ea0863-f05b-48c8-a447-1fec42af0afd.gif" }, + { + "id": "dim-viewed-files", + "description": "Dims viewed files in the PR file tree, keeping unreviewed files easy to find.", + "css": true, + "screenshot": null + }, { "id": "download-folder-button", "description": "Adds a button to download entire folders, via https://download-directory.github.io.", diff --git a/build/__snapshots__/imported-features.json b/build/__snapshots__/imported-features.json index 3638f8a8a2df..b1e43fee1313 100644 --- a/build/__snapshots__/imported-features.json +++ b/build/__snapshots__/imported-features.json @@ -38,6 +38,7 @@ "default-branch-button", "delete-branch", "dim-bots", + "dim-viewed-files", "download-folder-button", "easy-toggle-commit-messages", "easy-toggle-files", diff --git a/readme.md b/readme.md index a17d9e2ac81c..317edf9d63d1 100644 --- a/readme.md +++ b/readme.md @@ -235,6 +235,7 @@ https://github.com/refined-github/refined-github/wiki/Contributing#metadata-guid - [](# "batch-mark-files-as-viewed") [Mark/unmark multiple files as “Viewed” in the PR Files tab. Click on the first checkbox you want to mark/unmark and then `shift`-click another one; all the files between the two checkboxes will be marked/unmarked as “Viewed”.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257009611-17249bee-d2e2-42ac-bdf0-ebc90029544e.gif) - [](# "closing-remarks") 🔥 [Shows the first Git tag a merged PR was included in or suggests creating a release if not yet released.](https://user-images.githubusercontent.com/1402241/169497171-85d4a97f-413a-41b4-84ba-885dca2b51cf.png) - [](# "pr-jump-to-first-non-viewed-file") [Jumps to first non-viewed file in a PR when clicking on the progress bar.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257011208-764f509d-fed9-424b-84e9-c01cf2fd428b.gif) +- [](# "dim-viewed-files") Dims viewed files in the PR file tree, keeping unreviewed files easy to find. - [](# "view-last-pr-deployment") [Adds a link to open the latest deployment to the PR header.](https://github.com/user-attachments/assets/90d2498d-ef65-45b9-96eb-07e7988bea61) - [](# "no-unnecessary-split-diff-view") [Always uses unified diffs on files where split diffs aren’t useful.](https://user-images.githubusercontent.com/46634000/121495005-89af8600-c9d9-11eb-822d-77e0b987e3b1.png) - [](# "emphasize-draft-pr-label") [Makes it easier to distinguish draft PR in lists.](https://user-images.githubusercontent.com/1402241/218252438-062a1ab3-4437-436d-9140-87bee23aaefb.png) diff --git a/source/features/batch-mark-files-as-viewed.tsx b/source/features/batch-mark-files-as-viewed.tsx index 9b43ad99c19a..6325d3ad4d82 100644 --- a/source/features/batch-mark-files-as-viewed.tsx +++ b/source/features/batch-mark-files-as-viewed.tsx @@ -8,12 +8,8 @@ import showToast from '../github-helpers/toast.js'; import clickAll from '../helpers/click-all.js'; import {is} from '../helpers/css-selectors.js'; import getItemsBetween from '../helpers/get-items-between.js'; +import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; -export const viewedToggleSelector = [ - 'button[class*="MarkAsViewedButton"]', - // Old view - 'input.js-reviewed-checkbox', -] as const; const fileSelector = [ '[class^="Diff-module__diffTargetable"]', // Old view diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css new file mode 100644 index 000000000000..7cc07609454d --- /dev/null +++ b/source/features/dim-viewed-files.css @@ -0,0 +1,3 @@ +.rgh-dim-viewed-files:not(:hover, :focus-within) { + opacity: 50%; +} diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx new file mode 100644 index 000000000000..f793783636a1 --- /dev/null +++ b/source/features/dim-viewed-files.tsx @@ -0,0 +1,96 @@ +import './dim-viewed-files.css'; + +import {onAbort} from 'abort-utils'; +import delegate from 'delegate-it'; +import * as pageDetect from 'github-url-detection'; +import {$$, $$optional, $optional, elementExists} from 'select-dom'; + +import features from '../feature-manager.js'; +import {frame} from '../helpers/dom-utils.js'; +import observe from '../helpers/selector-observer.js'; +import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; + +const fileSelector = '[class^="Diff-module__diffTargetable"], .js-file'; +const treeLinkSelectors = [ + 'li[class*="file-tree-row"] a[href*="#"]', + 'ul[aria-label="File Tree"] a[href*="#"]', +] as const; +const dimmedClass = 'rgh-dim-viewed-files'; + +function updateTree(): void { + const viewedAnchors = new Map(); + for (const file of $$(fileSelector)) { + const toggle = $optional(viewedToggleSelector, file); + const viewed = toggle instanceof HTMLInputElement + ? toggle.checked + : file.hasAttribute('data-file-user-viewed') + || Boolean( + toggle && (toggle.getAttribute('aria-pressed') === 'true' || elementExists('.octicon-checkbox-fill', toggle)), + ); + + if (file.id) { + viewedAnchors.set('#' + file.id, viewed); + } + + const link = $optional([ + 'div[class*="file-path-section"] a', + '.file-info a.Link--primary', + ], file); + if (link && link.hash) { + viewedAnchors.set(link.hash, viewed); + } + } + + for (const link of $$(treeLinkSelectors)) { + const row = link.closest('li[class*="file-tree-row"]') ?? link; + const viewed = viewedAnchors.get(link.hash); + if (viewed !== undefined && row.classList.contains(dimmedClass) !== viewed) { + row.classList.toggle(dimmedClass, viewed); + } + } +} + +async function updateAfterRender(signal: AbortSignal): Promise { + await frame(); + if (!signal.aborted) { + updateTree(); + } +} + +export function init(signal: AbortSignal): void { + function handleToggle(): void { + void updateAfterRender(signal); + } + + observe( + [ + ...treeLinkSelectors, + '[class^="Diff-module__diffTargetable"]', + '.js-file', + ], + updateTree, + {signal}, + ); + delegate(viewedToggleSelector, 'click', handleToggle, {signal}); + delegate(viewedToggleSelector, 'change', handleToggle, {signal}); + updateTree(); + onAbort(signal, () => { + for (const row of $$optional('.' + dimmedClass)) { + row.classList.remove(dimmedClass); + } + }); +} + +void features.add(import.meta.url, { + include: [pageDetect.isPRFiles], + exclude: [pageDetect.isPRFile404, pageDetect.isPRCommit], + init, +}); + +/* + +Test URLs + +https://github.com/refined-github/sandbox/pull/55/files + +*/ diff --git a/source/features/keyboard-navigation.tsx b/source/features/keyboard-navigation.tsx index e7348557392f..9dd54f58247c 100644 --- a/source/features/keyboard-navigation.tsx +++ b/source/features/keyboard-navigation.tsx @@ -3,7 +3,7 @@ import {$$, $optional, closestElementOptional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import {isEditable} from '../helpers/dom-utils.js'; -import {viewedToggleSelector} from './batch-mark-files-as-viewed.js'; +import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; const isCommentGroupMinimized = (comment: HTMLElement): boolean => elementExists('.minimized-comment:not(.d-none)', comment) diff --git a/source/helpers/viewed-file-selectors.ts b/source/helpers/viewed-file-selectors.ts new file mode 100644 index 000000000000..fc14e132dc0a --- /dev/null +++ b/source/helpers/viewed-file-selectors.ts @@ -0,0 +1,4 @@ +export const viewedToggleSelector = [ + 'button[class*="MarkAsViewedButton"]', + 'input.js-reviewed-checkbox', +] as const; diff --git a/source/refined-github.ts b/source/refined-github.ts index 7315e70c0cee..b7fbd0ab03a6 100644 --- a/source/refined-github.ts +++ b/source/refined-github.ts @@ -127,6 +127,7 @@ import './features/deep-reblame.js'; import './features/clear-pr-merge-commit-message.js'; import './features/action-used-by-link.js'; import './features/batch-mark-files-as-viewed.js'; +import './features/dim-viewed-files.js'; import './features/quick-resolve-conflicts.js'; import './features/rerun-workflow.js'; import './features/stop-redirecting-in-notification-bar.js'; diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts new file mode 100644 index 000000000000..7fd1eecc0a9b --- /dev/null +++ b/test/dim-viewed-files.test.ts @@ -0,0 +1,95 @@ +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; + +import {init} from '../source/features/dim-viewed-files.js'; +import observe from '../source/helpers/selector-observer.js'; + +vi.mock('../source/feature-manager.js', () => ({default: {add: vi.fn()}})); +vi.mock('../source/helpers/selector-observer.js', () => ({default: vi.fn()})); + +let controller: AbortController; + +beforeEach(() => { + controller = new AbortController(); + vi.mocked(observe).mockClear(); + document.body.innerHTML = ` + +
+ +
+
+ +
+ `; +}); + +afterEach(() => { + controller.abort(); + document.body.replaceChildren(); +}); + +it('dims only files already marked as viewed', () => { + init(controller.signal); + expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(1); + expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); +}); + +it('updates after viewed controls are clicked', async () => { + init(controller.signal); + document.querySelector('#diff-first button')!.replaceChildren(); + document.querySelector('#diff-second button')!.innerHTML = ''; + document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + await vi.waitFor(() => { + expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(1); + expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('second.ts'); + }); +}); + +it('dims viewed files when the tree is rendered again', async () => { + init(controller.signal); + document.querySelector('ul')!.innerHTML = '
  • first.ts
  • '; + const [, update] = vi.mocked(observe).mock.calls[0]!; + update(document.querySelector('a')!, {signal: controller.signal}); + await vi.waitFor(() => { + expect(document.querySelector('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + }); +}); + +it('supports legacy viewed checkboxes and restores unviewed files', async () => { + document.querySelector('#diff-first')!.outerHTML = ` +
    + +
    + `; + init(controller.signal); + expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); + const checkbox = document.querySelector('input')!; + checkbox.checked = false; + checkbox.dispatchEvent(new Event('change', {bubbles: true})); + await vi.waitFor(() => { + expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); + }); +}); + +it('reads the current viewed state from aria-pressed', () => { + document.querySelector('#diff-first button')!.replaceChildren(); + document.querySelector('#diff-first button')!.setAttribute('aria-pressed', 'true'); + init(controller.signal); + expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); +}); + +it('removes dimming and ignores pending updates when unloaded', async () => { + init(controller.signal); + document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + controller.abort(); + expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); + document.querySelector('#diff-second button')!.innerHTML = ''; + await new Promise(resolve => { + requestAnimationFrame(() => { + resolve(); + }); + }); + expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); +}); From 4dde14623cf02775aeebc1c39ee8b570b0615638 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:33:22 +0200 Subject: [PATCH 02/30] `dim-viewed-files` - Dim folders whose files are all viewed Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build/__snapshots__/features-meta.json | 2 +- readme.md | 2 +- source/features/dim-viewed-files.tsx | 43 +++++++++++++---- test/dim-viewed-files.test.ts | 66 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/build/__snapshots__/features-meta.json b/build/__snapshots__/features-meta.json index 0f6117470eab..7072744e80c2 100644 --- a/build/__snapshots__/features-meta.json +++ b/build/__snapshots__/features-meta.json @@ -231,7 +231,7 @@ }, { "id": "dim-viewed-files", - "description": "Dims viewed files in the PR file tree, keeping unreviewed files easy to find.", + "description": "Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find.", "css": true, "screenshot": null }, diff --git a/readme.md b/readme.md index 317edf9d63d1..78d68921a948 100644 --- a/readme.md +++ b/readme.md @@ -235,7 +235,7 @@ https://github.com/refined-github/refined-github/wiki/Contributing#metadata-guid - [](# "batch-mark-files-as-viewed") [Mark/unmark multiple files as “Viewed” in the PR Files tab. Click on the first checkbox you want to mark/unmark and then `shift`-click another one; all the files between the two checkboxes will be marked/unmarked as “Viewed”.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257009611-17249bee-d2e2-42ac-bdf0-ebc90029544e.gif) - [](# "closing-remarks") 🔥 [Shows the first Git tag a merged PR was included in or suggests creating a release if not yet released.](https://user-images.githubusercontent.com/1402241/169497171-85d4a97f-413a-41b4-84ba-885dca2b51cf.png) - [](# "pr-jump-to-first-non-viewed-file") [Jumps to first non-viewed file in a PR when clicking on the progress bar.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257011208-764f509d-fed9-424b-84e9-c01cf2fd428b.gif) -- [](# "dim-viewed-files") Dims viewed files in the PR file tree, keeping unreviewed files easy to find. +- [](# "dim-viewed-files") Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find. - [](# "view-last-pr-deployment") [Adds a link to open the latest deployment to the PR header.](https://github.com/user-attachments/assets/90d2498d-ef65-45b9-96eb-07e7988bea61) - [](# "no-unnecessary-split-diff-view") [Always uses unified diffs on files where split diffs aren’t useful.](https://user-images.githubusercontent.com/46634000/121495005-89af8600-c9d9-11eb-822d-77e0b987e3b1.png) - [](# "emphasize-draft-pr-label") [Makes it easier to distinguish draft PR in lists.](https://user-images.githubusercontent.com/1402241/218252438-062a1ab3-4437-436d-9140-87bee23aaefb.png) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index f793783636a1..a8b2bd7a2865 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -3,7 +3,7 @@ import './dim-viewed-files.css'; import {onAbort} from 'abort-utils'; import delegate from 'delegate-it'; import * as pageDetect from 'github-url-detection'; -import {$$, $$optional, $optional, elementExists} from 'select-dom'; +import {$$optional, $optional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import {frame} from '../helpers/dom-utils.js'; @@ -15,11 +15,32 @@ const treeLinkSelectors = [ 'li[class*="file-tree-row"] a[href*="#"]', 'ul[aria-label="File Tree"] a[href*="#"]', ] as const; +const treeFileSelector = [ + 'li[class*="file-tree-row"]', + 'li[data-tree-entry-type="file"]', + '[role="treeitem"]:not([aria-expanded])', +] as const; +const treeFolderSelector = [ + '[role="treeitem"][aria-expanded]', + // Old view + 'li[data-tree-entry-type="directory"]', +] as const; const dimmedClass = 'rgh-dim-viewed-files'; +// Dim only the folder's own row, not its nested subtree, to avoid compounding opacity +function getFolderRow(folder: HTMLElement): HTMLElement { + for (const child of folder.children) { + if (child instanceof HTMLElement && !child.matches('ul, [role="group"]')) { + return child; + } + } + + return folder; +} + function updateTree(): void { const viewedAnchors = new Map(); - for (const file of $$(fileSelector)) { + for (const file of $$optional(fileSelector)) { const toggle = $optional(viewedToggleSelector, file); const viewed = toggle instanceof HTMLInputElement ? toggle.checked @@ -41,12 +62,17 @@ function updateTree(): void { } } - for (const link of $$(treeLinkSelectors)) { - const row = link.closest('li[class*="file-tree-row"]') ?? link; - const viewed = viewedAnchors.get(link.hash); - if (viewed !== undefined && row.classList.contains(dimmedClass) !== viewed) { - row.classList.toggle(dimmedClass, viewed); - } + const treeLinks = $$optional(treeLinkSelectors); + for (const link of treeLinks) { + const row = link.closest(treeFileSelector) ?? link; + row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); + } + + for (const folder of $$optional(treeFolderSelector)) { + const descendantFiles = treeLinks.filter(link => folder.contains(link)); + const viewed = descendantFiles.length > 0 + && descendantFiles.every(link => viewedAnchors.get(link.hash) === true); + getFolderRow(folder).classList.toggle(dimmedClass, viewed); } } @@ -65,6 +91,7 @@ export function init(signal: AbortSignal): void { observe( [ ...treeLinkSelectors, + ...treeFolderSelector, '[class^="Diff-module__diffTargetable"]', '.js-file', ], diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts index 7fd1eecc0a9b..46cbd5b7ce18 100644 --- a/test/dim-viewed-files.test.ts +++ b/test/dim-viewed-files.test.ts @@ -25,6 +25,25 @@ beforeEach(() => { `; }); +function setNestedTree(): void { + document.querySelector('ul[aria-label="File Tree"]')!.outerHTML = ` + + `; +} + afterEach(() => { controller.abort(); document.body.replaceChildren(); @@ -36,6 +55,53 @@ it('dims only files already marked as viewed', () => { expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); }); +it('dims nested folders only when all descendant files are viewed', async () => { + setNestedTree(); + init(controller.signal); + expect(document.querySelector('#folder-components > button')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + expect(document.querySelector('#folder-src > button')?.classList.contains('rgh-dim-viewed-files')).toBe(false); + + document.querySelector('#diff-second button')!.innerHTML = ''; + document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + await vi.waitFor(() => { + expect(document.querySelector('#folder-src > button')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + }); +}); + +it('dims folders in the React file tree without dimming their subtree', () => { + document.querySelector('ul[aria-label="File Tree"]')!.outerHTML = ` + + `; + init(controller.signal); + const folder = document.querySelector('#react-folder')!; + expect(folder.classList.contains('rgh-dim-viewed-files')).toBe(false); + expect(folder.querySelector('.PRIVATE_TreeView-item-container')?.classList.contains('rgh-dim-viewed-files')).toBe( + true, + ); + expect(folder.querySelector('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); +}); + +it('keeps folders undimmed when a descendant file state is unknown', () => { + document.querySelector('ul[aria-label="File Tree"]')!.innerHTML = ` +
  • + + +
  • + `; + init(controller.signal); + expect(document.querySelector('#folder-unknown > button')?.classList.contains('rgh-dim-viewed-files')).toBe(false); +}); + it('updates after viewed controls are clicked', async () => { init(controller.signal); document.querySelector('#diff-first button')!.replaceChildren(); From b17715a2068ae0e6ce1ea2dfcec0addff6274d41 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:41:57 +0200 Subject: [PATCH 03/30] Attached screenshot --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 78d68921a948..e777492260c0 100644 --- a/readme.md +++ b/readme.md @@ -235,7 +235,7 @@ https://github.com/refined-github/refined-github/wiki/Contributing#metadata-guid - [](# "batch-mark-files-as-viewed") [Mark/unmark multiple files as “Viewed” in the PR Files tab. Click on the first checkbox you want to mark/unmark and then `shift`-click another one; all the files between the two checkboxes will be marked/unmarked as “Viewed”.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257009611-17249bee-d2e2-42ac-bdf0-ebc90029544e.gif) - [](# "closing-remarks") 🔥 [Shows the first Git tag a merged PR was included in or suggests creating a release if not yet released.](https://user-images.githubusercontent.com/1402241/169497171-85d4a97f-413a-41b4-84ba-885dca2b51cf.png) - [](# "pr-jump-to-first-non-viewed-file") [Jumps to first non-viewed file in a PR when clicking on the progress bar.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257011208-764f509d-fed9-424b-84e9-c01cf2fd428b.gif) -- [](# "dim-viewed-files") Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find. +- [](# "dim-viewed-files") [Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find.](https://private-user-images.githubusercontent.com/9824294/665923384-1b7a5682-ec3d-4ca4-8608-71ad3b6a2228.png) - [](# "view-last-pr-deployment") [Adds a link to open the latest deployment to the PR header.](https://github.com/user-attachments/assets/90d2498d-ef65-45b9-96eb-07e7988bea61) - [](# "no-unnecessary-split-diff-view") [Always uses unified diffs on files where split diffs aren’t useful.](https://user-images.githubusercontent.com/46634000/121495005-89af8600-c9d9-11eb-822d-77e0b987e3b1.png) - [](# "emphasize-draft-pr-label") [Makes it easier to distinguish draft PR in lists.](https://user-images.githubusercontent.com/1402241/218252438-062a1ab3-4437-436d-9140-87bee23aaefb.png) From dc98122a7466cfdba1664c7bf520107672f29023 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:46:08 +0200 Subject: [PATCH 04/30] add screenshot to features meta data --- build/__snapshots__/features-meta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/__snapshots__/features-meta.json b/build/__snapshots__/features-meta.json index 7072744e80c2..0358ac853e33 100644 --- a/build/__snapshots__/features-meta.json +++ b/build/__snapshots__/features-meta.json @@ -233,7 +233,7 @@ "id": "dim-viewed-files", "description": "Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find.", "css": true, - "screenshot": null + "screenshot": "https://private-user-images.githubusercontent.com/9824294/665923384-1b7a5682-ec3d-4ca4-8608-71ad3b6a2228.png" }, { "id": "download-folder-button", From 068982aa50ed95ba243cb1b8d2a076352d58576b Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:51:49 +0200 Subject: [PATCH 05/30] Apply suggestion from @xremix --- source/features/keyboard-navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/features/keyboard-navigation.tsx b/source/features/keyboard-navigation.tsx index 9dd54f58247c..e7348557392f 100644 --- a/source/features/keyboard-navigation.tsx +++ b/source/features/keyboard-navigation.tsx @@ -3,7 +3,7 @@ import {$$, $optional, closestElementOptional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import {isEditable} from '../helpers/dom-utils.js'; -import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; +import {viewedToggleSelector} from './batch-mark-files-as-viewed.js'; const isCommentGroupMinimized = (comment: HTMLElement): boolean => elementExists('.minimized-comment:not(.d-none)', comment) From d91a927c273b78404bcc94a4fe4b122869de4bd1 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:53:01 +0200 Subject: [PATCH 06/30] undo changes --- source/features/batch-mark-files-as-viewed.tsx | 6 +++++- source/helpers/viewed-file-selectors.ts | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 source/helpers/viewed-file-selectors.ts diff --git a/source/features/batch-mark-files-as-viewed.tsx b/source/features/batch-mark-files-as-viewed.tsx index 6325d3ad4d82..9b43ad99c19a 100644 --- a/source/features/batch-mark-files-as-viewed.tsx +++ b/source/features/batch-mark-files-as-viewed.tsx @@ -8,8 +8,12 @@ import showToast from '../github-helpers/toast.js'; import clickAll from '../helpers/click-all.js'; import {is} from '../helpers/css-selectors.js'; import getItemsBetween from '../helpers/get-items-between.js'; -import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; +export const viewedToggleSelector = [ + 'button[class*="MarkAsViewedButton"]', + // Old view + 'input.js-reviewed-checkbox', +] as const; const fileSelector = [ '[class^="Diff-module__diffTargetable"]', // Old view diff --git a/source/helpers/viewed-file-selectors.ts b/source/helpers/viewed-file-selectors.ts deleted file mode 100644 index fc14e132dc0a..000000000000 --- a/source/helpers/viewed-file-selectors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const viewedToggleSelector = [ - 'button[class*="MarkAsViewedButton"]', - 'input.js-reviewed-checkbox', -] as const; From 25fd5a3efa3249e21cf066c7c59ef217e964bd85 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:54:13 +0200 Subject: [PATCH 07/30] fix build --- source/features/dim-viewed-files.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index a8b2bd7a2865..56e60b3f3b22 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -8,7 +8,13 @@ import {$$optional, $optional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import {frame} from '../helpers/dom-utils.js'; import observe from '../helpers/selector-observer.js'; -import {viewedToggleSelector} from '../helpers/viewed-file-selectors.js'; + +// Same as `batch-mark-files-as-viewed`; not imported to avoid pulling in its JSX dependencies +const viewedToggleSelector = [ + 'button[class*="MarkAsViewedButton"]', + // Old view + 'input.js-reviewed-checkbox', +] as const; const fileSelector = '[class^="Diff-module__diffTargetable"], .js-file'; const treeLinkSelectors = [ From 06684c3a6eee3f6e0a997d687405fba3df379bf4 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:56:55 +0200 Subject: [PATCH 08/30] simplify code --- source/features/dim-viewed-files.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 56e60b3f3b22..944cbd06161c 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -35,13 +35,7 @@ const dimmedClass = 'rgh-dim-viewed-files'; // Dim only the folder's own row, not its nested subtree, to avoid compounding opacity function getFolderRow(folder: HTMLElement): HTMLElement { - for (const child of folder.children) { - if (child instanceof HTMLElement && !child.matches('ul, [role="group"]')) { - return child; - } - } - - return folder; + return $optional(':scope > :not(ul, [role="group"])', folder) ?? folder; } function updateTree(): void { From 58eb1f2042812289b3dd8294ec6fc4828e81682c Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 19:58:39 +0200 Subject: [PATCH 09/30] improve performance --- source/features/dim-viewed-files.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 944cbd06161c..c1611108bc36 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -62,17 +62,22 @@ function updateTree(): void { } } - const treeLinks = $$optional(treeLinkSelectors); - for (const link of treeLinks) { + // A folder is viewed when it has files and all of them are viewed + const viewedFolders = new Map(); + for (const link of $$optional(treeLinkSelectors)) { + const viewed = viewedAnchors.get(link.hash) === true; const row = link.closest(treeFileSelector) ?? link; - row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); + row.classList.toggle(dimmedClass, viewed); + + let folder = link.closest(treeFolderSelector); + while (folder) { + viewedFolders.set(folder, (viewedFolders.get(folder) ?? true) && viewed); + folder = folder.parentElement?.closest(treeFolderSelector) ?? null; // eslint-disable-line unicorn/no-null + } } for (const folder of $$optional(treeFolderSelector)) { - const descendantFiles = treeLinks.filter(link => folder.contains(link)); - const viewed = descendantFiles.length > 0 - && descendantFiles.every(link => viewedAnchors.get(link.hash) === true); - getFolderRow(folder).classList.toggle(dimmedClass, viewed); + getFolderRow(folder).classList.toggle(dimmedClass, viewedFolders.get(folder) === true); } } From 3604d1bb3ad66d53e424e9d10086d471af78b23a Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 20:03:10 +0200 Subject: [PATCH 10/30] move folder implementation to an CSS approach --- source/features/dim-viewed-files.css | 8 +++++++ source/features/dim-viewed-files.tsx | 26 +-------------------- test/dim-viewed-files.test.ts | 35 ++++++++++++++++++++-------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index 7cc07609454d..a30abae5e269 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -1,3 +1,11 @@ +/* Files: rows marked as viewed by the JS */ .rgh-dim-viewed-files:not(:hover, :focus-within) { opacity: 50%; } + +/* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ +:is([role="treeitem"][aria-expanded], li[data-tree-entry-type="directory"]):has(.rgh-dim-viewed-files):not(:has( + :is(li[class*="file-tree-row"], li[data-tree-entry-type="file"], [role="treeitem"]):not([aria-expanded], [data-tree-entry-type="directory"], .rgh-dim-viewed-files) +)) > :not(ul, [role="group"], :hover, :focus-within) { + opacity: 50%; +} diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index c1611108bc36..9e4d7f35f070 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -26,18 +26,8 @@ const treeFileSelector = [ 'li[data-tree-entry-type="file"]', '[role="treeitem"]:not([aria-expanded])', ] as const; -const treeFolderSelector = [ - '[role="treeitem"][aria-expanded]', - // Old view - 'li[data-tree-entry-type="directory"]', -] as const; const dimmedClass = 'rgh-dim-viewed-files'; -// Dim only the folder's own row, not its nested subtree, to avoid compounding opacity -function getFolderRow(folder: HTMLElement): HTMLElement { - return $optional(':scope > :not(ul, [role="group"])', folder) ?? folder; -} - function updateTree(): void { const viewedAnchors = new Map(); for (const file of $$optional(fileSelector)) { @@ -62,22 +52,9 @@ function updateTree(): void { } } - // A folder is viewed when it has files and all of them are viewed - const viewedFolders = new Map(); for (const link of $$optional(treeLinkSelectors)) { - const viewed = viewedAnchors.get(link.hash) === true; const row = link.closest(treeFileSelector) ?? link; - row.classList.toggle(dimmedClass, viewed); - - let folder = link.closest(treeFolderSelector); - while (folder) { - viewedFolders.set(folder, (viewedFolders.get(folder) ?? true) && viewed); - folder = folder.parentElement?.closest(treeFolderSelector) ?? null; // eslint-disable-line unicorn/no-null - } - } - - for (const folder of $$optional(treeFolderSelector)) { - getFolderRow(folder).classList.toggle(dimmedClass, viewedFolders.get(folder) === true); + row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); } } @@ -96,7 +73,6 @@ export function init(signal: AbortSignal): void { observe( [ ...treeLinkSelectors, - ...treeFolderSelector, '[class^="Diff-module__diffTargetable"]', '.js-file', ], diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts index 46cbd5b7ce18..82c6f351ab8a 100644 --- a/test/dim-viewed-files.test.ts +++ b/test/dim-viewed-files.test.ts @@ -1,3 +1,4 @@ +import {readFileSync} from 'node:fs'; import {afterEach, beforeEach, expect, it, vi} from 'vitest'; import {init} from '../source/features/dim-viewed-files.js'; @@ -8,6 +9,21 @@ vi.mock('../source/helpers/selector-observer.js', () => ({default: vi.fn()})); let controller: AbortController; +// The selectors of the feature's CSS rules, so dimming can be tested without a layout engine +const dimmedSelector = readFileSync('source/features/dim-viewed-files.css', 'utf8') + .replaceAll(/\/\*.*?\*\//gsv, '') + .replaceAll(/\{[^\}]*\}/gv, ',') + // The test DOM's selector parser doesn't accept whitespace inside parentheses + .replaceAll(/\s+/gv, ' ') + .replaceAll('( ', '(') + .replaceAll(' )', ')') + .trim() + .replace(/,$/v, ''); + +function isDimmed(selector: string): boolean { + return document.querySelector(selector)!.matches(dimmedSelector); +} + beforeEach(() => { controller = new AbortController(); vi.mocked(observe).mockClear(); @@ -58,13 +74,14 @@ it('dims only files already marked as viewed', () => { it('dims nested folders only when all descendant files are viewed', async () => { setNestedTree(); init(controller.signal); - expect(document.querySelector('#folder-components > button')?.classList.contains('rgh-dim-viewed-files')).toBe(true); - expect(document.querySelector('#folder-src > button')?.classList.contains('rgh-dim-viewed-files')).toBe(false); + expect(isDimmed('#folder-components > button')).toBe(true); + expect(isDimmed('#folder-src > button')).toBe(false); + expect(isDimmed('#folder-src > ul')).toBe(false); document.querySelector('#diff-second button')!.innerHTML = ''; document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); await vi.waitFor(() => { - expect(document.querySelector('#folder-src > button')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + expect(isDimmed('#folder-src > button')).toBe(true); }); }); @@ -83,12 +100,10 @@ it('dims folders in the React file tree without dimming their subtree', () => { `; init(controller.signal); - const folder = document.querySelector('#react-folder')!; - expect(folder.classList.contains('rgh-dim-viewed-files')).toBe(false); - expect(folder.querySelector('.PRIVATE_TreeView-item-container')?.classList.contains('rgh-dim-viewed-files')).toBe( - true, - ); - expect(folder.querySelector('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + expect(isDimmed('#react-folder')).toBe(false); + expect(isDimmed('#react-folder > ul')).toBe(false); + expect(isDimmed('#react-folder .PRIVATE_TreeView-item-container')).toBe(true); + expect(isDimmed('#react-folder li')).toBe(true); }); it('keeps folders undimmed when a descendant file state is unknown', () => { @@ -99,7 +114,7 @@ it('keeps folders undimmed when a descendant file state is unknown', () => { `; init(controller.signal); - expect(document.querySelector('#folder-unknown > button')?.classList.contains('rgh-dim-viewed-files')).toBe(false); + expect(isDimmed('#folder-unknown > button')).toBe(false); }); it('updates after viewed controls are clicked', async () => { From 1b640c67bf7aeecb28f0b6817e7e7fb6af31ef0b Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Mon, 5 Oct 2026 20:04:00 +0200 Subject: [PATCH 11/30] Update dim-viewed-files.test.ts --- test/dim-viewed-files.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts index 82c6f351ab8a..f92b65f6574b 100644 --- a/test/dim-viewed-files.test.ts +++ b/test/dim-viewed-files.test.ts @@ -71,18 +71,24 @@ it('dims only files already marked as viewed', () => { expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); }); -it('dims nested folders only when all descendant files are viewed', async () => { +it('dims nested folders only when all descendant files are viewed', () => { setNestedTree(); init(controller.signal); expect(isDimmed('#folder-components > button')).toBe(true); expect(isDimmed('#folder-src > button')).toBe(false); expect(isDimmed('#folder-src > ul')).toBe(false); +}); +// Separate test because happy-dom caches `:has()` results across descendant changes +it('dims parent folders once their remaining files are viewed', async () => { + setNestedTree(); + init(controller.signal); document.querySelector('#diff-second button')!.innerHTML = ''; document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); await vi.waitFor(() => { - expect(isDimmed('#folder-src > button')).toBe(true); + expect(document.querySelectorAll('li.rgh-dim-viewed-files')).toHaveLength(2); }); + expect(isDimmed('#folder-src > button')).toBe(true); }); it('dims folders in the React file tree without dimming their subtree', () => { From 832f43176b646c6310930a7d6440d2e6215e1210 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Tue, 6 Oct 2026 09:12:47 +0200 Subject: [PATCH 12/30] improve performance of loading viewed files --- source/features/dim-viewed-files.tsx | 33 ++++++++++++++++++---------- test/dim-viewed-files.test.ts | 12 ++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 9e4d7f35f070..9fd0d13acc51 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -58,16 +58,24 @@ function updateTree(): void { } } -async function updateAfterRender(signal: AbortSignal): Promise { - await frame(); - if (!signal.aborted) { - updateTree(); +export function init(signal: AbortSignal): void { + // Batch the many observer/click callbacks during rendering into a single update per frame + let isUpdateScheduled = false; + async function scheduleUpdate(): Promise { + if (isUpdateScheduled) { + return; + } + + isUpdateScheduled = true; + await frame(); + isUpdateScheduled = false; + if (!signal.aborted) { + updateTree(); + } } -} -export function init(signal: AbortSignal): void { - function handleToggle(): void { - void updateAfterRender(signal); + function handleChange(): void { + void scheduleUpdate(); } observe( @@ -75,12 +83,15 @@ export function init(signal: AbortSignal): void { ...treeLinkSelectors, '[class^="Diff-module__diffTargetable"]', '.js-file', + // GitHub loads the viewed state after rendering the button, without adding a new file + 'button[class*="MarkAsViewedButton"][aria-pressed="true"]', + 'button[class*="MarkAsViewedButton"] .octicon-checkbox-fill', ], - updateTree, + handleChange, {signal}, ); - delegate(viewedToggleSelector, 'click', handleToggle, {signal}); - delegate(viewedToggleSelector, 'change', handleToggle, {signal}); + delegate(viewedToggleSelector, 'click', handleChange, {signal}); + delegate(viewedToggleSelector, 'change', handleChange, {signal}); updateTree(); onAbort(signal, () => { for (const row of $$optional('.' + dimmedClass)) { diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts index f92b65f6574b..5bbef244db3f 100644 --- a/test/dim-viewed-files.test.ts +++ b/test/dim-viewed-files.test.ts @@ -144,6 +144,18 @@ it('dims viewed files when the tree is rendered again', async () => { }); }); +it('dims files whose viewed state loads after rendering', async () => { + init(controller.signal); + const button = document.querySelector('#diff-second button')!; + button.setAttribute('aria-pressed', 'true'); + const [selectors, update] = vi.mocked(observe).mock.calls[0]; + expect([selectors].flat().some(selector => button.matches(selector))).toBe(true); + update(button, {signal: controller.signal}); + await vi.waitFor(() => { + expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(2); + }); +}); + it('supports legacy viewed checkboxes and restores unviewed files', async () => { document.querySelector('#diff-first')!.outerHTML = `
    From 5cab09e7bdf37539a75adc95f3b409bfa8ca8f75 Mon Sep 17 00:00:00 2001 From: Toni Hoffmann Date: Tue, 6 Oct 2026 11:31:36 +0200 Subject: [PATCH 13/30] fix formatting and linting --- source/features/dim-viewed-files.css | 22 ++++++-- source/features/dim-viewed-files.tsx | 7 +-- test/dim-viewed-files.test.ts | 80 +++++++++++++++------------- 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index a30abae5e269..a13cbef736c4 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -4,8 +4,22 @@ } /* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ -:is([role="treeitem"][aria-expanded], li[data-tree-entry-type="directory"]):has(.rgh-dim-viewed-files):not(:has( - :is(li[class*="file-tree-row"], li[data-tree-entry-type="file"], [role="treeitem"]):not([aria-expanded], [data-tree-entry-type="directory"], .rgh-dim-viewed-files) -)) > :not(ul, [role="group"], :hover, :focus-within) { - opacity: 50%; +[role='treeitem'][aria-expanded], +li[data-tree-entry-type='directory'] { + &:has(.rgh-dim-viewed-files):not( + :has( + :is( + li[class*='file-tree-row'], + li[data-tree-entry-type='file'], + [role='treeitem'] + ):not( + [aria-expanded], + [data-tree-entry-type='directory'], + .rgh-dim-viewed-files + ) + ) + ) + > :not(ul, [role='group'], :hover, :focus-within) { + opacity: 50%; + } } diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 9fd0d13acc51..4632f047b0e5 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -3,7 +3,7 @@ import './dim-viewed-files.css'; import {onAbort} from 'abort-utils'; import delegate from 'delegate-it'; import * as pageDetect from 'github-url-detection'; -import {$$optional, $optional, elementExists} from 'select-dom'; +import {$$optional, $optional, closestElementOptional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import {frame} from '../helpers/dom-utils.js'; @@ -47,13 +47,14 @@ function updateTree(): void { 'div[class*="file-path-section"] a', '.file-info a.Link--primary', ], file); - if (link && link.hash) { + // Missing when the diff is collapsed or its header has not rendered yet + if (link?.hash) { viewedAnchors.set(link.hash, viewed); } } for (const link of $$optional(treeLinkSelectors)) { - const row = link.closest(treeFileSelector) ?? link; + const row = closestElementOptional(treeFileSelector, link) ?? link; row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); } } diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts index 5bbef244db3f..7fa5196f2bbd 100644 --- a/test/dim-viewed-files.test.ts +++ b/test/dim-viewed-files.test.ts @@ -1,4 +1,5 @@ import {readFileSync} from 'node:fs'; +import {$, $$optional, $optional} from 'select-dom'; import {afterEach, beforeEach, expect, it, vi} from 'vitest'; import {init} from '../source/features/dim-viewed-files.js'; @@ -10,18 +11,23 @@ vi.mock('../source/helpers/selector-observer.js', () => ({default: vi.fn()})); let controller: AbortController; // The selectors of the feature's CSS rules, so dimming can be tested without a layout engine -const dimmedSelector = readFileSync('source/features/dim-viewed-files.css', 'utf8') - .replaceAll(/\/\*.*?\*\//gsv, '') - .replaceAll(/\{[^\}]*\}/gv, ',') +const css = readFileSync('source/features/dim-viewed-files.css', 'utf8').replaceAll(/\/\*.*?\*\//gsv, ''); +const dimmedSelector = css + // Flatten one level of nesting: `parent { &child {…} }` becomes `:is(parent)child` + .matchAll(/(?[^\{\}]+)\{(?[^\{\}]*(?:\{[^\{\}]*\}[^\{\}]*)?)\}/gv) + .map(({groups}) => { + const {parent, body} = groups!; + return body.includes('{') ? `:is(${parent})${body.slice(body.indexOf('&') + 1, body.indexOf('{'))}` : parent; + }) + .toArray() + .join(',') // The test DOM's selector parser doesn't accept whitespace inside parentheses .replaceAll(/\s+/gv, ' ') .replaceAll('( ', '(') - .replaceAll(' )', ')') - .trim() - .replace(/,$/v, ''); + .replaceAll(' )', ')'); function isDimmed(selector: string): boolean { - return document.querySelector(selector)!.matches(dimmedSelector); + return $(selector).matches(dimmedSelector); } beforeEach(() => { @@ -42,7 +48,7 @@ beforeEach(() => { }); function setNestedTree(): void { - document.querySelector('ul[aria-label="File Tree"]')!.outerHTML = ` + $('ul[aria-label="File Tree"]').outerHTML = `
    • @@ -67,8 +73,8 @@ afterEach(() => { it('dims only files already marked as viewed', () => { init(controller.signal); - expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(1); - expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); + expect($$optional('.rgh-dim-viewed-files')).toHaveLength(1); + expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); }); it('dims nested folders only when all descendant files are viewed', () => { @@ -83,16 +89,16 @@ it('dims nested folders only when all descendant files are viewed', () => { it('dims parent folders once their remaining files are viewed', async () => { setNestedTree(); init(controller.signal); - document.querySelector('#diff-second button')!.innerHTML = ''; - document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + $('#diff-second button').innerHTML = ''; + $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); await vi.waitFor(() => { - expect(document.querySelectorAll('li.rgh-dim-viewed-files')).toHaveLength(2); + expect($$optional('li.rgh-dim-viewed-files')).toHaveLength(2); }); expect(isDimmed('#folder-src > button')).toBe(true); }); it('dims folders in the React file tree without dimming their subtree', () => { - document.querySelector('ul[aria-label="File Tree"]')!.outerHTML = ` + $('ul[aria-label="File Tree"]').outerHTML = `
      • @@ -113,7 +119,7 @@ it('dims folders in the React file tree without dimming their subtree', () => { }); it('keeps folders undimmed when a descendant file state is unknown', () => { - document.querySelector('ul[aria-label="File Tree"]')!.innerHTML = ` + $('ul[aria-label="File Tree"]').innerHTML = `
      • @@ -125,70 +131,70 @@ it('keeps folders undimmed when a descendant file state is unknown', () => { it('updates after viewed controls are clicked', async () => { init(controller.signal); - document.querySelector('#diff-first button')!.replaceChildren(); - document.querySelector('#diff-second button')!.innerHTML = ''; - document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + $('#diff-first button').replaceChildren(); + $('#diff-second button').innerHTML = ''; + $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); await vi.waitFor(() => { - expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(1); - expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('second.ts'); + expect($$optional('.rgh-dim-viewed-files')).toHaveLength(1); + expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('second.ts'); }); }); it('dims viewed files when the tree is rendered again', async () => { init(controller.signal); - document.querySelector('ul')!.innerHTML = '
      • first.ts
      • '; - const [, update] = vi.mocked(observe).mock.calls[0]!; - update(document.querySelector('a')!, {signal: controller.signal}); + $('ul').innerHTML = '
      • first.ts
      • '; + const [, update] = vi.mocked(observe).mock.calls[0]; + update($('a'), {signal: controller.signal}); await vi.waitFor(() => { - expect(document.querySelector('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); + expect($optional('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); }); }); it('dims files whose viewed state loads after rendering', async () => { init(controller.signal); - const button = document.querySelector('#diff-second button')!; + const button = $('#diff-second button'); button.setAttribute('aria-pressed', 'true'); const [selectors, update] = vi.mocked(observe).mock.calls[0]; expect([selectors].flat().some(selector => button.matches(selector))).toBe(true); update(button, {signal: controller.signal}); await vi.waitFor(() => { - expect(document.querySelectorAll('.rgh-dim-viewed-files')).toHaveLength(2); + expect($$optional('.rgh-dim-viewed-files')).toHaveLength(2); }); }); it('supports legacy viewed checkboxes and restores unviewed files', async () => { - document.querySelector('#diff-first')!.outerHTML = ` + $('#diff-first').outerHTML = `
        `; init(controller.signal); - expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); - const checkbox = document.querySelector('input')!; + expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); + const checkbox = $('input'); checkbox.checked = false; checkbox.dispatchEvent(new Event('change', {bubbles: true})); await vi.waitFor(() => { - expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); + expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); }); }); it('reads the current viewed state from aria-pressed', () => { - document.querySelector('#diff-first button')!.replaceChildren(); - document.querySelector('#diff-first button')!.setAttribute('aria-pressed', 'true'); + $('#diff-first button').replaceChildren(); + $('#diff-first button').setAttribute('aria-pressed', 'true'); init(controller.signal); - expect(document.querySelector('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); + expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); }); it('removes dimming and ignores pending updates when unloaded', async () => { init(controller.signal); - document.querySelector('#diff-second button')!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); controller.abort(); - expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); - document.querySelector('#diff-second button')!.innerHTML = ''; + expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); + $('#diff-second button').innerHTML = ''; await new Promise(resolve => { requestAnimationFrame(() => { resolve(); }); }); - expect(document.querySelector('.rgh-dim-viewed-files')).toBeNull(); + expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); }); From e5fc52ef687e8ed64373f65959224a9e3f344d7c Mon Sep 17 00:00:00 2001 From: fregante Date: Tue, 6 Oct 2026 17:54:30 +0800 Subject: [PATCH 14/30] Discard changes to test/dim-viewed-files.test.ts --- test/dim-viewed-files.test.ts | 200 ---------------------------------- 1 file changed, 200 deletions(-) delete mode 100644 test/dim-viewed-files.test.ts diff --git a/test/dim-viewed-files.test.ts b/test/dim-viewed-files.test.ts deleted file mode 100644 index 7fa5196f2bbd..000000000000 --- a/test/dim-viewed-files.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import {readFileSync} from 'node:fs'; -import {$, $$optional, $optional} from 'select-dom'; -import {afterEach, beforeEach, expect, it, vi} from 'vitest'; - -import {init} from '../source/features/dim-viewed-files.js'; -import observe from '../source/helpers/selector-observer.js'; - -vi.mock('../source/feature-manager.js', () => ({default: {add: vi.fn()}})); -vi.mock('../source/helpers/selector-observer.js', () => ({default: vi.fn()})); - -let controller: AbortController; - -// The selectors of the feature's CSS rules, so dimming can be tested without a layout engine -const css = readFileSync('source/features/dim-viewed-files.css', 'utf8').replaceAll(/\/\*.*?\*\//gsv, ''); -const dimmedSelector = css - // Flatten one level of nesting: `parent { &child {…} }` becomes `:is(parent)child` - .matchAll(/(?[^\{\}]+)\{(?[^\{\}]*(?:\{[^\{\}]*\}[^\{\}]*)?)\}/gv) - .map(({groups}) => { - const {parent, body} = groups!; - return body.includes('{') ? `:is(${parent})${body.slice(body.indexOf('&') + 1, body.indexOf('{'))}` : parent; - }) - .toArray() - .join(',') - // The test DOM's selector parser doesn't accept whitespace inside parentheses - .replaceAll(/\s+/gv, ' ') - .replaceAll('( ', '(') - .replaceAll(' )', ')'); - -function isDimmed(selector: string): boolean { - return $(selector).matches(dimmedSelector); -} - -beforeEach(() => { - controller = new AbortController(); - vi.mocked(observe).mockClear(); - document.body.innerHTML = ` - -
        - -
        -
        - -
        - `; -}); - -function setNestedTree(): void { - $('ul[aria-label="File Tree"]').outerHTML = ` - - `; -} - -afterEach(() => { - controller.abort(); - document.body.replaceChildren(); -}); - -it('dims only files already marked as viewed', () => { - init(controller.signal); - expect($$optional('.rgh-dim-viewed-files')).toHaveLength(1); - expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); -}); - -it('dims nested folders only when all descendant files are viewed', () => { - setNestedTree(); - init(controller.signal); - expect(isDimmed('#folder-components > button')).toBe(true); - expect(isDimmed('#folder-src > button')).toBe(false); - expect(isDimmed('#folder-src > ul')).toBe(false); -}); - -// Separate test because happy-dom caches `:has()` results across descendant changes -it('dims parent folders once their remaining files are viewed', async () => { - setNestedTree(); - init(controller.signal); - $('#diff-second button').innerHTML = ''; - $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); - await vi.waitFor(() => { - expect($$optional('li.rgh-dim-viewed-files')).toHaveLength(2); - }); - expect(isDimmed('#folder-src > button')).toBe(true); -}); - -it('dims folders in the React file tree without dimming their subtree', () => { - $('ul[aria-label="File Tree"]').outerHTML = ` - - `; - init(controller.signal); - expect(isDimmed('#react-folder')).toBe(false); - expect(isDimmed('#react-folder > ul')).toBe(false); - expect(isDimmed('#react-folder .PRIVATE_TreeView-item-container')).toBe(true); - expect(isDimmed('#react-folder li')).toBe(true); -}); - -it('keeps folders undimmed when a descendant file state is unknown', () => { - $('ul[aria-label="File Tree"]').innerHTML = ` -
      • - - -
      • - `; - init(controller.signal); - expect(isDimmed('#folder-unknown > button')).toBe(false); -}); - -it('updates after viewed controls are clicked', async () => { - init(controller.signal); - $('#diff-first button').replaceChildren(); - $('#diff-second button').innerHTML = ''; - $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); - await vi.waitFor(() => { - expect($$optional('.rgh-dim-viewed-files')).toHaveLength(1); - expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('second.ts'); - }); -}); - -it('dims viewed files when the tree is rendered again', async () => { - init(controller.signal); - $('ul').innerHTML = '
      • first.ts
      • '; - const [, update] = vi.mocked(observe).mock.calls[0]; - update($('a'), {signal: controller.signal}); - await vi.waitFor(() => { - expect($optional('li')?.classList.contains('rgh-dim-viewed-files')).toBe(true); - }); -}); - -it('dims files whose viewed state loads after rendering', async () => { - init(controller.signal); - const button = $('#diff-second button'); - button.setAttribute('aria-pressed', 'true'); - const [selectors, update] = vi.mocked(observe).mock.calls[0]; - expect([selectors].flat().some(selector => button.matches(selector))).toBe(true); - update(button, {signal: controller.signal}); - await vi.waitFor(() => { - expect($$optional('.rgh-dim-viewed-files')).toHaveLength(2); - }); -}); - -it('supports legacy viewed checkboxes and restores unviewed files', async () => { - $('#diff-first').outerHTML = ` -
        - -
        - `; - init(controller.signal); - expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); - const checkbox = $('input'); - checkbox.checked = false; - checkbox.dispatchEvent(new Event('change', {bubbles: true})); - await vi.waitFor(() => { - expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); - }); -}); - -it('reads the current viewed state from aria-pressed', () => { - $('#diff-first button').replaceChildren(); - $('#diff-first button').setAttribute('aria-pressed', 'true'); - init(controller.signal); - expect($optional('.rgh-dim-viewed-files')?.textContent).toBe('first.ts'); -}); - -it('removes dimming and ignores pending updates when unloaded', async () => { - init(controller.signal); - $('#diff-second button').dispatchEvent(new MouseEvent('click', {bubbles: true})); - controller.abort(); - expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); - $('#diff-second button').innerHTML = ''; - await new Promise(resolve => { - requestAnimationFrame(() => { - resolve(); - }); - }); - expect($optional('.rgh-dim-viewed-files')).toBeUndefined(); -}); From be97e34fefe57a8f02adf5e71dc286eb188b500f Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 10:53:34 +0200 Subject: [PATCH 15/30] Scope viewed file tree selector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index a13cbef736c4..c91449ce56f9 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -4,8 +4,10 @@ } /* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ -[role='treeitem'][aria-expanded], -li[data-tree-entry-type='directory'] { +#files_bucket ul[aria-label='File Tree'] :is( + [role='treeitem'][aria-expanded], + li[data-tree-entry-type='directory'] +) { &:has(.rgh-dim-viewed-files):not( :has( :is( From 479e5bf6558d225a100823fb33d4f9d84604c314 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 10:54:39 +0200 Subject: [PATCH 16/30] Move feature to bottom of imports --- source/refined-github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/refined-github.ts b/source/refined-github.ts index b7fbd0ab03a6..cfb02518a67b 100644 --- a/source/refined-github.ts +++ b/source/refined-github.ts @@ -127,7 +127,6 @@ import './features/deep-reblame.js'; import './features/clear-pr-merge-commit-message.js'; import './features/action-used-by-link.js'; import './features/batch-mark-files-as-viewed.js'; -import './features/dim-viewed-files.js'; import './features/quick-resolve-conflicts.js'; import './features/rerun-workflow.js'; import './features/stop-redirecting-in-notification-bar.js'; @@ -222,3 +221,4 @@ import './features/delete-branch.js'; import './features/pr-from-branch.js'; import './features/mark-locked.js'; import './features/subscribe-releases.js'; +import './features/dim-viewed-files.js'; From a3847ba3cdb8054dd9c0a4c5f1c1420e2a9f63e4 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 10:55:21 +0200 Subject: [PATCH 17/30] Remove redundant dimming cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 4632f047b0e5..baa348ccf1ad 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -1,6 +1,5 @@ import './dim-viewed-files.css'; -import {onAbort} from 'abort-utils'; import delegate from 'delegate-it'; import * as pageDetect from 'github-url-detection'; import {$$optional, $optional, closestElementOptional, elementExists} from 'select-dom'; @@ -94,11 +93,6 @@ export function init(signal: AbortSignal): void { delegate(viewedToggleSelector, 'click', handleChange, {signal}); delegate(viewedToggleSelector, 'change', handleChange, {signal}); updateTree(); - onAbort(signal, () => { - for (const row of $$optional('.' + dimmedClass)) { - row.classList.remove(dimmedClass); - } - }); } void features.add(import.meta.url, { From 88d356b1d2e90151a360f9e6e9fd6a3640938e1e Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 10:57:58 +0200 Subject: [PATCH 18/30] Run tree update through observer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index baa348ccf1ad..a3a9e8366635 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -92,7 +92,6 @@ export function init(signal: AbortSignal): void { ); delegate(viewedToggleSelector, 'click', handleChange, {signal}); delegate(viewedToggleSelector, 'change', handleChange, {signal}); - updateTree(); } void features.add(import.meta.url, { From 7139a0a20ae5ae9d5f67b58aa63c3caba42f515f Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 10:59:57 +0200 Subject: [PATCH 19/30] Reuse debounce-fn for viewed file tree updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index a3a9e8366635..c292e1c5c106 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -1,11 +1,11 @@ import './dim-viewed-files.css'; +import debounce from 'debounce-fn'; import delegate from 'delegate-it'; import * as pageDetect from 'github-url-detection'; import {$$optional, $optional, closestElementOptional, elementExists} from 'select-dom'; import features from '../feature-manager.js'; -import {frame} from '../helpers/dom-utils.js'; import observe from '../helpers/selector-observer.js'; // Same as `batch-mark-files-as-viewed`; not imported to avoid pulling in its JSX dependencies @@ -27,7 +27,7 @@ const treeFileSelector = [ ] as const; const dimmedClass = 'rgh-dim-viewed-files'; -function updateTree(): void { +const updateTree = debounce((): void => { const viewedAnchors = new Map(); for (const file of $$optional(fileSelector)) { const toggle = $optional(viewedToggleSelector, file); @@ -56,26 +56,11 @@ function updateTree(): void { const row = closestElementOptional(treeFileSelector, link) ?? link; row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); } -} +}, {wait: 100}); export function init(signal: AbortSignal): void { - // Batch the many observer/click callbacks during rendering into a single update per frame - let isUpdateScheduled = false; - async function scheduleUpdate(): Promise { - if (isUpdateScheduled) { - return; - } - - isUpdateScheduled = true; - await frame(); - isUpdateScheduled = false; - if (!signal.aborted) { - updateTree(); - } - } - function handleChange(): void { - void scheduleUpdate(); + updateTree(); } observe( From 2997fa35372d4d4a520579c7c7372053b27b2ec0 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 11:01:09 +0200 Subject: [PATCH 20/30] Drop legacy viewed-file support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index c292e1c5c106..db706cd7aaf4 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -11,8 +11,6 @@ import observe from '../helpers/selector-observer.js'; // Same as `batch-mark-files-as-viewed`; not imported to avoid pulling in its JSX dependencies const viewedToggleSelector = [ 'button[class*="MarkAsViewedButton"]', - // Old view - 'input.js-reviewed-checkbox', ] as const; const fileSelector = '[class^="Diff-module__diffTargetable"], .js-file'; @@ -31,12 +29,10 @@ const updateTree = debounce((): void => { const viewedAnchors = new Map(); for (const file of $$optional(fileSelector)) { const toggle = $optional(viewedToggleSelector, file); - const viewed = toggle instanceof HTMLInputElement - ? toggle.checked - : file.hasAttribute('data-file-user-viewed') - || Boolean( - toggle && (toggle.getAttribute('aria-pressed') === 'true' || elementExists('.octicon-checkbox-fill', toggle)), - ); + const viewed = file.hasAttribute('data-file-user-viewed') + || Boolean( + toggle && (toggle.getAttribute('aria-pressed') === 'true' || elementExists('.octicon-checkbox-fill', toggle)), + ); if (file.id) { viewedAnchors.set('#' + file.id, viewed); From 4c3845635ec6cc9e7e03c05c8d2707f882e1aedb Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 11:34:23 +0200 Subject: [PATCH 21/30] Fix folder dimming in the new PR view Scope folder selectors to the labeled file tree without relying on the legacy files_bucket container. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index c91449ce56f9..0da5e77c0fc4 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -4,7 +4,7 @@ } /* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ -#files_bucket ul[aria-label='File Tree'] :is( +ul[aria-label='File Tree'] :is( [role='treeitem'][aria-expanded], li[data-tree-entry-type='directory'] ) { From f4cda770085a64e097e34f83a1cfc623c0a2ca74 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 11:35:55 +0200 Subject: [PATCH 22/30] Use CSS nesting for scoped folder selectors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.css | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index 0da5e77c0fc4..d17877735be4 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -4,24 +4,24 @@ } /* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ -ul[aria-label='File Tree'] :is( +ul[aria-label='File Tree'] { [role='treeitem'][aria-expanded], - li[data-tree-entry-type='directory'] -) { - &:has(.rgh-dim-viewed-files):not( - :has( - :is( - li[class*='file-tree-row'], - li[data-tree-entry-type='file'], - [role='treeitem'] - ):not( - [aria-expanded], - [data-tree-entry-type='directory'], - .rgh-dim-viewed-files + li[data-tree-entry-type='directory'] { + &:has(.rgh-dim-viewed-files):not( + :has( + :is( + li[class*='file-tree-row'], + li[data-tree-entry-type='file'], + [role='treeitem'] + ):not( + [aria-expanded], + [data-tree-entry-type='directory'], + .rgh-dim-viewed-files + ) ) ) - ) - > :not(ul, [role='group'], :hover, :focus-within) { - opacity: 50%; + > :not(ul, [role='group'], :hover, :focus-within) { + opacity: 50%; + } } } From 0327da6d15facb88f156f8100b35e13c40e4a7d7 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 13:38:02 +0200 Subject: [PATCH 23/30] Simplify viewed-file dimming for the new PR view Remove legacy selectors and redundant viewed-state checks. Use diff IDs, aria-pressed, and modern tree items directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.css | 13 +----- source/features/dim-viewed-files.tsx | 59 ++++++---------------------- 2 files changed, 14 insertions(+), 58 deletions(-) diff --git a/source/features/dim-viewed-files.css b/source/features/dim-viewed-files.css index d17877735be4..b6f07d90ecc6 100644 --- a/source/features/dim-viewed-files.css +++ b/source/features/dim-viewed-files.css @@ -5,19 +5,10 @@ /* Folders: dim only the folder's own row (not its subtree, to avoid compounding opacity) when it contains viewed files and no unviewed ones */ ul[aria-label='File Tree'] { - [role='treeitem'][aria-expanded], - li[data-tree-entry-type='directory'] { + [role='treeitem'][aria-expanded] { &:has(.rgh-dim-viewed-files):not( :has( - :is( - li[class*='file-tree-row'], - li[data-tree-entry-type='file'], - [role='treeitem'] - ):not( - [aria-expanded], - [data-tree-entry-type='directory'], - .rgh-dim-viewed-files - ) + [role='treeitem']:not([aria-expanded], .rgh-dim-viewed-files) ) ) > :not(ul, [role='group'], :hover, :focus-within) { diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index db706cd7aaf4..de6d52d28b6a 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -3,54 +3,26 @@ import './dim-viewed-files.css'; import debounce from 'debounce-fn'; import delegate from 'delegate-it'; import * as pageDetect from 'github-url-detection'; -import {$$optional, $optional, closestElementOptional, elementExists} from 'select-dom'; +import {$$optional, closestElement, elementExists} from 'select-dom'; import features from '../feature-manager.js'; import observe from '../helpers/selector-observer.js'; -// Same as `batch-mark-files-as-viewed`; not imported to avoid pulling in its JSX dependencies -const viewedToggleSelector = [ - 'button[class*="MarkAsViewedButton"]', -] as const; - -const fileSelector = '[class^="Diff-module__diffTargetable"], .js-file'; -const treeLinkSelectors = [ - 'li[class*="file-tree-row"] a[href*="#"]', - 'ul[aria-label="File Tree"] a[href*="#"]', -] as const; -const treeFileSelector = [ - 'li[class*="file-tree-row"]', - 'li[data-tree-entry-type="file"]', - '[role="treeitem"]:not([aria-expanded])', -] as const; +const viewedToggleSelector = 'button[class*="MarkAsViewedButton"]'; +const treeLinkSelector = 'ul[aria-label="File Tree"] a[href*="#"]'; const dimmedClass = 'rgh-dim-viewed-files'; const updateTree = debounce((): void => { - const viewedAnchors = new Map(); - for (const file of $$optional(fileSelector)) { - const toggle = $optional(viewedToggleSelector, file); - const viewed = file.hasAttribute('data-file-user-viewed') - || Boolean( - toggle && (toggle.getAttribute('aria-pressed') === 'true' || elementExists('.octicon-checkbox-fill', toggle)), - ); - - if (file.id) { - viewedAnchors.set('#' + file.id, viewed); - } - - const link = $optional([ - 'div[class*="file-path-section"] a', - '.file-info a.Link--primary', - ], file); - // Missing when the diff is collapsed or its header has not rendered yet - if (link?.hash) { - viewedAnchors.set(link.hash, viewed); + const viewedAnchors = new Set(); + for (const file of $$optional('[class^="Diff-module__diffTargetable"]')) { + if (elementExists(viewedToggleSelector + '[aria-pressed="true"]', file)) { + viewedAnchors.add('#' + file.id); } } - for (const link of $$optional(treeLinkSelectors)) { - const row = closestElementOptional(treeFileSelector, link) ?? link; - row.classList.toggle(dimmedClass, viewedAnchors.get(link.hash) === true); + for (const link of $$optional(treeLinkSelector)) { + const row = closestElement('[role="treeitem"]', link); + row.classList.toggle(dimmedClass, viewedAnchors.has(link.hash)); } }, {wait: 100}); @@ -60,19 +32,12 @@ export function init(signal: AbortSignal): void { } observe( - [ - ...treeLinkSelectors, - '[class^="Diff-module__diffTargetable"]', - '.js-file', - // GitHub loads the viewed state after rendering the button, without adding a new file - 'button[class*="MarkAsViewedButton"][aria-pressed="true"]', - 'button[class*="MarkAsViewedButton"] .octicon-checkbox-fill', - ], + // The pressed selector also catches viewed state loaded after the button renders. + `${treeLinkSelector}, ${viewedToggleSelector}, ${viewedToggleSelector}[aria-pressed="true"]`, handleChange, {signal}, ); delegate(viewedToggleSelector, 'click', handleChange, {signal}); - delegate(viewedToggleSelector, 'change', handleChange, {signal}); } void features.add(import.meta.url, { From 787acea92ee95af58b6139001a07601a08a9aaba Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 13:53:51 +0200 Subject: [PATCH 24/30] Observe viewed buttons only after their state loads Avoid marking unviewed buttons as seen before aria-pressed becomes true. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index de6d52d28b6a..4cb7b639b402 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -33,7 +33,7 @@ export function init(signal: AbortSignal): void { observe( // The pressed selector also catches viewed state loaded after the button renders. - `${treeLinkSelector}, ${viewedToggleSelector}, ${viewedToggleSelector}[aria-pressed="true"]`, + `${treeLinkSelector}, ${viewedToggleSelector}[aria-pressed="true"]`, handleChange, {signal}, ); From 6b242118d11b82a9e1b3f1be72641c9638771852 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 14:52:09 +0200 Subject: [PATCH 25/30] Check viewed state directly in the tree link loop Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 4cb7b639b402..6be58b88eebc 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -13,16 +13,12 @@ const treeLinkSelector = 'ul[aria-label="File Tree"] a[href*="#"]'; const dimmedClass = 'rgh-dim-viewed-files'; const updateTree = debounce((): void => { - const viewedAnchors = new Set(); - for (const file of $$optional('[class^="Diff-module__diffTargetable"]')) { - if (elementExists(viewedToggleSelector + '[aria-pressed="true"]', file)) { - viewedAnchors.add('#' + file.id); - } - } - for (const link of $$optional(treeLinkSelector)) { const row = closestElement('[role="treeitem"]', link); - row.classList.toggle(dimmedClass, viewedAnchors.has(link.hash)); + const viewed = elementExists( + `#${CSS.escape(link.hash.slice(1))} ${viewedToggleSelector}[aria-pressed="true"]`, + ); + row.classList.toggle(dimmedClass, viewed); } }, {wait: 100}); From ec0e5a24f8c3dc8f3360747ae34a10bbbf603db7 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 14:53:31 +0200 Subject: [PATCH 26/30] Remove redundant viewed-state callback wrapper Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 6be58b88eebc..7794f88da613 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -23,17 +23,13 @@ const updateTree = debounce((): void => { }, {wait: 100}); export function init(signal: AbortSignal): void { - function handleChange(): void { - updateTree(); - } - observe( // The pressed selector also catches viewed state loaded after the button renders. `${treeLinkSelector}, ${viewedToggleSelector}[aria-pressed="true"]`, - handleChange, + updateTree, {signal}, ); - delegate(viewedToggleSelector, 'click', handleChange, {signal}); + delegate(viewedToggleSelector, 'click', updateTree, {signal}); } void features.add(import.meta.url, { From 5147ccc66cc83ecf3d9f1c15df1404695c430ead Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 14:58:38 +0200 Subject: [PATCH 27/30] Use an array for new-view observer selectors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- source/features/dim-viewed-files.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/features/dim-viewed-files.tsx b/source/features/dim-viewed-files.tsx index 7794f88da613..a0a9d8287246 100644 --- a/source/features/dim-viewed-files.tsx +++ b/source/features/dim-viewed-files.tsx @@ -24,8 +24,11 @@ const updateTree = debounce((): void => { export function init(signal: AbortSignal): void { observe( - // The pressed selector also catches viewed state loaded after the button renders. - `${treeLinkSelector}, ${viewedToggleSelector}[aria-pressed="true"]`, + [ + treeLinkSelector, + // The pressed selector also catches viewed state loaded after the button renders. + `${viewedToggleSelector}[aria-pressed="true"]`, + ], updateTree, {signal}, ); From b3ac41d72b03d94e44a7bd40932206218032f049 Mon Sep 17 00:00:00 2001 From: xremix Date: Sat, 10 Oct 2026 15:46:19 +0200 Subject: [PATCH 28/30] move feature in readme to bottom --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index e777492260c0..3abc689c8dca 100644 --- a/readme.md +++ b/readme.md @@ -235,13 +235,13 @@ https://github.com/refined-github/refined-github/wiki/Contributing#metadata-guid - [](# "batch-mark-files-as-viewed") [Mark/unmark multiple files as “Viewed” in the PR Files tab. Click on the first checkbox you want to mark/unmark and then `shift`-click another one; all the files between the two checkboxes will be marked/unmarked as “Viewed”.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257009611-17249bee-d2e2-42ac-bdf0-ebc90029544e.gif) - [](# "closing-remarks") 🔥 [Shows the first Git tag a merged PR was included in or suggests creating a release if not yet released.](https://user-images.githubusercontent.com/1402241/169497171-85d4a97f-413a-41b4-84ba-885dca2b51cf.png) - [](# "pr-jump-to-first-non-viewed-file") [Jumps to first non-viewed file in a PR when clicking on the progress bar.](https://github-production-user-asset-6210df.s3.amazonaws.com/140871606/257011208-764f509d-fed9-424b-84e9-c01cf2fd428b.gif) -- [](# "dim-viewed-files") [Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find.](https://private-user-images.githubusercontent.com/9824294/665923384-1b7a5682-ec3d-4ca4-8608-71ad3b6a2228.png) - [](# "view-last-pr-deployment") [Adds a link to open the latest deployment to the PR header.](https://github.com/user-attachments/assets/90d2498d-ef65-45b9-96eb-07e7988bea61) - [](# "no-unnecessary-split-diff-view") [Always uses unified diffs on files where split diffs aren’t useful.](https://user-images.githubusercontent.com/46634000/121495005-89af8600-c9d9-11eb-822d-77e0b987e3b1.png) - [](# "emphasize-draft-pr-label") [Makes it easier to distinguish draft PR in lists.](https://user-images.githubusercontent.com/1402241/218252438-062a1ab3-4437-436d-9140-87bee23aaefb.png) - [](# "clean-checks-list") Prioritizes failing and pending checks in the merge checks list, and untruncates long check names. - [](# "mobile-tabs-pr") Makes the PR tabs smaller so they fit in the screen without scrolling. - [](# "linkify-pr-review-state") [Linkifies the PR review state labels ("Draft", "Changes requested", etc) in PR lists to filter by that state.](https://github.com/user-attachments/assets/9c59ba75-d2f2-41d4-a214-24d123f78428) +- [](# "dim-viewed-files") [Dims viewed files and folders containing only viewed files in the PR file tree, keeping unreviewed files easy to find.](https://private-user-images.githubusercontent.com/9824294/665923384-1b7a5682-ec3d-4ca4-8608-71ad3b6a2228.png)