
Bring sanity back to GitHub diffs.
DiffSanity is a Chrome extension (Manifest V3) for reviewing GitHub pull requests. It adds a DiffSanity icon button to every file on a PR’s Files changed tab, and a DiffSanity button next to GitHub’s Submit review that opens the PR’s first file. The button opens that file in a focused review page:
It works on github.com and on GitHub Enterprise Cloud with data residency (<enterprise>.ghe.com).
Everything runs in your browser. There is no backend, no OAuth and no tokens.
| GitHub file header | Review page (light) | Side by side | Review page (dark) | Azure DevOps Dark |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
Requires Node.js 24 LTS (or 22.22.2+). Building alone works on Node 20, but the tests need the newer versions.
npm install
npm run build
chrome://extensions and turn on Developer mode.dist/ folder.Ctrl/⌘-click the button to open the review in a background tab.
| Command | What it does |
|---|---|
npm run build |
Bundles src/ with esbuild and compiles styles/review.css with Tailwind CSS into dist/ |
npm run watch |
Rebuilds on change; reload the extension in chrome://extensions |
npm run typecheck |
tsc --noEmit (strict) |
npm test |
Unit tests (Vitest + jsdom) |
npm run e2e |
Loads dist/ in Playwright’s Chromium against real public PRs and saves screenshots to docs/screenshots/. First run: npx playwright install chromium |
npm run package |
Builds and zips dist/ into release/diffsanity-<version>.zip for the Chrome Web Store and Edge Add-ons. Bump version in both manifest.json and package.json first |
Contributing (people or AI agents): read AGENTS.md for the rules, code style, testing and release process.
Branding. assets/icon.png (512 px) is the source for the extension icons in icons/ (16/32/48/128 px; the 128 px icon follows the Chrome Web Store’s 96 px artwork with 16 px padding). assets/logo.png is the logo with the wordmark. The GitHub button uses an inline SVG in src/content/injectButton.ts.
flowchart LR
subgraph GitHub tab
C[content/index.ts<br/>observer + messaging] --> G[content/github.ts<br/>all GitHub DOM knowledge]
C --> B[content/injectButton.ts]
end
C -- diffsanity:open --> W[background/index.ts<br/>opens tab]
W --> R
subgraph Review tab: review.html
R[review/reviewPage.ts] --> F[fileSource.ts<br/>commits + raw files]
R --> D[diff.ts<br/>jsdiff]
R --> S[syntax.ts<br/>Prism]
R --> X[renderer.ts + layout.ts<br/>one column or side by side]
R --> M[minimap.ts<br/>canvas]
R --> K[comments.ts<br/>+ button, thread markers]
R --> A[sourceTab.ts<br/>hand-offs to the PR tab]
R --> T[fileTree.ts + sidebar.ts<br/>PR file list]
end
F -. fallback fetch, listFiles .-> C
K -. lineInfo / goToLine .-> C
R -. revealFile .-> C
| Path | Responsibility |
|---|---|
src/content/github.ts |
The only module that knows GitHub’s DOM. Finds file headers (path, old path, status, anchor), the PR’s full file list, the commits being compared and the colour mode. It also reads which diff lines and review threads GitHub renders, scrolls GitHub to a file, and opens GitHub’s comment box. |
src/content/injectButton.ts |
Adds one button per header, and one pull request button next to GitHub’s review button. Idempotent: it is safe to run on every DOM change. |
src/content/index.ts |
Watches the DOM (MutationObserver, throttled to 150 ms) plus turbo:load/popstate, so buttons survive client-side navigation, lazy-loaded files and re-renders. Acts only on /pull/N/files (and /changes). |
src/background/index.ts |
Opens review.html?<request> next to the source tab. |
src/shared/githubContext.ts |
Pure helpers: URL parsing, commit extraction, rename parsing, raw URLs, review-request (de)serialisation and validation, and the allowed-origin list. |
src/review/reviewPage.ts |
Runs the review tab. Switches files in place, caches each loaded file and its reading position (kept when the layout changes), switches the layout, and keeps the URL and the GitHub tab in step with the open file. |
src/review/fileSource.ts |
Resolves the base/head commits, gets the PR’s file list and downloads both file versions. The file list comes from the GitHub tab when it holds GitHub’s full list. Otherwise (for example after clicking to Files changed without a page load) the PR page is fetched. |
src/review/fileStatus.ts |
Plain-word labels and Octicon icons for a file’s status (new, modified, deleted, renamed). |
src/review/icons.ts |
Draws an inline Octicon SVG (no icon fonts or remote assets). |
src/review/fileTree.ts |
Builds the folder tree (folders first, single-child folders merged) and renders the file list with filtering and keyboard navigation. |
src/review/diffstat.ts |
Works out the five-block +/− change bar shown in the toolbar. |
src/review/sidebar.ts |
Shows, hides and resizes the file list, and remembers its width and visibility in localStorage. |
src/review/diff.ts |
Line diff (jsdiff diffArrays, 3 s timeout) to a normalised DiffLine[] model. |
src/review/syntax.ts |
Highlights whole files with Prism, so multi-line comments and strings are correct, then splits the output into per-line HTML. |
src/review/renderer.ts |
Renders rows at a fixed 20 px height in 200-line content-visibility: auto chunks. This keeps large files fast while Ctrl+F still finds everything. Side by side, it draws two panes that scroll sideways together with one bar pinned to the bottom. |
src/review/layout.ts |
Pairs diff lines into side-by-side rows (deleted lines next to the lines that replace them) and remembers the layout in localStorage (diffsanity.layout). |
src/review/minimap.ts |
Canvas minimap with change bands, a code-shape outline, a change ruler and review-thread marks. The canvas is only redrawn on resize, theme or thread changes; scrolling just moves the viewport element. |
src/review/themes.ts |
The theme list, and how the saved choice resolves to a theme (auto follows GitHub’s mode). Choices are stored in localStorage (diffsanity.theme), so they apply before first paint and sync to open tabs. The colours themselves are :root[data-theme="…"] CSS variables in styles/review.css. |
src/review/comments.ts |
Maps GitHub’s line keys (R12 = new line 12, L7 = old line 7) onto the full-file diff. Shows + on lines GitHub lets you comment on, and markers for existing threads. Clicking either switches to the PR tab and asks the content script to scroll there and open the comment box. It re-reads the PR tab whenever the review tab becomes visible again. DiffSanity never posts anything itself. |
src/review/sourceTab.ts |
Switches to the PR tab and sends it a request (open the comment box, open the review box with Approve selected), and switches back to explain when that fails. |
start_commit_oid/base_commit_oid and end_commit_oid in GitHub’s lazy-load URLs. The base is the merge base (or the start of a selected commit range), which is what GitHub diffs against, not the base-branch tip. If they are missing, the review page fetches the PR page HTML itself. As a last resort it uses the public REST API (pulls/N, then compare for the merge base).{origin}/{owner}/{repo}/raw/{sha}/{path}, where the origin is https://github.com or https://<enterprise>.ghe.com. Renamed files use the old path for the base. If the review page’s own request is refused, it asks the content script in the GitHub tab to fetch the file from the page’s origin. That request sends cookies to the page’s origin only, because the raw host it redirects to (with a token in the URL) refuses cross-origin requests that carry cookies.HEAD requests to the same raw URLs whether the file exists at the base and head commits: missing at the base means new, missing at the head means deleted, present on both means modified (or renamed when the old path is known). Only a 404 counts as missing, and only when the other side loaded, so a file that cannot be reached is left without an icon instead of being mislabelled. At most 500 files are checked, 6 at a time.All selectors live in src/content/github.ts (and the commit regexes in src/shared/githubContext.ts). If GitHub changes its markup, these are the only places to fix.
| What | Classic (server-rendered) view | React (“new Files changed”) view |
|---|---|---|
| File header | .file-header[data-path] |
h3 a[href^="#diff-"] inside [class*="DiffFileHeader-module__diff-file-header"] |
| File path | data-path |
[data-file-path] in the header (missing for added files), else the link text without invisible direction marks (U+200E) |
| Rename | link title/text old → new |
link text old → new, or .sr-only text old renamed to new |
| PR file list | file-tree [data-tree-entry-type="file"]: path in [data-filterable-item-text], status from the octicon-diff-* icon, anchor from a[href^="#diff-"] |
script[data-target="react-app.embeddedData"] → payload.pullRequestsChangesRoute.diffSummaries (path, pathDigest, changeType, line counts) |
| Show a file (from the file list) | #<anchor> if rendered, else click a[href="#<anchor>"] |
same |
| Deleted | data-file-deleted="true" |
inferred from a 404 on the head side |
| Anchor (for “Back”) | data-anchor |
href="#diff-…" |
| Button placement | end of .file-info |
end of the h3’s parent (after GitHub’s copy / expand icons) |
| Pull request button | start of .pr-toolbar .pr-review-tools (before “Review changes”; empty when signed out) |
just before the “Submit review” button (see Review box) |
| Commits | [data-url*="commit_oid"] / [src*="commit_oid"], then script[type="application/json"], then .file-header a[href*="/blob/<sha>/"], then the full page HTML |
same sources |
| Theme | html[data-color-mode] (auto resolves via prefers-color-scheme) |
same |
Diff line (<anchor>R12 / <anchor>L7) |
td[id] (line-number cells) |
td[data-line-anchor] (code cell) |
| Review thread | tr.inline-comments after the line’s row, one [id^="review-thread-or-comment-id-"] per thread |
[data-marker-id] inside the line’s code cell |
| Add-comment button | .js-add-line-comment in the row |
button[data-add-comment-button], rendered once the code cell is focused |
| Review box (“Approve”) | input[name="pull_request_review[event]"][value="approve"] inside the “Review changes” <details>, which is opened |
click button[class*="ReviewMenuButton-module__ReviewMenuButton"] (or a button whose text starts with “Submit review”), then input[name="reviewEvent"][value="approve"] |
| Signed in | meta[name="user-login"] has content |
same |
The selectors rely on semantic data attributes and stable link shapes rather than generated class names. There are two exceptions. A substring match on DiffFileHeader-module__diff-file-header is only used to find the header root; if it is missing, the h3’s parent is used instead. A substring match on ReviewMenuButton-module__ReviewMenuButton finds the “Submit review” button (for Approve and the pull request button); if it is missing, the button is found by its text.
Unit tests cover URL and commit parsing, the classic and React header fixtures, button idempotency, the diff model, syntax line-splitting, side-by-side row pairing and the minimap maths. They also cover reading diff lines and threads in both views, opening the comment box and the review box with Approve selected, mapping GitHub’s line keys onto the full file, and working out a file’s status from the commits (directly, through the GitHub tab, and when the files cannot be reached). That status check was also run against live github.com on a 70-file PR and matched GitHub’s own status for every new, modified, deleted and renamed file. npm run e2e checks these against live github.com:
pushState URL change plus a newly rendered file header gets a button only on the Files tab.Verified by hand while signed in (React view): + opens GitHub’s “Add a comment on line R116” box with the cursor in it, and thread markers match on a PR with comments.
Verified by hand on a GHE.com enterprise (internal repository, SSO): buttons appear on every file, the review page loads the whole file with the diff, minimap and revisions, and + appears on commentable lines. The file list was checked on two GHE.com PRs (44 and 22 files, including a renamed file): every file is listed and opens without errors. Every file also gets its status icon when Files changed was reached from the Conversation tab without a page load. Approve opens GitHub’s “Finish your review” box on the PR tab with Approve selected (nothing was submitted).
.js-add-line-comment) and the “Review changes” menu for Approve are not verified, because signed-in users get the React view. If the comment box fails, the line is still scrolled into view; if the review menu is not found, the review tab says so.ch units, so wide (CJK) characters can make the horizontal scroll width slightly short.*.ghe.com only. GitHub Enterprise Server (self-hosted, any domain) is not supported: each host would need to be granted at runtime through optional host permissions. On GHE.com the public REST API fallback for commits is not used.FileDiff model and the separate renderer and minimap are meant to make these additive.npm run package builds the store zip. Listing text, privacy answers and store images for the Chrome Web Store and Edge Add-ons are in docs/store/. Store images were made once from the E2E screenshots and are committed as-is.
DiffSanity collects no data; see PRIVACY.md. Released under the MIT License.