anvilsign in

collin/browser-terminal-extension

1// Pinning a tmux session (or one window of one) to a browser tab.
2//
3// The point is that moving between browser tabs moves the terminal with you:
4// the tab you keep the app in brings up the session you run the app from, and
5// the docs tab beside it brings up whatever you had there.
6//
7// The same pin also reads backwards — moving the terminal brings the browser
8// along — and that half is the more delicate one, because the browser is where
9// the user's hands are. Three things keep it from yanking anything:
10//
11// - It only ever *activates* a tab that is already open in the panel's own
12// browser window. Nothing is created, nothing is focused, no window is
13// raised. The worst it can do is show you a tab you already had.
14// - It does nothing at all when the tab already showing satisfies the pin,
15// which is what stops the two directions from chasing each other: forward
16// will not move a terminal that is already where the tab points, and
17// backward will not move a browser that is already on a tab that points
18// here. Whichever fires second finds its work done.
19// - It is off in one checkbox, separately from the forward direction, for
20// anyone who wants the terminal to follow without the browser leading.
21//
22// Two kinds of pin, and the difference is what they key on:
23//
24// by origin http://localhost:26210 -> a session. Any tab on that origin
25// matches, and the pin outlives the tab, the window and the
26// browser itself, because an origin is a name and a tab id is a
27// handle. This is the one to reach for.
28// by tab this exact tab, by the id Chrome gave it. Survives nothing —
29// tab ids are not reissued but they are not remembered across a
30// restart either — and exists for the case an origin cannot
31// express: two tabs on the same site pointing at different
32// sessions, or a page whose URL says nothing (a file:, a blank
33// tab, one of five identical Jira boards).
34//
35// A tab pin wins over an origin pin, being the more specific statement. Below
36// both sits detection: a tab on a localhost port that devport would hand to a
37// project we have a session in is treated as pinned to that session even though
38// nobody said so. That guess is only ever a fallback — an explicit pin at
39// either level overrides it, including the explicit *veto* that unpinning an
40// auto-matched tab writes, which is the only way to say "no, not this one" to a
41// rule that would otherwise keep re-deriving itself.
42//
43// Everything here is pure. Reading tabs, writing storage and telling tmux to
44// move are all sidebar.js's; this module only answers "given what is stored and
45// what is on the server, where should the terminal be?"
46
47/** Storage key. Deliberately not `pins`, which is this panel's *window* pins. */
48const TABPIN_KEY = "tabPins";
49
50/**
51 * An empty store. Shaped rather than `{}` so every caller can index into it
52 * without a guard.
53 * @returns {TbTabPinStore}
54 */
55function emptyStore() {
56 return { enabled: true, detect: true, reverse: true, byTab: {}, byOrigin: {} };
57}
58
59/**
60 * Storage is not a promise: it holds whatever some earlier version of this
61 * panel wrote, and a hostile page cannot reach it but a bug can. So anything
62 * that is not the shape we expect is dropped rather than trusted.
63 *
64 * @param {unknown} raw
65 * @returns {TbTabPinStore}
66 */
67function loadStore(raw) {
68 const store = emptyStore();
69 if (!raw || typeof raw !== "object") return store;
70 const v = /** @type {Record<string, unknown>} */ (raw);
71 if (v.enabled === false) store.enabled = false;
72 if (v.detect === false) store.detect = false;
73 if (v.reverse === false) store.reverse = false;
74 store.byTab = cleanMap(v.byTab);
75 store.byOrigin = cleanMap(v.byOrigin);
76 return store;
77}
78
79/** @param {unknown} raw @returns {Record<string, TbPinTarget | null>} */
80function cleanMap(raw) {
81 /** @type {Record<string, TbPinTarget | null>} */
82 const out = {};
83 if (!raw || typeof raw !== "object") return out;
84 for (const [key, value] of Object.entries(raw)) {
85 // null is a veto — "this tab is pinned to nothing, stop guessing" — and is
86 // as meaningful a stored value as a target.
87 if (value === null) {
88 out[key] = null;
89 continue;
90 }
91 if (!value || typeof value !== "object") continue;
92 const t = /** @type {Record<string, unknown>} */ (value);
93 if (typeof t.session !== "string" || !t.session) continue;
94 out[key] = {
95 session: t.session,
96 window: typeof t.window === "string" && t.window ? t.window : null,
97 };
98 }
99 return out;
100}
101
102/**
103 * The origin a pin would key on, or "" for a tab that cannot carry one.
104 *
105 * Only http and https. Everything else — the browser's own pages, extension
106 * pages, `file:`, `about:blank` — either has no origin worth the name or is a
107 * page this extension is not allowed to look at anyway, and a tab pin is the
108 * answer for those.
109 *
110 * @param {string | undefined} url
111 * @returns {string}
112 */
113function originOf(url) {
114 if (!url) return "";
115 try {
116 const u = new URL(url);
117 if (u.protocol !== "http:" && u.protocol !== "https:") return "";
118 return u.origin;
119 } catch {
120 return "";
121 }
122}
123
124/**
125 * The port a local development server would be on, or null.
126 *
127 * Local only, and deliberately so: devport's mapping says nothing about a port
128 * on someone else's host, and a public site that happens to sit on :26210 is
129 * not your project.
130 *
131 * @param {string} origin
132 * @returns {number | null}
133 */
134function localPort(origin) {
135 if (!origin) return null;
136 try {
137 const u = new URL(origin);
138 const host = u.hostname;
139 const local =
140 host === "localhost" ||
141 host === "127.0.0.1" ||
142 host === "[::1]" ||
143 host === "::1" ||
144 host.endsWith(".localhost");
145 if (!local || !u.port) return null;
146 return Number(u.port);
147 } catch {
148 return null;
149 }
150}
151
152/**
153 * The last path segment, which is the name devport hashes: it takes the git
154 * repo's directory name, and a session's working directory is normally that
155 * directory or something under it.
156 *
157 * @param {string | null | undefined} path
158 * @returns {string}
159 */
160function baseName(path) {
161 if (!path) return "";
162 const parts = path.replace(/\/+$/, "").split("/");
163 return parts[parts.length - 1] ?? "";
164}
165
166/**
167 * The session a localhost origin most likely belongs to, by devport's hash, or
168 * null when nothing matches.
169 *
170 * Two candidate names per session, because either can be the one devport was
171 * run under: the working directory's own name, and the session name (the
172 * omnibar names sessions after the project directory, so it usually *is* the
173 * name). The directory is checked first — it is what devport actually reads.
174 *
175 * Ambiguity resolves to nothing. Blocks collide by design, and a wrong
176 * auto-switch is worse than no auto-switch: it moves a terminal you were
177 * reading out from under you for a reason you cannot see.
178 *
179 * @param {string} origin
180 * @param {TbSessionInfo[]} sessions
181 * @param {typeof Devport} devport
182 * @returns {TbPinTarget | null}
183 */
184function detectTarget(origin, sessions, devport) {
185 const port = localPort(origin);
186 if (port === null || devport.blockOf(port) === null) return null;
187
188 /** @type {TbSessionInfo[]} */
189 const hits = [];
190 for (const s of sessions) {
191 if (devport.owns(port, baseName(s.path)) || devport.owns(port, s.name)) hits.push(s);
192 }
193 if (hits.length !== 1) return null;
194 return { session: hits[0].name, window: null };
195}
196
197/**
198 * Where the terminal should go for this tab, and why — or null for "stay where
199 * you are", which is the answer for every tab nobody has said anything about.
200 *
201 * @param {TbTabPinStore} store
202 * @param {{ id?: number, url?: string }} tab
203 * @param {TbSessionInfo[]} sessions
204 * @param {typeof Devport} devport
205 * @returns {{ target: TbPinTarget, source: TbPinSource } | null}
206 */
207function resolve(store, tab, sessions, devport) {
208 if (!store.enabled) return null;
209 const origin = originOf(tab.url);
210
211 // Presence is the statement, so `in` rather than a truthiness test: a stored
212 // null is a veto and has to stop the search rather than fall through it.
213 const byTab = tab.id != null ? String(tab.id) : "";
214 if (byTab && byTab in store.byTab) {
215 const t = store.byTab[byTab];
216 return t ? { target: t, source: "tab" } : null;
217 }
218 if (origin && origin in store.byOrigin) {
219 const t = store.byOrigin[origin];
220 return t ? { target: t, source: "origin" } : null;
221 }
222 if (!store.detect || !origin) return null;
223 const auto = detectTarget(origin, sessions, devport);
224 return auto ? { target: auto, source: "detect" } : null;
225}
226
227/**
228 * How specific a rule is, for picking between two tabs that both point here.
229 * The same order the forward direction searches in, which is the point: the
230 * tab a pin would choose going one way is the tab it chooses coming back.
231 */
232const SOURCE_RANK = { tab: 0, origin: 1, detect: 2 };
233
234/**
235 * The browser tab to show now that the terminal is at `spot` — or null for
236 * "leave the browser alone", which is the answer far more often than not.
237 *
238 * Deliberately expressed as the forward rule run over every tab rather than as
239 * a second set of rules read out of the store backwards. A pin, a site pin, a
240 * veto and a devport guess already compose into one answer per tab; asking each
241 * tab "would you bring the terminal here?" and keeping the ones that say yes
242 * means the two directions cannot disagree about what a pin means, and that a
243 * veto keeps vetoing when read from this end.
244 *
245 * Two tabs can answer yes — two tabs on the pinned origin, most obviously — so
246 * ties go to the more specific rule, and then to whichever tab is earlier in
247 * `tabs`. The caller sorts by last use, so that is the one you were reading.
248 *
249 * The tab already showing wins outright, by stopping the search: if it points
250 * here there is nothing to do, and doing nothing is what keeps this from
251 * fighting the forward direction.
252 *
253 * @param {TbTabPinStore} store
254 * @param {TbSpot} spot where the terminal is now
255 * @param {{ id?: number, url?: string, active?: boolean }[]} tabs the panel's
256 * own browser window, most recently used first
257 * @param {TbSessionInfo[]} sessions
258 * @param {typeof Devport} devport
259 * @returns {{ tabId: number, source: TbPinSource } | null}
260 */
261function reverse(store, spot, tabs, sessions, devport) {
262 if (!store.enabled || !store.reverse) return null;
263 /** @type {{ tabId: number, source: TbPinSource } | null} */
264 let best = null;
265 for (const tab of tabs) {
266 if (tab.id == null) continue;
267 const hit = resolve(store, tab, sessions, devport);
268 if (!hit) continue;
269 // Against the *resolved* pin, not the pin as written, for the reason
270 // `applyTabPin` compares that way too: a pin to a window that has closed is
271 // a pin to its session, and has to compare as one from either end.
272 const at = locate(hit.target, sessions);
273 if (!at) continue;
274 if (!sameSpot({ session: at.session, window: at.window?.id ?? null }, spot)) continue;
275 if (tab.active) return null;
276 if (!best || SOURCE_RANK[hit.source] < SOURCE_RANK[best.source]) {
277 best = { tabId: tab.id, source: hit.source };
278 }
279 }
280 return best;
281}
282
283/**
284 * The pin as the terminal can actually act on it: the window if it is still
285 * there, the session's own active window if it is not, and null when the whole
286 * session has gone.
287 *
288 * A pin outliving its window is the normal case, not the broken one — you pin
289 * the session you run the dev server in, then close and reopen the window it
290 * was running in. Falling back to the session keeps that pin useful instead of
291 * quietly dead.
292 *
293 * @param {TbPinTarget} target
294 * @param {TbSessionInfo[]} sessions
295 * @returns {{ session: string, window: TbWindowInfo | null } | null}
296 */
297function locate(target, sessions) {
298 const s = sessions.find((x) => x.name === target.session);
299 if (!s) return null;
300 const w = target.window ? s.windows.find((x) => x.id === target.window) : null;
301 return { session: s.name, window: w ?? null };
302}
303
304/**
305 * Set or clear one entry, returning a new store — the caller writes it to
306 * storage, so nothing here mutates what is already live.
307 *
308 * `target` null writes the veto; undefined removes the entry entirely and lets
309 * the layer below (an origin pin, then detection) answer again.
310 *
311 * @param {TbTabPinStore} store
312 * @param {"tab" | "origin"} kind
313 * @param {string | number} key
314 * @param {TbPinTarget | null | undefined} target
315 * @returns {TbTabPinStore}
316 */
317function put(store, kind, key, target) {
318 const next = {
319 ...store,
320 byTab: { ...store.byTab },
321 byOrigin: { ...store.byOrigin },
322 };
323 const map = kind === "tab" ? next.byTab : next.byOrigin;
324 if (target === undefined) delete map[String(key)];
325 else map[String(key)] = target;
326 return next;
327}
328
329/**
330 * Drop tab pins for tabs that no longer exist.
331 *
332 * Tab ids are per-run, so without this the map grows by one entry every time a
333 * pinned tab is closed and never shrinks. Origin pins are left alone: they are
334 * names, and a name whose session is not running today may be running tomorrow.
335 *
336 * @param {TbTabPinStore} store
337 * @param {number[]} liveTabIds
338 * @returns {TbTabPinStore | null} null when nothing needed dropping
339 */
340function pruneTabs(store, liveTabIds) {
341 const live = new Set(liveTabIds.map(String));
342 const stale = Object.keys(store.byTab).filter((id) => !live.has(id));
343 if (!stale.length) return null;
344 const next = { ...store, byTab: { ...store.byTab }, byOrigin: { ...store.byOrigin } };
345 for (const id of stale) delete next.byTab[id];
346 return next;
347}
348
349/**
350 * Whether two places in tmux are the same place.
351 *
352 * A target with no window names a whole session, so the comparison stops at the
353 * session name — "the session, wherever it is currently pointed" is satisfied by
354 * any window of it.
355 *
356 * @param {TbSpot | null} a
357 * @param {TbSpot | null} b
358 */
359function sameSpot(a, b) {
360 if (!a || !b) return false;
361 if (a.session !== b.session) return false;
362 if (!a.window || !b.window) return true;
363 return a.window === b.window;
364}
365
366/**
367 * The whole state machine for "where should the terminal be, and what do we owe
368 * it afterwards" — one step per browser tab change.
369 *
370 * A pin moving the terminal is an excursion, not a relocation. Leaving for a tab
371 * with no pin of its own puts it back where it was before the first pinned tab
372 * took it, so flicking between a pinned tab and an unpinned one flicks the
373 * terminal between the two places rather than stranding it on the pinned one.
374 *
375 * Three things make that safe to do automatically:
376 *
377 * - Only the *first* pin in a run records a return. Pinned tab to pinned tab
378 * to unpinned goes back to where the run started, not to the middle of it,
379 * because the middle was never somewhere the user chose to be.
380 * - Nothing is owed when the pin had nothing to do. Landing on a tab pinned
381 * to where you already are records no return, so leaving it moves nothing.
382 * - A terminal that has been moved by hand since is left alone. The return is
383 * an undo of a move this code made, and once that move is gone there is
384 * nothing to undo — dragging the user back from somewhere they steered to
385 * themselves would be the panel overruling them.
386 *
387 * @param {TbPinReturn | null} owed what an earlier pin move left outstanding
388 * @param {TbSpot | null} target where the showing tab points, null if nowhere
389 * @param {TbSpot} at where the terminal is now
390 * @returns {{ go: TbSpot | null, owed: TbPinReturn | null }} where to move it,
391 * and what is outstanding afterwards
392 */
393function step(owed, target, at) {
394 if (!target) {
395 // Unpinned. Hand the terminal back if the excursion is still standing.
396 if (owed && sameSpot(at, owed.to)) return { go: owed.from, owed: null };
397 return { go: null, owed: null };
398 }
399 if (sameSpot(at, target)) {
400 // Already there. An outstanding return survives — this tab is part of the
401 // same excursion — but a new one is not opened, because nothing moved.
402 return { go: null, owed: owed ? { ...owed, to: target } : null };
403 }
404 return { go: target, owed: owed ? { ...owed, to: target } : { from: at, to: target } };
405}
406
407/**
408 * How the pin reads in a menu or a tooltip.
409 *
410 * @param {TbPinTarget} target
411 * @returns {string}
412 */
413function describe(target) {
414 return target.window ? `${target.session} (one window)` : target.session;
415}
416
417const Tabpin = {
418 KEY: TABPIN_KEY,
419 emptyStore,
420 loadStore,
421 originOf,
422 localPort,
423 baseName,
424 detectTarget,
425 resolve,
426 reverse,
427 locate,
428 put,
429 pruneTabs,
430 sameSpot,
431 step,
432 describe,
433};
434
435if (typeof module !== "undefined" && module.exports) {
436 module.exports = Tabpin;
437}