| 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. */ |
| 48 | const 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 | */ |
| 55 | function 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 | */ |
| 67 | function 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>} */ |
| 80 | function 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 | */ |
| 113 | function 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 | */ |
| 134 | function 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 | */ |
| 160 | function 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 | */ |
| 184 | function 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 | */ |
| 207 | function 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 | */ |
| 232 | const 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 | */ |
| 261 | function 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 | */ |
| 297 | function 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 | */ |
| 317 | function 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 | */ |
| 340 | function 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 | */ |
| 359 | function 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 | */ |
| 393 | function 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 | */ |
| 413 | function describe(target) { |
| 414 | return target.window ? `${target.session} (one window)` : target.session; |
| 415 | } |
| 416 | |
| 417 | const 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 | |
| 435 | if (typeof module !== "undefined" && module.exports) { |
| 436 | module.exports = Tabpin; |
| 437 | } |