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