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