This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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();