This repository has no description
14 kB
492 lines
1/* Workspace Tabs - Firefox extension for workspace-manager integration
2 *
3 * - Saves tabs to ~/workspaces/<name>.json via native host on every tab change
4 * - Restores tabs from workspace file when switching to a workspace
5 * - Maintains a hidden anchor window to keep Firefox alive
6 * - Provides "Send to workspace" context menu
7 */
8
9const HOST_NAME = "workspace_tabs";
10const ANCHOR_TITLE = "workspace-anchor";
11
12let port = null;
13let pendingRequests = new Map();
14let requestId = 0;
15let lastWorkspace = null;
16let anchorWindowId = null;
17let restoringWorkspaces = new Set();
18let pendingNewWorkspaceTabId = null;
19let savePending = false;
20let initialized = false;
21
22// ── Native messaging ────────────────────────────────────────
23
24function connectNative() {
25 try {
26 port = browser.runtime.connectNative(HOST_NAME);
27 port.onMessage.addListener(handleNativeMessage);
28 port.onDisconnect.addListener(() => {
29 console.warn("Native host disconnected, reconnecting in 2s...");
30 port = null;
31 setTimeout(connectNative, 2000);
32 });
33 } catch (e) {
34 console.error("Failed to connect to native host:", e);
35 setTimeout(connectNative, 5000);
36 }
37}
38
39function sendNative(cmd, params = {}) {
40 return new Promise((resolve, reject) => {
41 if (!port) {
42 reject(new Error("Native host not connected"));
43 return;
44 }
45 const id = ++requestId;
46 pendingRequests.set(id, { resolve, reject });
47 port.postMessage({ id, cmd, ...params });
48 setTimeout(() => {
49 if (pendingRequests.has(id)) {
50 pendingRequests.delete(id);
51 reject(new Error("Request timed out"));
52 }
53 }, 5000);
54 });
55}
56
57function handleNativeMessage(msg) {
58 if (msg.event === "workspace_change") {
59 onWorkspaceChange(msg.workspace);
60 return;
61 }
62 if (msg.id && pendingRequests.has(msg.id)) {
63 const { resolve } = pendingRequests.get(msg.id);
64 pendingRequests.delete(msg.id);
65 resolve(msg);
66 }
67}
68
69// ── Workspace queries ───────────────────────────────────────
70
71async function getCurrentWorkspace() {
72 try {
73 const resp = await sendNative("current_workspace");
74 return resp.workspace || null;
75 } catch (e) {
76 return null;
77 }
78}
79
80async function listWorkspaces() {
81 try {
82 const resp = await sendNative("list_workspaces");
83 return resp.workspaces || [];
84 } catch (e) {
85 return [];
86 }
87}
88
89// ── Firefox window <-> workspace mapping ────────────────────
90
91// Throws on query failure so callers can distinguish "no Firefox windows"
92// from "we don't know" — a silent empty result here under load caused
93// duplicate window restoration.
94async function getFirefoxWindowMappings() {
95 const resp = await sendNative("map_windows");
96 if (resp.error) throw new Error(resp.error);
97 if (!resp.windows) throw new Error("map_windows: no windows field");
98
99 const allWindows = await browser.windows.getAll({ populate: true });
100 const result = new Map();
101 const matched = new Set();
102
103 for (const mapping of resp.windows) {
104 const swayTitle = mapping.windowTitle || "";
105 for (const win of allWindows) {
106 if (matched.has(win.id) || win.id === anchorWindowId) continue;
107 const activeTab = win.tabs && win.tabs.find((t) => t.active);
108 if (
109 activeTab &&
110 activeTab.title &&
111 swayTitle.startsWith(activeTab.title)
112 ) {
113 result.set(mapping.workspace, win.id);
114 matched.add(win.id);
115 break;
116 }
117 }
118 }
119 return result;
120}
121
122async function getWorkspacesWithFirefox() {
123 const resp = await sendNative("map_windows");
124 if (resp.error) throw new Error(resp.error);
125 if (!resp.windows) throw new Error("map_windows: no windows field");
126 return new Set(resp.windows.map((w) => w.workspace));
127}
128
129// ── Anchor window (keeps Firefox alive) ─────────────────────
130
131async function ensureAnchorWindow() {
132 if (anchorWindowId !== null) {
133 try {
134 await browser.windows.get(anchorWindowId);
135 return; // still exists
136 } catch (e) {
137 anchorWindowId = null;
138 }
139 }
140
141 // Close any existing windows (Firefox's default startup window)
142 const existingWindows = await browser.windows.getAll();
143
144 // Create anchor window with a page that sets the window title
145 const win = await browser.windows.create({
146 url: browser.runtime.getURL("anchor.html"),
147 });
148 anchorWindowId = win.id;
149
150 // Close Firefox's default window(s)
151 for (const w of existingWindows) {
152 if (w.id !== anchorWindowId) {
153 try {
154 await browser.windows.remove(w.id);
155 } catch (e) {
156 // already closed
157 }
158 }
159 }
160 // Daemon's for_window rule hides anchor when title updates to "workspace-anchor"
161}
162
163// Prevent anchor window from being closed
164browser.windows.onRemoved.addListener((windowId) => {
165 if (windowId === anchorWindowId) {
166 anchorWindowId = null;
167 ensureAnchorWindow();
168 }
169});
170
171// ── Tab saving (to workspace file) ─────────────────────────
172
173async function saveTabsForWorkspace(workspace, mappings) {
174 if (!workspace) return;
175
176 const windowId = mappings.get(workspace);
177 if (!windowId) return;
178
179 try {
180 const tabs = await browser.tabs.query({ windowId });
181 const tabData = tabs
182 .filter((t) => t.url && !t.url.startsWith("about:"))
183 .map((t) => ({
184 url: t.url,
185 pinned: t.pinned,
186 active: t.active,
187 }));
188
189 await sendNative("save_tabs", { workspace, tabs: tabData });
190 } catch (e) {
191 console.error(`Failed to save tabs for ${workspace}:`, e);
192 }
193}
194
195async function saveAllTabs() {
196 let mappings;
197 try {
198 mappings = await getFirefoxWindowMappings();
199 } catch (e) {
200 console.warn("Skipping save — could not query window mappings:", e);
201 return;
202 }
203 for (const [workspace] of mappings) {
204 await saveTabsForWorkspace(workspace, mappings);
205 }
206}
207
208// Debounced save: collects rapid changes, saves after 500ms idle
209function scheduleSave() {
210 if (savePending) return;
211 savePending = true;
212 setTimeout(async () => {
213 savePending = false;
214 await saveAllTabs();
215 }, 500);
216}
217
218// Listen to all tab events that should trigger a save
219browser.tabs.onCreated.addListener(scheduleSave);
220browser.tabs.onRemoved.addListener(scheduleSave);
221browser.tabs.onUpdated.addListener((_tabId, changeInfo) => {
222 // Save on URL changes and title changes (navigation complete)
223 if (changeInfo.url || changeInfo.title) {
224 scheduleSave();
225 }
226});
227browser.tabs.onMoved.addListener(scheduleSave);
228browser.tabs.onActivated.addListener(scheduleSave);
229browser.tabs.onAttached.addListener(scheduleSave);
230browser.tabs.onDetached.addListener(scheduleSave);
231
232// ── Tab restoring (from workspace file) ─────────────────────
233
234async function restoreWorkspace(workspace) {
235 if (restoringWorkspaces.has(workspace)) return;
236 restoringWorkspaces.add(workspace);
237
238 try {
239 const resp = await sendNative("get_tabs", { workspace });
240 const tabs = resp.tabs || [];
241 if (tabs.length === 0) return;
242
243 // Create window with the first tab to avoid a blank "new tab"
244 const first = tabs[0];
245 const newWindow = await browser.windows.create({ url: first.url });
246 const windowId = newWindow.id;
247
248 // Pin the first tab if needed
249 if (first.pinned && newWindow.tabs && newWindow.tabs[0]) {
250 await browser.tabs.update(newWindow.tabs[0].id, { pinned: true });
251 }
252
253 // Create remaining tabs
254 for (let i = 1; i < tabs.length; i++) {
255 await browser.tabs.create({
256 windowId,
257 url: tabs[i].url,
258 pinned: tabs[i].pinned,
259 active: tabs[i].active,
260 });
261 }
262 } catch (e) {
263 console.error(`Failed to restore workspace ${workspace}:`, e);
264 } finally {
265 restoringWorkspaces.delete(workspace);
266 }
267}
268
269// ── Workspace change handling ────────────────────────────────
270
271async function onWorkspaceChange(workspace) {
272 if (!workspace || workspace === lastWorkspace) return;
273
274 // Save all workspaces' tabs before switching
275 await saveAllTabs();
276
277 lastWorkspace = workspace;
278
279 // Don't restore tabs during startup — only after user switches workspace
280 if (!initialized) return;
281
282 // Check if this workspace already has a Firefox window. If we can't tell
283 // (e.g. swaymsg timed out under load), skip — restoring blindly would
284 // duplicate an existing window.
285 let withFirefox;
286 try {
287 withFirefox = await getWorkspacesWithFirefox();
288 } catch (e) {
289 console.warn(`Skipping restore for ${workspace} — query failed:`, e);
290 return;
291 }
292 if (withFirefox.has(workspace)) return;
293
294 // No Firefox window — restore saved tabs
295 const resp = await sendNative("get_tabs", { workspace });
296 const tabs = resp.tabs || [];
297 if (tabs.length > 0) {
298 await restoreWorkspace(workspace);
299 }
300}
301
302// ── Send tab to workspace ───────────────────────────────────
303
304async function sendTabToWorkspace(tabId, targetWorkspace) {
305 try {
306 const mappings = await getFirefoxWindowMappings();
307 const targetWindowId = mappings.get(targetWorkspace);
308
309 if (targetWindowId) {
310 await browser.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
311 } else {
312 const sourceWorkspace = await getCurrentWorkspace();
313 const newWindow = await browser.windows.create({ tabId });
314 await sendNative("move_to_workspace", { name: targetWorkspace });
315 if (sourceWorkspace) {
316 await sendNative("goto_workspace", { name: sourceWorkspace });
317 }
318 }
319 // Save both workspaces after move
320 scheduleSave();
321 } catch (e) {
322 console.error(`Failed to send tab to workspace ${targetWorkspace}:`, e);
323 }
324}
325
326// Handle messages from the new-workspace popup
327browser.runtime.onMessage.addListener((msg) => {
328 if (
329 msg.action === "sendToNewWorkspace" &&
330 msg.workspace &&
331 pendingNewWorkspaceTabId
332 ) {
333 const tabId = pendingNewWorkspaceTabId;
334 pendingNewWorkspaceTabId = null;
335 sendTabToWorkspace(tabId, msg.workspace);
336 }
337});
338
339// ── Context menu ────────────────────────────────────────────
340
341async function rebuildContextMenu() {
342 await browser.menus.removeAll();
343
344 browser.menus.create({
345 id: "send-to-workspace",
346 title: "Send to workspace",
347 contexts: ["tab"],
348 });
349
350 const allWorkspaces = await listWorkspaces();
351 const withFirefox = await getWorkspacesWithFirefox();
352 const wsWithFirefox = [];
353 const wsWithoutFirefox = [];
354
355 for (const ws of allWorkspaces) {
356 if (withFirefox.has(ws)) {
357 wsWithFirefox.push(ws);
358 } else {
359 wsWithoutFirefox.push(ws);
360 }
361 }
362
363 for (const ws of wsWithFirefox) {
364 browser.menus.create({
365 id: `ws-${ws}`,
366 parentId: "send-to-workspace",
367 title: `● ${ws}`,
368 contexts: ["tab"],
369 });
370 }
371
372 if (wsWithFirefox.length > 0 && wsWithoutFirefox.length > 0) {
373 browser.menus.create({
374 id: "ws-separator",
375 parentId: "send-to-workspace",
376 type: "separator",
377 contexts: ["tab"],
378 });
379 }
380
381 for (const ws of wsWithoutFirefox) {
382 browser.menus.create({
383 id: `ws-${ws}`,
384 parentId: "send-to-workspace",
385 title: `○ ${ws}`,
386 contexts: ["tab"],
387 });
388 }
389
390 browser.menus.create({
391 id: "ws-separator-new",
392 parentId: "send-to-workspace",
393 type: "separator",
394 contexts: ["tab"],
395 });
396
397 browser.menus.create({
398 id: "ws-new",
399 parentId: "send-to-workspace",
400 title: "New workspace…",
401 contexts: ["tab"],
402 });
403}
404
405browser.menus.onShown.addListener(async (info) => {
406 if (info.contexts.includes("tab")) {
407 await rebuildContextMenu();
408 browser.menus.refresh();
409 }
410});
411
412browser.menus.onClicked.addListener(async (info, tab) => {
413 if (!tab || !tab.id) return;
414
415 if (info.menuItemId === "ws-new") {
416 pendingNewWorkspaceTabId = tab.id;
417 browser.windows.create({
418 url: browser.runtime.getURL("new-workspace.html"),
419 type: "popup",
420 width: 320,
421 height: 60,
422 });
423 return;
424 }
425
426 if (info.menuItemId.startsWith("ws-")) {
427 const workspace = info.menuItemId.slice(3);
428 await sendTabToWorkspace(tab.id, workspace);
429 }
430});
431
432// ── Init ────────────────────────────────────────────────────
433
434async function init() {
435 // Connect to native host first — we need it to save existing tabs
436 connectNative();
437 await new Promise((r) => setTimeout(r, 500));
438
439 try {
440 lastWorkspace = await getCurrentWorkspace();
441 } catch (e) {
442 console.warn("Failed to get current workspace:", e);
443 }
444
445 const hadExistingWindows = (await browser.windows.getAll()).length > 0;
446
447 // Save all existing tabs before creating the anchor window
448 // (ensureAnchorWindow closes all windows, so save first)
449 if (hadExistingWindows) {
450 try {
451 await saveAllTabs();
452 } catch (e) {
453 console.error("Failed to save existing tabs before anchor creation:", e);
454 }
455 }
456
457 // Create anchor window — closes existing windows via the for_window rule
458 try {
459 await ensureAnchorWindow();
460 } catch (e) {
461 console.error("Failed to create anchor window:", e);
462 }
463
464 try {
465 await rebuildContextMenu();
466 } catch (e) {
467 console.warn("Failed to build context menu:", e);
468 }
469
470 // Mark init complete — workspace changes from now on will restore tabs
471 initialized = true;
472
473 // Restore tabs for the initial workspace if it has saved tabs from a previous session
474 if (lastWorkspace) {
475 let withFirefox;
476 try {
477 withFirefox = await getWorkspacesWithFirefox();
478 } catch (e) {
479 console.warn("Skipping initial restore — query failed:", e);
480 return;
481 }
482 if (!withFirefox.has(lastWorkspace)) {
483 const resp = await sendNative("get_tabs", { workspace: lastWorkspace });
484 const tabs = resp.tabs || [];
485 if (tabs.length > 0) {
486 await restoreWorkspace(lastWorkspace);
487 }
488 }
489 }
490}
491
492init();