This repository has no description
0

Configure Feed

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

tools.workspace-manager: add workspace-browser, run as systemd service

+801 -5
+1
2configs/browsers.nix
··· 2 2 { 3 3 environment.systemPackages = [ 4 4 self.packages.${pkgs.system}.firefox 5 + self.packages.${pkgs.system}.workspace-browser 5 6 ]; 6 7 environment.variables.BROWSER = "/run/current-system/sw/bin/firefox"; 7 8 }
+24 -3
tools/firefox/flake-module.nix
··· 1 + { self, ... }: 1 2 { 2 3 perSystem = 3 - { pkgs, ... }: 4 + { 5 + pkgs, 6 + lib, 7 + system, 8 + ... 9 + }: 4 10 let 5 11 tridactylrc = ./tridactylrc; 6 12 7 - firefoxBase = pkgs.wrapFirefox pkgs.firefox-unwrapped { 13 + workspaceTabsExtension = self.packages.${system}.workspace-tabs-extension; 14 + 15 + firefoxBase = pkgs.wrapFirefox pkgs.firefox-devedition-unwrapped { 8 16 nativeMessagingHosts = [ 9 17 pkgs.tridactyl-native 18 + self.packages.${system}.workspace-tabs-native-host 19 + ]; 20 + extraPolicies = { 21 + Extensions.Install = [ 22 + "${workspaceTabsExtension}/workspace-tabs@workspace-manager.xpi" 23 + ]; 24 + }; 25 + extraPrefsFiles = [ 26 + (pkgs.writeText "workspace-tabs-prefs.js" '' 27 + lockPref("xpinstall.signatures.required", false); 28 + lockPref("browser.startup.page", 0); 29 + lockPref("browser.sessionstore.resume_from_crash", false); 30 + '') 10 31 ]; 11 32 }; 12 33 ··· 32 53 ln -sf "$target" "$config_file" 33 54 fi 34 55 35 - exec ${firefoxBase}/bin/firefox "$@" 56 + exec ${lib.getExe firefoxBase} "$@" 36 57 ''; 37 58 in 38 59 {
+39 -1
tools/workspace-manager/daemon.py
··· 271 271 272 272 manager = WorkspaceManager() 273 273 274 + # Subscribers: list of (StreamWriter, Event) tuples 275 + subscribers: list[tuple[asyncio.StreamWriter, asyncio.Event]] = [] 276 + 277 + 278 + async def notify_subscribers(old_workspace: str | None, new_workspace: str) -> None: 279 + """Notify all subscribers of a workspace change.""" 280 + event = ( 281 + json.dumps( 282 + { 283 + "event": "workspace_change", 284 + "old": old_workspace, 285 + "new": new_workspace, 286 + } 287 + ).encode() 288 + + b"\n" 289 + ) 290 + for writer, done in subscribers: 291 + try: 292 + writer.write(event) 293 + await writer.drain() 294 + except (OSError, ConnectionResetError): 295 + done.set() 296 + 274 297 275 298 async def subscribe_sway() -> None: 276 299 """Subscribe to sway IPC workspace events""" ··· 308 331 file=sys.stderr, 309 332 ) 310 333 await manager.handle_workspace_change(old_name, new_name) 334 + await notify_subscribers(old_name, new_name) 311 335 except json.JSONDecodeError as e: 312 336 print(f"Failed to parse sway event: {e}", file=sys.stderr) 313 337 ··· 336 360 command = data.decode().strip() 337 361 338 362 response: dict[str, Any] 339 - if command == "dir": 363 + if command == "subscribe": 364 + # Keep connection open and send workspace change events. 365 + # Disconnection is detected in notify_subscribers when write fails. 366 + done = asyncio.Event() 367 + subscribers.append((writer, done)) 368 + print("New subscriber connected", file=sys.stderr) 369 + try: 370 + await done.wait() 371 + except asyncio.CancelledError: 372 + pass 373 + finally: 374 + subscribers[:] = [(w, d) for w, d in subscribers if w is not writer] 375 + print("Subscriber disconnected", file=sys.stderr) 376 + return 377 + elif command == "dir": 340 378 response = {"directory": str(manager.get_current_directory())} 341 379 elif command == "workspace": 342 380 response = {"workspace": manager.current_workspace}
+60 -1
tools/workspace-manager/flake-module.nix
··· 1 1 { ... }: 2 2 { 3 3 perSystem = 4 - { pkgs, ... }: 4 + { 5 + pkgs, 6 + ... 7 + }: 5 8 let 6 9 python = pkgs.python3; 7 10 in ··· 45 48 ''; 46 49 meta.mainProgram = "workspace-manager-daemon"; 47 50 }; 51 + 52 + # Open Firefox for the current workspace 53 + packages.workspace-browser = pkgs.writeShellScriptBin "workspace-browser" '' 54 + exec firefox "$@" 55 + ''; 56 + 57 + # Native messaging host for the workspace-tabs Firefox extension 58 + packages.workspace-tabs-native-host = pkgs.stdenv.mkDerivation { 59 + pname = "workspace-tabs-native-host"; 60 + version = "0.1.0"; 61 + src = ./.; 62 + nativeBuildInputs = [ 63 + pkgs.makeWrapper 64 + pkgs.ruff 65 + python.pkgs.mypy 66 + ]; 67 + doCheck = true; 68 + checkPhase = '' 69 + runHook preCheck 70 + ruff check workspace-tabs-host.py 71 + ruff format --check workspace-tabs-host.py 72 + mypy --strict workspace-tabs-host.py 73 + runHook postCheck 74 + ''; 75 + installPhase = '' 76 + runHook preInstall 77 + mkdir -p $out/bin $out/lib/mozilla/native-messaging-hosts 78 + cp workspace-tabs-host.py $out/bin/workspace-tabs-host 79 + chmod +x $out/bin/workspace-tabs-host 80 + wrapProgram $out/bin/workspace-tabs-host \ 81 + --prefix PATH : ${python}/bin:${pkgs.sway}/bin 82 + 83 + cat > $out/lib/mozilla/native-messaging-hosts/workspace_tabs.json <<EOF 84 + { 85 + "name": "workspace_tabs", 86 + "description": "Native messaging host for Workspace Tabs extension", 87 + "path": "$out/bin/workspace-tabs-host", 88 + "type": "stdio", 89 + "allowed_extensions": ["workspace-tabs@workspace-manager"] 90 + } 91 + EOF 92 + runHook postInstall 93 + ''; 94 + }; 95 + 96 + # Firefox extension for workspace tab management (packaged as .xpi) 97 + packages.workspace-tabs-extension = 98 + pkgs.runCommand "workspace-tabs-extension" 99 + { 100 + nativeBuildInputs = [ pkgs.zip ]; 101 + } 102 + '' 103 + mkdir -p $out 104 + cd ${./firefox-extension} 105 + zip -r "$out/workspace-tabs@workspace-manager.xpi" ./* 106 + ''; 48 107 }; 49 108 }
+166
tools/workspace-manager/workspace-tabs-host.py
··· 1 + #!/usr/bin/env python3 2 + """Native messaging host for the Workspace Tabs Firefox extension.""" 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import os 8 + import select 9 + import socket 10 + import struct 11 + import subprocess 12 + import sys 13 + from pathlib import Path 14 + from typing import Any 15 + 16 + RUNTIME_DIR = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) 17 + SOCKET_PATH = RUNTIME_DIR / "workspace-manager" / "socket" 18 + 19 + 20 + def read_message() -> dict[str, Any] | None: 21 + """Read a native messaging message from stdin.""" 22 + raw = sys.stdin.buffer.read(4) 23 + if not raw or len(raw) < 4: 24 + return None 25 + length = struct.unpack("=I", raw)[0] 26 + result: dict[str, Any] = json.loads(sys.stdin.buffer.read(length)) 27 + return result 28 + 29 + 30 + def write_message(msg: dict[str, Any]) -> None: 31 + """Write a native messaging message to stdout.""" 32 + data = json.dumps(msg).encode() 33 + sys.stdout.buffer.write(struct.pack("=I", len(data)) + data) 34 + sys.stdout.buffer.flush() 35 + 36 + 37 + def run(cmd: list[str]) -> str: 38 + """Run a command and return stripped stdout, or empty string on failure.""" 39 + try: 40 + r = subprocess.run(cmd, capture_output=True, text=True, timeout=2) # noqa: S603 41 + except (OSError, subprocess.TimeoutExpired): 42 + return "" 43 + return r.stdout.strip() if r.returncode == 0 else "" 44 + 45 + 46 + def get_firefox_windows() -> list[dict[str, str]]: 47 + """Get all Firefox windows and their workspaces from sway tree.""" 48 + try: 49 + tree: dict[str, Any] = json.loads(run(["swaymsg", "-t", "get_tree", "-r"])) 50 + except (json.JSONDecodeError, ValueError): 51 + return [] 52 + 53 + results: list[dict[str, str]] = [] 54 + 55 + def traverse(node: dict[str, Any], workspace: str) -> None: 56 + node_type = node.get("type", "") 57 + if node_type == "workspace": 58 + workspace = node.get("name", workspace) 59 + app_id = node.get("app_id", "") or "" 60 + if "firefox" in app_id.lower() and node_type == "con": 61 + results.append( 62 + { 63 + "workspace": workspace, 64 + "windowTitle": node.get("name", ""), 65 + } 66 + ) 67 + for child in node.get("nodes", []) + node.get("floating_nodes", []): 68 + traverse(child, workspace) 69 + 70 + traverse(tree, "") 71 + return results 72 + 73 + 74 + def handle(msg: dict[str, Any]) -> dict[str, Any]: 75 + """Handle a single message from the extension.""" 76 + mid: int = msg.get("id", 0) 77 + cmd = msg.get("cmd", "") 78 + if cmd == "current_workspace": 79 + return {"id": mid, "workspace": run(["workspace-manager", "workspace"])} 80 + if cmd == "list_workspaces": 81 + try: 82 + ws: list[dict[str, Any]] = json.loads( 83 + run(["swaymsg", "-t", "get_workspaces", "-r"]) 84 + ) 85 + return {"id": mid, "workspaces": [w["name"] for w in ws]} 86 + except (json.JSONDecodeError, KeyError): 87 + return {"id": mid, "workspaces": []} 88 + if cmd == "goto_workspace": 89 + run(["goto-workspace", msg.get("name", "")]) 90 + return {"id": mid, "ok": True} 91 + if cmd == "move_to_workspace": 92 + run(["swaymsg", "move", "container", "to", "workspace", msg.get("name", "")]) 93 + return {"id": mid, "ok": True} 94 + if cmd == "map_windows": 95 + return {"id": mid, "windows": get_firefox_windows()} 96 + return {"id": mid, "error": f"Unknown: {cmd}"} 97 + 98 + 99 + def connect_subscriber() -> socket.socket | None: 100 + """Connect to workspace-manager daemon as a subscriber.""" 101 + try: 102 + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 103 + sock.connect(str(SOCKET_PATH)) 104 + sock.sendall(b"subscribe") 105 + sock.setblocking(False) 106 + return sock 107 + except OSError as e: 108 + print(f"Failed to subscribe to workspace-manager: {e}", file=sys.stderr) 109 + return None 110 + 111 + 112 + def main() -> None: 113 + """Main loop: handle Firefox messages and workspace-manager events.""" 114 + sub_sock = connect_subscriber() 115 + stdin_fd = sys.stdin.buffer.fileno() 116 + sub_buf = b"" 117 + 118 + while True: 119 + read_fds = [stdin_fd] 120 + if sub_sock is not None: 121 + read_fds.append(sub_sock.fileno()) 122 + 123 + try: 124 + readable, _, _ = select.select(read_fds, [], [], 10.0) 125 + except (ValueError, OSError): 126 + break 127 + 128 + if not readable and sub_sock is None: 129 + sub_sock = connect_subscriber() 130 + continue 131 + 132 + for fd in readable: 133 + if fd == stdin_fd: 134 + msg = read_message() 135 + if msg is None: 136 + return 137 + write_message(handle(msg)) 138 + elif sub_sock is not None and fd == sub_sock.fileno(): 139 + try: 140 + data = sub_sock.recv(4096) 141 + if not data: 142 + # Daemon disconnected, try to reconnect 143 + sub_sock.close() 144 + sub_sock = connect_subscriber() 145 + continue 146 + sub_buf += data 147 + while b"\n" in sub_buf: 148 + line, sub_buf = sub_buf.split(b"\n", 1) 149 + event: dict[str, Any] = json.loads(line) 150 + # Push workspace change event to Firefox 151 + write_message( 152 + { 153 + "id": 0, 154 + "event": "workspace_change", 155 + "workspace": event.get("new", ""), 156 + } 157 + ) 158 + except (OSError, json.JSONDecodeError) as e: 159 + print(f"Subscriber error: {e}", file=sys.stderr) 160 + if sub_sock is not None: 161 + sub_sock.close() 162 + sub_sock = None 163 + 164 + 165 + if __name__ == "__main__": 166 + main()
+12
2configs/desktops/sway/default.nix
··· 27 27 { 28 28 # Make goto-workspace available system-wide for workspace switching 29 29 environment.systemPackages = [ gotoWorkspace ]; 30 + 31 + # Firefox as a systemd user service, started on login 32 + systemd.user.services.firefox = { 33 + description = "Firefox Web Browser"; 34 + partOf = [ "sway-session.target" ]; 35 + wantedBy = [ "sway-session.target" ]; 36 + after = [ "sway-session.target" ]; 37 + serviceConfig = { 38 + Type = "simple"; 39 + ExecStart = lib.getExe self.packages.${pkgs.system}.firefox; 40 + }; 41 + }; 30 42 imports = [ 31 43 ../lib/wayland.nix 32 44 ./noctalia.nix
+440
tools/workspace-manager/firefox-extension/background.js
··· 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 + 8 + const HOST_NAME = "workspace_tabs"; 9 + 10 + let port = null; 11 + let pendingRequests = new Map(); 12 + let requestId = 0; 13 + let lastWorkspace = null; 14 + let restoringWorkspaces = new Set(); // prevent double-restore 15 + let pendingNewWorkspaceTabId = null; 16 + 17 + // Handle messages from the new-workspace popup 18 + browser.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 + 30 + function 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 + 45 + function 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 + 63 + function 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 + 76 + async 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 + 85 + async 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) 95 + async 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 + 126 + async 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 + 138 + async 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 + 166 + async function getSavedTabs() { 167 + const stored = await browser.storage.local.get("savedTabs"); 168 + return (stored && stored.savedTabs) || {}; 169 + } 170 + 171 + async 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 180 + async 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 207 + async 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 227 + async 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 + 297 + async 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 + 313 + async 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 + 335 + async 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 + 399 + browser.menus.onShown.addListener(async (info) => { 400 + if (info.contexts.includes("tab")) { 401 + await rebuildContextMenu(); 402 + browser.menus.refresh(); 403 + } 404 + }); 405 + 406 + browser.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 + 428 + async 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 + 440 + init();
+20
tools/workspace-manager/firefox-extension/manifest.json
··· 1 + { 2 + "manifest_version": 2, 3 + "name": "Workspace Tabs", 4 + "version": "1.0.0", 5 + "description": "Manage Firefox tabs per workspace, integrated with workspace-manager", 6 + "permissions": [ 7 + "tabs", 8 + "storage", 9 + "menus", 10 + "nativeMessaging" 11 + ], 12 + "background": { 13 + "scripts": ["background.js"] 14 + }, 15 + "browser_specific_settings": { 16 + "gecko": { 17 + "id": "workspace-tabs@workspace-manager" 18 + } 19 + } 20 + }
+25
tools/workspace-manager/firefox-extension/new-workspace.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <style> 5 + body { 6 + font-family: system-ui; 7 + padding: 12px; 8 + min-width: 280px; 9 + } 10 + input { 11 + width: 100%; 12 + padding: 6px; 13 + box-sizing: border-box; 14 + font-size: 14px; 15 + } 16 + input:focus { 17 + outline: 2px solid #0060df; 18 + } 19 + </style> 20 + </head> 21 + <body> 22 + <input id="name" type="text" placeholder="Workspace name…" autofocus /> 23 + <script src="new-workspace.js"></script> 24 + </body> 25 + </html>
+14
tools/workspace-manager/firefox-extension/new-workspace.js
··· 1 + document.getElementById("name").addEventListener("keydown", (e) => { 2 + if (e.key === "Enter") { 3 + const name = e.target.value.trim(); 4 + if (name) { 5 + browser.runtime.sendMessage({ 6 + action: "sendToNewWorkspace", 7 + workspace: name, 8 + }); 9 + } 10 + window.close(); 11 + } else if (e.key === "Escape") { 12 + window.close(); 13 + } 14 + });