anvilsign in

collin/browser-terminal-extension

main / extension / lib / tabpin.js
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 https://app.localhost -> 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` name that portless would hand to
37// a project we have a session in is treated as pinned to that session even
38// though 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 hostname an origin carries, or "".
126 *
127 * Detection has nothing else to go on: portless takes the port out of the URL
128 * on purpose, so `https://app.localhost` and `https://app.localhost:8443` name
129 * the same project and the port is noise.
130 *
131 * @param {string} origin
132 * @returns {string}
133 */
134function hostOf(origin) {
135 if (!origin) return "";
136 try {
137 return new URL(origin).hostname;
138 } catch {
139 return "";
140 }
141}
142
143/**
144 * The last path segment, which is the name portless falls back to: absent a
145 * `package.json` name it uses the git repo's directory name, and a session's
146 * working directory is normally that directory or something under it.
147 *
148 * @param {string | null | undefined} path
149 * @returns {string}
150 */
151function baseName(path) {
152 if (!path) return "";
153 const parts = path.replace(/\/+$/, "").split("/");
154 return parts[parts.length - 1] ?? "";
155}
156
157/**
158 * The session a `.localhost` origin most likely belongs to, by portless's
159 * naming, or null when nothing matches.
160 *
161 * Two candidate names per session, because either can be the one portless
162 * inferred: the working directory's own name, and the session name (the omnibar
163 * names sessions after the project directory, so it usually *is* the name). The
164 * directory is checked first — it is what portless actually reads.
165 *
166 * Ambiguity resolves to nothing. Two checkouts of one project can share a name,
167 * and a wrong auto-switch is worse than no auto-switch: it moves a terminal you
168 * were reading out from under you for a reason you cannot see.
169 *
170 * @param {string} origin
171 * @param {TbSessionInfo[]} sessions
172 * @param {typeof Portless} portless
173 * @returns {TbPinTarget | null}
174 */
175function detectTarget(origin, sessions, portless) {
176 const host = hostOf(origin);
177 if (!portless.split(host)) return null;
178
179 /** @type {TbSessionInfo[]} */
180 const hits = [];
181 for (const s of sessions) {
182 if (portless.owns(host, baseName(s.path)) || portless.owns(host, s.name)) hits.push(s);
183 }
184 if (hits.length !== 1) return null;
185 return { session: hits[0].name, window: null };
186}
187
188/**
189 * Where the terminal should go for this tab, and why — or null for "stay where
190 * you are", which is the answer for every tab nobody has said anything about.
191 *
192 * @param {TbTabPinStore} store
193 * @param {{ id?: number, url?: string }} tab
194 * @param {TbSessionInfo[]} sessions
195 * @param {typeof Portless} portless
196 * @returns {{ target: TbPinTarget, source: TbPinSource } | null}
197 */
198function resolve(store, tab, sessions, portless) {
199 if (!store.enabled) return null;
200 const origin = originOf(tab.url);
201
202 // Presence is the statement, so `in` rather than a truthiness test: a stored
203 // null is a veto and has to stop the search rather than fall through it.
204 const byTab = tab.id != null ? String(tab.id) : "";
205 if (byTab && byTab in store.byTab) {
206 const t = store.byTab[byTab];
207 return t ? { target: t, source: "tab" } : null;
208 }
209 if (origin && origin in store.byOrigin) {
210 const t = store.byOrigin[origin];
211 return t ? { target: t, source: "origin" } : null;
212 }
213 if (!store.detect || !origin) return null;
214 const auto = detectTarget(origin, sessions, portless);
215 return auto ? { target: auto, source: "detect" } : null;
216}
217
218/**
219 * How specific a rule is, for picking between two tabs that both point here.
220 * The same order the forward direction searches in, which is the point: the
221 * tab a pin would choose going one way is the tab it chooses coming back.
222 */
223const SOURCE_RANK = { tab: 0, origin: 1, detect: 2 };
224
225/**
226 * The browser tab to show now that the terminal is at `spot` — or null for
227 * "leave the browser alone", which is the answer far more often than not.
228 *
229 * Deliberately expressed as the forward rule run over every tab rather than as
230 * a second set of rules read out of the store backwards. A pin, a site pin, a
231 * veto and a portless guess already compose into one answer per tab; asking each
232 * tab "would you bring the terminal here?" and keeping the ones that say yes
233 * means the two directions cannot disagree about what a pin means, and that a
234 * veto keeps vetoing when read from this end.
235 *
236 * Two tabs can answer yes — two tabs on the pinned origin, most obviously — so
237 * ties go to the more specific rule, and then to whichever tab is earlier in
238 * `tabs`. The caller sorts by last use, so that is the one you were reading.
239 *
240 * The tab already showing wins outright, by stopping the search: if it points
241 * here there is nothing to do, and doing nothing is what keeps this from
242 * fighting the forward direction.
243 *
244 * @param {TbTabPinStore} store
245 * @param {TbSpot} spot where the terminal is now
246 * @param {{ id?: number, url?: string, active?: boolean }[]} tabs the panel's
247 * own browser window, most recently used first
248 * @param {TbSessionInfo[]} sessions
249 * @param {typeof Portless} portless
250 * @returns {{ tabId: number, source: TbPinSource } | null}
251 */
252function reverse(store, spot, tabs, sessions, portless) {
253 if (!store.enabled || !store.reverse) return null;
254 /** @type {{ tabId: number, source: TbPinSource } | null} */
255 let best = null;
256 for (const tab of tabs) {
257 if (tab.id == null) continue;
258 const hit = resolve(store, tab, sessions, portless);
259 if (!hit) continue;
260 // Against the *resolved* pin, not the pin as written, for the reason
261 // `applyTabPin` compares that way too: a pin to a window that has closed is
262 // a pin to its session, and has to compare as one from either end.
263 const at = locate(hit.target, sessions);
264 if (!at) continue;
265 if (!sameSpot({ session: at.session, window: at.window?.id ?? null }, spot)) continue;
266 if (tab.active) return null;
267 if (!best || SOURCE_RANK[hit.source] < SOURCE_RANK[best.source]) {
268 best = { tabId: tab.id, source: hit.source };
269 }
270 }
271 return best;
272}
273
274/**
275 * The pin as the terminal can actually act on it: the window if it is still
276 * there, the session's own active window if it is not, and null when the whole
277 * session has gone.
278 *
279 * A pin outliving its window is the normal case, not the broken one — you pin
280 * the session you run the dev server in, then close and reopen the window it
281 * was running in. Falling back to the session keeps that pin useful instead of
282 * quietly dead.
283 *
284 * @param {TbPinTarget} target
285 * @param {TbSessionInfo[]} sessions
286 * @returns {{ session: string, window: TbWindowInfo | null } | null}
287 */
288function locate(target, sessions) {
289 const s = sessions.find((x) => x.name === target.session);
290 if (!s) return null;
291 const w = target.window ? s.windows.find((x) => x.id === target.window) : null;
292 return { session: s.name, window: w ?? null };
293}
294
295/**
296 * Set or clear one entry, returning a new store — the caller writes it to
297 * storage, so nothing here mutates what is already live.
298 *
299 * `target` null writes the veto; undefined removes the entry entirely and lets
300 * the layer below (an origin pin, then detection) answer again.
301 *
302 * @param {TbTabPinStore} store
303 * @param {"tab" | "origin"} kind
304 * @param {string | number} key
305 * @param {TbPinTarget | null | undefined} target
306 * @returns {TbTabPinStore}
307 */
308function put(store, kind, key, target) {
309 const next = {
310 ...store,
311 byTab: { ...store.byTab },
312 byOrigin: { ...store.byOrigin },
313 };
314 const map = kind === "tab" ? next.byTab : next.byOrigin;
315 if (target === undefined) delete map[String(key)];
316 else map[String(key)] = target;
317 return next;
318}
319
320/**
321 * Drop tab pins for tabs that no longer exist.
322 *
323 * Tab ids are per-run, so without this the map grows by one entry every time a
324 * pinned tab is closed and never shrinks. Origin pins are left alone: they are
325 * names, and a name whose session is not running today may be running tomorrow.
326 *
327 * @param {TbTabPinStore} store
328 * @param {number[]} liveTabIds
329 * @returns {TbTabPinStore | null} null when nothing needed dropping
330 */
331function pruneTabs(store, liveTabIds) {
332 const live = new Set(liveTabIds.map(String));
333 const stale = Object.keys(store.byTab).filter((id) => !live.has(id));
334 if (!stale.length) return null;
335 const next = { ...store, byTab: { ...store.byTab }, byOrigin: { ...store.byOrigin } };
336 for (const id of stale) delete next.byTab[id];
337 return next;
338}
339
340/**
341 * Whether two places in tmux are the same place.
342 *
343 * A target with no window names a whole session, so the comparison stops at the
344 * session name — "the session, wherever it is currently pointed" is satisfied by
345 * any window of it.
346 *
347 * @param {TbSpot | null} a
348 * @param {TbSpot | null} b
349 */
350function sameSpot(a, b) {
351 if (!a || !b) return false;
352 if (a.session !== b.session) return false;
353 if (!a.window || !b.window) return true;
354 return a.window === b.window;
355}
356
357/**
358 * The whole state machine for "where should the terminal be, and what do we owe
359 * it afterwards" — one step per browser tab change.
360 *
361 * A pin moving the terminal is an excursion, not a relocation. Leaving for a tab
362 * with no pin of its own puts it back where it was before the first pinned tab
363 * took it, so flicking between a pinned tab and an unpinned one flicks the
364 * terminal between the two places rather than stranding it on the pinned one.
365 *
366 * Three things make that safe to do automatically:
367 *
368 * - Only the *first* pin in a run records a return. Pinned tab to pinned tab
369 * to unpinned goes back to where the run started, not to the middle of it,
370 * because the middle was never somewhere the user chose to be.
371 * - Nothing is owed when the pin had nothing to do. Landing on a tab pinned
372 * to where you already are records no return, so leaving it moves nothing.
373 * - A terminal that has been moved by hand since is left alone. The return is
374 * an undo of a move this code made, and once that move is gone there is
375 * nothing to undo — dragging the user back from somewhere they steered to
376 * themselves would be the panel overruling them.
377 *
378 * @param {TbPinReturn | null} owed what an earlier pin move left outstanding
379 * @param {TbSpot | null} target where the showing tab points, null if nowhere
380 * @param {TbSpot} at where the terminal is now
381 * @returns {{ go: TbSpot | null, owed: TbPinReturn | null }} where to move it,
382 * and what is outstanding afterwards
383 */
384function step(owed, target, at) {
385 if (!target) {
386 // Unpinned. Hand the terminal back if the excursion is still standing.
387 if (owed && sameSpot(at, owed.to)) return { go: owed.from, owed: null };
388 return { go: null, owed: null };
389 }
390 if (sameSpot(at, target)) {
391 // Already there. An outstanding return survives — this tab is part of the
392 // same excursion — but a new one is not opened, because nothing moved.
393 return { go: null, owed: owed ? { ...owed, to: target } : null };
394 }
395 return { go: target, owed: owed ? { ...owed, to: target } : { from: at, to: target } };
396}
397
398/**
399 * How the pin reads in a menu or a tooltip.
400 *
401 * @param {TbPinTarget} target
402 * @returns {string}
403 */
404function describe(target) {
405 return target.window ? `${target.session} (one window)` : target.session;
406}
407
408const Tabpin = {
409 KEY: TABPIN_KEY,
410 emptyStore,
411 loadStore,
412 originOf,
413 hostOf,
414 baseName,
415 detectTarget,
416 resolve,
417 reverse,
418 locate,
419 put,
420 pruneTabs,
421 sameSpot,
422 step,
423 describe,
424};
425
426if (typeof module !== "undefined" && module.exports) {
427 module.exports = Tabpin;
428}