| ⋯ 143 unchanged lines |
| 144 | 144 | | // you type into, where what it does is leave for somewhere else. Enter is |
| 145 | 145 | | // not in either row: it lives under the mic, in the corner beside every |
| 146 | 146 | | // row, and so never moves with the layout. |
| 147 | | - | $("omni-strip").prepend($("pick")); |
| 147 | + | $("omni-strip").prepend($("pick"), $("device-toggle")); |
| 148 | 148 | | $("omni-strip").append($("jump")); |
| 149 | 149 | | $("omni-strip").hidden = false; |
| 150 | 150 | | // The session row is gone, so what it was showing has to go with it: a |
| ⋯ 12 unchanged lines |
| 163 | 163 | | top.append($("session-new"), $("session-name")); |
| 164 | 164 | | // Picker then jump lead the window row, in that order: both act on a pane |
| 165 | 165 | | // rather than on the tabs. |
| 166 | | - | row.prepend($("pick"), $("jump")); |
| 166 | + | row.prepend($("pick"), $("device-toggle"), $("jump")); |
| 167 | 167 | | row.append($("tab-new")); |
| 168 | 168 | | $("omni-strip").hidden = true; |
| 169 | 169 | | closeOmni(); |
| ⋯ 1167 unchanged lines |
| 1337 | 1337 | | const elsewhere = w.active && !current; |
| 1338 | 1338 | | // The browser tab on screen sends the terminal here. Worth a mark, because |
| 1339 | 1339 | | // otherwise the panel moves on its own and nothing on it says why. |
| 1340 | | - | const linked = tabPinMark({ session: owner ?? sessionName, window: w.id }); |
| 1340 | + | const pinTarget = { session: owner ?? sessionName, window: w.id }; |
| 1341 | + | const linked = tabPinMark(pinTarget); |
| 1341 | 1342 | | |
| 1342 | 1343 | | const tab = button( |
| 1343 | 1344 | | { |
| ⋯ 60 unchanged lines |
| 1404 | 1405 | | }, |
| 1405 | 1406 | | tab, |
| 1406 | 1407 | | canClose && closeButton(w, lastInSession ? owner : undefined), |
| 1408 | + | linked && unpinButton(pinTarget, linked), |
| 1407 | 1409 | | ); |
| 1408 | 1410 | | // The slot and not the tab: the ✕ is its sibling, and a drag that started on |
| 1409 | 1411 | | // the tab alone would leave the ✕ behind. |
| ⋯ 293 unchanged lines |
| 1703 | 1705 | | // The menu labels name the origin, and the indicator marks the row this |
| 1704 | 1706 | | // tab points at. Both are stale the moment the tab changed. |
| 1705 | 1707 | | repaintTabs(); |
| 1708 | + | syncDeviceToggle(); |
| 1706 | 1709 | | }) |
| 1707 | 1710 | | .catch(() => {}); |
| 1708 | 1711 | | } |
| ⋯ 10 unchanged lines |
| 1719 | 1722 | | .then((tab) => { |
| 1720 | 1723 | | browserTab = { id: tab.id, url: tab.url }; |
| 1721 | 1724 | | repaintTabs(); |
| 1725 | + | syncDeviceToggle(); |
| 1722 | 1726 | | }) |
| 1723 | 1727 | | .catch(() => {}); |
| 1724 | 1728 | | return; |
| ⋯ 9 unchanged lines |
| 1734 | 1738 | | browserTab = { id: tabId, url: change.url }; |
| 1735 | 1739 | | scheduleTabPin(); |
| 1736 | 1740 | | repaintTabs(); |
| 1741 | + | syncDeviceToggle(); |
| 1737 | 1742 | | }); |
| 1738 | 1743 | | |
| 1739 | 1744 | | // Tab ids are never reissued, so a pin whose tab is gone can only ever be dead |
| ⋯ 13 unchanged lines |
| 1753 | 1758 | | |
| 1754 | 1759 | | /** |
| 1755 | 1760 | | * Whether the showing browser tab points at this exact row, so the row can say |
| 1756 | | - | * so. Windows match on their own id; a session-level pin marks the session and |
| 1757 | | - | * every row of it stays unmarked. |
| 1761 | + | * so. Windows match on their own id. |
| 1762 | + | * |
| 1763 | + | * A session's own row (chip or session-tab) also lights up for a pin that |
| 1764 | + | * names one of its *windows*, not just a session-level pin — that window's own |
| 1765 | + | * row carries the precise mark when it is on screen, but a folded group or a |
| 1766 | + | * session you are not attached to never renders its windows at all, and the |
| 1767 | + | * session row is the only thing left to say the pin is in here somewhere. |
| 1758 | 1768 | | * |
| 1759 | 1769 | | * @param {{ session?: string | null, window?: string | null }} row |
| 1760 | 1770 | | * @returns {TbPinSource | null} |
| ⋯ 2 unchanged lines |
| 1763 | 1773 | | const at = pinNow; |
| 1764 | 1774 | | if (!at) return null; |
| 1765 | 1775 | | if (row.window) return at.window?.id === row.window ? at.source : null; |
| 1766 | | - | return !at.window && at.session === row.session ? at.source : null; |
| 1776 | + | return at.session === row.session ? at.source : null; |
| 1767 | 1777 | | } |
| 1768 | 1778 | | |
| 1769 | 1779 | | /** How a pin explains itself in a tooltip. */ |
| ⋯ 4 unchanged lines |
| 1774 | 1784 | | }; |
| 1775 | 1785 | | |
| 1776 | 1786 | | /** |
| 1787 | + | * Removes whichever pin currently links `target` to the browser tab on |
| 1788 | + | * screen — shared by the context menu's "Unpin from..." line and the pin icon |
| 1789 | + | * a linked window's tab wears, so there is one place that knows how each kind |
| 1790 | + | * of pin comes off. |
| 1791 | + | * |
| 1792 | + | * Unpinning a detected match cannot just delete an entry — there is no entry, |
| 1793 | + | * and the guess would be back before the click had landed. It writes the veto |
| 1794 | + | * instead, at the level the guess was made: the origin. |
| 1795 | + | * |
| 1796 | + | * @param {{ session?: string | null, window?: string | null }} target |
| 1797 | + | */ |
| 1798 | + | function unpinHere(target) { |
| 1799 | + | const here = tabPinMark({ session: target.session, window: target.window }); |
| 1800 | + | if (!here) return; |
| 1801 | + | const origin = Tabpin.originOf(browserTab.url); |
| 1802 | + | const tabKey = browserTab.id != null ? String(browserTab.id) : ""; |
| 1803 | + | if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined)); |
| 1804 | + | else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined)); |
| 1805 | + | else saveTabPins(Tabpin.put(tabPins, "origin", origin, null)); |
| 1806 | + | } |
| 1807 | + | |
| 1808 | + | /** |
| 1777 | 1809 | | * The pin lines of a context menu, for a window (a window id) or a whole |
| 1778 | 1810 | | * session (none). Both kinds of key are offered because they answer different |
| 1779 | 1811 | | * questions — see the header of lib/tabpin.js — and neither is offered for a |
| ⋯ 9 unchanged lines |
| 1789 | 1821 | | const here = tabPinMark({ session: target.session, window: target.window }); |
| 1790 | 1822 | | |
| 1791 | 1823 | | if (here) { |
| 1792 | | - | // Unpinning a detected match cannot just delete an entry — there is no |
| 1793 | | - | // entry, and the guess would be back before the menu had closed. It writes |
| 1794 | | - | // the veto instead, at the level the guess was made: the origin. |
| 1795 | 1824 | | const label = here === "tab" ? "Unpin from this browser tab" : `Unpin from ${shortOrigin(origin)}`; |
| 1796 | | - | menuItem(menu, label, () => { |
| 1797 | | - | if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined)); |
| 1798 | | - | else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined)); |
| 1799 | | - | else saveTabPins(Tabpin.put(tabPins, "origin", origin, null)); |
| 1800 | | - | }); |
| 1825 | + | menuItem(menu, label, () => unpinHere(target)); |
| 1801 | 1826 | | return; |
| 1802 | 1827 | | } |
| 1803 | 1828 | | |
| ⋯ 117 unchanged lines |
| 1921 | 1946 | | }); |
| 1922 | 1947 | | } |
| 1923 | 1948 | | |
| 1949 | + | // The same split the "+" drop target performs, as a line you can find without |
| 1950 | + | // knowing the drag exists. Withheld for a session's last window, where the |
| 1951 | + | // drag allows it — a drop needs a "+" that lights up for every tab, but a menu |
| 1952 | + | // line can be selective, and here the split would only rename the session the |
| 1953 | + | // window is already alone in. |
| 1954 | + | if (!lastInSession) { |
| 1955 | + | item("Move to new session", () => { |
| 1956 | + | const name = sessionNameFor( |
| 1957 | + | w.name, |
| 1958 | + | lastSessions.map((s) => s.name), |
| 1959 | + | ); |
| 1960 | + | tmuxCommand({ cmd: "new-session-with-window", window: w.id, name }); |
| 1961 | + | log(`new session ${name} from window ${w.index} (${w.name})`); |
| 1962 | + | term.focus(); |
| 1963 | + | }); |
| 1964 | + | } |
| 1965 | + | |
| 1924 | 1966 | | if (closable) { |
| 1925 | 1967 | | const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => { |
| 1926 | 1968 | | tmuxCommand({ cmd: "kill-window", window: w.id }); |
| ⋯ 72 unchanged lines |
| 1999 | 2041 | | ); |
| 2000 | 2042 | | } |
| 2001 | 2043 | | |
| 2044 | + | /** |
| 2045 | + | * The pin icon a window's tab wears once the browser tab on screen is linked |
| 2046 | + | * to it. One click is the whole gesture — `unpinHere` already knows which |
| 2047 | + | * kind of pin to take off, and the tooltip already said why it was there. |
| 2048 | + | * |
| 2049 | + | * @param {{ session?: string | null, window?: string | null }} target |
| 2050 | + | * @param {TbPinSource} source |
| 2051 | + | */ |
| 2052 | + | function unpinButton(target, source) { |
| 2053 | + | const label = `${PIN_SOURCE_NOTE[source]} — click to unpin`; |
| 2054 | + | return button( |
| 2055 | + | { |
| 2056 | + | class: "tab-pin", |
| 2057 | + | title: label, |
| 2058 | + | attrs: { "aria-label": label }, |
| 2059 | + | on: { |
| 2060 | + | /** @param {MouseEvent} e */ |
| 2061 | + | click: (e) => { |
| 2062 | + | // The tab underneath would otherwise read this as "select me". |
| 2063 | + | e.stopPropagation(); |
| 2064 | + | unpinHere(target); |
| 2065 | + | term.focus(); |
| 2066 | + | }, |
| 2067 | + | }, |
| 2068 | + | }, |
| 2069 | + | pinIcon(), |
| 2070 | + | ); |
| 2071 | + | } |
| 2072 | + | |
| 2002 | 2073 | | const SVG_NS = "http://www.w3.org/2000/svg"; |
| 2003 | 2074 | | |
| 2004 | 2075 | | /** |
| ⋯ 16 unchanged lines |
| 2021 | 2092 | | return svg; |
| 2022 | 2093 | | } |
| 2023 | 2094 | | |
| 2095 | + | /** Google's own "keep_off" glyph — the pin-with-a-strike Chrome's Material |
| 2096 | + | * Symbols icon set uses for exactly this — dropped in verbatim rather than |
| 2097 | + | * redrawn by hand. */ |
| 2098 | + | function pinIcon() { |
| 2099 | + | const svg = document.createElementNS(SVG_NS, "svg"); |
| 2100 | + | svg.setAttribute("viewBox", "0 0 24 24"); |
| 2101 | + | svg.setAttribute("aria-hidden", "true"); |
| 2102 | + | const path = document.createElementNS(SVG_NS, "path"); |
| 2103 | + | path.setAttribute( |
| 2104 | + | "d", |
| 2105 | + | "M17 3v2h-1v8.175L7.825 5L7 4.175V3zm-5 20l-1-1v-6H6v-2l2-2v-1.15L1.4 4.2l1.4-1.4l18.4 18.4l-1.45 1.4l-6.6-6.6H13v6z", |
| 2106 | + | ); |
| 2107 | + | path.setAttribute("fill", "currentColor"); |
| 2108 | + | svg.appendChild(path); |
| 2109 | + | return svg; |
| 2110 | + | } |
| 2111 | + | |
| 2024 | 2112 | | /** |
| 2025 | 2113 | | * Open a window in a session and go to it. |
| 2026 | 2114 | | * |
| ⋯ 1258 unchanged lines |
| 3285 | 3373 | | /** |
| 3286 | 3374 | | * One row of the dropdown. |
| 3287 | 3375 | | * @typedef {object} TbOmniItem |
| 3288 | | - | * @property {"window" | "session" | "pane" | "ssh" | "create" | "claude" | "run" |
| 3289 | | - | * | "action" | "project" | "path"} kind |
| 3376 | + | * @property {"window" | "session" | "pane" | "tab" | "ssh" | "create" | "claude" |
| 3377 | + | * | "run" | "action" | "project" | "path"} kind |
| 3290 | 3378 | | * @property {string} label the name, matched against and shown first |
| 3291 | 3379 | | * @property {string} meta where it is — dimmed, after the label |
| 3292 | 3380 | | * @property {string} [mark] a character in front of the label, for the kinds |
| ⋯ 107 unchanged lines |
| 3400 | 3488 | | session: 0, |
| 3401 | 3489 | | window: 1, |
| 3402 | 3490 | | pane: 2, |
| 3403 | | - | ssh: 3, |
| 3404 | | - | create: 4, |
| 3405 | | - | claude: 5, |
| 3406 | | - | run: 6, |
| 3407 | | - | action: 7, |
| 3491 | + | tab: 3, |
| 3492 | + | ssh: 4, |
| 3493 | + | create: 5, |
| 3494 | + | claude: 6, |
| 3495 | + | run: 7, |
| 3496 | + | action: 8, |
| 3408 | 3497 | | // Both only ever appear on their own — a `~` in the box is a mode, and these |
| 3409 | 3498 | | // are the only rows in it — so their rank is a formality. |
| 3410 | | - | project: 8, |
| 3411 | | - | path: 9, |
| 3499 | + | project: 9, |
| 3500 | + | path: 10, |
| 3412 | 3501 | | }; |
| 3413 | 3502 | | |
| 3414 | 3503 | | /** |
| ⋯ 9 unchanged lines |
| 3424 | 3513 | | /** How many matches the list shows before it stops. */ |
| 3425 | 3514 | | const OMNI_LIMIT = 8; |
| 3426 | 3515 | | |
| 3516 | + | // --- the box as a tab switcher ---------------------------------------------- |
| 3517 | + | // |
| 3518 | + | // The other half of what a panel this shape is looking at: the browser window |
| 3519 | + | // it is attached to. Typing `localhost:8000` finds the tab serving it and Enter |
| 3520 | + | // shows it, which is the thing the address bar cannot be asked for from in here |
| 3521 | + | // — Ctrl+L belongs to the terminal, and no extension API can focus the real one. |
| 3522 | + | // |
| 3523 | + | // Nothing here needs a permission that is not already granted: `tabs` is in |
| 3524 | + | // both manifests for the pins, and a tab's title and URL come with it. |
| 3525 | + | |
| 3526 | + | /** |
| 3527 | + | * What a tab row's score is pushed up by, so that everything on the tmux server |
| 3528 | + | * outranks a page in the browser. |
| 3529 | + | * |
| 3530 | + | * Below the panes and the hosts, which is the bottom of the list, and it costs |
| 3531 | + | * the tab rows nothing they wanted: an address matches no session, window or |
| 3532 | + | * host name, so the query that is looking for a tab finds only tabs anyway. The |
| 3533 | + | * penalty decides the other case — a word that means both, a tab titled `build` |
| 3534 | + | * while a window is called `build` — and there the window is what the box is |
| 3535 | + | * for. |
| 3536 | + | */ |
| 3537 | + | const OMNI_TAB_PENALTY = 60; |
| 3538 | + | |
| 3539 | + | /** |
| 3540 | + | * The window's tabs, as of the last time the box asked. |
| 3541 | + | * |
| 3542 | + | * Kept rather than queried per keystroke, because `omniSuggestions` is |
| 3543 | + | * synchronous and `api.tabs.query` is not. |
| 3544 | + | * |
| 3545 | + | * @type {TbTab[]} |
| 3546 | + | */ |
| 3547 | + | let lastTabs = []; |
| 3548 | + | |
| 3427 | 3549 | | /** |
| 3550 | + | * Re-read the window's tabs, and repaint the list if it is up. |
| 3551 | + | * |
| 3552 | + | * Asked for when the box takes focus, and on every tab event while the list is |
| 3553 | + | * showing — which is what keeps a row from offering a tab that closed under it. |
| 3554 | + | * Not asked for otherwise: with the list down there is nothing to be stale. |
| 3555 | + | */ |
| 3556 | + | function syncOmniTabs() { |
| 3557 | + | if (panelWindowId == null) return; |
| 3558 | + | api.tabs |
| 3559 | + | .query({ windowId: panelWindowId }) |
| 3560 | + | .then((tabs) => { |
| 3561 | + | lastTabs = tabs; |
| 3562 | + | if (!$("omni-list").hidden) refreshOmni(); |
| 3563 | + | }) |
| 3564 | + | .catch(() => {}); |
| 3565 | + | } |
| 3566 | + | |
| 3567 | + | const repaintOmniTabs = () => { |
| 3568 | + | if (!$("omni-list").hidden) syncOmniTabs(); |
| 3569 | + | }; |
| 3570 | + | api.tabs.onCreated.addListener(repaintOmniTabs); |
| 3571 | + | api.tabs.onRemoved.addListener(repaintOmniTabs); |
| 3572 | + | api.tabs.onUpdated.addListener(repaintOmniTabs); |
| 3573 | + | api.tabs.onActivated.addListener(repaintOmniTabs); |
| 3574 | + | |
| 3575 | + | /** |
| 3576 | + | * A URL as the box should match and show it: the scheme, a leading `www.` and a |
| 3577 | + | * trailing slash taken off, because none of the three is anything you would |
| 3578 | + | * type to find the tab again. `https://localhost:8000/` becomes `localhost:8000`. |
| 3579 | + | * |
| 3580 | + | * Only http(s) is trimmed. A `chrome://`, `about:` or `file://` address is |
| 3581 | + | * mostly scheme — take it off and what is left names nothing. |
| 3582 | + | * |
| 3583 | + | * @param {string} url |
| 3584 | + | * @returns {string} |
| 3585 | + | */ |
| 3586 | + | function tabAddress(url) { |
| 3587 | + | let u; |
| 3588 | + | try { |
| 3589 | + | u = new URL(url); |
| 3590 | + | } catch { |
| 3591 | + | return url; |
| 3592 | + | } |
| 3593 | + | if (u.protocol !== "http:" && u.protocol !== "https:") return url.replace(/\/+$/, ""); |
| 3594 | + | const rest = `${u.pathname}${u.search}`.replace(/\/$/, ""); |
| 3595 | + | return u.host.replace(/^www\./, "") + rest; |
| 3596 | + | } |
| 3597 | + | |
| 3598 | + | /** |
| 3599 | + | * The better of two `omniScore`s, where -1 means "did not match" rather than a |
| 3600 | + | * score and so cannot go into the comparison as one. |
| 3601 | + | * |
| 3602 | + | * @param {number} a |
| 3603 | + | * @param {number} b |
| 3604 | + | * @returns {number} lower is better, -1 when neither matched |
| 3605 | + | */ |
| 3606 | + | function bestScore(a, b) { |
| 3607 | + | if (a < 0) return b; |
| 3608 | + | if (b < 0) return a; |
| 3609 | + | return Math.min(a, b); |
| 3610 | + | } |
| 3611 | + | |
| 3612 | + | /** |
| 3613 | + | * The window's tabs, as rows. |
| 3614 | + | * |
| 3615 | + | * Matched on the title *and* on the address, best of the two, because which of |
| 3616 | + | * them you know is not the same from tab to tab: a dev server is `localhost:8000` |
| 3617 | + | * and its title is whatever the framework put there, while a doc page has a |
| 3618 | + | * title you would recognise and a URL you would not. |
| 3619 | + | * |
| 3620 | + | * This window's tabs only. The panel belongs to one browser window — Chrome |
| 3621 | + | * gives each window its own side panel — so a row that focused another window |
| 3622 | + | * would leave the terminal behind in this one, which is a strange thing for the |
| 3623 | + | * terminal's own box to offer. |
| 3624 | + | * |
| 3625 | + | * Titles and URLs are page-controlled, which is the same footing tmux names are |
| 3626 | + | * on: both reach the DOM through textContent and nothing else. |
| 3627 | + | * |
| 3628 | + | * @param {string} q already lowercased and trimmed |
| 3629 | + | * @returns {TbOmniItem[]} |
| 3630 | + | */ |
| 3631 | + | function tabSuggestions(q) { |
| 3632 | + | /** @type {TbOmniItem[]} */ |
| 3633 | + | const out = []; |
| 3634 | + | for (const tab of lastTabs) { |
| 3635 | + | const id = tab.id; |
| 3636 | + | // The showing tab is left out for the reason the current session is: going |
| 3637 | + | // there is where you already are. |
| 3638 | + | if (id == null || tab.active) continue; |
| 3639 | + | const where = tabAddress(tab.url ?? ""); |
| 3640 | + | const score = bestScore(omniScore(tab.title ?? "", q), omniScore(where, q)); |
| 3641 | + | if (score < 0) continue; |
| 3642 | + | out.push({ |
| 3643 | + | kind: "tab", |
| 3644 | + | label: tab.title || where, |
| 3645 | + | meta: where, |
| 3646 | + | score: score + OMNI_TAB_PENALTY, |
| 3647 | + | // Showing the tab is the whole of it. The keyboard stays where `runOmni` |
| 3648 | + | // puts it — a panel cannot hand focus to the page — and if the tab is |
| 3649 | + | // pinned, showing it moves the terminal too: the same forward pin that a |
| 3650 | + | // click on the tab would have run. |
| 3651 | + | run: () => api.tabs.update(id, { active: true }).catch(() => {}), |
| 3652 | + | }); |
| 3653 | + | } |
| 3654 | + | return out; |
| 3655 | + | } |
| 3656 | + | |
| 3657 | + | /** |
| 3428 | 3658 | | * @param {string} query what is in the box |
| 3429 | 3659 | | * @returns {TbOmniItem[]} |
| 3430 | 3660 | | */ |
| ⋯ 93 unchanged lines |
| 3524 | 3754 | | }); |
| 3525 | 3755 | | } |
| 3526 | 3756 | | |
| 3757 | + | // The browser's own tabs. Only with a query, for the same reason the windows |
| 3758 | + | // and the panes are: an empty box is the server's short list, and every tab |
| 3759 | + | // in the window listed under it would bury the thing it opened for. |
| 3760 | + | if (q) out.push(...tabSuggestions(q)); |
| 3761 | + | |
| 3527 | 3762 | | // Machines, from what ssh already knows about. Only with a query, for the |
| 3528 | 3763 | | // same reason the windows are: an empty box is a starting point rather than |
| 3529 | 3764 | | // an inventory, and the sessions are the short list it offers. |
| ⋯ 1042 unchanged lines |
| 4572 | 4807 | | // on when the panel is still coming up around it. Asking for it here makes |
| 4573 | 4808 | | // the shortcut mean the same thing however it arrived: the box, its name |
| 4574 | 4809 | | // selected, and everywhere else already listed under it. |
| 4810 | + | // The tab rows come from a query, and the shortcut is the one path into the |
| 4811 | + | // box that the focus listener below does not cover: pressed with the caret |
| 4812 | + | // already here, no focus event fires and the list would be built from |
| 4813 | + | // whatever the window's tabs were the last time it was open. |
| 4575 | 4814 | | omniDirty = false; |
| 4815 | + | syncOmniTabs(); |
| 4576 | 4816 | | refreshOmni(); |
| 4577 | 4817 | | } |
| 4578 | 4818 | | |
| ⋯ 21 unchanged lines |
| 4600 | 4840 | | $area("omni").addEventListener("focus", () => { |
| 4601 | 4841 | | omniDirty = false; |
| 4602 | 4842 | | $area("omni").select(); |
| 4843 | + | syncOmniTabs(); |
| 4603 | 4844 | | refreshOmni(); |
| 4604 | 4845 | | }); |
| 4605 | 4846 | | |
| ⋯ 631 unchanged lines |
| 5237 | 5478 | | } |
| 5238 | 5479 | | }); |
| 5239 | 5480 | | |
| 5481 | + | // --- mobile device emulation -------------------------------------------- |
| 5482 | + | // |
| 5483 | + | // DevTools' own device toolbar, reachable from here: chrome.debugger is the |
| 5484 | + | // only extension-facing way to drive the CDP Emulation domain, and there is |
| 5485 | + | // no Firefox equivalent, so api.debugger is both the permission gate and the |
| 5486 | + | // feature gate. See the "debugger" bullet in README's Security model for why |
| 5487 | + | // this is the one standing, non-revocable permission in this codebase. |
| 5488 | + | |
| 5489 | + | /** A DevTools-ish default: iPhone 12/13-class viewport, in each orientation. */ |
| 5490 | + | const MOBILE_DEVICE_METRICS = { |
| 5491 | + | portrait: { |
| 5492 | + | width: 390, |
| 5493 | + | height: 844, |
| 5494 | + | deviceScaleFactor: 3, |
| 5495 | + | mobile: true, |
| 5496 | + | screenOrientation: { type: "portraitPrimary", angle: 0 }, |
| 5497 | + | }, |
| 5498 | + | landscape: { |
| 5499 | + | width: 844, |
| 5500 | + | height: 390, |
| 5501 | + | deviceScaleFactor: 3, |
| 5502 | + | mobile: true, |
| 5503 | + | screenOrientation: { type: "landscapePrimary", angle: 90 }, |
| 5504 | + | }, |
| 5505 | + | }; |
| 5506 | + | const MOBILE_UA = |
| 5507 | + | "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + |
| 5508 | + | "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; |
| 5509 | + | |
| 5510 | + | /** |
| 5511 | + | * Tabs currently under CDP device-metrics override, and which orientation |
| 5512 | + | * each is showing. One button drives all three states: off -> portrait -> |
| 5513 | + | * landscape -> off. |
| 5514 | + | * @type {Map<number, "portrait" | "landscape">} |
| 5515 | + | */ |
| 5516 | + | const emulating = new Map(); |
| 5517 | + | |
| 5518 | + | /** |
| 5519 | + | * Apply a metrics override and force a repaint against it. Blink does not |
| 5520 | + | * reliably repaint on a metrics change alone — without the synthetic resize |
| 5521 | + | * below, a rotate can leave the page painted at the old size until something |
| 5522 | + | * external forces a layout pass. DevTools' own device toolbar never hits |
| 5523 | + | * this: resizing its panel is the forcing function this dispatches by hand |
| 5524 | + | * instead. |
| 5525 | + | * @param {number} tabId |
| 5526 | + | * @param {typeof MOBILE_DEVICE_METRICS.portrait} metrics |
| 5527 | + | */ |
| 5528 | + | async function applyDeviceMetrics(tabId, metrics) { |
| 5529 | + | if (!api.debugger) return; |
| 5530 | + | await api.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", metrics); |
| 5531 | + | await api.debugger |
| 5532 | + | .sendCommand({ tabId }, "Runtime.evaluate", { |
| 5533 | + | expression: "window.dispatchEvent(new Event('resize'))", |
| 5534 | + | }) |
| 5535 | + | .catch(() => {}); |
| 5536 | + | } |
| 5537 | + | |
| 5538 | + | /** |
| 5539 | + | * Undo the on-sequence and drop the CDP session. |
| 5540 | + | * @param {number} tabId |
| 5541 | + | */ |
| 5542 | + | async function stopEmulating(tabId) { |
| 5543 | + | if (!api.debugger) return; |
| 5544 | + | await api.debugger |
| 5545 | + | .sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride") |
| 5546 | + | .catch(() => {}); |
| 5547 | + | await api.debugger |
| 5548 | + | .sendCommand({ tabId }, "Emulation.setTouchEmulationEnabled", { enabled: false }) |
| 5549 | + | .catch(() => {}); |
| 5550 | + | await api.debugger |
| 5551 | + | .sendCommand({ tabId }, "Runtime.evaluate", { |
| 5552 | + | expression: "window.dispatchEvent(new Event('resize'))", |
| 5553 | + | }) |
| 5554 | + | .catch(() => {}); |
| 5555 | + | await api.debugger.detach({ tabId }).catch(() => {}); |
| 5556 | + | } |
| 5557 | + | |
| 5558 | + | /** |
| 5559 | + | * Reflect the tracked tab's place in the off/portrait/landscape cycle, or |
| 5560 | + | * that the feature does not exist at all here — Firefox has no api.debugger, |
| 5561 | + | * so the button just stays off permanently rather than failing on click. |
| 5562 | + | */ |
| 5563 | + | function syncDeviceToggle() { |
| 5564 | + | const btn = /** @type {HTMLButtonElement} */ ($("device-toggle")); |
| 5565 | + | if (!api.debugger) { |
| 5566 | + | btn.disabled = true; |
| 5567 | + | btn.title = "Mobile emulation needs Chrome's debugger API — not available in Firefox"; |
| 5568 | + | return; |
| 5569 | + | } |
| 5570 | + | const orientation = browserTab.id != null ? emulating.get(browserTab.id) : undefined; |
| 5571 | + | btn.classList.toggle("active", orientation != null); |
| 5572 | + | btn.title = |
| 5573 | + | orientation === "portrait" |
| 5574 | + | ? "Rotate to landscape (click again to turn off)" |
| 5575 | + | : orientation === "landscape" |
| 5576 | + | ? "Turn off mobile emulation" |
| 5577 | + | : "Emulate a mobile device on this tab"; |
| 5578 | + | } |
| 5579 | + | |
| 5580 | + | $("device-toggle").addEventListener("click", async () => { |
| 5581 | + | if (!api.debugger) return; |
| 5582 | + | const tabId = browserTab.id; |
| 5583 | + | if (tabId == null) return; |
| 5584 | + | |
| 5585 | + | const orientation = emulating.get(tabId); |
| 5586 | + | if (orientation === "landscape") { |
| 5587 | + | await stopEmulating(tabId); |
| 5588 | + | emulating.delete(tabId); |
| 5589 | + | syncDeviceToggle(); |
| 5590 | + | return; |
| 5591 | + | } |
| 5592 | + | if (orientation === "portrait") { |
| 5593 | + | await applyDeviceMetrics(tabId, MOBILE_DEVICE_METRICS.landscape); |
| 5594 | + | emulating.set(tabId, "landscape"); |
| 5595 | + | syncDeviceToggle(); |
| 5596 | + | return; |
| 5597 | + | } |
| 5598 | + | |
| 5599 | + | if (SKIP_URL.test(browserTab.url ?? "")) { |
| 5600 | + | log("Can't emulate here — browser-internal and extension pages are off limits."); |
| 5601 | + | return; |
| 5602 | + | } |
| 5603 | + | |
| 5604 | + | try { |
| 5605 | + | await api.debugger.attach({ tabId }, "1.3"); |
| 5606 | + | } catch (e) { |
| 5607 | + | log(`Mobile toggle: ${e instanceof Error ? e.message : e}`); |
| 5608 | + | return; |
| 5609 | + | } |
| 5610 | + | try { |
| 5611 | + | await applyDeviceMetrics(tabId, MOBILE_DEVICE_METRICS.portrait); |
| 5612 | + | // Deprecated in the CDP spec (setDeviceMetricsOverride's `mobile` flag now |
| 5613 | + | // implies touch), but DevTools' own device toolbar still sends it, so kept |
| 5614 | + | // for parity. |
| 5615 | + | await api.debugger.sendCommand({ tabId }, "Emulation.setTouchEmulationEnabled", { enabled: true }); |
| 5616 | + | await api.debugger.sendCommand({ tabId }, "Emulation.setUserAgentOverride", { userAgent: MOBILE_UA }); |
| 5617 | + | } catch (e) { |
| 5618 | + | // Never leave a half-configured session attached. |
| 5619 | + | await stopEmulating(tabId); |
| 5620 | + | log(`Mobile toggle: ${e instanceof Error ? e.message : e}`); |
| 5621 | + | return; |
| 5622 | + | } |
| 5623 | + | emulating.set(tabId, "portrait"); |
| 5624 | + | syncDeviceToggle(); |
| 5625 | + | }); |
| 5626 | + | |
| 5627 | + | // Fires when the session ends without our own detach() call: the tab closed, |
| 5628 | + | // or the user dismissed Chrome's debugging infobar themselves. Either way the |
| 5629 | + | // button has gone stale and has to resync. |
| 5630 | + | api.debugger?.onDetach.addListener((source) => { |
| 5631 | + | if (source.tabId == null) return; |
| 5632 | + | emulating.delete(source.tabId); |
| 5633 | + | syncDeviceToggle(); |
| 5634 | + | }); |
| 5635 | + | |
| 5240 | 5636 | | // Escape from the sidebar too. The picker handles Escape itself, but only when |
| 5241 | 5637 | | // the page has keyboard focus — if you started the pick from here, focus is |
| 5242 | 5638 | | // still in the panel and the key never reaches the page. |
| ⋯ 87 unchanged lines |
| 5330 | 5726 | | window.addEventListener("focus", reportFocus); |
| 5331 | 5727 | | window.addEventListener("blur", reportFocus); |
| 5332 | 5728 | | connectToggle(); |
| 5729 | + | // Sets the disabled state on Firefox immediately, ahead of the first tab |
| 5730 | + | // query resolving. |
| 5731 | + | syncDeviceToggle(); |
| 5333 | 5732 | | |
| 5334 | 5733 | | // A pick made while the sidebar was closed is parked in storage. Nothing is |
| 5335 | 5734 | | // typed for these: the terminal has moved on, and whatever screenshot went with |
| ⋯ 365 unchanged lines |