This repository has no description
0

Configure Feed

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

tools.workspace-manager: simplify architecture

- Daemon: only tracks workspace/directory, emits events, persists tabs
- Remove on_create/on_enter/on_leave hooks, tmux management, restore flag
- Firefox extension: saves tabs to ~/workspaces/<name>.json via daemon
- Firefox extension: anchor window keeps Firefox alive, hidden in scratchpad
- Daemon registers sway for_window rule before Firefox starts
- Firefox service: Restart=always, starts after workspace-manager
- Native host: passes tab save/load through daemon (single writer)
- Workspace menu: simplified to restore tabs, close, edit, delete
- Remove workspace-on-new.sh (no longer needed)
- NixOS module: only directory option remains

+344 -696
+94 -299
tools/workspace-manager/daemon.py
··· 2 2 """ 3 3 Workspace Manager Daemon 4 4 5 - A compositor-agnostic workspace manager that: 6 - - Subscribes to sway IPC for workspace change events 7 - - Runs user-defined hooks on workspace enter/leave 8 - - Provides current workspace and directory info via Unix socket 5 + Tracks the current sway workspace and provides: 6 + - Current workspace name and directory via Unix socket 7 + - Workspace change events to subscribers 9 8 10 - Config merging: user config is loaded first, then system config (declarative) overrides per-field 9 + Config: ~/workspaces/<name>.json with at minimum {"directory": "..."} 10 + System config (from Nix) takes precedence over user config. 11 11 """ 12 12 13 13 from __future__ import annotations ··· 16 16 import json 17 17 import os 18 18 import signal 19 - import subprocess 20 19 import sys 21 20 from pathlib import Path 22 21 from typing import Any ··· 46 45 Path(_default_dir_env).expanduser() if _default_dir_env else Path.home() 47 46 ) 48 47 49 - TERMINAL_COMMAND = os.environ.get("WORKSPACE_MANAGER_TERMINAL", "kitty") 50 - 51 - _on_new_handler_env = os.environ.get("WORKSPACE_MANAGER_ON_NEW_HANDLER") 52 - ON_NEW_HANDLER: str | None = _on_new_handler_env if _on_new_handler_env else None 53 - 54 48 55 49 def load_config_file(config_file: Path) -> dict[str, Any] | None: 56 50 """Load a workspace config file, returning None if it doesn't exist or fails.""" ··· 64 58 return None 65 59 66 60 67 - class WorkspaceConfig: 68 - """Configuration for a workspace. 61 + def get_workspace_directory(name: str) -> Path: 62 + """Get the directory for a workspace by merging user and system config.""" 63 + merged: dict[str, Any] = {} 69 64 70 - Merges user config with system config (declarative). 71 - System config fields take precedence over user config fields. 72 - """ 65 + user_data = load_config_file(CONFIG_DIR / f"{name}.json") 66 + if user_data is not None: 67 + merged.update(user_data) 73 68 74 - def __init__(self, name: str) -> None: 75 - self.name = name 76 - self.directory: Path = DEFAULT_DIRECTORY 77 - self.on_enter: list[str] = [] 78 - self.on_leave: list[str] = [] 79 - self.on_create: list[str] = [] 80 - self._load() 81 - 82 - def _load(self) -> None: 83 - # Load user config first (base) 84 - user_config_file = CONFIG_DIR / f"{self.name}.json" 85 - user_data = load_config_file(user_config_file) 86 - 87 - # Load system config (declarative, takes precedence) 88 - system_data: dict[str, Any] | None = None 89 - if SYSTEM_CONFIG_DIR is not None: 90 - system_config_file = SYSTEM_CONFIG_DIR / f"{self.name}.json" 91 - system_data = load_config_file(system_config_file) 92 - 93 - # Merge: start with user config, override with system config 94 - merged: dict[str, Any] = {} 95 - if user_data is not None: 96 - merged.update(user_data) 69 + if SYSTEM_CONFIG_DIR is not None: 70 + system_data = load_config_file(SYSTEM_CONFIG_DIR / f"{name}.json") 97 71 if system_data is not None: 98 72 merged.update(system_data) 99 73 100 - # Apply merged config 101 - if "directory" in merged: 102 - self.directory = Path(str(merged["directory"])).expanduser() 103 - if "on_enter" in merged: 104 - self.on_enter = list(merged["on_enter"]) 105 - if "on_leave" in merged: 106 - self.on_leave = list(merged["on_leave"]) 107 - if "on_create" in merged: 108 - self.on_create = list(merged["on_create"]) 74 + if "directory" in merged: 75 + return Path(str(merged["directory"])).expanduser() 76 + return DEFAULT_DIRECTORY 109 77 110 78 111 79 class WorkspaceManager: 112 - """Manages workspace state and configuration""" 80 + """Tracks current workspace state.""" 113 81 114 82 def __init__(self) -> None: 115 83 self.current_workspace: str | None = None 116 - self.configs: dict[str, WorkspaceConfig] = {} 117 - self.created_workspaces: set[str] = set() 118 - 119 - def get_config(self, workspace: str) -> WorkspaceConfig: 120 - """Get or create config for a workspace (configs are loaded fresh each time)""" 121 - # Always reload config to pick up changes 122 - self.configs[workspace] = WorkspaceConfig(workspace) 123 - return self.configs[workspace] 124 84 125 85 def get_current_directory(self) -> Path: 126 - """Get the directory for the current workspace""" 127 86 if self.current_workspace: 128 - config = self.get_config(self.current_workspace) 129 - return config.directory 87 + return get_workspace_directory(self.current_workspace) 130 88 return DEFAULT_DIRECTORY 131 89 132 - def _tmux_session_exists(self, session_name: str) -> bool: 133 - """Check if a tmux session already exists""" 134 - try: 135 - result = subprocess.run( 136 - ["tmux", "has-session", "-t", session_name], 137 - capture_output=True, 138 - ) 139 - return result.returncode == 0 140 - except FileNotFoundError: 141 - print("Warning: tmux not found", file=sys.stderr) 142 - return False 143 - 144 - async def _run_on_create(self, workspace: str, config: WorkspaceConfig) -> None: 145 - """Start on_create commands each in their own tmux session + terminal. 146 - 147 - For each command, creates a tmux session named <workspace>-start<N> 148 - running the command, then opens a terminal window attached to it. 149 - If the terminal is closed, reattach with: tmux attach -t <workspace>-start<N> 150 - """ 151 - if not config.on_create: 152 - return 153 - 154 - work_dir = str(config.directory) 155 - 156 - for i, cmd in enumerate(config.on_create, start=1): 157 - session_name = f"{workspace}-start{i}" 158 - 159 - # Skip if tmux session already exists (e.g. from a previous run) 160 - if self._tmux_session_exists(session_name): 161 - print( 162 - f"Tmux session '{session_name}' already exists, skipping", 163 - file=sys.stderr, 164 - ) 165 - continue 166 - 167 - # Create a detached tmux session running the command 168 - try: 169 - subprocess.run( 170 - [ 171 - "tmux", 172 - "new-session", 173 - "-d", 174 - "-s", 175 - session_name, 176 - "-c", 177 - work_dir, 178 - cmd, 179 - ], 180 - check=True, 181 - ) 182 - print( 183 - f"Created tmux session '{session_name}': {cmd}", 184 - file=sys.stderr, 185 - ) 186 - except (OSError, subprocess.CalledProcessError) as e: 187 - print( 188 - f"Failed to create tmux session '{session_name}': {e}", 189 - file=sys.stderr, 190 - ) 191 - continue 192 - 193 - # Open a terminal window attached to the tmux session 194 - try: 195 - subprocess.Popen( 196 - [ 197 - TERMINAL_COMMAND, 198 - "-T", 199 - session_name, 200 - "tmux", 201 - "attach", 202 - "-t", 203 - session_name, 204 - ], 205 - start_new_session=True, 206 - ) 207 - print( 208 - f"Opened terminal for tmux session '{session_name}'", 209 - file=sys.stderr, 210 - ) 211 - except OSError as e: 212 - print( 213 - f"Failed to open terminal for '{session_name}': {e}", 214 - file=sys.stderr, 215 - ) 216 - 217 - def _workspace_has_windows(self, workspace: str) -> bool: 218 - """Check if a workspace already has windows (via sway tree).""" 219 - try: 220 - result = subprocess.run( 221 - ["swaymsg", "-t", "get_tree", "-r"], 222 - capture_output=True, 223 - text=True, 224 - timeout=2, 225 - ) 226 - if result.returncode != 0: 227 - return False 228 - tree: dict[str, Any] = json.loads(result.stdout) 229 - # Find the workspace node and check for app containers 230 - for output in tree.get("nodes", []): 231 - for ws in output.get("nodes", []): 232 - if ws.get("name") == workspace: 233 - 234 - def has_apps(node: dict[str, Any]) -> bool: 235 - if node.get("type") == "con" and ( 236 - node.get("app_id") or node.get("pid") 237 - ): 238 - return True 239 - for child in node.get("nodes", []) + node.get( 240 - "floating_nodes", [] 241 - ): 242 - if has_apps(child): 243 - return True 244 - return False 245 - 246 - return has_apps(ws) 247 - except (OSError, json.JSONDecodeError, subprocess.TimeoutExpired) as e: 248 - print(f"Failed to check workspace windows: {e}", file=sys.stderr) 249 - return False 250 - 251 - async def _ask_on_new(self, workspace: str) -> bool: 252 - """Ask the user whether to restore a workspace via the on-new handler. 253 - 254 - Returns True if on_create should be run, False to skip. 255 - If no handler is configured, always returns True (auto-restore). 256 - Skips the prompt if the workspace already has windows. 257 - """ 258 - if self._workspace_has_windows(workspace): 259 - return True 260 - if ON_NEW_HANDLER is None: 261 - return True 262 - try: 263 - proc = await asyncio.create_subprocess_exec( 264 - ON_NEW_HANDLER, 265 - workspace, 266 - stdout=asyncio.subprocess.PIPE, 267 - stderr=asyncio.subprocess.PIPE, 268 - ) 269 - _, stderr = await proc.communicate() 270 - if stderr: 271 - print( 272 - f"on-new handler stderr: {stderr.decode().strip()}", 273 - file=sys.stderr, 274 - ) 275 - # Exit 0 = restore, anything else = skip 276 - return proc.returncode == 0 277 - except FileNotFoundError: 278 - print( 279 - f"on-new handler not found: {ON_NEW_HANDLER}", 280 - file=sys.stderr, 281 - ) 282 - return True 283 - except Exception as e: 284 - print(f"on-new handler error: {e}", file=sys.stderr) 285 - return True 286 - 287 - async def handle_workspace_change( 90 + def handle_workspace_change( 288 91 self, old_workspace: str | None, new_workspace: str 289 - ) -> bool: 290 - """Handle a workspace focus change event. 291 - 292 - Returns True if the workspace should be restored, False to start fresh. 293 - """ 294 - # Run on_leave hooks for old workspace 295 - if old_workspace: 296 - old_config = self.get_config(old_workspace) 297 - for hook in old_config.on_leave: 298 - await self._run_hook(hook, "on_leave", old_workspace) 299 - 300 - # Update current workspace 92 + ) -> None: 301 93 self.current_workspace = new_workspace 302 - 303 - # Run on_create hooks if this is the first time entering the workspace 304 - should_restore = True 305 - new_config = self.get_config(new_workspace) 306 - if new_workspace not in self.created_workspaces: 307 - self.created_workspaces.add(new_workspace) 308 - should_restore = await self._ask_on_new(new_workspace) 309 - if should_restore: 310 - await self._run_on_create(new_workspace, new_config) 311 - 312 - # Run on_enter hooks for new workspace 313 - for hook in new_config.on_enter: 314 - await self._run_hook(hook, "on_enter", new_workspace) 315 - 316 - # Write state file 317 94 self._write_state() 318 95 319 - return should_restore 320 - 321 - async def _run_hook(self, command: str, hook_type: str, workspace: str) -> None: 322 - """Run a hook command in the background""" 323 - try: 324 - # Run in background, don't wait 325 - subprocess.Popen( 326 - command, 327 - shell=True, 328 - stdout=subprocess.DEVNULL, 329 - stderr=subprocess.DEVNULL, 330 - start_new_session=True, 331 - ) 332 - print(f"Ran {hook_type} hook for {workspace}: {command}", file=sys.stderr) 333 - except OSError as e: 334 - print( 335 - f"Failed to run {hook_type} hook for {workspace}: {e}", 336 - file=sys.stderr, 337 - ) 338 - 339 96 def _write_state(self) -> None: 340 - """Write current state to state file""" 341 97 state: dict[str, Any] = { 342 98 "workspace": self.current_workspace, 343 99 "directory": str(self.get_current_directory()), ··· 356 112 subscribers: list[tuple[asyncio.StreamWriter, asyncio.Event]] = [] 357 113 358 114 359 - async def notify_subscribers( 360 - old_workspace: str | None, new_workspace: str, *, restore: bool = True 361 - ) -> None: 115 + async def notify_subscribers(old_workspace: str | None, new_workspace: str) -> None: 362 116 """Notify all subscribers of a workspace change.""" 363 117 event = ( 364 118 json.dumps( ··· 366 120 "event": "workspace_change", 367 121 "old": old_workspace, 368 122 "new": new_workspace, 369 - "restore": restore, 370 123 } 371 124 ).encode() 372 125 + b"\n" ··· 380 133 381 134 382 135 async def subscribe_sway() -> None: 383 - """Subscribe to sway IPC workspace events""" 136 + """Subscribe to sway IPC workspace events.""" 384 137 print("Starting sway workspace subscription...", file=sys.stderr) 385 138 386 139 while True: ··· 414 167 f"Workspace change: {old_name} -> {new_name}", 415 168 file=sys.stderr, 416 169 ) 417 - restore = await manager.handle_workspace_change( 418 - old_name, new_name 419 - ) 420 - await notify_subscribers( 421 - old_name, new_name, restore=restore 422 - ) 170 + manager.handle_workspace_change(old_name, new_name) 171 + await notify_subscribers(old_name, new_name) 423 172 except json.JSONDecodeError as e: 424 173 print(f"Failed to parse sway event: {e}", file=sys.stderr) 425 174 426 - # If swaymsg exits, wait a bit and retry 427 175 await proc.wait() 428 176 print("swaymsg exited, retrying in 5 seconds...", file=sys.stderr) 429 177 await asyncio.sleep(5) ··· 439 187 await asyncio.sleep(5) 440 188 441 189 190 + def load_user_config(workspace: str) -> dict[str, Any]: 191 + """Load user workspace config, returning empty dict if not found.""" 192 + config_file = CONFIG_DIR / f"{workspace}.json" 193 + return load_config_file(config_file) or {} 194 + 195 + 196 + def save_user_config(workspace: str, updates: dict[str, Any]) -> bool: 197 + """Merge updates into user workspace config and write to disk.""" 198 + config_file = CONFIG_DIR / f"{workspace}.json" 199 + try: 200 + existing = load_user_config(workspace) 201 + existing.update(updates) 202 + CONFIG_DIR.mkdir(parents=True, exist_ok=True) 203 + with open(config_file, "w") as f: 204 + json.dump(existing, f, indent=2) 205 + return True 206 + except OSError as e: 207 + print(f"Failed to save config for {workspace}: {e}", file=sys.stderr) 208 + return False 209 + 210 + 442 211 async def handle_client( 443 212 reader: asyncio.StreamReader, writer: asyncio.StreamWriter 444 213 ) -> None: 445 - """Handle a client connection to the Unix socket""" 214 + """Handle a client connection to the Unix socket.""" 446 215 try: 447 - data = await reader.read(1024) 448 - command = data.decode().strip() 216 + data = await reader.read(65536) 217 + raw = data.decode().strip() 449 218 450 219 response: dict[str, Any] 451 - if command == "subscribe": 452 - # Keep connection open and send workspace change events. 453 - # Disconnection is detected in notify_subscribers when write fails. 220 + 221 + # Try JSON command first, fall back to simple text commands 222 + msg: dict[str, Any] | None = None 223 + try: 224 + msg = json.loads(raw) 225 + except json.JSONDecodeError: 226 + pass 227 + 228 + if msg is not None: 229 + cmd = msg.get("cmd", "") 230 + if cmd == "save-tabs": 231 + workspace = msg.get("workspace", "") 232 + tabs: list[Any] = msg.get("tabs", []) 233 + if workspace: 234 + ok = save_user_config(workspace, {"tabs": tabs}) 235 + response = {"ok": ok} 236 + else: 237 + response = {"ok": False, "error": "No workspace specified"} 238 + elif cmd == "get-tabs": 239 + workspace = msg.get("workspace", "") 240 + if workspace: 241 + config = load_user_config(workspace) 242 + response = {"tabs": config.get("tabs", [])} 243 + else: 244 + response = {"tabs": []} 245 + else: 246 + response = {"error": f"Unknown JSON command: {cmd}"} 247 + elif raw == "subscribe": 454 248 done = asyncio.Event() 455 249 subscribers.append((writer, done)) 456 250 print("New subscriber connected", file=sys.stderr) ··· 462 256 subscribers[:] = [(w, d) for w, d in subscribers if w is not writer] 463 257 print("Subscriber disconnected", file=sys.stderr) 464 258 return 465 - elif command == "dir": 259 + elif raw == "dir": 466 260 response = {"directory": str(manager.get_current_directory())} 467 - elif command == "workspace": 261 + elif raw == "workspace": 468 262 response = {"workspace": manager.current_workspace} 469 - elif command == "status": 263 + elif raw == "status": 470 264 response = { 471 265 "workspace": manager.current_workspace, 472 266 "directory": str(manager.get_current_directory()), 473 267 "running": True, 474 268 } 475 269 else: 476 - response = {"error": f"Unknown command: {command}"} 270 + response = {"error": f"Unknown command: {raw}"} 477 271 478 272 writer.write(json.dumps(response).encode() + b"\n") 479 273 await writer.drain() ··· 485 279 486 280 487 281 async def socket_server() -> None: 488 - """Run the Unix socket server for CLI queries""" 489 - # Ensure socket directory exists 282 + """Run the Unix socket server.""" 490 283 SOCKET_DIR.mkdir(parents=True, exist_ok=True) 491 284 492 - # Remove stale socket if exists 493 285 if SOCKET_PATH.exists(): 494 286 SOCKET_PATH.unlink() 495 287 496 288 server = await asyncio.start_unix_server(handle_client, path=str(SOCKET_PATH)) 497 - 498 - # Make socket accessible 499 289 os.chmod(SOCKET_PATH, 0o600) 500 290 501 291 print(f"Socket server listening on {SOCKET_PATH}", file=sys.stderr) ··· 505 295 506 296 507 297 async def get_current_workspace() -> str | None: 508 - """Get the current workspace from sway""" 298 + """Get the current workspace from sway.""" 509 299 try: 510 300 proc = await asyncio.create_subprocess_exec( 511 301 "swaymsg", ··· 526 316 527 317 528 318 async def main() -> None: 529 - """Main entry point""" 530 - # Ensure user config directory exists (system config is read-only from nix store) 319 + """Main entry point.""" 531 320 CONFIG_DIR.mkdir(parents=True, exist_ok=True) 532 321 533 - # Log config directories 534 322 if SYSTEM_CONFIG_DIR: 535 323 print(f"System config dir: {SYSTEM_CONFIG_DIR}", file=sys.stderr) 536 324 print(f"User config dir: {CONFIG_DIR}", file=sys.stderr) 537 325 538 - # Get initial workspace 326 + # Register sway rule to hide Firefox anchor window before Firefox starts 327 + try: 328 + proc = await asyncio.create_subprocess_exec( 329 + "swaymsg", 330 + 'for_window [title="^workspace-anchor"] move scratchpad', 331 + ) 332 + await proc.wait() 333 + print("Registered sway rule for anchor window", file=sys.stderr) 334 + except Exception as e: 335 + print(f"Failed to register anchor sway rule: {e}", file=sys.stderr) 336 + 539 337 initial_workspace = await get_current_workspace() 540 338 if initial_workspace: 541 339 manager.current_workspace = initial_workspace 542 340 manager._write_state() 543 341 print(f"Initial workspace: {initial_workspace}", file=sys.stderr) 544 342 545 - # Handle shutdown gracefully 546 343 loop = asyncio.get_event_loop() 547 344 548 345 def shutdown(sig: signal.Signals) -> None: ··· 553 350 for sig in (signal.SIGTERM, signal.SIGINT): 554 351 loop.add_signal_handler(sig, shutdown, sig) 555 352 556 - # Run both tasks concurrently 557 353 try: 558 354 await asyncio.gather( 559 355 subscribe_sway(), ··· 562 358 except asyncio.CancelledError: 563 359 print("Shutdown complete", file=sys.stderr) 564 360 finally: 565 - # Cleanup socket 566 361 if SOCKET_PATH.exists(): 567 362 SOCKET_PATH.unlink() 568 363
+6 -60
tools/workspace-manager/flake-module.nix
··· 16 16 type = lib.types.str; 17 17 description = "Directory to use for this workspace"; 18 18 }; 19 - on_enter = lib.mkOption { 20 - type = lib.types.listOf lib.types.str; 21 - default = [ ]; 22 - description = "Commands to run when entering this workspace"; 23 - }; 24 - on_leave = lib.mkOption { 25 - type = lib.types.listOf lib.types.str; 26 - default = [ ]; 27 - description = "Commands to run when leaving this workspace"; 28 - }; 29 - on_create = lib.mkOption { 30 - type = lib.types.listOf lib.types.str; 31 - default = [ ]; 32 - description = "Commands to run in a tmux session when the workspace is first entered. Each command gets its own tmux window with visible logs. Reattach with: tmux attach -t ws-<name>"; 33 - }; 34 19 }; 35 20 }; 36 21 ··· 38 23 systemConfigDir = pkgs.linkFarm "workspace-manager-configs" ( 39 24 lib.mapAttrsToList (name: ws: { 40 25 name = "${name}.json"; 41 - path = pkgs.writeText "${name}.json" ( 42 - builtins.toJSON { 43 - inherit (ws) 44 - directory 45 - on_enter 46 - on_leave 47 - on_create 48 - ; 49 - } 50 - ); 26 + path = pkgs.writeText "${name}.json" (builtins.toJSON { inherit (ws) directory; }); 51 27 }) cfg.workspaces 52 28 ); 53 29 in ··· 67 43 description = "Declarative workspace configurations (take precedence over user configs)"; 68 44 example = lib.literalExpression '' 69 45 { 70 - dev = { 71 - directory = "/home/user/src/myproject"; 72 - on_enter = [ "notify-send 'Entered dev workspace'" ]; 73 - }; 74 - music = { 75 - directory = "/home/user/Music"; 76 - }; 46 + dev = { directory = "/home/user/src/myproject"; }; 47 + music = { directory = "/home/user/Music"; }; 77 48 } 78 49 ''; 79 50 }; ··· 88 59 type = lib.types.str; 89 60 default = "~"; 90 61 description = "Default directory for workspaces without configuration"; 91 - }; 92 - 93 - terminalCommand = lib.mkOption { 94 - type = lib.types.str; 95 - default = "kitty"; 96 - description = "Terminal emulator to use for on_create commands. Must support '-T title' for setting the window title."; 97 - }; 98 - 99 - onNewHandler = lib.mkOption { 100 - type = lib.types.nullOr lib.types.str; 101 - default = "${self.packages.${pkgs.system}.workspace-on-new}/bin/workspace-on-new"; 102 - description = "Script called when entering a workspace for the first time. Exit 0 to restore (run on_create), non-zero to skip."; 103 62 }; 104 63 }; 105 64 ··· 117 76 "WORKSPACE_MANAGER_CONFIG_DIR=${cfg.configDir}" 118 77 "WORKSPACE_MANAGER_SYSTEM_CONFIG_DIR=${systemConfigDir}" 119 78 "WORKSPACE_MANAGER_DEFAULT_DIR=${cfg.defaultDirectory}" 120 - "WORKSPACE_MANAGER_TERMINAL=${cfg.terminalCommand}" 121 - ] 122 - ++ lib.optional (cfg.onNewHandler != null) "WORKSPACE_MANAGER_ON_NEW_HANDLER=${cfg.onNewHandler}"; 79 + ]; 123 80 }; 124 81 }; 125 82 }; ··· 152 109 pkgs.jq 153 110 pkgs.rofi 154 111 pkgs.libnotify 155 - pkgs.tmux 156 112 pkgs.kitty 157 113 pkgs.findutils 158 114 pkgs.gnused 159 115 pkgs.coreutils 160 116 pkgs.gnugrep 117 + pkgs.firefox 161 118 ]; 162 119 text = builtins.readFile ./workspace-menu.sh; 163 - }; 164 - 165 - packages.workspace-on-new = pkgs.writeShellApplication { 166 - name = "workspace-on-new"; 167 - runtimeInputs = [ 168 - pkgs.jq 169 - pkgs.rofi 170 - pkgs.coreutils 171 - pkgs.gnused 172 - ]; 173 - text = builtins.readFile ./workspace-on-new.sh; 174 120 }; 175 121 176 122 packages.workspace-manager-daemon = pkgs.stdenv.mkDerivation { ··· 196 142 cp daemon.py $out/bin/workspace-manager-daemon 197 143 chmod +x $out/bin/workspace-manager-daemon 198 144 wrapProgram $out/bin/workspace-manager-daemon \ 199 - --prefix PATH : ${python}/bin:${pkgs.sway}/bin:${pkgs.tmux}/bin 145 + --prefix PATH : ${python}/bin:${pkgs.sway}/bin 200 146 runHook postInstall 201 147 ''; 202 148 meta.mainProgram = "workspace-manager-daemon";
+37 -139
tools/workspace-manager/workspace-menu.sh
··· 1 1 #!/usr/bin/env bash 2 2 # Workspace Menu - rofi-based menu for workspace management 3 - # Provides: save layout, restore workspace, delete config, edit config 4 3 5 4 set -euo pipefail 6 5 7 6 WORKSPACES_DIR="${HOME}/workspaces" 8 7 mkdir -p "$WORKSPACES_DIR" 9 8 10 - # Get current workspace name from sway 11 9 get_current_workspace() { 12 10 swaymsg -r -t get_workspaces | jq -r '.[] | select(.focused == true).name' 13 11 } 14 12 15 - # Get the sway tree for the current workspace (app_id for each window) 16 - capture_layout() { 17 - local ws_name="$1" 18 - swaymsg -r -t get_tree | jq --arg ws "$ws_name" ' 19 - [.nodes[].nodes[] | 20 - select(.name == $ws) | 21 - .. | 22 - select(.type? == "con" and .app_id? != null) | 23 - {app_id: .app_id}] 24 - ' 25 - } 26 - 27 - # Save current workspace layout 28 - action_save() { 29 - local ws_name 30 - ws_name=$(get_current_workspace) 31 - local config_file="${WORKSPACES_DIR}/${ws_name}.json" 32 - 33 - # Capture current windows 34 - local layout 35 - layout=$(capture_layout "$ws_name") 36 - 37 - # Load existing config or start fresh 38 - local existing="{}" 39 - if [ -f "$config_file" ]; then 40 - existing=$(cat "$config_file") 41 - fi 42 - 43 - # Merge layout into existing config 44 - echo "$existing" | jq --argjson layout "$layout" '. + {layout: $layout}' > "$config_file" 45 - 46 - notify-send "Workspace Manager" "Saved layout for workspace '${ws_name}' ($(echo "$layout" | jq 'length') windows)" 47 - } 48 - 49 - # Launch an app by its app_id in the given working directory 50 - launch_app() { 51 - local app_id="$1" 52 - local work_dir="$2" 53 - 54 - case "$app_id" in 55 - kitty|foot|alacritty|wezterm) 56 - # Terminal emulators: open in workspace directory 57 - cd "$work_dir" && "$app_id" & 58 - ;; 59 - *) 60 - # Skip everything else (browsers handled by Firefox extension) 61 - ;; 62 - esac 63 - } 64 - 65 - # Restore workspace to its original state (kill windows + re-run on_create + reopen layout) 13 + # Restore browser tabs from saved config (opens a new Firefox window with saved tabs) 66 14 action_restore() { 67 15 local ws_name 68 16 ws_name=$(get_current_workspace) ··· 73 21 return 1 74 22 fi 75 23 76 - # Confirm via rofi 24 + local tab_count 25 + tab_count=$(jq '.tabs // [] | length' "$config_file" 2>/dev/null) 26 + 27 + if [ "$tab_count" -eq 0 ]; then 28 + notify-send "Workspace Manager" "No saved tabs for workspace '${ws_name}'" 29 + return 0 30 + fi 31 + 32 + # Open each saved tab URL in Firefox 33 + local urls 34 + urls=$(jq -r '.tabs // [] | .[].url' "$config_file" 2>/dev/null) 35 + 36 + local first=true 37 + while IFS= read -r url; do 38 + [ -z "$url" ] && continue 39 + if [ "$first" = true ]; then 40 + firefox --new-window "$url" & 41 + first=false 42 + sleep 1 43 + else 44 + firefox "$url" & 45 + fi 46 + done <<< "$urls" 47 + 48 + notify-send "Workspace Manager" "Restored ${tab_count} tabs for workspace '${ws_name}'" 49 + } 50 + 51 + # Close workspace: kill all windows 52 + action_close() { 53 + local ws_name 54 + ws_name=$(get_current_workspace) 55 + 77 56 local confirm 78 - confirm=$(printf "Yes\nNo" | rofi -dmenu -p "Restore workspace '${ws_name}'? (kills all windows)") 57 + confirm=$(printf "Yes\nNo" | rofi -dmenu -p "Close workspace '${ws_name}'? (kills all windows)") 79 58 if [ "$confirm" != "Yes" ]; then 80 59 return 0 81 60 fi 82 61 83 - # Kill all windows on current workspace 84 62 local pids 85 63 pids=$(swaymsg -r -t get_tree | jq --arg ws "$ws_name" ' 86 64 [.nodes[].nodes[] | ··· 94 72 kill "$pid" 2>/dev/null || true 95 73 done 96 74 97 - # Wait a moment for windows to close 98 - sleep 1 99 - 100 - local directory 101 - directory=$(jq -r '.directory // "~"' "$config_file" 2>/dev/null) 102 - directory="${directory/#\~/$HOME}" 103 - 104 - # Run on_create commands in tmux sessions 105 - local on_create 106 - on_create=$(jq -r '.on_create // [] | .[]' "$config_file" 2>/dev/null) 107 - 108 - if [ -n "$on_create" ]; then 109 - local i=1 110 - while IFS= read -r cmd; do 111 - local session_name="${ws_name}-start${i}" 112 - # Kill existing tmux session if present 113 - tmux kill-session -t "$session_name" 2>/dev/null || true 114 - # Create new tmux session 115 - tmux new-session -d -s "$session_name" -c "$directory" "$cmd" 116 - # Open terminal attached to it 117 - kitty -T "$session_name" tmux attach -t "$session_name" & 118 - i=$((i + 1)) 119 - done <<< "$on_create" 120 - fi 121 - 122 - # Reopen windows from saved layout 123 - local layout_count 124 - layout_count=$(jq '.layout // [] | length' "$config_file" 2>/dev/null) 125 - 126 - if [ "$layout_count" -gt 0 ]; then 127 - local app_ids 128 - app_ids=$(jq -r '.layout // [] | .[].app_id // empty' "$config_file" 2>/dev/null) 129 - 130 - while IFS= read -r app_id; do 131 - [ -n "$app_id" ] && launch_app "$app_id" "$directory" 132 - done <<< "$app_ids" 133 - fi 134 - 135 - notify-send "Workspace Manager" "Restored workspace '${ws_name}'" 75 + notify-send "Workspace Manager" "Closed workspace '${ws_name}'" 136 76 } 137 77 138 78 # Delete a workspace configuration ··· 164 104 local ws_name 165 105 ws_name=$(get_current_workspace) 166 106 167 - # List existing configs + option for current workspace 168 107 local configs 169 108 configs=$(find "$WORKSPACES_DIR" -name '*.json' -printf '%f\n' | sed 's/\.json$//' | sort) 170 109 171 - # Prepend current workspace if not already in list 172 110 if ! echo "$configs" | grep -qx "$ws_name"; then 173 111 configs="${ws_name}"$'\n'"${configs}" 174 112 fi ··· 181 119 182 120 local config_file="${WORKSPACES_DIR}/${selected}.json" 183 121 184 - # Create default config if it doesn't exist 185 122 if [ ! -f "$config_file" ]; then 186 123 echo '{}' > "$config_file" 187 124 fi 188 125 189 - # Open in terminal with $EDITOR 190 126 kitty "${EDITOR:-vim}" "$config_file" & 191 - } 192 - 193 - # Close workspace: kill all windows and remove the workspace 194 - action_close() { 195 - local ws_name 196 - ws_name=$(get_current_workspace) 197 - 198 - local confirm 199 - confirm=$(printf "Yes\nNo" | rofi -dmenu -p "Close workspace '${ws_name}'? (kills all windows)") 200 - if [ "$confirm" != "Yes" ]; then 201 - return 0 202 - fi 203 - 204 - # Kill all windows on current workspace 205 - local pids 206 - pids=$(swaymsg -r -t get_tree | jq --arg ws "$ws_name" ' 207 - [.nodes[].nodes[] | 208 - select(.name == $ws) | 209 - .. | 210 - select(.type? == "con" and .pid? != null) | 211 - .pid] | unique | .[] 212 - ') 213 - 214 - for pid in $pids; do 215 - kill "$pid" 2>/dev/null || true 216 - done 217 - 218 - # Kill associated tmux sessions 219 - local i=1 220 - while tmux has-session -t "${ws_name}-start${i}" 2>/dev/null; do 221 - tmux kill-session -t "${ws_name}-start${i}" 2>/dev/null || true 222 - i=$((i + 1)) 223 - done 224 - 225 - notify-send "Workspace Manager" "Closed workspace '${ws_name}'" 226 127 } 227 128 228 129 # Main menu ··· 231 132 ws_name=$(get_current_workspace) 232 133 233 134 local choice 234 - choice=$(printf "Save layout\nRestore workspace\nClose workspace\nEdit config\nDelete config" | \ 135 + choice=$(printf "Restore tabs\nClose workspace\nEdit config\nDelete config" | \ 235 136 rofi -dmenu -p "Workspace: ${ws_name}") 236 137 237 138 case "$choice" in 238 - "Save layout") 239 - action_save 240 - ;; 241 - "Restore workspace") 139 + "Restore tabs") 242 140 action_restore 243 141 ;; 244 142 "Close workspace")
-57
tools/workspace-manager/workspace-on-new.sh
··· 1 - #!/usr/bin/env bash 2 - # Called by workspace-manager-daemon when entering a workspace for the first time. 3 - # Shows a rofi menu to restore saved session or start fresh. 4 - # Arguments: $1 = workspace name 5 - 6 - set -euo pipefail 7 - 8 - WORKSPACES_DIR="${HOME}/workspaces" 9 - WS_NAME="$1" 10 - CONFIG_FILE="${WORKSPACES_DIR}/${WS_NAME}.json" 11 - 12 - # No config file — nothing to restore, proceed silently 13 - if [ ! -f "$CONFIG_FILE" ]; then 14 - exit 0 15 - fi 16 - 17 - config=$(cat "$CONFIG_FILE") 18 - 19 - directory=$(echo "$config" | jq -r '.directory // empty') 20 - on_create=$(echo "$config" | jq -r '.on_create // []') 21 - layout=$(echo "$config" | jq -r '.layout // []') 22 - 23 - on_create_count=$(echo "$on_create" | jq 'length') 24 - layout_count=$(echo "$layout" | jq 'length') 25 - 26 - # Nothing meaningful in config — proceed silently 27 - if [ "$on_create_count" -eq 0 ] && [ "$layout_count" -eq 0 ] && [ -z "$directory" ]; then 28 - exit 0 29 - fi 30 - 31 - # Build preview of what can be restored 32 - preview="" 33 - if [ -n "$directory" ]; then 34 - preview="Directory: ${directory}\n" 35 - fi 36 - if [ "$on_create_count" -gt 0 ]; then 37 - cmds=$(echo "$on_create" | jq -r '.[]' | sed 's/^/ /') 38 - preview="${preview}Commands (${on_create_count}):\n${cmds}\n" 39 - fi 40 - if [ "$layout_count" -gt 0 ]; then 41 - apps=$(echo "$layout" | jq -r '.[].app_id // .[].class // "unknown"' | sort -u | sed 's/^/ /') 42 - preview="${preview}Saved windows (${layout_count}):\n${apps}\n" 43 - fi 44 - 45 - choice=$(echo -e "Restore session\nNew session" | \ 46 - rofi -dmenu -p "Workspace: ${WS_NAME}" -mesg "$(echo -e "$preview")") 47 - 48 - case "$choice" in 49 - "Restore session") 50 - # Exit 0 — daemon will run on_create, Firefox will restore tabs 51 - exit 0 52 - ;; 53 - "New session"|"") 54 - # Exit 1 — daemon will skip on_create, Firefox won't restore tabs 55 - exit 1 56 - ;; 57 - esac
+38 -4
tools/workspace-manager/workspace-tabs-host.py
··· 1 1 #!/usr/bin/env python3 2 - """Native messaging host for the Workspace Tabs Firefox extension.""" 2 + """Native messaging host for the Workspace Tabs Firefox extension. 3 + 4 + Bridges Firefox extension with workspace-manager daemon and sway. 5 + Tab persistence goes through the daemon (single writer to workspace files). 6 + """ 3 7 4 8 from __future__ import annotations 5 9 ··· 41 45 except (OSError, subprocess.TimeoutExpired): 42 46 return "" 43 47 return r.stdout.strip() if r.returncode == 0 else "" 48 + 49 + 50 + def daemon_query(msg: dict[str, Any]) -> dict[str, Any]: 51 + """Send a JSON command to the workspace-manager daemon and return the response.""" 52 + try: 53 + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 54 + sock.connect(str(SOCKET_PATH)) 55 + sock.settimeout(5.0) 56 + sock.sendall(json.dumps(msg).encode()) 57 + # Shutdown write side so daemon knows we're done sending 58 + sock.shutdown(socket.SHUT_WR) 59 + data = b"" 60 + while True: 61 + chunk = sock.recv(65536) 62 + if not chunk: 63 + break 64 + data += chunk 65 + sock.close() 66 + if data: 67 + result: dict[str, Any] = json.loads(data.strip()) 68 + return result 69 + except (OSError, json.JSONDecodeError) as e: 70 + print(f"Daemon query failed: {e}", file=sys.stderr) 71 + return {} 44 72 45 73 46 74 def get_firefox_windows() -> list[dict[str, str]]: ··· 93 121 return {"id": mid, "ok": True} 94 122 if cmd == "map_windows": 95 123 return {"id": mid, "windows": get_firefox_windows()} 124 + if cmd == "save_tabs": 125 + workspace = msg.get("workspace", "") 126 + tabs = msg.get("tabs", []) 127 + resp = daemon_query({"cmd": "save-tabs", "workspace": workspace, "tabs": tabs}) 128 + return {"id": mid, "ok": resp.get("ok", False)} 129 + if cmd == "get_tabs": 130 + workspace = msg.get("workspace", "") 131 + resp = daemon_query({"cmd": "get-tabs", "workspace": workspace}) 132 + return {"id": mid, "tabs": resp.get("tabs", [])} 96 133 return {"id": mid, "error": f"Unknown: {cmd}"} 97 134 98 135 ··· 139 176 try: 140 177 data = sub_sock.recv(4096) 141 178 if not data: 142 - # Daemon disconnected, try to reconnect 143 179 sub_sock.close() 144 180 sub_sock = connect_subscriber() 145 181 continue ··· 147 183 while b"\n" in sub_buf: 148 184 line, sub_buf = sub_buf.split(b"\n", 1) 149 185 event: dict[str, Any] = json.loads(line) 150 - # Push workspace change event to Firefox 151 186 write_message( 152 187 { 153 188 "id": 0, 154 189 "event": "workspace_change", 155 190 "workspace": event.get("new", ""), 156 - "restore": event.get("restore", True), 157 191 } 158 192 ) 159 193 except (OSError, json.JSONDecodeError) as e:
+8 -2
2configs/desktops/sway/default.nix
··· 28 28 # Make goto-workspace available system-wide for workspace switching 29 29 environment.systemPackages = [ gotoWorkspace ]; 30 30 31 - # Firefox as a systemd user service, started on login 31 + # Firefox as a persistent systemd user service — starts after workspace-manager 32 + # so the sway rule to hide the anchor window is already registered 32 33 systemd.user.services.firefox = { 33 34 description = "Firefox Web Browser"; 34 35 partOf = [ "sway-session.target" ]; 35 36 wantedBy = [ "sway-session.target" ]; 36 - after = [ "sway-session.target" ]; 37 + after = [ 38 + "sway-session.target" 39 + "workspace-manager.service" 40 + ]; 37 41 serviceConfig = { 38 42 Type = "simple"; 39 43 ExecStart = lib.getExe self.packages.${pkgs.system}.firefox; 44 + Restart = "always"; 45 + RestartSec = 2; 40 46 }; 41 47 }; 42 48 imports = [
+6
tools/workspace-manager/firefox-extension/anchor.html
··· 1 + <html> 2 + <head> 3 + <title>workspace-anchor</title> 4 + </head> 5 + <body></body> 6 + </html>
+155 -134
tools/workspace-manager/firefox-extension/background.js
··· 1 1 /* Workspace Tabs - Firefox extension for workspace-manager integration 2 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. 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 6 7 */ 7 8 8 9 const HOST_NAME = "workspace_tabs"; 10 + const ANCHOR_TITLE = "workspace-anchor"; 9 11 10 12 let port = null; 11 13 let pendingRequests = new Map(); 12 14 let requestId = 0; 13 15 let lastWorkspace = null; 14 - let restoringWorkspaces = new Set(); // prevent double-restore 16 + let anchorWindowId = null; 17 + let restoringWorkspaces = new Set(); 15 18 let pendingNewWorkspaceTabId = null; 19 + let savePending = false; 20 + let initialized = false; 16 21 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 - }); 22 + // ── Native messaging ──────────────────────────────────────── 29 23 30 24 function connectNative() { 31 25 try { ··· 62 56 63 57 function handleNativeMessage(msg) { 64 58 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); 59 + onWorkspaceChange(msg.workspace); 68 60 return; 69 61 } 70 62 if (msg.id && pendingRequests.has(msg.id)) { ··· 73 65 resolve(msg); 74 66 } 75 67 } 68 + 69 + // ── Workspace queries ─────────────────────────────────────── 76 70 77 71 async function getCurrentWorkspace() { 78 72 try { ··· 92 86 } 93 87 } 94 88 95 - // Get Firefox windows per workspace from sway tree (always fresh) 89 + // ── Firefox window <-> workspace mapping ──────────────────── 90 + 96 91 async function getFirefoxWindowMappings() { 97 92 try { 98 93 const resp = await sendNative("map_windows"); ··· 105 100 for (const mapping of resp.windows) { 106 101 const swayTitle = mapping.windowTitle || ""; 107 102 for (const win of allWindows) { 108 - if (matched.has(win.id)) continue; 103 + if (matched.has(win.id) || win.id === anchorWindowId) continue; 109 104 const activeTab = win.tabs && win.tabs.find((t) => t.active); 110 105 if ( 111 106 activeTab && ··· 134 129 } 135 130 } 136 131 137 - // ── Tab saving ────────────────────────────────────────────── 132 + // ── Anchor window (keeps Firefox alive) ───────────────────── 138 133 139 - async function saveAllTabs() { 140 - try { 141 - const mappings = await getFirefoxWindowMappings(); 142 - const stored = (await browser.storage.local.get("savedTabs")) || {}; 143 - const savedTabs = stored.savedTabs || {}; 134 + async 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 + } 144 143 145 - for (const [workspace, windowId] of mappings) { 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) { 146 156 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 - })); 157 + await browser.windows.remove(w.id); 156 158 } catch (e) { 157 - // window might have closed 159 + // already closed 158 160 } 159 161 } 162 + } 163 + // Daemon's for_window rule hides anchor when title updates to "workspace-anchor" 164 + } 160 165 161 - await browser.storage.local.set({ savedTabs }); 166 + // Prevent anchor window from being closed 167 + browser.windows.onRemoved.addListener((windowId) => { 168 + if (windowId === anchorWindowId) { 169 + anchorWindowId = null; 170 + ensureAnchorWindow(); 171 + } 172 + }); 173 + 174 + // ── Tab saving (to workspace file) ───────────────────────── 175 + 176 + async 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 }); 162 194 } catch (e) { 163 - console.error("Failed to save tabs:", e); 195 + console.error(`Failed to save tabs for ${workspace}:`, e); 164 196 } 165 197 } 166 198 167 - async function getSavedTabs() { 168 - const stored = await browser.storage.local.get("savedTabs"); 169 - return (stored && stored.savedTabs) || {}; 199 + async function saveAllTabs() { 200 + const mappings = await getFirefoxWindowMappings(); 201 + for (const [workspace] of mappings) { 202 + await saveTabsForWorkspace(workspace); 203 + } 170 204 } 171 205 172 - async function clearSavedTabs(workspace) { 173 - const savedTabs = await getSavedTabs(); 174 - delete savedTabs[workspace]; 175 - await browser.storage.local.set({ savedTabs }); 206 + // Debounced save: collects rapid changes, saves after 500ms idle 207 + function scheduleSave() { 208 + if (savePending) return; 209 + savePending = true; 210 + setTimeout(async () => { 211 + savePending = false; 212 + if (lastWorkspace) { 213 + await saveTabsForWorkspace(lastWorkspace); 214 + } 215 + }, 500); 176 216 } 177 217 178 - // ── Tab restoring ─────────────────────────────────────────── 218 + // Listen to all tab events that should trigger a save 219 + browser.tabs.onCreated.addListener(scheduleSave); 220 + browser.tabs.onRemoved.addListener(scheduleSave); 221 + browser.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 + }); 227 + browser.tabs.onMoved.addListener(scheduleSave); 228 + browser.tabs.onActivated.addListener(scheduleSave); 229 + browser.tabs.onAttached.addListener(scheduleSave); 230 + browser.tabs.onDetached.addListener(scheduleSave); 179 231 180 - // Restore tabs into a window, removing the initial blank tab 232 + // ── Tab restoring (from workspace file) ───────────────────── 233 + 181 234 async function restoreTabsInWindow(tabDataList, windowId) { 182 235 let activeTabId = null; 183 236 for (const tab of tabDataList) { ··· 204 257 } 205 258 } 206 259 207 - // Restore saved tabs for a workspace on the current sway workspace 208 260 async function restoreWorkspace(workspace) { 209 261 if (restoringWorkspaces.has(workspace)) return; 210 262 restoringWorkspaces.add(workspace); 211 263 212 264 try { 213 - const savedTabs = await getSavedTabs(); 214 - const tabs = savedTabs[workspace]; 215 - if (!tabs || tabs.length === 0) return; 265 + const resp = await sendNative("get_tabs", { workspace }); 266 + const tabs = resp.tabs || []; 267 + if (tabs.length === 0) return; 216 268 217 269 const newWindow = await browser.windows.create({}); 218 270 await restoreTabsInWindow(tabs, newWindow.id); 219 - await clearSavedTabs(workspace); 220 271 } catch (e) { 221 272 console.error(`Failed to restore workspace ${workspace}:`, e); 222 273 } finally { ··· 224 275 } 225 276 } 226 277 227 - // On startup: restore windows for all currently open workspaces 228 - async 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 278 // ── Workspace change handling ──────────────────────────────── 297 279 298 - async function onWorkspaceChange(workspace, restore = true) { 280 + async function onWorkspaceChange(workspace) { 299 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 + 300 288 lastWorkspace = workspace; 301 289 302 - // If restore is false, the user chose "New session" — don't restore tabs 303 - if (!restore) return; 290 + // Don't restore tabs during startup — only after user switches workspace 291 + if (!initialized) return; 304 292 305 - // Check if this workspace has saved tabs but no Firefox window 293 + // Check if this workspace already has a Firefox window 306 294 const withFirefox = await getWorkspacesWithFirefox(); 307 295 if (withFirefox.has(workspace)) return; 308 296 309 - const savedTabs = await getSavedTabs(); 310 - if (savedTabs[workspace] && savedTabs[workspace].length > 0) { 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) { 311 301 await restoreWorkspace(workspace); 312 302 } 313 303 } ··· 329 319 await sendNative("goto_workspace", { name: sourceWorkspace }); 330 320 } 331 321 } 322 + // Save both workspaces after move 323 + scheduleSave(); 332 324 } catch (e) { 333 325 console.error(`Failed to send tab to workspace ${targetWorkspace}:`, e); 334 326 } 335 327 } 328 + 329 + // Handle messages from the new-workspace popup 330 + browser.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 + }); 336 341 337 342 // ── Context menu ──────────────────────────────────────────── 338 343 ··· 430 435 // ── Init ──────────────────────────────────────────────────── 431 436 432 437 async 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 433 446 connectNative(); 434 447 await new Promise((r) => setTimeout(r, 500)); 435 448 436 - lastWorkspace = await getCurrentWorkspace(); 437 - await restoreOnStartup(); 438 - await rebuildContextMenu(); 449 + try { 450 + lastWorkspace = await getCurrentWorkspace(); 451 + } catch (e) { 452 + console.warn("Failed to get current workspace:", e); 453 + } 439 454 440 - // Save tabs periodically 441 - setInterval(saveAllTabs, 30000); 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; 442 463 } 443 464 444 465 init();
-1
tools/workspace-manager/firefox-extension/manifest.json
··· 5 5 "description": "Manage Firefox tabs per workspace, integrated with workspace-manager", 6 6 "permissions": [ 7 7 "tabs", 8 - "storage", 9 8 "menus", 10 9 "nativeMessaging" 11 10 ],