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