| 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. */ |
| 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 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 | */ |
| 134 | function 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 | */ |
| 151 | function 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 | */ |
| 175 | function 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 | */ |
| 198 | function 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 | */ |
| 223 | const 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 | */ |
| 252 | function 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 | */ |
| 288 | function 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 | */ |
| 308 | function 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 | */ |
| 331 | function 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 | */ |
| 350 | function 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 | */ |
| 384 | function 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 | */ |
| 404 | function describe(target) { |
| 405 | return target.window ? `${target.session} (one window)` : target.session; |
| 406 | } |
| 407 | |
| 408 | const 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 | |
| 426 | if (typeof module !== "undefined" && module.exports) { |
| 427 | module.exports = Tabpin; |
| 428 | } |