anvilsign in

collin/browser-terminal-extension

main / extension / lib / split.js
1// Running the picker across Chrome's split view.
2//
3// A split view is two tabs side by side in one window. Both are rendered and
4// both are clickable, but only one is `active` — Chrome marks a single tab
5// active even while its partner is fully visible. So the obvious
6// `tabs.query({active: true})` always resolves to whichever half last had
7// focus, and a pick started from the other half highlights nothing.
8//
9// The fix is to inject into every visible half at once and take the first
10// answer. `splitViewId` (Chrome 140+) is what makes the set knowable; it is
11// read-only detection data, so nothing here creates or dissolves a split.
12// Firefox has neither the property nor the feature, and falls through to the
13// single-tab path.
14//
15// Loaded by both the sidebar (a document) and the background worker (no DOM),
16// so nothing here may touch `document`.
17
18/**
19 * Every tab a pick should run in: the active one, plus the other half of its
20 * split view when there is one. Active first — its failure is the one worth
21 * reporting, because it is the half the user was looking at.
22 *
23 * @param {TbExtensionApi} api
24 * @param {TbTab} tab the active tab
25 * @returns {Promise<TbTab[]>}
26 */
27async function pickTargets(api, tab) {
28 if (!tab || tab.id == null) return [];
29 if (tab.splitViewId == null) return [tab];
30 try {
31 // Chrome throws on an unrecognised query key rather than ignoring it, so a
32 // browser that reports splitViewId but won't query on it lands in the catch.
33 const tabs = await api.tabs.query({ splitViewId: tab.splitViewId, currentWindow: true });
34 const out = [tab];
35 for (const t of tabs) if (t.id != null && t.id !== tab.id) out.push(t);
36 return out;
37 } catch {
38 return [tab];
39 }
40}
41
42/**
43 * Tear down a picker overlay from outside the page.
44 *
45 * picker.js parks its cleanup on `window.__tbPickerActive`; calling it resolves
46 * that pick's promise with null, exactly as an Escape would.
47 */
48function tbCancelPicker() {
49 if (window.__tbPickerActive) window.__tbPickerActive();
50}
51
52/**
53 * Cancel picks running in `tabIds`. Failures are ignored: a tab that closed or
54 * navigated took its picker with it.
55 *
56 * @param {TbExtensionApi} api
57 * @param {number[]} tabIds
58 */
59async function cancelPicks(api, tabIds) {
60 await Promise.all(
61 tabIds.map((tabId) =>
62 api.scripting.executeScript({ target: { tabId }, func: tbCancelPicker }).catch(() => {}),
63 ),
64 );
65}
66
67/**
68 * Run the picker in every candidate tab at once and take the first real answer.
69 *
70 * The first half to settle wins, and the losers are torn down — including on a
71 * cancel, so an Escape in one pane doesn't leave an overlay live in the other.
72 * An injection that *fails* is not a settle: on the keyboard-shortcut path the
73 * `activeTab` grant covers only the active half, so the partner half usually
74 * refuses unless its origin was granted, and that must not end a pick the
75 * active half is still serving.
76 *
77 * @template T
78 * @param {TbExtensionApi} api
79 * @param {TbTab[]} tabs
80 * @param {() => Promise<T | null>} func the picker, serialised into each page
81 * @returns {Promise<{ tabId: number | undefined; value: T | null; error: string }>}
82 */
83function racePick(api, tabs, func) {
84 /** @type {number[]} */
85 const ids = [];
86 for (const t of tabs) if (t?.id != null) ids.push(t.id);
87 if (!ids.length) return Promise.resolve({ tabId: undefined, value: null, error: "no page to pick in" });
88
89 return new Promise((resolve) => {
90 let finished = false;
91 let pending = ids.length;
92 let firstError = "";
93
94 for (const tabId of ids) {
95 api.scripting
96 .executeScript({ target: { tabId }, func })
97 // `undefined` means the injection itself failed; `null` means the pick
98 // was cancelled. Only the second one ends the race.
99 .then((results) => results?.[0]?.result ?? null)
100 .catch((e) => {
101 if (!firstError) firstError = e instanceof Error ? e.message : String(e);
102 return undefined;
103 })
104 .then((value) => {
105 if (finished) return;
106 if (value === undefined) {
107 if (--pending === 0) {
108 resolve({ tabId: ids[0], value: null, error: firstError || "injection failed" });
109 }
110 return;
111 }
112 finished = true;
113 cancelPicks(
114 api,
115 ids.filter((other) => other !== tabId),
116 );
117 resolve({ tabId, value: /** @type {T | null} */ (value), error: "" });
118 });
119 }
120 });
121}
122
123const Split = { pickTargets, racePick, cancelPicks };
124
125if (typeof module !== "undefined" && module.exports) {
126 module.exports = Split;
127}