This repository has no description
13 kB
465 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
91async function getFirefoxWindowMappings() {
92 try {
93 const resp = await sendNative("map_windows");
94 if (!resp.windows) return new Map();
95
96 const allWindows = await browser.windows.getAll({ populate: true });
97 const result = new Map();
98 const matched = new Set();
99
100 for (const mapping of resp.windows) {
101 const swayTitle = mapping.windowTitle || "";
102 for (const win of allWindows) {
103 if (matched.has(win.id) || win.id === anchorWindowId) continue;
104 const activeTab = win.tabs && win.tabs.find((t) => t.active);
105 if (
106 activeTab &&
107 activeTab.title &&
108 swayTitle.startsWith(activeTab.title)
109 ) {
110 result.set(mapping.workspace, win.id);
111 matched.add(win.id);
112 break;
113 }
114 }
115 }
116 return result;
117 } catch (e) {
118 return new Map();
119 }
120}
121
122async function getWorkspacesWithFirefox() {
123 try {
124 const resp = await sendNative("map_windows");
125 if (!resp.windows) return new Set();
126 return new Set(resp.windows.map((w) => w.workspace));
127 } catch (e) {
128 return new Set();
129 }
130}
131
132// ── Anchor window (keeps Firefox alive) ─────────────────────
133
134async function ensureAnchorWindow() {
135 if (anchorWindowId !== null) {
136 try {
137 await browser.windows.get(anchorWindowId);
138 return; // still exists
139 } catch (e) {
140 anchorWindowId = null;
141 }
142 }
143
144 // Close any existing windows (Firefox's default startup window)
145 const existingWindows = await browser.windows.getAll();
146
147 // Create anchor window with a page that sets the window title
148 const win = await browser.windows.create({
149 url: browser.runtime.getURL("anchor.html"),
150 });
151 anchorWindowId = win.id;
152
153 // Close Firefox's default window(s)
154 for (const w of existingWindows) {
155 if (w.id !== anchorWindowId) {
156 try {
157 await browser.windows.remove(w.id);
158 } catch (e) {
159 // already closed
160 }
161 }
162 }
163 // Daemon's for_window rule hides anchor when title updates to "workspace-anchor"
164}
165
166// Prevent anchor window from being closed
167browser.windows.onRemoved.addListener((windowId) => {
168 if (windowId === anchorWindowId) {
169 anchorWindowId = null;
170 ensureAnchorWindow();
171 }
172});
173
174// ── Tab saving (to workspace file) ─────────────────────────
175
176async function saveTabsForWorkspace(workspace) {
177 if (!workspace) return;
178
179 const mappings = await getFirefoxWindowMappings();
180 const windowId = mappings.get(workspace);
181 if (!windowId) return;
182
183 try {
184 const tabs = await browser.tabs.query({ windowId });
185 const tabData = tabs
186 .filter((t) => t.url && !t.url.startsWith("about:"))
187 .map((t) => ({
188 url: t.url,
189 pinned: t.pinned,
190 active: t.active,
191 }));
192
193 await sendNative("save_tabs", { workspace, tabs: tabData });
194 } catch (e) {
195 console.error(`Failed to save tabs for ${workspace}:`, e);
196 }
197}
198
199async function saveAllTabs() {
200 const mappings = await getFirefoxWindowMappings();
201 for (const [workspace] of mappings) {
202 await saveTabsForWorkspace(workspace);
203 }
204}
205
206// Debounced save: collects rapid changes, saves after 500ms idle
207function scheduleSave() {
208 if (savePending) return;
209 savePending = true;
210 setTimeout(async () => {
211 savePending = false;
212 if (lastWorkspace) {
213 await saveTabsForWorkspace(lastWorkspace);
214 }
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 restoreTabsInWindow(tabDataList, windowId) {
235 let activeTabId = null;
236 for (const tab of tabDataList) {
237 const created = await browser.tabs.create({
238 windowId,
239 url: tab.url,
240 pinned: tab.pinned,
241 active: tab.active,
242 });
243 if (tab.active) activeTabId = created.id;
244 }
245
246 // Remove initial blank tab
247 const allTabs = await browser.tabs.query({ windowId });
248 for (const t of allTabs) {
249 if (
250 (t.url === "about:blank" || t.url === "about:newtab") &&
251 t.id !== activeTabId &&
252 allTabs.length > 1
253 ) {
254 await browser.tabs.remove(t.id);
255 break;
256 }
257 }
258}
259
260async function restoreWorkspace(workspace) {
261 if (restoringWorkspaces.has(workspace)) return;
262 restoringWorkspaces.add(workspace);
263
264 try {
265 const resp = await sendNative("get_tabs", { workspace });
266 const tabs = resp.tabs || [];
267 if (tabs.length === 0) return;
268
269 const newWindow = await browser.windows.create({});
270 await restoreTabsInWindow(tabs, newWindow.id);
271 } catch (e) {
272 console.error(`Failed to restore workspace ${workspace}:`, e);
273 } finally {
274 restoringWorkspaces.delete(workspace);
275 }
276}
277
278// ── Workspace change handling ────────────────────────────────
279
280async function onWorkspaceChange(workspace) {
281 if (!workspace || workspace === lastWorkspace) return;
282
283 // Save tabs for the workspace we're leaving
284 if (lastWorkspace) {
285 await saveTabsForWorkspace(lastWorkspace);
286 }
287
288 lastWorkspace = workspace;
289
290 // Don't restore tabs during startup — only after user switches workspace
291 if (!initialized) return;
292
293 // Check if this workspace already has a Firefox window
294 const withFirefox = await getWorkspacesWithFirefox();
295 if (withFirefox.has(workspace)) return;
296
297 // No Firefox window — restore saved tabs
298 const resp = await sendNative("get_tabs", { workspace });
299 const tabs = resp.tabs || [];
300 if (tabs.length > 0) {
301 await restoreWorkspace(workspace);
302 }
303}
304
305// ── Send tab to workspace ───────────────────────────────────
306
307async function sendTabToWorkspace(tabId, targetWorkspace) {
308 try {
309 const mappings = await getFirefoxWindowMappings();
310 const targetWindowId = mappings.get(targetWorkspace);
311
312 if (targetWindowId) {
313 await browser.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
314 } else {
315 const sourceWorkspace = await getCurrentWorkspace();
316 const newWindow = await browser.windows.create({ tabId });
317 await sendNative("move_to_workspace", { name: targetWorkspace });
318 if (sourceWorkspace) {
319 await sendNative("goto_workspace", { name: sourceWorkspace });
320 }
321 }
322 // Save both workspaces after move
323 scheduleSave();
324 } catch (e) {
325 console.error(`Failed to send tab to workspace ${targetWorkspace}:`, e);
326 }
327}
328
329// Handle messages from the new-workspace popup
330browser.runtime.onMessage.addListener((msg) => {
331 if (
332 msg.action === "sendToNewWorkspace" &&
333 msg.workspace &&
334 pendingNewWorkspaceTabId
335 ) {
336 const tabId = pendingNewWorkspaceTabId;
337 pendingNewWorkspaceTabId = null;
338 sendTabToWorkspace(tabId, msg.workspace);
339 }
340});
341
342// ── Context menu ────────────────────────────────────────────
343
344async function rebuildContextMenu() {
345 await browser.menus.removeAll();
346
347 browser.menus.create({
348 id: "send-to-workspace",
349 title: "Send to workspace",
350 contexts: ["tab"],
351 });
352
353 const allWorkspaces = await listWorkspaces();
354 const withFirefox = await getWorkspacesWithFirefox();
355 const wsWithFirefox = [];
356 const wsWithoutFirefox = [];
357
358 for (const ws of allWorkspaces) {
359 if (withFirefox.has(ws)) {
360 wsWithFirefox.push(ws);
361 } else {
362 wsWithoutFirefox.push(ws);
363 }
364 }
365
366 for (const ws of wsWithFirefox) {
367 browser.menus.create({
368 id: `ws-${ws}`,
369 parentId: "send-to-workspace",
370 title: `● ${ws}`,
371 contexts: ["tab"],
372 });
373 }
374
375 if (wsWithFirefox.length > 0 && wsWithoutFirefox.length > 0) {
376 browser.menus.create({
377 id: "ws-separator",
378 parentId: "send-to-workspace",
379 type: "separator",
380 contexts: ["tab"],
381 });
382 }
383
384 for (const ws of wsWithoutFirefox) {
385 browser.menus.create({
386 id: `ws-${ws}`,
387 parentId: "send-to-workspace",
388 title: `○ ${ws}`,
389 contexts: ["tab"],
390 });
391 }
392
393 browser.menus.create({
394 id: "ws-separator-new",
395 parentId: "send-to-workspace",
396 type: "separator",
397 contexts: ["tab"],
398 });
399
400 browser.menus.create({
401 id: "ws-new",
402 parentId: "send-to-workspace",
403 title: "New workspace…",
404 contexts: ["tab"],
405 });
406}
407
408browser.menus.onShown.addListener(async (info) => {
409 if (info.contexts.includes("tab")) {
410 await rebuildContextMenu();
411 browser.menus.refresh();
412 }
413});
414
415browser.menus.onClicked.addListener(async (info, tab) => {
416 if (!tab || !tab.id) return;
417
418 if (info.menuItemId === "ws-new") {
419 pendingNewWorkspaceTabId = tab.id;
420 browser.windows.create({
421 url: browser.runtime.getURL("new-workspace.html"),
422 type: "popup",
423 width: 320,
424 height: 60,
425 });
426 return;
427 }
428
429 if (info.menuItemId.startsWith("ws-")) {
430 const workspace = info.menuItemId.slice(3);
431 await sendTabToWorkspace(tab.id, workspace);
432 }
433});
434
435// ── Init ────────────────────────────────────────────────────
436
437async function init() {
438 // Create anchor window immediately — doesn't need native messaging
439 try {
440 await ensureAnchorWindow();
441 } catch (e) {
442 console.error("Failed to create anchor window:", e);
443 }
444
445 // Connect to native host for workspace integration
446 connectNative();
447 await new Promise((r) => setTimeout(r, 500));
448
449 try {
450 lastWorkspace = await getCurrentWorkspace();
451 } catch (e) {
452 console.warn("Failed to get current workspace:", e);
453 }
454
455 try {
456 await rebuildContextMenu();
457 } catch (e) {
458 console.warn("Failed to build context menu:", e);
459 }
460
461 // Mark init complete — workspace changes from now on will restore tabs
462 initialized = true;
463}
464
465init();