Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save warrenseine/c52ab0ebb9d45bd4d4073a2319ac8399 to your computer and use it in GitHub Desktop.

Select an option

Save warrenseine/c52ab0ebb9d45bd4d4073a2319ac8399 to your computer and use it in GitHub Desktop.
Align added and deleted files in Linear split diffs
// ==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