collin/browser-terminal-extension · a71dc952
Drop stale autorepeat bursts flushed into the terminal under CPU load
Collin Richards · 2026-08-17 22:21 UTC · a71dc9528f5d70932d60b380d72f96bed25332d9 · parent ea0845f8 · browse files
modifiedextension/sidebar.js+902 −134
| ⋯ 23 unchanged lines | |||
| 24 | 24 | ||
| 25 | 25 | /** @param {string} id @returns {HTMLInputElement} */ | |
| 26 | 26 | const $input = (id) => /** @type {HTMLInputElement} */ ($(id)); | |
| 27 | + | /** @param {string} id @returns {HTMLTextAreaElement} */ | |
| 28 | + | const $area = (id) => /** @type {HTMLTextAreaElement} */ ($(id)); | |
| 27 | 29 | /** @param {string} id @returns {HTMLSelectElement} */ | |
| 28 | 30 | const $select = (id) => /** @type {HTMLSelectElement} */ ($(id)); | |
| 29 | 31 | /** @param {string} id @returns {HTMLButtonElement} */ | |
| ⋯ 106 unchanged lines | |||
| 136 | 138 | // The name field comes with it: "+" holds the menu that makes a session | |
| 137 | 139 | // now, and the field it opens has to be in a row that is on screen. | |
| 138 | 140 | row.append($("tab-new"), $("session-name")); | |
| 139 | - | // Picker and jump stay the pair they are in the nested layout, and follow | |
| 140 | - | // jump into the row below — controls that act on a pane rather than on the | |
| 141 | - | // tabs, at the start of the row you type into. Enter is not in either row: | |
| 142 | - | // it lives under the mic, in the corner beside every row, and so never | |
| 143 | - | // moves with the layout. | |
| 144 | - | $("omni-strip").prepend($("pick"), $("jump")); | |
| 141 | + | // Picker and jump both follow the tabs into the row below — controls that | |
| 142 | + | // act on a pane rather than on the tabs — but they take opposite ends of | |
| 143 | + | // it: the picker leads, and jump sits past the box, at the end of the row | |
| 144 | + | // you type into, where what it does is leave for somewhere else. Enter is | |
| 145 | + | // not in either row: it lives under the mic, in the corner beside every | |
| 146 | + | // row, and so never moves with the layout. | |
| 147 | + | $("omni-strip").prepend($("pick")); | |
| 148 | + | $("omni-strip").append($("jump")); | |
| 145 | 149 | $("omni-strip").hidden = false; | |
| 146 | 150 | // The session row is gone, so what it was showing has to go with it: a | |
| 147 | 151 | // stale session tab left in a hidden strip still answers `querySelector` | |
| ⋯ 128 unchanged lines | |||
| 276 | 280 | term.open($("term")); | |
| 277 | 281 | fit.fit(); | |
| 278 | 282 | ||
| 283 | + | // Autorepeat under load. When the browser process is starved, the key's | |
| 284 | + | // release sits in the queue behind it while the OS keeps generating repeats | |
| 285 | + | // for a key it still believes is held; when the process catches up, the whole | |
| 286 | + | // backlog is flushed into the pty at once and a single tap comes out as a row | |
| 287 | + | // of characters. Nothing here can stop the OS repeating, but the burst is | |
| 288 | + | // easy to tell from a real hold: genuine autorepeat arrives ~30ms apart with | |
| 289 | + | // fresh timestamps, a flushed backlog arrives all in one turn with old ones. | |
| 290 | + | // Only `repeat` events are ever dropped, so the first press of a key always | |
| 291 | + | // goes through, and a deliberate hold still repeats, just never faster than | |
| 292 | + | // the OS would have delivered it live. | |
| 293 | + | const REPEAT_MIN_GAP_MS = 20; | |
| 294 | + | const REPEAT_MAX_AGE_MS = 150; | |
| 295 | + | let lastRepeatAt = 0; | |
| 296 | + | /** @param {KeyboardEvent} e */ | |
| 297 | + | function staleRepeat(e) { | |
| 298 | + | if (!e.repeat) return false; | |
| 299 | + | const now = performance.now(); | |
| 300 | + | const stale = now - e.timeStamp > REPEAT_MAX_AGE_MS || now - lastRepeatAt < REPEAT_MIN_GAP_MS; | |
| 301 | + | if (!stale) lastRepeatAt = now; | |
| 302 | + | return stale; | |
| 303 | + | } | |
| 304 | + | ||
| 279 | 305 | // Returning false keeps the keystroke out of the pty. | |
| 280 | 306 | term.attachCustomKeyEventHandler((e) => { | |
| 281 | 307 | if (e.type !== "keydown") return true; | |
| 308 | + | if (staleRepeat(e)) return false; | |
| 282 | 309 | return !handlePanelKey(e); | |
| 283 | 310 | }); | |
| 284 | 311 | ||
| ⋯ 200 unchanged lines | |||
| 485 | 512 | * | |
| 486 | 513 | * @param {"on"|"off"|"pending"} state whether reconnect is worth offering | |
| 487 | 514 | * @param {string} text | |
| 515 | + | * @param {string} [detail] one sentence for the overlay card, when the caller | |
| 516 | + | * knows better than `offlineHint` what would fix it | |
| 488 | 517 | */ | |
| 489 | - | function setStatus(state, text) { | |
| 518 | + | function setStatus(state, text, detail) { | |
| 490 | 519 | $("status-text").textContent = text; | |
| 491 | 520 | $("reconnect").hidden = state === "on"; | |
| 521 | + | showOffline(state, text, detail); | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | /** | |
| 525 | + | * The same state, in the middle of the panel. The header line is the whole | |
| 526 | + | * story while things work — a connected panel says so with its tabs — but a | |
| 527 | + | * dead socket leaves a scrollback that still looks alive above a word small | |
| 528 | + | * enough to miss, so it gets a card over the terminal until it is fixed. | |
| 529 | + | * | |
| 530 | + | * @param {"on"|"off"|"pending"} state | |
| 531 | + | * @param {string} text what the header says, which is also the card's heading | |
| 532 | + | * @param {string} [detail] what to do about it; derived from `text` if absent | |
| 533 | + | */ | |
| 534 | + | function showOffline(state, text, detail) { | |
| 535 | + | const box = $("offline"); | |
| 536 | + | if (state === "on") { | |
| 537 | + | box.hidden = true; | |
| 538 | + | return; | |
| 539 | + | } | |
| 540 | + | box.dataset.state = state; | |
| 541 | + | $("offline-title").textContent = state === "pending" ? "connecting…" : text; | |
| 542 | + | $("offline-msg").textContent = state === "pending" ? "" : (detail ?? offlineHint(text)); | |
| 543 | + | box.hidden = false; | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | /** | |
| 547 | + | * One sentence naming the thing that would fix it. Two ways a socket stays | |
| 548 | + | * down — nothing is listening, or what is listening does not believe us — and | |
| 549 | + | * they are fixed in different places, so the card must not guess wrong. | |
| 550 | + | * | |
| 551 | + | * @param {string} reason the header's wording, which is the daemon's when it | |
| 552 | + | * rejected us and this panel's otherwise | |
| 553 | + | */ | |
| 554 | + | function offlineHint(reason) { | |
| 555 | + | if (/token|auth|setup/i.test(reason)) { | |
| 556 | + | return "Open settings and paste the token termbridge token prints."; | |
| 557 | + | } | |
| 558 | + | if (/origin|paired/i.test(reason)) { | |
| 559 | + | return "This extension is not paired yet. Settings has the command to run."; | |
| 560 | + | } | |
| 561 | + | const url = $input("url").value.trim() || "the daemon"; | |
| 562 | + | return `Nothing is answering at ${url}. Start it with termbridge serve, then reconnect.`; | |
| 492 | 563 | } | |
| 493 | 564 | ||
| 494 | 565 | // One place decides what the header says, so anything that repaints it (a | |
| ⋯ 135 unchanged lines | |||
| 630 | 701 | // one is named. | |
| 631 | 702 | const current = msg.session ?? sessionName; | |
| 632 | 703 | renderHeader(sessions, current, agents); | |
| 704 | + | // The first frame is the first moment a pin can be acted on: until it | |
| 705 | + | // arrives there is no session list to resolve one against. Opening the | |
| 706 | + | // panel while sitting on a pinned tab lands on the right session | |
| 707 | + | // because of this, and only because of this. | |
| 708 | + | if (!pinAppliedOnConnect) { | |
| 709 | + | pinAppliedOnConnect = true; | |
| 710 | + | applyTabPin(); | |
| 711 | + | } | |
| 712 | + | // And the other direction, after the render: this reads `lastSessions`, | |
| 713 | + | // which `renderHeader` is what sets. | |
| 714 | + | noteSpotChange(); | |
| 633 | 715 | } else if (msg.type === "path") { | |
| 634 | 716 | // An answer to something the omnibar asked about a directory. Keyed by | |
| 635 | 717 | // the query, so one that arrives after the box has moved on is filed | |
| ⋯ 44 unchanged lines | |||
| 680 | 762 | cancelTalkSubmit(); | |
| 681 | 763 | sessionName = null; | |
| 682 | 764 | tmuxMode = false; | |
| 765 | + | // The next socket gets to place us again, and it is a new client on a | |
| 766 | + | // server whose sessions may have moved since. | |
| 767 | + | pinAppliedOnConnect = false; | |
| 768 | + | // Nothing to hand back: the client that was borrowed is gone, and the next | |
| 769 | + | // socket lands wherever the daemon puts it. | |
| 770 | + | pinOwed = null; | |
| 771 | + | // Same reasoning for the other direction: wherever the next socket lands is | |
| 772 | + | // a starting position, not somewhere the user steered to, so it must not | |
| 773 | + | // read as a move and rearrange the browser. | |
| 774 | + | lastSpot = null; | |
| 775 | + | forwardGoingTo = null; | |
| 776 | + | clearTimeout(reverseTimer); | |
| 683 | 777 | renderHeader([], null, []); | |
| 684 | 778 | refreshStatus(); | |
| 685 | 779 | log(`closed code=${ev.code} ${ev.reason || ""}`); | |
| ⋯ 36 unchanged lines | |||
| 722 | 816 | function renderHeader(sessions, current, agents) { | |
| 723 | 817 | lastSessions = sessions; | |
| 724 | 818 | lastAgents = agents; | |
| 819 | + | // Resolved once per frame rather than once per tab: every row asks whether it | |
| 820 | + | // is the pinned one, and the answer cannot change between two rows of the | |
| 821 | + | // same repaint. | |
| 822 | + | pinNow = currentPinTarget(); | |
| 725 | 823 | // Before the early return below: the jump button is in whichever row exists, | |
| 726 | 824 | // and what it says comes from the agents rather than from either layout. | |
| 727 | 825 | syncJump(); | |
| ⋯ 228 unchanged lines | |||
| 956 | 1054 | */ | |
| 957 | 1055 | function repaintTabs() { | |
| 958 | 1056 | $("tabs").dataset.sig = ""; | |
| 959 | - | if (tabMode === "groups") renderGroups(lastSessions, sessionName, lastAgents); | |
| 960 | - | else renderTabs(lastWindows, lastAgents); | |
| 1057 | + | // The other entry point is renderHeader, and this one does not go through it: | |
| 1058 | + | // a repaint caused by a new pin, or by the browser tab changing under us, | |
| 1059 | + | // has to re-resolve before the rows ask which of them is marked. | |
| 1060 | + | pinNow = currentPinTarget(); | |
| 1061 | + | if (tabMode === "groups") { | |
| 1062 | + | renderGroups(lastSessions, sessionName, lastAgents); | |
| 1063 | + | return; | |
| 1064 | + | } | |
| 1065 | + | // The nested layout keeps its two rows on separate signatures, and a | |
| 1066 | + | // session-level browser pin marks the top one. Its signature has to be | |
| 1067 | + | // cleared too, or the dot waits for a frame that changes something else. | |
| 1068 | + | $("sessions").dataset.sig = ""; | |
| 1069 | + | renderSessionTabs(lastSessions, sessionName, lastAgents); | |
| 1070 | + | renderTabs(lastWindows, lastAgents); | |
| 961 | 1071 | } | |
| 962 | 1072 | ||
| 963 | 1073 | /** | |
| ⋯ 12 unchanged lines | |||
| 976 | 1086 | // Reset, not decoration: the groups layout points this same button at the | |
| 977 | 1087 | // home session and says so, and switching back would otherwise leave that | |
| 978 | 1088 | // promise on a button that no longer keeps it. | |
| 979 | - | $("tab-new").title = | |
| 980 | - | (sessionName ? `New window in ${sessionName}` : "New window") + SPLIT_HINT + HOLD_HINT; | |
| 1089 | + | $("tab-new").title = newTabTitle(sessionName); | |
| 981 | 1090 | strip.hidden = !show; | |
| 982 | 1091 | // The hairline between the two rows belongs to the session row, and only | |
| 983 | 1092 | // while there is a window row under it to be separated from. | |
| ⋯ 199 unchanged lines | |||
| 1183 | 1292 | // the way a session tab marks "attached elsewhere", but it is not the tab you | |
| 1184 | 1293 | // are on and must not be drawn as one. | |
| 1185 | 1294 | const elsewhere = w.active && !current; | |
| 1295 | + | // The browser tab on screen sends the terminal here. Worth a mark, because | |
| 1296 | + | // otherwise the panel moves on its own and nothing on it says why. | |
| 1297 | + | const linked = tabPinMark({ session: owner ?? sessionName, window: w.id }); | |
| 1186 | 1298 | ||
| 1187 | 1299 | const tab = button( | |
| 1188 | 1300 | { | |
| ⋯ 15 unchanged lines | |||
| 1204 | 1316 | !w.active && w.activity && "activity", | |
| 1205 | 1317 | elsewhere && "current window of that session", | |
| 1206 | 1318 | isPinned && "pinned — right-click to unpin", | |
| 1319 | + | linked && PIN_SOURCE_NOTE[linked], | |
| 1207 | 1320 | ), | |
| 1208 | 1321 | on: { | |
| 1209 | 1322 | click: () => { | |
| ⋯ 20 unchanged lines | |||
| 1230 | 1343 | { | |
| 1231 | 1344 | class: | |
| 1232 | 1345 | `tab-slot${current ? " active" : ""}${canClose ? " closable" : ""}` + | |
| 1233 | - | `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}`, | |
| 1346 | + | `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}` + | |
| 1347 | + | `${linked ? " tab-linked" : ""}`, | |
| 1234 | 1348 | on: { | |
| 1235 | 1349 | /** @param {MouseEvent} e */ | |
| 1236 | 1350 | contextmenu: (e) => { | |
| ⋯ 38 unchanged lines | |||
| 1275 | 1389 | else if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id }); | |
| 1276 | 1390 | } | |
| 1277 | 1391 | ||
| 1278 | - | // --- tab context menu ------------------------------------------------------- | |
| 1392 | + | // --- pinning a session to a browser tab ------------------------------------- | |
| 1393 | + | // | |
| 1394 | + | // The rules live in lib/tabpin.js, which is pure. This is the half that has to | |
| 1395 | + | // touch the world: which browser tab is showing, what storage holds, and the | |
| 1396 | + | // one tmux command a match turns into. | |
| 1279 | 1397 | // | |
| 1280 | - | // Built rather than native: an extension page gets the browser's own menu here, | |
| 1281 | - | // which has nothing to say about tmux windows. Deliberately not a <dialog> or | |
| 1282 | - | // anything modal — a modal in this panel would block the socket's message | |
| 1283 | - | // handler while it is up. | |
| 1398 | + | // The trigger is deliberately narrow. Only a tab in *this* browser window can | |
| 1399 | + | // move this panel — a side panel belongs to one window, and a tab activated in | |
| 1400 | + | // another window is another panel's business — and the panel only ever | |
| 1401 | + | // activates a tab in that same window going the other way. | |
| 1402 | + | ||
| 1403 | + | /** @type {TbTabPinStore} */ | |
| 1404 | + | let tabPins = Tabpin.emptyStore(); | |
| 1405 | + | ||
| 1406 | + | /** This panel's browser window. Null until `windows.getCurrent()` answers. */ | |
| 1407 | + | let panelWindowId = /** @type {number | null} */ (null); | |
| 1408 | + | ||
| 1409 | + | /** The tab currently showing in this window, as far as we have been told. */ | |
| 1410 | + | let browserTab = /** @type {{ id?: number, url?: string }} */ ({}); | |
| 1284 | 1411 | ||
| 1285 | - | let openMenu = /** @type {HTMLElement | null} */ (null); | |
| 1412 | + | /** | |
| 1413 | + | * What the showing tab resolves to, as of the last repaint. Held rather than | |
| 1414 | + | * recomputed per row — see `renderHeader`. | |
| 1415 | + | * @type {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null} | |
| 1416 | + | */ | |
| 1417 | + | let pinNow = null; | |
| 1286 | 1418 | ||
| 1287 | 1419 | /** | |
| 1288 | - | * Set while a group menu's name field is open, so closing the menu commits what | |
| 1289 | - | * was typed in it. See `renameRow`. | |
| 1290 | - | * @type {(() => void) | null} | |
| 1420 | + | * The excursion in progress: where a pin took the terminal from, and where it | |
| 1421 | + | * put it. `Tabpin.step` owns the rules; this is only where the answer is kept | |
| 1422 | + | * between two tab changes. | |
| 1423 | + | * @type {TbPinReturn | null} | |
| 1291 | 1424 | */ | |
| 1292 | - | let pendingRename = null; | |
| 1425 | + | let pinOwed = null; | |
| 1293 | 1426 | ||
| 1294 | - | function closeTabMenu() { | |
| 1295 | - | const commit = pendingRename; | |
| 1296 | - | pendingRename = null; | |
| 1297 | - | commit?.(); | |
| 1298 | - | openMenu?.remove(); | |
| 1299 | - | openMenu = null; | |
| 1300 | - | // The session info panel shares this machinery, and the dot it hangs off has | |
| 1301 | - | // to stop claiming it is open. | |
| 1302 | - | if (infoOpen) { | |
| 1303 | - | infoOpen = false; | |
| 1304 | - | $("omni-here").setAttribute("aria-expanded", "false"); | |
| 1305 | - | } | |
| 1306 | - | } | |
| 1427 | + | /** Whether the pin has been applied since this socket came up. */ | |
| 1428 | + | let pinAppliedOnConnect = false; | |
| 1307 | 1429 | ||
| 1308 | 1430 | /** | |
| 1309 | - | * One line of a menu. Every one of them does the same two things in the same | |
| 1310 | - | * order — dismiss the menu, then act — because a menu still standing over the | |
| 1311 | - | * thing it just changed is the wrong half of the gesture. | |
| 1431 | + | * How long a tab has to stay on screen before the terminal follows it. | |
| 1312 | 1432 | * | |
| 1313 | - | * @param {HTMLElement} menu | |
| 1314 | - | * @param {string} label | |
| 1315 | - | * @param {() => void} run | |
| 1433 | + | * Ctrl-Tab through six tabs fires six activations, and without this the panel | |
| 1434 | + | * would drag tmux through every one of them — six switch-clients for a gesture | |
| 1435 | + | * that meant one. Short enough to be invisible when you land somewhere on | |
| 1436 | + | * purpose: the tmux switch is local and the status frame that redraws the | |
| 1437 | + | * header was up to a second away regardless. | |
| 1316 | 1438 | */ | |
| 1317 | - | function menuItem(menu, label, run) { | |
| 1318 | - | const b = button({ | |
| 1319 | - | text: label, | |
| 1320 | - | attrs: { role: "menuitem" }, | |
| 1321 | - | on: { | |
| 1322 | - | click: () => { | |
| 1323 | - | closeTabMenu(); | |
| 1324 | - | run(); | |
| 1325 | - | }, | |
| 1326 | - | }, | |
| 1327 | - | }); | |
| 1328 | - | menu.appendChild(b); | |
| 1329 | - | return b; | |
| 1439 | + | const PIN_SETTLE_MS = 140; | |
| 1440 | + | ||
| 1441 | + | /** @type {ReturnType<typeof setTimeout> | undefined} */ | |
| 1442 | + | let pinTimer; | |
| 1443 | + | ||
| 1444 | + | /** @param {TbTabPinStore} next */ | |
| 1445 | + | function saveTabPins(next) { | |
| 1446 | + | tabPins = next; | |
| 1447 | + | storage.set({ [Tabpin.KEY]: next }); | |
| 1448 | + | repaintTabs(); | |
| 1449 | + | applyTabPin(); | |
| 1330 | 1450 | } | |
| 1331 | 1451 | ||
| 1332 | 1452 | /** | |
| 1333 | - | * @param {MouseEvent} e | |
| 1334 | - | * @param {TbWindowInfo} w | |
| 1335 | - | * @param {object} opts | |
| 1336 | - | * @param {boolean} opts.pinned | |
| 1337 | - | * @param {boolean} opts.closable | |
| 1338 | - | * @param {string} [opts.owner] | |
| 1339 | - | * @param {boolean} [opts.current] | |
| 1340 | - | * @param {boolean} [opts.lastInSession] | |
| 1453 | + | * Where the tab on screen says the terminal belongs, already checked against | |
| 1454 | + | * the server — or null when nothing does. | |
| 1455 | + | * | |
| 1456 | + | * @returns {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null} | |
| 1341 | 1457 | */ | |
| 1342 | - | function openTabMenu(e, w, { pinned: isPinned, closable, owner, current, lastInSession }) { | |
| 1343 | - | closeTabMenu(); | |
| 1344 | - | const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } }); | |
| 1345 | - | /** @param {string} label @param {() => void} run */ | |
| 1346 | - | const item = (label, run) => menuItem(menu, label, run); | |
| 1458 | + | function currentPinTarget() { | |
| 1459 | + | const hit = Tabpin.resolve(tabPins, browserTab, lastSessions, Devport); | |
| 1460 | + | if (!hit) return null; | |
| 1461 | + | const at = Tabpin.locate(hit.target, lastSessions); | |
| 1462 | + | return at ? { ...at, source: hit.source } : null; | |
| 1463 | + | } | |
| 1464 | + | ||
| 1465 | + | /** | |
| 1466 | + | * Where the terminal is: the session this panel's client is on, and that | |
| 1467 | + | * session's active window. Null before the first status frame, which is the | |
| 1468 | + | * only honest answer — a return has to know where it is returning *from*, and | |
| 1469 | + | * guessing at that would move the terminal somewhere nobody was. | |
| 1470 | + | * | |
| 1471 | + | * @returns {TbSpot | null} | |
| 1472 | + | */ | |
| 1473 | + | function currentSpot() { | |
| 1474 | + | if (!sessionName) return null; | |
| 1475 | + | const s = lastSessions.find((x) => x.name === sessionName); | |
| 1476 | + | if (!s) return null; | |
| 1477 | + | return { session: sessionName, window: s.windows.find((w) => w.active)?.id ?? null }; | |
| 1478 | + | } | |
| 1479 | + | ||
| 1480 | + | /** | |
| 1481 | + | * Put the terminal somewhere. The counterpart of a tab click, minus the focus: | |
| 1482 | + | * the hands that caused this are on a web page, and the terminal moving | |
| 1483 | + | * underneath is the feature — the caret leaving the page for it is not. | |
| 1484 | + | * | |
| 1485 | + | * @param {TbSpot} spot | |
| 1486 | + | */ | |
| 1487 | + | function gotoSpot(spot) { | |
| 1488 | + | const s = lastSessions.find((x) => x.name === spot.session); | |
| 1489 | + | if (!s) return; | |
| 1490 | + | const w = spot.window ? s.windows.find((x) => x.id === spot.window) : null; | |
| 1491 | + | // A window that has closed since leaves the session as the whole answer, | |
| 1492 | + | // which is the same fallback `Tabpin.locate` makes for a pin. | |
| 1493 | + | if (w) selectWindow(w, spot.session); | |
| 1494 | + | else if (spot.session !== sessionName) tmuxCommand({ cmd: "switch", session: spot.session }); | |
| 1495 | + | } | |
| 1496 | + | ||
| 1497 | + | /** | |
| 1498 | + | * Act on the tab now showing: follow its pin, or hand the terminal back if an | |
| 1499 | + | * earlier pin borrowed it. | |
| 1500 | + | * | |
| 1501 | + | * Silent about every kind of miss, and they are all ordinary: no pin, a pin to | |
| 1502 | + | * a session that is not running, a pin to where we already are, a return that | |
| 1503 | + | * has been overtaken by a switch made by hand. None of them is a failure, and a | |
| 1504 | + | * panel that logged them would log on every tab switch. | |
| 1505 | + | */ | |
| 1506 | + | function applyTabPin() { | |
| 1507 | + | if (!connected) return; | |
| 1508 | + | const at = currentSpot(); | |
| 1509 | + | if (!at) return; | |
| 1510 | + | const hit = currentPinTarget(); | |
| 1511 | + | // The resolved *window* matters here, not the pin as written: a pin to a | |
| 1512 | + | // window that has closed is a pin to its session, and it must compare as one. | |
| 1513 | + | const target = hit ? { session: hit.session, window: hit.window?.id ?? null } : null; | |
| 1514 | + | const { go, owed } = Tabpin.step(pinOwed, target, at); | |
| 1515 | + | pinOwed = owed; | |
| 1516 | + | if (!go) return; | |
| 1517 | + | forwardGoingTo = go; | |
| 1518 | + | gotoSpot(go); | |
| 1519 | + | } | |
| 1520 | + | ||
| 1521 | + | /** | |
| 1522 | + | * Follow the browser after a beat — see `PIN_SETTLE_MS`. Every path that can | |
| 1523 | + | * change which pin applies goes through here, so a burst of them collapses into | |
| 1524 | + | * one move rather than a queue of them. | |
| 1525 | + | */ | |
| 1526 | + | function scheduleTabPin() { | |
| 1527 | + | clearTimeout(pinTimer); | |
| 1528 | + | pinTimer = setTimeout(applyTabPin, PIN_SETTLE_MS); | |
| 1529 | + | } | |
| 1530 | + | ||
| 1531 | + | // --- and the other way: the browser follows the terminal --------------------- | |
| 1532 | + | // | |
| 1533 | + | // Same pins, read backwards by `Tabpin.reverse`. The trigger is the terminal | |
| 1534 | + | // arriving somewhere new, whatever moved it — a click on a session tab in this | |
| 1535 | + | // panel, a `prefix n` typed into the pane, another client switching a session | |
| 1536 | + | // this one is watching. All of those are the user going somewhere, and none of | |
| 1537 | + | // them is distinguishable from the others by the time the status frame lands. | |
| 1538 | + | // | |
| 1539 | + | // What this will not do is as much of the design as what it will. It activates | |
| 1540 | + | // a tab that is already open in this panel's window and stops there: it does | |
| 1541 | + | // not create tabs, does not focus the browser, does not raise a window, and | |
| 1542 | + | // does not touch a tab in another window. So the whole failure mode is showing | |
| 1543 | + | // you a tab you already had open, which is recoverable with Ctrl-Tab. | |
| 1544 | + | ||
| 1545 | + | /** Where the terminal was as of the last frame, so a move can be told from a | |
| 1546 | + | * repaint. Null until the first frame names a session. | |
| 1547 | + | * @type {TbSpot | null} */ | |
| 1548 | + | let lastSpot = null; | |
| 1549 | + | ||
| 1550 | + | /** | |
| 1551 | + | * A tab activation this panel asked for, which must not come back around as a | |
| 1552 | + | * reason to move the terminal. | |
| 1553 | + | * | |
| 1554 | + | * `Tabpin.reverse` already refuses to move a browser that is showing a tab | |
| 1555 | + | * pointing where the terminal is, so the loop cannot run away regardless. This | |
| 1556 | + | * closes the narrower window underneath that: our own `tabs.update` fires | |
| 1557 | + | * `onActivated`, and the status frame that would have told `applyTabPin` where | |
| 1558 | + | * the terminal now is may not have arrived yet. Acting on the stale answer | |
| 1559 | + | * would send tmux a switch to the place it is already going. | |
| 1560 | + | * | |
| 1561 | + | * @type {number | null} | |
| 1562 | + | */ | |
| 1563 | + | let reverseGoingTo = null; | |
| 1564 | + | ||
| 1565 | + | /** @type {ReturnType<typeof setTimeout> | undefined} */ | |
| 1566 | + | let reverseTimer; | |
| 1567 | + | ||
| 1568 | + | /** | |
| 1569 | + | * A terminal move this panel's *forward* direction asked for, which must not | |
| 1570 | + | * come back around as a reason to move the browser. | |
| 1571 | + | * | |
| 1572 | + | * A pin arriving somewhere is already harmless — the tab that sent it there is | |
| 1573 | + | * showing, so `Tabpin.reverse` finds its work done. The case this exists for is | |
| 1574 | + | * the other half of the loan: handing the terminal *back* when you leave a | |
| 1575 | + | * pinned tab lands it wherever it was borrowed from, and if some third tab is | |
| 1576 | + | * pinned to that place, this would chase it and undo the tab switch the user | |
| 1577 | + | * just made by hand. A return is an undo of a move this code made, and it has | |
| 1578 | + | * no business rearranging the browser on the way. | |
| 1579 | + | * | |
| 1580 | + | * @type {TbSpot | null} | |
| 1581 | + | */ | |
| 1582 | + | let forwardGoingTo = null; | |
| 1583 | + | ||
| 1584 | + | /** | |
| 1585 | + | * Show the tab that points at wherever the terminal has landed, if one is open | |
| 1586 | + | * and is not already showing. | |
| 1587 | + | */ | |
| 1588 | + | function applyReverseTabPin() { | |
| 1589 | + | if (!connected || panelWindowId == null) return; | |
| 1590 | + | const at = currentSpot(); | |
| 1591 | + | if (!at) return; | |
| 1592 | + | api.tabs | |
| 1593 | + | .query({ windowId: panelWindowId }) | |
| 1594 | + | .then((tabs) => { | |
| 1595 | + | // Most recently used first, which is the tie-break `Tabpin.reverse` | |
| 1596 | + | // leans on: of two tabs on the pinned origin, the one you were reading. | |
| 1597 | + | // Chrome before 121 has no `lastAccessed`, and then the sort is a no-op | |
| 1598 | + | // and tab order decides — a worse answer, not a broken one. | |
| 1599 | + | const order = [...tabs].sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0)); | |
| 1600 | + | const hit = Tabpin.reverse(tabPins, at, order, lastSessions, Devport); | |
| 1601 | + | if (!hit) return; | |
| 1602 | + | reverseGoingTo = hit.tabId; | |
| 1603 | + | api.tabs.update(hit.tabId, { active: true }).catch(() => { | |
| 1604 | + | reverseGoingTo = null; | |
| 1605 | + | }); | |
| 1606 | + | }) | |
| 1607 | + | .catch(() => {}); | |
| 1608 | + | } | |
| 1609 | + | ||
| 1610 | + | /** | |
| 1611 | + | * Act on where the terminal is, after a beat. | |
| 1612 | + | * | |
| 1613 | + | * The beat is the same bargain as `PIN_SETTLE_MS` and for the same reason from | |
| 1614 | + | * the other end: holding prefix-n through six windows should move the browser | |
| 1615 | + | * once, at the end, rather than flicking it through five tabs on the way. | |
| 1616 | + | */ | |
| 1617 | + | function scheduleReverseTabPin() { | |
| 1618 | + | clearTimeout(reverseTimer); | |
| 1619 | + | reverseTimer = setTimeout(applyReverseTabPin, PIN_SETTLE_MS); | |
| 1620 | + | } | |
| 1621 | + | ||
| 1622 | + | /** | |
| 1623 | + | * Notice the terminal moving. Called once per status frame, which is the only | |
| 1624 | + | * thing that can tell us — every mover of tmux is a frame by the time it gets | |
| 1625 | + | * here, including this panel's own commands. | |
| 1626 | + | * | |
| 1627 | + | * The first frame after a connect is a starting position rather than a move, | |
| 1628 | + | * and is skipped: opening the panel is not a reason to rearrange the browser, | |
| 1629 | + | * and the forward direction is meanwhile busy putting the terminal on the tab | |
| 1630 | + | * you already have up. | |
| 1631 | + | */ | |
| 1632 | + | function noteSpotChange() { | |
| 1633 | + | const at = currentSpot(); | |
| 1634 | + | if (!at) return; | |
| 1635 | + | const was = lastSpot; | |
| 1636 | + | lastSpot = at; | |
| 1637 | + | if (!was || Tabpin.sameSpot(was, at)) return; | |
| 1638 | + | if (forwardGoingTo && Tabpin.sameSpot(forwardGoingTo, at)) { | |
| 1639 | + | // The browser moved the terminal; the terminal does not get to move it | |
| 1640 | + | // back. See `forwardGoingTo`. | |
| 1641 | + | forwardGoingTo = null; | |
| 1642 | + | return; | |
| 1643 | + | } | |
| 1644 | + | forwardGoingTo = null; | |
| 1645 | + | scheduleReverseTabPin(); | |
| 1646 | + | } | |
| 1647 | + | ||
| 1648 | + | /** | |
| 1649 | + | * Re-read the showing tab and act on it. Called for the events that can change | |
| 1650 | + | * which pin applies, and once at startup for the tab that was already there. | |
| 1651 | + | */ | |
| 1652 | + | function syncTabPin() { | |
| 1653 | + | if (panelWindowId == null) return; | |
| 1654 | + | api.tabs | |
| 1655 | + | .query({ active: true, windowId: panelWindowId }) | |
| 1656 | + | .then(([tab]) => { | |
| 1657 | + | if (!tab) return; | |
| 1658 | + | browserTab = { id: tab.id, url: tab.url }; | |
| 1659 | + | scheduleTabPin(); | |
| 1660 | + | // The menu labels name the origin, and the indicator marks the row this | |
| 1661 | + | // tab points at. Both are stale the moment the tab changed. | |
| 1662 | + | repaintTabs(); | |
| 1663 | + | }) | |
| 1664 | + | .catch(() => {}); | |
| 1665 | + | } | |
| 1666 | + | ||
| 1667 | + | api.tabs.onActivated.addListener((info) => { | |
| 1668 | + | if (info.windowId !== panelWindowId) return; | |
| 1669 | + | if (info.tabId === reverseGoingTo) { | |
| 1670 | + | // Our own doing — see `reverseGoingTo`. The tab still has to be recorded | |
| 1671 | + | // and the header still has to redraw, since the indicator dot moves with | |
| 1672 | + | // it; what is skipped is treating it as news about where the user went. | |
| 1673 | + | reverseGoingTo = null; | |
| 1674 | + | api.tabs | |
| 1675 | + | .get(info.tabId) | |
| 1676 | + | .then((tab) => { | |
| 1677 | + | browserTab = { id: tab.id, url: tab.url }; | |
| 1678 | + | repaintTabs(); | |
| 1679 | + | }) | |
| 1680 | + | .catch(() => {}); | |
| 1681 | + | return; | |
| 1682 | + | } | |
| 1683 | + | syncTabPin(); | |
| 1684 | + | }); | |
| 1685 | + | ||
| 1686 | + | // A tab that navigates is a different origin and so possibly a different pin. | |
| 1687 | + | // Only the showing one matters: a background tab finishing a redirect is not a | |
| 1688 | + | // reason to move the terminal. | |
| 1689 | + | api.tabs.onUpdated.addListener((tabId, change, tab) => { | |
| 1690 | + | if (!change.url || tab.windowId !== panelWindowId || tabId !== browserTab.id) return; | |
| 1691 | + | browserTab = { id: tabId, url: change.url }; | |
| 1692 | + | scheduleTabPin(); | |
| 1693 | + | repaintTabs(); | |
| 1694 | + | }); | |
| 1695 | + | ||
| 1696 | + | // Tab ids are never reissued, so a pin whose tab is gone can only ever be dead | |
| 1697 | + | // weight in storage. | |
| 1698 | + | api.tabs.onRemoved.addListener((tabId) => { | |
| 1699 | + | if (!(String(tabId) in tabPins.byTab)) return; | |
| 1700 | + | const next = Tabpin.put(tabPins, "tab", tabId, undefined); | |
| 1701 | + | tabPins = next; | |
| 1702 | + | storage.set({ [Tabpin.KEY]: next }); | |
| 1703 | + | }); | |
| 1704 | + | ||
| 1705 | + | /** `http://localhost:26210` reads better as `localhost:26210` in a menu. | |
| 1706 | + | * @param {string} origin */ | |
| 1707 | + | function shortOrigin(origin) { | |
| 1708 | + | return origin.replace(/^https?:\/\//, ""); | |
| 1709 | + | } | |
| 1710 | + | ||
| 1711 | + | /** | |
| 1712 | + | * Whether the showing browser tab points at this exact row, so the row can say | |
| 1713 | + | * so. Windows match on their own id; a session-level pin marks the session and | |
| 1714 | + | * every row of it stays unmarked. | |
| 1715 | + | * | |
| 1716 | + | * @param {{ session?: string | null, window?: string | null }} row | |
| 1717 | + | * @returns {TbPinSource | null} | |
| 1718 | + | */ | |
| 1719 | + | function tabPinMark(row) { | |
| 1720 | + | const at = pinNow; | |
| 1721 | + | if (!at) return null; | |
| 1722 | + | if (row.window) return at.window?.id === row.window ? at.source : null; | |
| 1723 | + | return !at.window && at.session === row.session ? at.source : null; | |
| 1724 | + | } | |
| 1725 | + | ||
| 1726 | + | /** How a pin explains itself in a tooltip. */ | |
| 1727 | + | const PIN_SOURCE_NOTE = { | |
| 1728 | + | tab: "pinned to this browser tab", | |
| 1729 | + | origin: "pinned to this site", | |
| 1730 | + | detect: "matched to this site by its devport port", | |
| 1731 | + | }; | |
| 1347 | 1732 | ||
| 1348 | - | item(isPinned ? "Unpin" : "Pin", () => { | |
| 1349 | - | const ids = pinnedIds(owner); | |
| 1350 | - | if (isPinned) ids.delete(w.id); | |
| 1351 | - | else ids.add(w.id); | |
| 1352 | - | savePins(owner, ids); | |
| 1733 | + | /** | |
| 1734 | + | * The pin lines of a context menu, for a window (a window id) or a whole | |
| 1735 | + | * session (none). Both kinds of key are offered because they answer different | |
| 1736 | + | * questions — see the header of lib/tabpin.js — and neither is offered for a | |
| 1737 | + | * tab whose URL cannot carry a pin, which is every browser page and every | |
| 1738 | + | * `file:`. | |
| 1739 | + | * | |
| 1740 | + | * @param {HTMLElement} menu | |
| 1741 | + | * @param {TbPinTarget} target | |
| 1742 | + | */ | |
| 1743 | + | function pinMenuItems(menu, target) { | |
| 1744 | + | const origin = Tabpin.originOf(browserTab.url); | |
| 1745 | + | const tabKey = browserTab.id != null ? String(browserTab.id) : ""; | |
| 1746 | + | const here = tabPinMark({ session: target.session, window: target.window }); | |
| 1747 | + | ||
| 1748 | + | if (here) { | |
| 1749 | + | // Unpinning a detected match cannot just delete an entry — there is no | |
| 1750 | + | // entry, and the guess would be back before the menu had closed. It writes | |
| 1751 | + | // the veto instead, at the level the guess was made: the origin. | |
| 1752 | + | const label = here === "tab" ? "Unpin from this browser tab" : `Unpin from ${shortOrigin(origin)}`; | |
| 1753 | + | menuItem(menu, label, () => { | |
| 1754 | + | if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined)); | |
| 1755 | + | else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined)); | |
| 1756 | + | else saveTabPins(Tabpin.put(tabPins, "origin", origin, null)); | |
| 1757 | + | }); | |
| 1758 | + | return; | |
| 1759 | + | } | |
| 1760 | + | ||
| 1761 | + | const what = target.window ? "this window" : target.session; | |
| 1762 | + | if (origin) { | |
| 1763 | + | menuItem(menu, `Pin ${what} to ${shortOrigin(origin)}`, () => | |
| 1764 | + | saveTabPins(Tabpin.put(tabPins, "origin", origin, target)), | |
| 1765 | + | ); | |
| 1766 | + | } | |
| 1767 | + | if (tabKey) { | |
| 1768 | + | menuItem(menu, `Pin ${what} to this browser tab`, () => | |
| 1769 | + | saveTabPins(Tabpin.put(tabPins, "tab", tabKey, target)), | |
| 1770 | + | ); | |
| 1771 | + | } | |
| 1772 | + | } | |
| 1773 | + | ||
| 1774 | + | // --- tab context menu ------------------------------------------------------- | |
| 1775 | + | // | |
| 1776 | + | // Built rather than native: an extension page gets the browser's own menu here, | |
| 1777 | + | // which has nothing to say about tmux windows. Deliberately not a <dialog> or | |
| 1778 | + | // anything modal — a modal in this panel would block the socket's message | |
| 1779 | + | // handler while it is up. | |
| 1780 | + | ||
| 1781 | + | let openMenu = /** @type {HTMLElement | null} */ (null); | |
| 1782 | + | ||
| 1783 | + | /** | |
| 1784 | + | * Set while a group menu's name field is open, so closing the menu commits what | |
| 1785 | + | * was typed in it. See `renameRow`. | |
| 1786 | + | * @type {(() => void) | null} | |
| 1787 | + | */ | |
| 1788 | + | let pendingRename = null; | |
| 1789 | + | ||
| 1790 | + | function closeTabMenu() { | |
| 1791 | + | const commit = pendingRename; | |
| 1792 | + | pendingRename = null; | |
| 1793 | + | commit?.(); | |
| 1794 | + | openMenu?.remove(); | |
| 1795 | + | openMenu = null; | |
| 1796 | + | // The session info panel shares this machinery, and the dot it hangs off has | |
| 1797 | + | // to stop claiming it is open. | |
| 1798 | + | if (infoOpen) { | |
| 1799 | + | infoOpen = false; | |
| 1800 | + | $("omni-here").setAttribute("aria-expanded", "false"); | |
| 1801 | + | } | |
| 1802 | + | } | |
| 1803 | + | ||
| 1804 | + | /** | |
| 1805 | + | * One line of a menu. Every one of them does the same two things in the same | |
| 1806 | + | * order — dismiss the menu, then act — because a menu still standing over the | |
| 1807 | + | * thing it just changed is the wrong half of the gesture. | |
| 1808 | + | * | |
| 1809 | + | * @param {HTMLElement} menu | |
| 1810 | + | * @param {string} label | |
| 1811 | + | * @param {() => void} run | |
| 1812 | + | */ | |
| 1813 | + | function menuItem(menu, label, run) { | |
| 1814 | + | const b = button({ | |
| 1815 | + | text: label, | |
| 1816 | + | attrs: { role: "menuitem" }, | |
| 1817 | + | on: { | |
| 1818 | + | click: () => { | |
| 1819 | + | closeTabMenu(); | |
| 1820 | + | run(); | |
| 1821 | + | }, | |
| 1822 | + | }, | |
| 1823 | + | }); | |
| 1824 | + | menu.appendChild(b); | |
| 1825 | + | return b; | |
| 1826 | + | } | |
| 1827 | + | ||
| 1828 | + | /** | |
| 1829 | + | * @param {MouseEvent} e | |
| 1830 | + | * @param {TbWindowInfo} w | |
| 1831 | + | * @param {object} opts | |
| 1832 | + | * @param {boolean} opts.pinned | |
| 1833 | + | * @param {boolean} opts.closable | |
| 1834 | + | * @param {string} [opts.owner] | |
| 1835 | + | * @param {boolean} [opts.current] | |
| 1836 | + | * @param {boolean} [opts.lastInSession] | |
| 1837 | + | */ | |
| 1838 | + | function openTabMenu(e, w, { pinned: isPinned, closable, owner, current, lastInSession }) { | |
| 1839 | + | closeTabMenu(); | |
| 1840 | + | const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } }); | |
| 1841 | + | /** @param {string} label @param {() => void} run */ | |
| 1842 | + | const item = (label, run) => menuItem(menu, label, run); | |
| 1843 | + | ||
| 1844 | + | item(isPinned ? "Unpin" : "Pin", () => { | |
| 1845 | + | const ids = pinnedIds(owner); | |
| 1846 | + | if (isPinned) ids.delete(w.id); | |
| 1847 | + | else ids.add(w.id); | |
| 1848 | + | savePins(owner, ids); | |
| 1353 | 1849 | repaintTabs(); | |
| 1354 | 1850 | }); | |
| 1355 | 1851 | ||
| ⋯ 4 unchanged lines | |||
| 1360 | 1856 | }); | |
| 1361 | 1857 | } | |
| 1362 | 1858 | ||
| 1859 | + | // Both levels, from the one menu: the window you right-clicked, and the | |
| 1860 | + | // session it belongs to. They are genuinely different pins — "this browser | |
| 1861 | + | // tab brings up my dev server window" and "this browser tab brings up that | |
| 1862 | + | // project, wherever I left it" — and the second is the one most people want. | |
| 1863 | + | const pinTo = owner ?? sessionName; | |
| 1864 | + | if (pinTo) { | |
| 1865 | + | pinMenuItems(menu, { session: pinTo, window: w.id }); | |
| 1866 | + | pinMenuItems(menu, { session: pinTo, window: null }); | |
| 1867 | + | } | |
| 1868 | + | ||
| 1869 | + | // A tab's own menu is the nearest thing to "open another one beside this", | |
| 1870 | + | // and beside this means in the session this tab belongs to — the owner in | |
| 1871 | + | // groups mode, where the row spans the server, and the panel's session | |
| 1872 | + | // otherwise. | |
| 1873 | + | const beside = owner ?? sessionName; | |
| 1874 | + | if (beside && lastSessions.some((s) => s.name === beside)) { | |
| 1875 | + | item("New Claude", () => { | |
| 1876 | + | newClaudeIn(beside); | |
| 1877 | + | term.focus(); | |
| 1878 | + | }); | |
| 1879 | + | } | |
| 1880 | + | ||
| 1363 | 1881 | if (closable) { | |
| 1364 | 1882 | const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => { | |
| 1365 | 1883 | tmuxCommand({ cmd: "kill-window", window: w.id }); | |
| ⋯ 32 unchanged lines | |||
| 1398 | 1916 | /** @param {Event} e */ | |
| 1399 | 1917 | function onDismiss(e) { | |
| 1400 | 1918 | if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return; | |
| 1919 | + | // A press on the dot is the first half of a click that means "put it away". | |
| 1920 | + | // This guard gets there first and would leave the panel closed, so the click | |
| 1921 | + | // behind it would read a closed panel and open it straight back up; the flag | |
| 1922 | + | // is how that click learns the press it belongs to did the closing. | |
| 1923 | + | infoToggledOff = | |
| 1924 | + | infoOpen && e.target instanceof Node && !!$("omni-here").contains(e.target); | |
| 1401 | 1925 | closeTabMenu(); | |
| 1402 | 1926 | } | |
| 1403 | 1927 | ||
| ⋯ 18 unchanged lines | |||
| 1422 | 1946 | e.stopPropagation(); | |
| 1423 | 1947 | tmuxCommand({ cmd: "kill-window", window: w.id }); | |
| 1424 | 1948 | // The one destructive thing in the panel, and tmux has no undo for it, | |
| 1425 | - | // so it at least leaves a record of what went. | |
| 1426 | - | log(`killed window ${w.index} (${w.name})`); | |
| 1427 | - | term.focus(); | |
| 1428 | - | }, | |
| 1429 | - | }, | |
| 1430 | - | }, | |
| 1431 | - | strokeIcon("M4 4l8 8M12 4l-8 8"), | |
| 1432 | - | ); | |
| 1949 | + | // so it at least leaves a record of what went. | |
| 1950 | + | log(`killed window ${w.index} (${w.name})`); | |
| 1951 | + | term.focus(); | |
| 1952 | + | }, | |
| 1953 | + | }, | |
| 1954 | + | }, | |
| 1955 | + | strokeIcon("M4 4l8 8M12 4l-8 8"), | |
| 1956 | + | ); | |
| 1957 | + | } | |
| 1958 | + | ||
| 1959 | + | const SVG_NS = "http://www.w3.org/2000/svg"; | |
| 1960 | + | ||
| 1961 | + | /** | |
| 1962 | + | * The header's icons are inline SVG rather than unicode glyphs, which render at | |
| 1963 | + | * wildly different weights depending on the platform's fallback font. The ones | |
| 1964 | + | * built here are the same, just built rather than written out. | |
| 1965 | + | * | |
| 1966 | + | * @param {string} d | |
| 1967 | + | */ | |
| 1968 | + | function strokeIcon(d) { | |
| 1969 | + | const svg = document.createElementNS(SVG_NS, "svg"); | |
| 1970 | + | svg.setAttribute("viewBox", "0 0 16 16"); | |
| 1971 | + | svg.setAttribute("aria-hidden", "true"); | |
| 1972 | + | const path = document.createElementNS(SVG_NS, "path"); | |
| 1973 | + | path.setAttribute("d", d); | |
| 1974 | + | path.setAttribute("stroke", "currentColor"); | |
| 1975 | + | path.setAttribute("stroke-width", "2"); | |
| 1976 | + | path.setAttribute("stroke-linecap", "round"); | |
| 1977 | + | svg.appendChild(path); | |
| 1978 | + | return svg; | |
| 1979 | + | } | |
| 1980 | + | ||
| 1981 | + | /** | |
| 1982 | + | * Open a window in a session and go to it. | |
| 1983 | + | * | |
| 1984 | + | * A new window needs no name: tmux names it after whatever it runs, and renames | |
| 1985 | + | * it as you cd around. So this is one click and no prompt. | |
| 1986 | + | * | |
| 1987 | + | * A session that does not exist yet cannot be given a window — but making it | |
| 1988 | + | * *is* making the window, since `create` is `new-session -A`, which comes up | |
| 1989 | + | * with one. That is the case the home session hits on a fresh tmux server, | |
| 1990 | + | * where "+" is the first thing that ever names it. | |
| 1991 | + | * | |
| 1992 | + | * @param {string | null} session | |
| 1993 | + | */ | |
| 1994 | + | function newWindowIn(session) { | |
| 1995 | + | if (!session) return; | |
| 1996 | + | if (lastSessions.some((s) => s.name === session)) { | |
| 1997 | + | tmuxCommand({ cmd: "new-window", session }); | |
| 1998 | + | } else { | |
| 1999 | + | tmuxCommand({ cmd: "create", session }); | |
| 2000 | + | } | |
| 2001 | + | } | |
| 2002 | + | ||
| 2003 | + | /** | |
| 2004 | + | * Open a window running Claude in a session and go to it. | |
| 2005 | + | * | |
| 2006 | + | * The same window `!claude` and the omnibar's "New Claude" row open — `run` | |
| 2007 | + | * rather than the daemon's Claude request, which exists to carry a prompt and | |
| 2008 | + | * there is no prompt here. | |
| 2009 | + | * | |
| 2010 | + | * Unlike `newWindowIn` this cannot make the session it lands in: the daemon's | |
| 2011 | + | * `run` is a `new-window` and nothing else, so a session that does not exist | |
| 2012 | + | * yet would take the command nowhere. Every caller offers the row only for a | |
| 2013 | + | * session the last frame named. | |
| 2014 | + | * | |
| 2015 | + | * @param {string | null} session | |
| 2016 | + | */ | |
| 2017 | + | function newClaudeIn(session) { | |
| 2018 | + | if (!session) return; | |
| 2019 | + | tmuxCommand({ cmd: "run", session, command: "claude" }); | |
| 2020 | + | } | |
| 2021 | + | ||
| 2022 | + | /* What a plain click on "+" makes. | |
| 2023 | + | ||
| 2024 | + | The button opens a window either way; the question is what is running in it. | |
| 2025 | + | "claude" is the default because that is what the window is nearly always for | |
| 2026 | + | — a shell is one `exit` away inside it, and a Claude in a shell is a command | |
| 2027 | + | you had to type. "window" is the old behaviour, for anyone whose "+" means a | |
| 2028 | + | shell. | |
| 2029 | + | ||
| 2030 | + | Only the plain click follows this. The hold menu still offers both, so | |
| 2031 | + | whichever is not the default is one gesture away rather than a settings trip, | |
| 2032 | + | and it lists the default first. */ | |
| 2033 | + | /** @type {Record<string, { verb: string, hint: string }>} */ | |
| 2034 | + | const NEW_TAB_ACTIONS = { | |
| 2035 | + | claude: { | |
| 2036 | + | verb: "New Claude in", | |
| 2037 | + | hint: '"+" opens a window running claude. Hold it for a plain shell.', | |
| 2038 | + | }, | |
| 2039 | + | window: { | |
| 2040 | + | verb: "New window in", | |
| 2041 | + | hint: '"+" opens a window with a shell. Hold it for claude.', | |
| 2042 | + | }, | |
| 2043 | + | }; | |
| 2044 | + | let newTabAction = "claude"; | |
| 2045 | + | ||
| 2046 | + | /** @returns {"claude" | "window"} what "+" does, as a key of NEW_TAB_ACTIONS. */ | |
| 2047 | + | function plainNewTab() { | |
| 2048 | + | return newTabAction === "window" ? "window" : "claude"; | |
| 2049 | + | } | |
| 2050 | + | ||
| 2051 | + | /** | |
| 2052 | + | * The pref only reaches two places — the tooltips, which `renderHeader` writes | |
| 2053 | + | * on every frame, and the settings card. So this repaints the header rather | |
| 2054 | + | * than touching the button itself. | |
| 2055 | + | */ | |
| 2056 | + | function applyNewTabAction() { | |
| 2057 | + | const a = NEW_TAB_ACTIONS[plainNewTab()]; | |
| 2058 | + | $select("newtab-select").value = plainNewTab(); | |
| 2059 | + | $("newtab-hint").textContent = a.hint; | |
| 2060 | + | renderHeader(lastSessions, sessionName, lastAgents); | |
| 1433 | 2061 | } | |
| 1434 | 2062 | ||
| 1435 | - | const SVG_NS = "http://www.w3.org/2000/svg"; | |
| 2063 | + | /** @param {string} value */ | |
| 2064 | + | function setNewTabAction(value) { | |
| 2065 | + | newTabAction = NEW_TAB_ACTIONS[value] ? value : "claude"; | |
| 2066 | + | storage.set({ newTabAction }); | |
| 2067 | + | applyNewTabAction(); | |
| 2068 | + | } | |
| 1436 | 2069 | ||
| 1437 | 2070 | /** | |
| 1438 | - | * The header's icons are inline SVG rather than unicode glyphs, which render at | |
| 1439 | - | * wildly different weights depending on the platform's fallback font. The ones | |
| 1440 | - | * built here are the same, just built rather than written out. | |
| 2071 | + | * What "+" promises, in the session it will actually land in. Both layouts word | |
| 2072 | + | * it the same way and only differ in which session that is. | |
| 1441 | 2073 | * | |
| 1442 | - | * @param {string} d | |
| 2074 | + | * @param {string | null | undefined} target | |
| 1443 | 2075 | */ | |
| 1444 | - | function strokeIcon(d) { | |
| 1445 | - | const svg = document.createElementNS(SVG_NS, "svg"); | |
| 1446 | - | svg.setAttribute("viewBox", "0 0 16 16"); | |
| 1447 | - | svg.setAttribute("aria-hidden", "true"); | |
| 1448 | - | const path = document.createElementNS(SVG_NS, "path"); | |
| 1449 | - | path.setAttribute("d", d); | |
| 1450 | - | path.setAttribute("stroke", "currentColor"); | |
| 1451 | - | path.setAttribute("stroke-width", "2"); | |
| 1452 | - | path.setAttribute("stroke-linecap", "round"); | |
| 1453 | - | svg.appendChild(path); | |
| 1454 | - | return svg; | |
| 2076 | + | function newTabTitle(target) { | |
| 2077 | + | const { verb } = NEW_TAB_ACTIONS[plainNewTab()]; | |
| 2078 | + | return (target ? `${verb} ${target}` : verb.replace(/ in$/, "")) + SPLIT_HINT + HOLD_HINT; | |
| 1455 | 2079 | } | |
| 1456 | 2080 | ||
| 1457 | 2081 | /** | |
| 1458 | - | * Open a window in a session and go to it. | |
| 1459 | - | * | |
| 1460 | - | * A new window needs no name: tmux names it after whatever it runs, and renames | |
| 1461 | - | * it as you cd around. So this is one click and no prompt. | |
| 1462 | - | * | |
| 1463 | - | * A session that does not exist yet cannot be given a window — but making it | |
| 1464 | - | * *is* making the window, since `create` is `new-session -A`, which comes up | |
| 1465 | - | * with one. That is the case the home session hits on a fresh tmux server, | |
| 1466 | - | * where "+" is the first thing that ever names it. | |
| 2082 | + | * Do what a plain "+" click means, in whichever session it points at. | |
| 1467 | 2083 | * | |
| 1468 | 2084 | * @param {string | null} session | |
| 1469 | 2085 | */ | |
| 1470 | - | function newWindowIn(session) { | |
| 1471 | - | if (!session) return; | |
| 1472 | - | if (lastSessions.some((s) => s.name === session)) { | |
| 1473 | - | tmuxCommand({ cmd: "new-window", session }); | |
| 2086 | + | function newTabIn(session) { | |
| 2087 | + | // A session the last frame has not named cannot be given a Claude window (see | |
| 2088 | + | // `newClaudeIn`), and `newWindowIn` is the one path that can make it. So the | |
| 2089 | + | // first "+" on a fresh server opens a shell whatever the pref says — there is | |
| 2090 | + | // no session yet for anything else to run in. | |
| 2091 | + | if (plainNewTab() === "claude" && session && lastSessions.some((s) => s.name === session)) { | |
| 2092 | + | newClaudeIn(session); | |
| 1474 | 2093 | } else { | |
| 1475 | - | tmuxCommand({ cmd: "create", session }); | |
| 2094 | + | newWindowIn(session); | |
| 1476 | 2095 | } | |
| 1477 | 2096 | } | |
| 1478 | 2097 | ||
| ⋯ 29 unchanged lines | |||
| 1508 | 2127 | // offers the other thing the strip can hold. Here that other thing is a tmux | |
| 1509 | 2128 | // session — a tab group — and this is where it gets made, so the row never | |
| 1510 | 2129 | // needs a second icon beside the first that looks the same and does something | |
| 1511 | - | // else. The plain click is untouched: it still opens a window, immediately. | |
| 2130 | + | // else. The plain click stays one gesture and no prompt: it opens a window | |
| 2131 | + | // immediately, running whatever `NEW_TAB_ACTIONS` says — and the menu holds the | |
| 2132 | + | // other kind, so that pref is a hold away rather than a settings trip. | |
| 1512 | 2133 | // | |
| 1513 | 2134 | // The menu is the same one every tab and chip in the row uses, so it dismisses | |
| 1514 | 2135 | // the same way and only one of them is ever up. | |
| ⋯ 11 unchanged lines | |||
| 1526 | 2147 | closeTabMenu(); | |
| 1527 | 2148 | const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } }); | |
| 1528 | 2149 | const target = newWindowTarget() ?? defaultSession; | |
| 1529 | - | menuItem(menu, `New window in ${target}`, () => { | |
| 1530 | - | newWindowIn(target); | |
| 1531 | - | term.focus(); | |
| 1532 | - | }); | |
| 2150 | + | const window_ = () => | |
| 2151 | + | menuItem(menu, `New window in ${target}`, () => { | |
| 2152 | + | newWindowIn(target); | |
| 2153 | + | term.focus(); | |
| 2154 | + | }); | |
| 2155 | + | // Only once the session exists: see `newClaudeIn`. On a fresh server "+" | |
| 2156 | + | // itself is what names the home session, and until it has, there is nowhere | |
| 2157 | + | // for a `run` to open a window. | |
| 2158 | + | const claude = () => { | |
| 2159 | + | if (!lastSessions.some((s) => s.name === target)) return; | |
| 2160 | + | menuItem(menu, `New Claude in ${target}`, () => { | |
| 2161 | + | newClaudeIn(target); | |
| 2162 | + | term.focus(); | |
| 2163 | + | }); | |
| 2164 | + | }; | |
| 2165 | + | // The default first, so the menu reads in the order the button does: what a | |
| 2166 | + | // plain click would have done, then the other one. | |
| 2167 | + | if (plainNewTab() === "claude") { | |
| 2168 | + | claude(); | |
| 2169 | + | window_(); | |
| 2170 | + | } else { | |
| 2171 | + | window_(); | |
| 2172 | + | claude(); | |
| 2173 | + | } | |
| 1533 | 2174 | // Named rather than immediate, unlike the window: a session's name is the | |
| 1534 | 2175 | // only handle a terminal gives you on it. See "new session" below. | |
| 1535 | 2176 | menuItem(menu, "New session…", showSessionInput); | |
| ⋯ 14 unchanged lines | |||
| 1550 | 2191 | newHeld = false; | |
| 1551 | 2192 | return; | |
| 1552 | 2193 | } | |
| 1553 | - | newWindowIn(newWindowTarget()); | |
| 2194 | + | newTabIn(newWindowTarget()); | |
| 1554 | 2195 | term.focus(); | |
| 1555 | 2196 | }); | |
| 1556 | 2197 | ||
| ⋯ 331 unchanged lines | |||
| 1888 | 2529 | is already in the air, so the button is where it gets announced. */ | |
| 1889 | 2530 | const SPLIT_HINT = "\nDrop a window here to give it a session of its own"; | |
| 1890 | 2531 | ||
| 1891 | - | /** Third line of the window "+"'s tooltip: the menu is the only place a session | |
| 1892 | - | gets made in groups mode, and a held button announces itself nowhere else. */ | |
| 1893 | - | const HOLD_HINT = "\nHold or right-click for a new session"; | |
| 2532 | + | /** Third line of the window "+"'s tooltip: the menu holds the kind of tab the | |
| 2533 | + | plain click is not (see `NEW_TAB_ACTIONS`) and, in groups mode, is the only | |
| 2534 | + | place a session gets made — and a held button announces itself nowhere | |
| 2535 | + | else. */ | |
| 2536 | + | const HOLD_HINT = "\nHold or right-click for the other kind, or a new session"; | |
| 1894 | 2537 | ||
| 1895 | 2538 | /** | |
| 1896 | 2539 | * A tmux session name made out of a window name. tmux windows are named after | |
| ⋯ 320 unchanged lines | |||
| 2217 | 2860 | // the ambiguity was the whole complaint about having a second one beside it. | |
| 2218 | 2861 | // It names the session it actually adds to, which is not always the one you | |
| 2219 | 2862 | // are on and not always the home session either. | |
| 2220 | - | $("tab-new").title = `New window in ${newWindowTarget() ?? defaultSession}${SPLIT_HINT}${HOLD_HINT}`; | |
| 2863 | + | $("tab-new").title = newTabTitle(newWindowTarget() ?? defaultSession); | |
| 2221 | 2864 | strip.hidden = !show; | |
| 2222 | 2865 | // A chip carries the session name, so the status text has nothing left to | |
| 2223 | 2866 | // say — the same trade the session row makes in the nested layout. | |
| ⋯ 108 unchanged lines | |||
| 2332 | 2975 | // default about the same thing. | |
| 2333 | 2976 | const colour = groupColor(s.name); | |
| 2334 | 2977 | const state = claude?.state ?? "none"; | |
| 2978 | + | const linked = tabPinMark({ session: s.name, window: null }); | |
| 2335 | 2979 | const chip = button( | |
| 2336 | 2980 | { | |
| 2337 | 2981 | class: | |
| 2338 | 2982 | `group-chip${isCurrent ? " current" : ""}${folded ? " folded" : ""}` + | |
| 2339 | - | `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}`, | |
| 2983 | + | `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}` + | |
| 2984 | + | `${linked ? " tab-linked" : ""}`, | |
| 2340 | 2985 | css: { "--group-h": String(colour) }, | |
| 2341 | 2986 | data: { session: s.name }, | |
| 2342 | 2987 | attrs: { "aria-expanded": !folded }, | |
| ⋯ 3 unchanged lines | |||
| 2346 | 2991 | isCurrent ? "you are here" : s.attached && "attached elsewhere", | |
| 2347 | 2992 | claude && `claude ${claude.state} — ${agentLabel(claude)}`, | |
| 2348 | 2993 | folded ? "folded — click to unfold" : "click to fold", | |
| 2994 | + | linked && PIN_SOURCE_NOTE[linked], | |
| 2349 | 2995 | "right-click to rename or recolour", | |
| 2350 | 2996 | ), | |
| 2351 | 2997 | on: { | |
| ⋯ 203 unchanged lines | |||
| 2555 | 3201 | term.focus(); | |
| 2556 | 3202 | }); | |
| 2557 | 3203 | ||
| 3204 | + | item("New Claude here", () => { | |
| 3205 | + | newClaudeIn(s.name); | |
| 3206 | + | term.focus(); | |
| 3207 | + | }); | |
| 3208 | + | ||
| 3209 | + | pinMenuItems(menu, { session: s.name, window: null }); | |
| 3210 | + | ||
| 2558 | 3211 | item(folded ? "Fold the others" : "Fold everything else", () => { | |
| 2559 | 3212 | foldedGroups = new Set(lastSessions.map((x) => x.name).filter((n) => n !== s.name)); | |
| 2560 | 3213 | storage.set({ foldedGroups: [...foldedGroups] }); | |
| ⋯ 757 unchanged lines | |||
| 3318 | 3971 | * @param {string} prompt | |
| 3319 | 3972 | */ | |
| 3320 | 3973 | function completePath(next, prompt) { | |
| 3321 | - | const input = $input("omni"); | |
| 3974 | + | const input = $area("omni"); | |
| 3322 | 3975 | input.value = prompt ? `${next}/ ${prompt}` : `${next}/`; | |
| 3323 | 3976 | omniDirty = true; | |
| 3324 | 3977 | input.focus(); | |
| ⋯ 56 unchanged lines | |||
| 3381 | 4034 | * Called on every status frame, so it has two things it must not walk over — | |
| 3382 | 4035 | * a query being typed, and the selection that focusing just made. | |
| 3383 | 4036 | */ | |
| 4037 | + | /** | |
| 4038 | + | * Size the box to its text: one line when there is one, taller as it wraps, | |
| 4039 | + | * and no further than the cap in `sidebar.css` — past that it scrolls. | |
| 4040 | + | * | |
| 4041 | + | * A textarea has no intrinsic height, so this is the whole of "it grows". The | |
| 4042 | + | * reset to `auto` first is what lets it shrink again: `scrollHeight` of a box | |
| 4043 | + | * already taller than its content is that taller height, so measuring without | |
| 4044 | + | * it makes the box a ratchet. | |
| 4045 | + | * | |
| 4046 | + | * The header is a flex column above a `flex: 1` terminal, so a taller pill | |
| 4047 | + | * takes its pixels from the terminal, and the ResizeObserver on `#term` refits | |
| 4048 | + | * tmux to the rows that are left. Nothing here has to say so. | |
| 4049 | + | */ | |
| 4050 | + | function growOmni() { | |
| 4051 | + | const input = $area("omni"); | |
| 4052 | + | input.style.height = "auto"; | |
| 4053 | + | input.style.height = `${input.scrollHeight}px`; | |
| 4054 | + | } | |
| 4055 | + | ||
| 4056 | + | // Rewrapping is the panel's width changing, and that is the one thing that can | |
| 4057 | + | // change the line count without anyone touching the box. | |
| 4058 | + | window.addEventListener("resize", growOmni); | |
| 4059 | + | ||
| 3384 | 4060 | function syncOmniHere() { | |
| 3385 | - | const input = $input("omni"); | |
| 4061 | + | const input = $area("omni"); | |
| 3386 | 4062 | const here = connected && tmuxMode && sessionName ? sessionName : ""; | |
| 3387 | 4063 | ||
| 3388 | 4064 | // Shown only when there is a session for it to describe: an info button over | |
| ⋯ 14 unchanged lines | |||
| 3403 | 4079 | } | |
| 3404 | 4080 | ||
| 3405 | 4081 | if (omniDirty || document.activeElement === input) { | |
| 4082 | + | growOmni(); | |
| 3406 | 4083 | takePendingOmniFocus(); | |
| 3407 | 4084 | return; | |
| 3408 | 4085 | } | |
| 3409 | 4086 | input.value = here; | |
| 4087 | + | growOmni(); | |
| 3410 | 4088 | input.title = here | |
| 3411 | 4089 | ? `${here} — type to jump to a window, session or Claude pane, ` + | |
| 3412 | 4090 | `name a session to create it, or ask a new Claude in this directory` | |
| ⋯ 15 unchanged lines | |||
| 3428 | 4106 | ||
| 3429 | 4107 | /** Set while the panel is up, so a status frame redraws it in place. */ | |
| 3430 | 4108 | let infoOpen = false; | |
| 4109 | + | /** | |
| 4110 | + | * Set when the outside-press guard closed the panel because the press landed on | |
| 4111 | + | * the dot itself. The click that follows is the rest of that same press, so it | |
| 4112 | + | * has to leave the panel closed rather than treat it as a fresh open. | |
| 4113 | + | */ | |
| 4114 | + | let infoToggledOff = false; | |
| 3431 | 4115 | /** Reset whenever the panel opens: the copy button's label is a one-shot. */ | |
| 3432 | 4116 | let infoCopied = false; | |
| 3433 | 4117 | ||
| ⋯ 146 unchanged lines | |||
| 3580 | 4264 | * and a click anywhere else close it. | |
| 3581 | 4265 | */ | |
| 3582 | 4266 | function openSessionInfo() { | |
| 3583 | - | const wasOpen = infoOpen; | |
| 4267 | + | const wasOpen = infoOpen || infoToggledOff; | |
| 4268 | + | infoToggledOff = false; | |
| 3584 | 4269 | closeTabMenu(); | |
| 3585 | 4270 | // The dot is a toggle: clicking it again is how you put the panel away | |
| 3586 | 4271 | // without having to find somewhere neutral to click. | |
| ⋯ 32 unchanged lines | |||
| 3619 | 4304 | /** Drop whatever was typed and show the location again. */ | |
| 3620 | 4305 | function revertOmni() { | |
| 3621 | 4306 | omniDirty = false; | |
| 3622 | - | const input = $input("omni"); | |
| 4307 | + | const input = $area("omni"); | |
| 3623 | 4308 | input.value = connected && tmuxMode && sessionName ? sessionName : ""; | |
| 3624 | 4309 | input.select(); | |
| 3625 | 4310 | refreshOmni(); | |
| ⋯ 1 unchanged line | |||
| 3627 | 4312 | ||
| 3628 | 4313 | /** Rebuild the dropdown from whatever is in the box. */ | |
| 3629 | 4314 | function refreshOmni() { | |
| 3630 | - | const input = $input("omni"); | |
| 4315 | + | const input = $area("omni"); | |
| 3631 | 4316 | const list = $("omni-list"); | |
| 3632 | 4317 | syncOmniHere(); | |
| 3633 | 4318 | if (tabMode !== "groups" || !connected || !tmuxMode) return closeOmni(); | |
| ⋯ 168 unchanged lines | |||
| 3802 | 4487 | return; | |
| 3803 | 4488 | } | |
| 3804 | 4489 | omniFocusAsked = 0; | |
| 3805 | - | const input = $input("omni"); | |
| 4490 | + | const input = $area("omni"); | |
| 3806 | 4491 | ||
| 3807 | 4492 | // A panel coming up for the first time gets its focus somewhere in the next | |
| 3808 | 4493 | // few hundred milliseconds, and a selection made before that arrives is | |
| ⋯ 38 unchanged lines | |||
| 3847 | 4532 | refreshOmni(); | |
| 3848 | 4533 | } | |
| 3849 | 4534 | ||
| 3850 | - | $input("omni").addEventListener("input", () => { | |
| 4535 | + | $area("omni").addEventListener("input", () => { | |
| 4536 | + | const input = $area("omni"); | |
| 4537 | + | // The box wraps, but it still holds one line: Enter runs a row rather than | |
| 4538 | + | // breaking the line, so the only way a newline gets in is a paste — and a | |
| 4539 | + | // command with a hard newline in the middle of it is not what was pasted, | |
| 4540 | + | // it is what the clipboard happened to be carrying. Each becomes a space, | |
| 4541 | + | // and the caret keeps its place because the length does not change. | |
| 4542 | + | if (input.value.includes("\n")) { | |
| 4543 | + | const at = input.selectionStart; | |
| 4544 | + | input.value = input.value.replace(/[\r\n]/g, " "); | |
| 4545 | + | input.setSelectionRange(at, at); | |
| 4546 | + | } | |
| 3851 | 4547 | // The location has been typed over, so it is a query from here on. | |
| 3852 | 4548 | omniDirty = true; | |
| 3853 | 4549 | refreshOmni(); | |
| ⋯ 3 unchanged lines | |||
| 3857 | 4553 | // behaviour that makes "the box holds where you are" and "the box is how you go | |
| 3858 | 4554 | // somewhere else" the same box. Opening the list here rather than on the first | |
| 3859 | 4555 | // keystroke: with nothing typed it is already the list of everywhere else. | |
| 3860 | - | $input("omni").addEventListener("focus", () => { | |
| 4556 | + | $area("omni").addEventListener("focus", () => { | |
| 3861 | 4557 | omniDirty = false; | |
| 3862 | - | $input("omni").select(); | |
| 4558 | + | $area("omni").select(); | |
| 3863 | 4559 | refreshOmni(); | |
| 3864 | 4560 | }); | |
| 3865 | 4561 | ||
| 3866 | 4562 | // Late enough for a row's own click to have run first. Leaving focus abandons | |
| 3867 | 4563 | // whatever was typed, exactly as a browser's does — the box goes back to | |
| 3868 | 4564 | // saying where you are. | |
| 3869 | - | $input("omni").addEventListener("blur", () => | |
| 4565 | + | $area("omni").addEventListener("blur", () => | |
| 3870 | 4566 | setTimeout(() => { | |
| 3871 | 4567 | // A blur that leaves the caret where it was is the panel gaining or losing | |
| 3872 | 4568 | // the keyboard, not the box being left — and that happens under the box on | |
| 3873 | 4569 | // the way up, when the shortcut is what opened this panel. Closing on it | |
| 3874 | 4570 | // would take the list away from a box that is still focused. | |
| 3875 | - | if (document.activeElement === $input("omni")) return; | |
| 4571 | + | if (document.activeElement === $area("omni")) return; | |
| 3876 | 4572 | closeOmni(); | |
| 3877 | 4573 | omniDirty = false; | |
| 3878 | 4574 | syncOmniHere(); | |
| 3879 | 4575 | }, 0), | |
| 3880 | 4576 | ); | |
| 3881 | 4577 | ||
| 3882 | - | $input("omni").addEventListener("keydown", (e) => { | |
| 4578 | + | $area("omni").addEventListener("keydown", (e) => { | |
| 3883 | 4579 | const ev = /** @type {KeyboardEvent} */ (e); | |
| 3884 | 4580 | const key = ev.key; | |
| 3885 | 4581 | // Ctrl+J / Ctrl+K move the selection too, but only while the list is up: | |
| ⋯ 157 unchanged lines | |||
| 4043 | 4739 | * @param {TbAgent} [claude] the agent worth reporting anywhere in this session | |
| 4044 | 4740 | */ | |
| 4045 | 4741 | function sessionTab(s, selected, claude) { | |
| 4742 | + | const linked = tabPinMark({ session: s.name, window: null }); | |
| 4046 | 4743 | const tab = button( | |
| 4047 | 4744 | { | |
| 4048 | - | class: `session-tab${s.attached && !selected ? " attached" : ""}`, | |
| 4745 | + | class: | |
| 4746 | + | `session-tab${s.attached && !selected ? " attached" : ""}` + | |
| 4747 | + | `${linked ? " tab-linked" : ""}`, | |
| 4049 | 4748 | attrs: { role: "tab", "aria-selected": selected }, | |
| 4050 | 4749 | data: { session: s.name }, | |
| 4051 | 4750 | // No window count on the tab. The row below it *is* the count for the | |
| ⋯ 4 unchanged lines | |||
| 4056 | 4755 | `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`, | |
| 4057 | 4756 | s.attached && !selected && "attached elsewhere", | |
| 4058 | 4757 | claude && `claude ${claude.state} — ${agentLabel(claude)}`, | |
| 4758 | + | linked && PIN_SOURCE_NOTE[linked], | |
| 4059 | 4759 | ), | |
| 4060 | 4760 | on: { | |
| 4061 | 4761 | click: () => { | |
| ⋯ 2 unchanged lines | |||
| 4064 | 4764 | if (!selected) tmuxCommand({ cmd: "switch", session: s.name }); | |
| 4065 | 4765 | term.focus(); | |
| 4066 | 4766 | }, | |
| 4767 | + | // The nested layout has no group chip, so this is the only place a | |
| 4768 | + | // session-level pin can be reached from in it. | |
| 4769 | + | /** @param {MouseEvent} e */ | |
| 4770 | + | contextmenu: (e) => { | |
| 4771 | + | e.preventDefault(); | |
| 4772 | + | closeTabMenu(); | |
| 4773 | + | const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } }); | |
| 4774 | + | pinMenuItems(menu, { session: s.name, window: null }); | |
| 4775 | + | // Nothing to offer for a tab with neither an origin nor an id — a | |
| 4776 | + | // browser page, say. An empty menu is worse than none. | |
| 4777 | + | if (!menu.childElementCount) return; | |
| 4778 | + | document.body.appendChild(menu); | |
| 4779 | + | placeMenu(menu, e); | |
| 4780 | + | }, | |
| 4067 | 4781 | }, | |
| 4068 | 4782 | }, | |
| 4069 | 4783 | // The glyph goes on a session you are not on and nowhere else. It reports the | |
| ⋯ 419 unchanged lines | |||
| 4489 | 5203 | if (pickTabs.length) { | |
| 4490 | 5204 | e.preventDefault(); | |
| 4491 | 5205 | cancelPick(); | |
| 5206 | + | return; | |
| 4492 | 5207 | } | |
| 5208 | + | // A pick the keyboard shortcut started belongs to the worker, and this panel | |
| 5209 | + | // has no record of it. Ask; the worker ignores it when nothing is picking. | |
| 5210 | + | togglePort?.postMessage({ type: "cancel-pick" }); | |
| 4493 | 5211 | }); | |
| 4494 | 5212 | ||
| 4495 | 5213 | // Results arriving from the background worker (the keyboard-shortcut path). | |
| ⋯ 23 unchanged lines | |||
| 4519 | 5237 | ||
| 4520 | 5238 | function connectToggle() { | |
| 4521 | 5239 | api.windows.getCurrent().then((win) => { | |
| 5240 | + | // The same answer the tab pins need: which browser window this panel is | |
| 5241 | + | // one of. Set here rather than in a second getCurrent() call, because this | |
| 5242 | + | // one already runs at startup and the pins are useless without it. | |
| 5243 | + | // | |
| 5244 | + | // First connection only. Chrome retires an idle service worker and this | |
| 5245 | + | // runs again a second later, and re-running the whole follow on each of | |
| 5246 | + | // those would keep re-deciding a question the tab has not re-asked. | |
| 5247 | + | const first = panelWindowId == null; | |
| 5248 | + | panelWindowId = win.id; | |
| 5249 | + | if (first) syncTabPin(); | |
| 4522 | 5250 | const port = api.runtime.connect({ name: "sidebar" }); | |
| 4523 | 5251 | togglePort = port; | |
| 4524 | 5252 | port.postMessage({ type: "hello", windowId: win.id, focused: document.hasFocus() }); | |
| ⋯ 203 unchanged lines | |||
| 4728 | 5456 | densitySelect.addEventListener("change", () => setDensity(densitySelect.value)); | |
| 4729 | 5457 | const tabModeSelect = $select("tabmode-select"); | |
| 4730 | 5458 | tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value)); | |
| 5459 | + | const newTabSelect = $select("newtab-select"); | |
| 5460 | + | newTabSelect.addEventListener("change", () => setNewTabAction(newTabSelect.value)); | |
| 5461 | + | ||
| 5462 | + | // The tab-pin switches. Pins are kept when the feature is turned off — you are | |
| 5463 | + | // silencing it, not throwing away what you told it — so nothing here touches | |
| 5464 | + | // `byTab` or `byOrigin`. | |
| 5465 | + | const followBox = $input("follow-tabs"); | |
| 5466 | + | const detectBox = $input("detect-devport"); | |
| 5467 | + | const leadBox = $input("lead-tabs"); | |
| 5468 | + | ||
| 5469 | + | function applyTabPinSettings() { | |
| 5470 | + | followBox.checked = tabPins.enabled; | |
| 5471 | + | detectBox.checked = tabPins.detect; | |
| 5472 | + | leadBox.checked = tabPins.reverse; | |
| 5473 | + | // Both of these are subordinate clauses of the feature, and a live checkbox | |
| 5474 | + | // that cannot do anything is a worse answer than a greyed-out one. | |
| 5475 | + | detectBox.disabled = !tabPins.enabled; | |
| 5476 | + | leadBox.disabled = !tabPins.enabled; | |
| 5477 | + | } | |
| 5478 | + | ||
| 5479 | + | followBox.addEventListener("change", () => { | |
| 5480 | + | saveTabPins({ ...tabPins, enabled: followBox.checked }); | |
| 5481 | + | applyTabPinSettings(); | |
| 5482 | + | }); | |
| 5483 | + | detectBox.addEventListener("change", () => { | |
| 5484 | + | saveTabPins({ ...tabPins, detect: detectBox.checked }); | |
| 5485 | + | applyTabPinSettings(); | |
| 5486 | + | }); | |
| 5487 | + | leadBox.addEventListener("change", () => { | |
| 5488 | + | saveTabPins({ ...tabPins, reverse: leadBox.checked }); | |
| 5489 | + | applyTabPinSettings(); | |
| 5490 | + | }); | |
| 4731 | 5491 | ||
| 4732 | 5492 | $("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1)); | |
| 4733 | 5493 | $("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1)); | |
| ⋯ 68 unchanged lines | |||
| 4802 | 5562 | }); | |
| 4803 | 5563 | $("reconnect").addEventListener("click", connect); | |
| 4804 | 5564 | $("connect").addEventListener("click", connect); | |
| 5565 | + | $("offline-retry").addEventListener("click", connect); | |
| 5566 | + | $("offline-settings").addEventListener("click", openSettings); | |
| 4805 | 5567 | $("trust-cert").addEventListener("click", () => { | |
| 4806 | 5568 | const u = new URL($input("url").value.trim()); | |
| 4807 | 5569 | api.tabs.create({ url: `https://${u.host}/` }); | |
| ⋯ 23 unchanged lines | |||
| 4831 | 5593 | "pins", | |
| 4832 | 5594 | "sessionOrder", | |
| 4833 | 5595 | "tabMode", | |
| 5596 | + | "newTabAction", | |
| 4834 | 5597 | "foldedGroups", | |
| 5598 | + | Tabpin.KEY, | |
| 4835 | 5599 | ]; | |
| 4836 | 5600 | ||
| 4837 | 5601 | storage.get(STORED_KEYS).then((v) => { | |
| 4838 | 5602 | if (v.pins && typeof v.pins === "object") pins = v.pins; | |
| 5603 | + | tabPins = Tabpin.loadStore(v[Tabpin.KEY]); | |
| 5604 | + | applyTabPinSettings(); | |
| 4839 | 5605 | if (Array.isArray(v.foldedGroups)) { | |
| 4840 | 5606 | foldedGroups = new Set(v.foldedGroups.filter((n) => typeof n === "string")); | |
| 4841 | 5607 | } | |
| ⋯ 5 unchanged lines | |||
| 4847 | 5613 | themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto"; | |
| 4848 | 5614 | density = DENSITIES[v.density] ? v.density : "normal"; | |
| 4849 | 5615 | tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested"; | |
| 5616 | + | newTabAction = NEW_TAB_ACTIONS[v.newTabAction] ? v.newTabAction : "claude"; | |
| 4850 | 5617 | const stored = Number(v.fontSize); | |
| 4851 | 5618 | fontSize = | |
| 4852 | 5619 | Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX | |
| ⋯ 2 unchanged lines | |||
| 4855 | 5622 | applyTheme(); | |
| 4856 | 5623 | applyDensity(); | |
| 4857 | 5624 | applyTabMode(); | |
| 5625 | + | applyNewTabAction(); | |
| 4858 | 5626 | applyFontSize(); | |
| 4859 | 5627 | if (v.token) $input("token").value = v.token; | |
| 4860 | 5628 | if (v.url) $input("url").value = v.url; | |
| ⋯ 14 unchanged lines | |||