This repository has no description
13 kB
444 lines
1/* Workspace Tabs - Firefox extension for workspace-manager integration
2 *
3 * Provides "Send to workspace" context menu on tabs.
4 * Queries sway tree directly to find Firefox windows per workspace.
5 * Saves/restores tabs per workspace across restarts.
6 */
7
8const HOST_NAME = "workspace_tabs";
9
10let port = null;
11let pendingRequests = new Map();
12let requestId = 0;
13let lastWorkspace = null;
14let restoringWorkspaces = new Set(); // prevent double-restore
15let pendingNewWorkspaceTabId = null;
16
17// Handle messages from the new-workspace popup
18browser.runtime.onMessage.addListener((msg) => {
19 if (
20 msg.action === "sendToNewWorkspace" &&
21 msg.workspace &&
22 pendingNewWorkspaceTabId
23 ) {
24 const tabId = pendingNewWorkspaceTabId;
25 pendingNewWorkspaceTabId = null;
26 sendTabToWorkspace(tabId, msg.workspace);
27 }
28});
29
30function connectNative() {
31 try {
32 port = browser.runtime.connectNative(HOST_NAME);
33 port.onMessage.addListener(handleNativeMessage);
34 port.onDisconnect.addListener(() => {
35 console.warn("Native host disconnected, reconnecting in 2s...");
36 port = null;
37 setTimeout(connectNative, 2000);
38 });
39 } catch (e) {
40 console.error("Failed to connect to native host:", e);
41 setTimeout(connectNative, 5000);
42 }
43}
44
45function sendNative(cmd, params = {}) {
46 return new Promise((resolve, reject) => {
47 if (!port) {
48 reject(new Error("Native host not connected"));
49 return;
50 }
51 const id = ++requestId;
52 pendingRequests.set(id, { resolve, reject });
53 port.postMessage({ id, cmd, ...params });
54 setTimeout(() => {
55 if (pendingRequests.has(id)) {
56 pendingRequests.delete(id);
57 reject(new Error("Request timed out"));
58 }
59 }, 5000);
60 });
61}
62
63function handleNativeMessage(msg) {
64 if (msg.event === "workspace_change") {
65 // Push event from workspace-manager — handle workspace switch
66 // restore flag: true = restore saved tabs, false = start fresh
67 onWorkspaceChange(msg.workspace, msg.restore !== false);
68 return;
69 }
70 if (msg.id && pendingRequests.has(msg.id)) {
71 const { resolve } = pendingRequests.get(msg.id);
72 pendingRequests.delete(msg.id);
73 resolve(msg);
74 }
75}
76
77async function getCurrentWorkspace() {
78 try {
79 const resp = await sendNative("current_workspace");
80 return resp.workspace || null;
81 } catch (e) {
82 return null;
83 }
84}
85
86async function listWorkspaces() {
87 try {
88 const resp = await sendNative("list_workspaces");
89 return resp.workspaces || [];
90 } catch (e) {
91 return [];
92 }
93}
94
95// Get Firefox windows per workspace from sway tree (always fresh)
96async function getFirefoxWindowMappings() {
97 try {
98 const resp = await sendNative("map_windows");
99 if (!resp.windows) return new Map();
100
101 const allWindows = await browser.windows.getAll({ populate: true });
102 const result = new Map();
103 const matched = new Set();
104
105 for (const mapping of resp.windows) {
106 const swayTitle = mapping.windowTitle || "";
107 for (const win of allWindows) {
108 if (matched.has(win.id)) continue;
109 const activeTab = win.tabs && win.tabs.find((t) => t.active);
110 if (
111 activeTab &&
112 activeTab.title &&
113 swayTitle.startsWith(activeTab.title)
114 ) {
115 result.set(mapping.workspace, win.id);
116 matched.add(win.id);
117 break;
118 }
119 }
120 }
121 return result;
122 } catch (e) {
123 return new Map();
124 }
125}
126
127async function getWorkspacesWithFirefox() {
128 try {
129 const resp = await sendNative("map_windows");
130 if (!resp.windows) return new Set();
131 return new Set(resp.windows.map((w) => w.workspace));
132 } catch (e) {
133 return new Set();
134 }
135}
136
137// ── Tab saving ──────────────────────────────────────────────
138
139async function saveAllTabs() {
140 try {
141 const mappings = await getFirefoxWindowMappings();
142 const stored = (await browser.storage.local.get("savedTabs")) || {};
143 const savedTabs = stored.savedTabs || {};
144
145 for (const [workspace, windowId] of mappings) {
146 try {
147 const tabs = await browser.tabs.query({ windowId });
148 savedTabs[workspace] = tabs
149 .filter((t) => t.url && !t.url.startsWith("about:"))
150 .map((t) => ({
151 url: t.url,
152 title: t.title,
153 pinned: t.pinned,
154 active: t.active,
155 }));
156 } catch (e) {
157 // window might have closed
158 }
159 }
160
161 await browser.storage.local.set({ savedTabs });
162 } catch (e) {
163 console.error("Failed to save tabs:", e);
164 }
165}
166
167async function getSavedTabs() {
168 const stored = await browser.storage.local.get("savedTabs");
169 return (stored && stored.savedTabs) || {};
170}
171
172async function clearSavedTabs(workspace) {
173 const savedTabs = await getSavedTabs();
174 delete savedTabs[workspace];
175 await browser.storage.local.set({ savedTabs });
176}
177
178// ── Tab restoring ───────────────────────────────────────────
179
180// Restore tabs into a window, removing the initial blank tab
181async function restoreTabsInWindow(tabDataList, windowId) {
182 let activeTabId = null;
183 for (const tab of tabDataList) {
184 const created = await browser.tabs.create({
185 windowId,
186 url: tab.url,
187 pinned: tab.pinned,
188 active: tab.active,
189 });
190 if (tab.active) activeTabId = created.id;
191 }
192
193 // Remove initial blank tab
194 const allTabs = await browser.tabs.query({ windowId });
195 for (const t of allTabs) {
196 if (
197 (t.url === "about:blank" || t.url === "about:newtab") &&
198 t.id !== activeTabId &&
199 allTabs.length > 1
200 ) {
201 await browser.tabs.remove(t.id);
202 break;
203 }
204 }
205}
206
207// Restore saved tabs for a workspace on the current sway workspace
208async function restoreWorkspace(workspace) {
209 if (restoringWorkspaces.has(workspace)) return;
210 restoringWorkspaces.add(workspace);
211
212 try {
213 const savedTabs = await getSavedTabs();
214 const tabs = savedTabs[workspace];
215 if (!tabs || tabs.length === 0) return;
216
217 const newWindow = await browser.windows.create({});
218 await restoreTabsInWindow(tabs, newWindow.id);
219 await clearSavedTabs(workspace);
220 } catch (e) {
221 console.error(`Failed to restore workspace ${workspace}:`, e);
222 } finally {
223 restoringWorkspaces.delete(workspace);
224 }
225}
226
227// On startup: restore windows for all currently open workspaces
228async function restoreOnStartup() {
229 const savedTabs = await getSavedTabs();
230 const openWorkspaces = await listWorkspaces();
231 const currentWorkspace = await getCurrentWorkspace();
232
233 // Get the initial window Firefox created (reuse it for current workspace)
234 const initialWindows = await browser.windows.getAll({ populate: true });
235 let initialWindowUsed = false;
236
237 // Restore current workspace first, reusing the initial window
238 if (
239 currentWorkspace &&
240 savedTabs[currentWorkspace] &&
241 savedTabs[currentWorkspace].length > 0 &&
242 openWorkspaces.includes(currentWorkspace)
243 ) {
244 restoringWorkspaces.add(currentWorkspace);
245 try {
246 const win = initialWindows[0];
247 if (win) {
248 await restoreTabsInWindow(savedTabs[currentWorkspace], win.id);
249 initialWindowUsed = true;
250 }
251 await clearSavedTabs(currentWorkspace);
252 } catch (e) {
253 console.error(`Failed to restore current workspace:`, e);
254 } finally {
255 restoringWorkspaces.delete(currentWorkspace);
256 }
257 }
258
259 // Restore other workspaces
260 for (const ws of Object.keys(savedTabs)) {
261 if (ws === currentWorkspace) continue;
262 if (!savedTabs[ws] || savedTabs[ws].length === 0) continue;
263 if (!openWorkspaces.includes(ws)) continue;
264
265 restoringWorkspaces.add(ws);
266 try {
267 await sendNative("goto_workspace", { name: ws });
268 const newWindow = await browser.windows.create({});
269 await restoreTabsInWindow(savedTabs[ws], newWindow.id);
270 await clearSavedTabs(ws);
271 } catch (e) {
272 console.error(`Failed to restore workspace ${ws} on startup:`, e);
273 } finally {
274 restoringWorkspaces.delete(ws);
275 }
276 }
277
278 // Switch back to the original workspace
279 if (currentWorkspace) {
280 await sendNative("goto_workspace", { name: currentWorkspace });
281 }
282
283 // Close the initial window if it wasn't used
284 if (!initialWindowUsed && initialWindows.length > 0) {
285 const allWindows = await browser.windows.getAll();
286 if (allWindows.length > 1) {
287 try {
288 await browser.windows.remove(initialWindows[0].id);
289 } catch (e) {
290 // already closed
291 }
292 }
293 }
294}
295
296// ── Workspace change handling ────────────────────────────────
297
298async function onWorkspaceChange(workspace, restore = true) {
299 if (!workspace || workspace === lastWorkspace) return;
300 lastWorkspace = workspace;
301
302 // If restore is false, the user chose "New session" — don't restore tabs
303 if (!restore) return;
304
305 // Check if this workspace has saved tabs but no Firefox window
306 const withFirefox = await getWorkspacesWithFirefox();
307 if (withFirefox.has(workspace)) return;
308
309 const savedTabs = await getSavedTabs();
310 if (savedTabs[workspace] && savedTabs[workspace].length > 0) {
311 await restoreWorkspace(workspace);
312 }
313}
314
315// ── Send tab to workspace ───────────────────────────────────
316
317async function sendTabToWorkspace(tabId, targetWorkspace) {
318 try {
319 const mappings = await getFirefoxWindowMappings();
320 const targetWindowId = mappings.get(targetWorkspace);
321
322 if (targetWindowId) {
323 await browser.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
324 } else {
325 const sourceWorkspace = await getCurrentWorkspace();
326 const newWindow = await browser.windows.create({ tabId });
327 await sendNative("move_to_workspace", { name: targetWorkspace });
328 if (sourceWorkspace) {
329 await sendNative("goto_workspace", { name: sourceWorkspace });
330 }
331 }
332 } catch (e) {
333 console.error(`Failed to send tab to workspace ${targetWorkspace}:`, e);
334 }
335}
336
337// ── Context menu ────────────────────────────────────────────
338
339async function rebuildContextMenu() {
340 await browser.menus.removeAll();
341
342 browser.menus.create({
343 id: "send-to-workspace",
344 title: "Send to workspace",
345 contexts: ["tab"],
346 });
347
348 const allWorkspaces = await listWorkspaces();
349 const withFirefox = await getWorkspacesWithFirefox();
350 const wsWithFirefox = [];
351 const wsWithoutFirefox = [];
352
353 for (const ws of allWorkspaces) {
354 if (withFirefox.has(ws)) {
355 wsWithFirefox.push(ws);
356 } else {
357 wsWithoutFirefox.push(ws);
358 }
359 }
360
361 for (const ws of wsWithFirefox) {
362 browser.menus.create({
363 id: `ws-${ws}`,
364 parentId: "send-to-workspace",
365 title: `● ${ws}`,
366 contexts: ["tab"],
367 });
368 }
369
370 if (wsWithFirefox.length > 0 && wsWithoutFirefox.length > 0) {
371 browser.menus.create({
372 id: "ws-separator",
373 parentId: "send-to-workspace",
374 type: "separator",
375 contexts: ["tab"],
376 });
377 }
378
379 for (const ws of wsWithoutFirefox) {
380 browser.menus.create({
381 id: `ws-${ws}`,
382 parentId: "send-to-workspace",
383 title: `○ ${ws}`,
384 contexts: ["tab"],
385 });
386 }
387
388 browser.menus.create({
389 id: "ws-separator-new",
390 parentId: "send-to-workspace",
391 type: "separator",
392 contexts: ["tab"],
393 });
394
395 browser.menus.create({
396 id: "ws-new",
397 parentId: "send-to-workspace",
398 title: "New workspace…",
399 contexts: ["tab"],
400 });
401}
402
403browser.menus.onShown.addListener(async (info) => {
404 if (info.contexts.includes("tab")) {
405 await rebuildContextMenu();
406 browser.menus.refresh();
407 }
408});
409
410browser.menus.onClicked.addListener(async (info, tab) => {
411 if (!tab || !tab.id) return;
412
413 if (info.menuItemId === "ws-new") {
414 pendingNewWorkspaceTabId = tab.id;
415 browser.windows.create({
416 url: browser.runtime.getURL("new-workspace.html"),
417 type: "popup",
418 width: 320,
419 height: 60,
420 });
421 return;
422 }
423
424 if (info.menuItemId.startsWith("ws-")) {
425 const workspace = info.menuItemId.slice(3);
426 await sendTabToWorkspace(tab.id, workspace);
427 }
428});
429
430// ── Init ────────────────────────────────────────────────────
431
432async function init() {
433 connectNative();
434 await new Promise((r) => setTimeout(r, 500));
435
436 lastWorkspace = await getCurrentWorkspace();
437 await restoreOnStartup();
438 await rebuildContextMenu();
439
440 // Save tabs periodically
441 setInterval(saveAllTabs, 30000);
442}
443
444init();