Created
October 15, 2026 14:16
-
-
Save warrenseine/c52ab0ebb9d45bd4d4073a2319ac8399 to your computer and use it in GitHub Desktop.
Align added and deleted files in Linear split diffs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Align added and deleted files in Linear split diffs | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2026-10-15 | |
| // @description Show added files on the right and deleted files on the left of Linear's split diff | |
| // @author Warren Seine | |
| // @match https://linear.app/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=linear.app | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| const FILE = '[role="group"][aria-label^="Changes in "]'; | |
| const HEADER = '[data-review-file-header]'; | |
| const GRID = '[data-diff-sticky-window] > div > div'; | |
| const SIDE = 'data-split-side'; | |
| /* | |
| * Linear renders a file with one side only (added or deleted) as a single | |
| * full-width column, even in split view. Half its width, on the side its | |
| * lines belong to, keeps it in line with the files around it. | |
| */ | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| ${FILE}[${SIDE}="right"] { width: 50% !important; margin-left: 50% !important; } | |
| ${FILE}[${SIDE}="left"] { width: 50% !important; } | |
| `; | |
| document.head.appendChild(style); | |
| /* | |
| * The split or unified choice lives in a popover that is not in the page | |
| * until opened, so it is read off the diffs instead: a split file has a | |
| * column per side, a unified one has a single column despite changes on | |
| * both. A pull request of only added files shows neither, so the last mode | |
| * seen is kept for it. | |
| */ | |
| const MODE = 'linear-diff-split-mode'; | |
| function remembered() { | |
| try { | |
| return localStorage.getItem(MODE) !== 'unified'; | |
| } catch (error) { | |
| return true; | |
| } | |
| } | |
| function remember(split) { | |
| try { | |
| localStorage.setItem(MODE, split ? 'split' : 'unified'); | |
| } catch (error) { | |
| /* ignore */ | |
| } | |
| } | |
| function columns(file) { | |
| const grid = file.querySelector(GRID); | |
| return grid ? getComputedStyle(grid).gridTemplateColumns : null; | |
| } | |
| // The header counts the added and deleted lines, e.g. +121 and -3. | |
| function counts(file) { | |
| const header = file.previousElementSibling; | |
| const found = {added: false, deleted: false}; | |
| if (!header || !header.matches(HEADER)) return found; | |
| for (const element of header.querySelectorAll('*')) { | |
| if (element.children.length) continue; | |
| const text = element.textContent.trim(); | |
| if (/^\+\d+$/.test(text)) found.added = true; | |
| if (/^[-−]\d+$/.test(text)) found.deleted = true; | |
| } | |
| return found; | |
| } | |
| function align() { | |
| const files = [...document.querySelectorAll(FILE)].map(file => { | |
| const cols = columns(file); | |
| return {file, cols, split: !!cols && cols.includes('[modified-code]'), ...counts(file)}; | |
| }); | |
| if (files.some(f => f.split)) remember(true); | |
| else if (files.some(f => f.cols && f.added && f.deleted)) remember(false); | |
| const split = remembered(); | |
| for (const f of files) { | |
| let side = null; | |
| if (split && f.cols && !f.split) { | |
| if (f.added && !f.deleted) side = 'right'; | |
| if (f.deleted && !f.added) side = 'left'; | |
| } | |
| if (side) { | |
| if (f.file.getAttribute(SIDE) !== side) f.file.setAttribute(SIDE, side); | |
| } else if (f.file.hasAttribute(SIDE)) { | |
| f.file.removeAttribute(SIDE); | |
| } | |
| } | |
| } | |
| // Files render as they scroll into view, and reviews open by client-side | |
| // navigation, so keep watching the page. | |
| let scheduled = false; | |
| new MutationObserver(() => { | |
| if (scheduled) return; | |
| scheduled = true; | |
| setTimeout(() => { | |
| scheduled = false; | |
| align(); | |
| }, 100); | |
| }).observe(document.body, {childList: true, subtree: true}); | |
| align(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment