This repository has no description
0

Configure Feed

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

tools.workspace-manager: add sway integration with rofi menu

- Add workspace-menu (mod4+space): save layout, restore, close, edit, delete
- Add workspace-on-new: prompt to restore or start fresh on new workspace
- Pass restore flag through daemon -> native host -> Firefox extension
- Skip prompt for workspaces that already have windows
- Move NixOS module from 3modules/ to tools/ as wrapperModule
- Declare flake.wrapperModules option for multi-module merging

+579 -128
+6
flake.nix
··· 193 193 ./games/flake-module.nix 194 194 195 195 ./wrapperModules/pi/flake-module.nix 196 + { 197 + options.flake.wrapperModules = nixpkgs.lib.mkOption { 198 + type = nixpkgs.lib.types.lazyAttrsOf nixpkgs.lib.types.raw; 199 + default = { }; 200 + }; 201 + } 196 202 ] 197 203 ++ ( 198 204 # Auto-import all flake-module.nix files from tools subdirectories
-1
3modules/default.nix
··· 4 4 ./screenlock.nix 5 5 ./fetchWallpaper.nix 6 6 ./sync-containers3.nix 7 - ./workspace-manager.nix 8 7 ]; 9 8 }
-117
3modules/workspace-manager.nix
··· 1 - { 2 - config, 3 - lib, 4 - pkgs, 5 - self, 6 - ... 7 - }: 8 - let 9 - cfg = config.lass.workspace-manager; 10 - 11 - workspaceType = lib.types.submodule { 12 - options = { 13 - directory = lib.mkOption { 14 - type = lib.types.str; 15 - description = "Directory to use for this workspace"; 16 - }; 17 - on_enter = lib.mkOption { 18 - type = lib.types.listOf lib.types.str; 19 - default = [ ]; 20 - description = "Commands to run when entering this workspace"; 21 - }; 22 - on_leave = lib.mkOption { 23 - type = lib.types.listOf lib.types.str; 24 - default = [ ]; 25 - description = "Commands to run when leaving this workspace"; 26 - }; 27 - on_create = lib.mkOption { 28 - type = lib.types.listOf lib.types.str; 29 - default = [ ]; 30 - 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>"; 31 - }; 32 - }; 33 - }; 34 - 35 - # Generate a directory with JSON files for each declared workspace 36 - systemConfigDir = pkgs.linkFarm "workspace-manager-configs" ( 37 - lib.mapAttrsToList (name: ws: { 38 - name = "${name}.json"; 39 - path = pkgs.writeText "${name}.json" ( 40 - builtins.toJSON { 41 - inherit (ws) 42 - directory 43 - on_enter 44 - on_leave 45 - on_create 46 - ; 47 - } 48 - ); 49 - }) cfg.workspaces 50 - ); 51 - in 52 - { 53 - options.lass.workspace-manager = { 54 - enable = lib.mkEnableOption "workspace manager daemon for sway"; 55 - 56 - package = lib.mkOption { 57 - type = lib.types.package; 58 - default = self.packages.${pkgs.system}.workspace-manager-daemon; 59 - description = "The workspace-manager-daemon package to use"; 60 - }; 61 - 62 - workspaces = lib.mkOption { 63 - type = lib.types.attrsOf workspaceType; 64 - default = { }; 65 - description = "Declarative workspace configurations (take precedence over user configs)"; 66 - example = lib.literalExpression '' 67 - { 68 - dev = { 69 - directory = "/home/user/src/myproject"; 70 - on_enter = [ "notify-send 'Entered dev workspace'" ]; 71 - }; 72 - music = { 73 - directory = "/home/user/Music"; 74 - }; 75 - } 76 - ''; 77 - }; 78 - 79 - configDir = lib.mkOption { 80 - type = lib.types.str; 81 - default = "~/workspaces"; 82 - description = "Directory containing user workspace configuration files"; 83 - }; 84 - 85 - defaultDirectory = lib.mkOption { 86 - type = lib.types.str; 87 - default = "~"; 88 - description = "Default directory for workspaces without configuration"; 89 - }; 90 - 91 - terminalCommand = lib.mkOption { 92 - type = lib.types.str; 93 - default = "kitty"; 94 - description = "Terminal emulator to use for on_create commands. Must support '-T title' for setting the window title."; 95 - }; 96 - }; 97 - 98 - config = lib.mkIf cfg.enable { 99 - systemd.user.services.workspace-manager = { 100 - description = "Workspace Manager Daemon"; 101 - partOf = [ "sway-session.target" ]; 102 - wantedBy = [ "sway-session.target" ]; 103 - serviceConfig = { 104 - Type = "simple"; 105 - ExecStart = "${cfg.package}/bin/workspace-manager-daemon"; 106 - Restart = "on-failure"; 107 - RestartSec = 5; 108 - Environment = [ 109 - "WORKSPACE_MANAGER_CONFIG_DIR=${cfg.configDir}" 110 - "WORKSPACE_MANAGER_SYSTEM_CONFIG_DIR=${systemConfigDir}" 111 - "WORKSPACE_MANAGER_DEFAULT_DIR=${cfg.defaultDirectory}" 112 - "WORKSPACE_MANAGER_TERMINAL=${cfg.terminalCommand}" 113 - ]; 114 - }; 115 - }; 116 - }; 117 - }
+1
machines/xerxes/config.nix
··· 15 15 ../../2configs/radicle.nix 16 16 ../../2configs/steam.nix 17 17 ../../2configs/auto-timezone.nix 18 + self.wrapperModules.workspace-manager 18 19 ]; 19 20 system.stateVersion = "25.05"; 20 21
+94 -6
tools/workspace-manager/daemon.py
··· 48 48 49 49 TERMINAL_COMMAND = os.environ.get("WORKSPACE_MANAGER_TERMINAL", "kitty") 50 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 + 51 54 52 55 def load_config_file(config_file: Path) -> dict[str, Any] | None: 53 56 """Load a workspace config file, returning None if it doesn't exist or fails.""" ··· 211 214 file=sys.stderr, 212 215 ) 213 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 + 214 287 async def handle_workspace_change( 215 288 self, old_workspace: str | None, new_workspace: str 216 - ) -> None: 217 - """Handle a workspace focus change event""" 289 + ) -> bool: 290 + """Handle a workspace focus change event. 291 + 292 + Returns True if the workspace should be restored, False to start fresh. 293 + """ 218 294 # Run on_leave hooks for old workspace 219 295 if old_workspace: 220 296 old_config = self.get_config(old_workspace) ··· 225 301 self.current_workspace = new_workspace 226 302 227 303 # Run on_create hooks if this is the first time entering the workspace 304 + should_restore = True 228 305 new_config = self.get_config(new_workspace) 229 306 if new_workspace not in self.created_workspaces: 230 307 self.created_workspaces.add(new_workspace) 231 - await self._run_on_create(new_workspace, new_config) 308 + should_restore = await self._ask_on_new(new_workspace) 309 + if should_restore: 310 + await self._run_on_create(new_workspace, new_config) 232 311 233 312 # Run on_enter hooks for new workspace 234 313 for hook in new_config.on_enter: ··· 236 315 237 316 # Write state file 238 317 self._write_state() 318 + 319 + return should_restore 239 320 240 321 async def _run_hook(self, command: str, hook_type: str, workspace: str) -> None: 241 322 """Run a hook command in the background""" ··· 275 356 subscribers: list[tuple[asyncio.StreamWriter, asyncio.Event]] = [] 276 357 277 358 278 - async def notify_subscribers(old_workspace: str | None, new_workspace: str) -> None: 359 + async def notify_subscribers( 360 + old_workspace: str | None, new_workspace: str, *, restore: bool = True 361 + ) -> None: 279 362 """Notify all subscribers of a workspace change.""" 280 363 event = ( 281 364 json.dumps( ··· 283 366 "event": "workspace_change", 284 367 "old": old_workspace, 285 368 "new": new_workspace, 369 + "restore": restore, 286 370 } 287 371 ).encode() 288 372 + b"\n" ··· 330 414 f"Workspace change: {old_name} -> {new_name}", 331 415 file=sys.stderr, 332 416 ) 333 - await manager.handle_workspace_change(old_name, new_name) 334 - await notify_subscribers(old_name, new_name) 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 + ) 335 423 except json.JSONDecodeError as e: 336 424 print(f"Failed to parse sway event: {e}", file=sys.stderr) 337 425
+154 -1
tools/workspace-manager/flake-module.nix
··· 1 - { ... }: 1 + { self, ... }: 2 2 { 3 + flake.wrapperModules.workspace-manager = 4 + { 5 + config, 6 + lib, 7 + pkgs, 8 + ... 9 + }: 10 + let 11 + cfg = config.lass.workspace-manager; 12 + 13 + workspaceType = lib.types.submodule { 14 + options = { 15 + directory = lib.mkOption { 16 + type = lib.types.str; 17 + description = "Directory to use for this workspace"; 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 + }; 35 + }; 36 + 37 + # Generate a directory with JSON files for each declared workspace 38 + systemConfigDir = pkgs.linkFarm "workspace-manager-configs" ( 39 + lib.mapAttrsToList (name: ws: { 40 + 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 + ); 51 + }) cfg.workspaces 52 + ); 53 + in 54 + { 55 + options.lass.workspace-manager = { 56 + enable = lib.mkEnableOption "workspace manager daemon for sway"; 57 + 58 + package = lib.mkOption { 59 + type = lib.types.package; 60 + default = self.packages.${pkgs.system}.workspace-manager-daemon; 61 + description = "The workspace-manager-daemon package to use"; 62 + }; 63 + 64 + workspaces = lib.mkOption { 65 + type = lib.types.attrsOf workspaceType; 66 + default = { }; 67 + description = "Declarative workspace configurations (take precedence over user configs)"; 68 + example = lib.literalExpression '' 69 + { 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 + }; 77 + } 78 + ''; 79 + }; 80 + 81 + configDir = lib.mkOption { 82 + type = lib.types.str; 83 + default = "~/workspaces"; 84 + description = "Directory containing user workspace configuration files"; 85 + }; 86 + 87 + defaultDirectory = lib.mkOption { 88 + type = lib.types.str; 89 + default = "~"; 90 + 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 + }; 104 + }; 105 + 106 + config = lib.mkIf cfg.enable { 107 + systemd.user.services.workspace-manager = { 108 + description = "Workspace Manager Daemon"; 109 + partOf = [ "sway-session.target" ]; 110 + wantedBy = [ "sway-session.target" ]; 111 + serviceConfig = { 112 + Type = "simple"; 113 + ExecStart = "${cfg.package}/bin/workspace-manager-daemon"; 114 + Restart = "on-failure"; 115 + RestartSec = 5; 116 + Environment = [ 117 + "WORKSPACE_MANAGER_CONFIG_DIR=${cfg.configDir}" 118 + "WORKSPACE_MANAGER_SYSTEM_CONFIG_DIR=${systemConfigDir}" 119 + "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}"; 123 + }; 124 + }; 125 + }; 126 + }; 127 + 3 128 perSystem = 4 129 { 5 130 pkgs, ··· 19 144 text = builtins.readFile ./workspace-manager.sh; 20 145 }).overrideAttrs 21 146 { passthru.usage = builtins.readFile ./usage.kdl; }; 147 + 148 + packages.workspace-menu = pkgs.writeShellApplication { 149 + name = "workspace-menu"; 150 + runtimeInputs = [ 151 + pkgs.sway 152 + pkgs.jq 153 + pkgs.rofi 154 + pkgs.libnotify 155 + pkgs.tmux 156 + pkgs.kitty 157 + pkgs.findutils 158 + pkgs.gnused 159 + pkgs.coreutils 160 + pkgs.gnugrep 161 + ]; 162 + 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 + }; 22 175 23 176 packages.workspace-manager-daemon = pkgs.stdenv.mkDerivation { 24 177 pname = "workspace-manager-daemon";
+256
tools/workspace-manager/workspace-menu.sh
··· 1 + #!/usr/bin/env bash 2 + # Workspace Menu - rofi-based menu for workspace management 3 + # Provides: save layout, restore workspace, delete config, edit config 4 + 5 + set -euo pipefail 6 + 7 + WORKSPACES_DIR="${HOME}/workspaces" 8 + mkdir -p "$WORKSPACES_DIR" 9 + 10 + # Get current workspace name from sway 11 + get_current_workspace() { 12 + swaymsg -r -t get_workspaces | jq -r '.[] | select(.focused == true).name' 13 + } 14 + 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) 66 + action_restore() { 67 + local ws_name 68 + ws_name=$(get_current_workspace) 69 + local config_file="${WORKSPACES_DIR}/${ws_name}.json" 70 + 71 + if [ ! -f "$config_file" ]; then 72 + notify-send "Workspace Manager" "No config found for workspace '${ws_name}'" 73 + return 1 74 + fi 75 + 76 + # Confirm via rofi 77 + local confirm 78 + confirm=$(printf "Yes\nNo" | rofi -dmenu -p "Restore workspace '${ws_name}'? (kills all windows)") 79 + if [ "$confirm" != "Yes" ]; then 80 + return 0 81 + fi 82 + 83 + # Kill all windows on current workspace 84 + local pids 85 + pids=$(swaymsg -r -t get_tree | jq --arg ws "$ws_name" ' 86 + [.nodes[].nodes[] | 87 + select(.name == $ws) | 88 + .. | 89 + select(.type? == "con" and .pid? != null) | 90 + .pid] | unique | .[] 91 + ') 92 + 93 + for pid in $pids; do 94 + kill "$pid" 2>/dev/null || true 95 + done 96 + 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}'" 136 + } 137 + 138 + # Delete a workspace configuration 139 + action_delete() { 140 + local configs 141 + configs=$(find "$WORKSPACES_DIR" -name '*.json' -printf '%f\n' | sed 's/\.json$//' | sort) 142 + 143 + if [ -z "$configs" ]; then 144 + notify-send "Workspace Manager" "No workspace configs found" 145 + return 0 146 + fi 147 + 148 + local selected 149 + selected=$(echo "$configs" | rofi -dmenu -p "Delete workspace config") 150 + if [ -z "$selected" ]; then 151 + return 0 152 + fi 153 + 154 + local confirm 155 + confirm=$(printf "Yes\nNo" | rofi -dmenu -p "Delete config for '${selected}'?") 156 + if [ "$confirm" = "Yes" ]; then 157 + rm "${WORKSPACES_DIR}/${selected}.json" 158 + notify-send "Workspace Manager" "Deleted config for '${selected}'" 159 + fi 160 + } 161 + 162 + # Edit a workspace configuration 163 + action_edit() { 164 + local ws_name 165 + ws_name=$(get_current_workspace) 166 + 167 + # List existing configs + option for current workspace 168 + local configs 169 + configs=$(find "$WORKSPACES_DIR" -name '*.json' -printf '%f\n' | sed 's/\.json$//' | sort) 170 + 171 + # Prepend current workspace if not already in list 172 + if ! echo "$configs" | grep -qx "$ws_name"; then 173 + configs="${ws_name}"$'\n'"${configs}" 174 + fi 175 + 176 + local selected 177 + selected=$(echo "$configs" | rofi -dmenu -p "Edit workspace config") 178 + if [ -z "$selected" ]; then 179 + return 0 180 + fi 181 + 182 + local config_file="${WORKSPACES_DIR}/${selected}.json" 183 + 184 + # Create default config if it doesn't exist 185 + if [ ! -f "$config_file" ]; then 186 + echo '{}' > "$config_file" 187 + fi 188 + 189 + # Open in terminal with $EDITOR 190 + 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 + } 227 + 228 + # Main menu 229 + main() { 230 + local ws_name 231 + ws_name=$(get_current_workspace) 232 + 233 + local choice 234 + choice=$(printf "Save layout\nRestore workspace\nClose workspace\nEdit config\nDelete config" | \ 235 + rofi -dmenu -p "Workspace: ${ws_name}") 236 + 237 + case "$choice" in 238 + "Save layout") 239 + action_save 240 + ;; 241 + "Restore workspace") 242 + action_restore 243 + ;; 244 + "Close workspace") 245 + action_close 246 + ;; 247 + "Edit config") 248 + action_edit 249 + ;; 250 + "Delete config") 251 + action_delete 252 + ;; 253 + esac 254 + } 255 + 256 + main
+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
+1
tools/workspace-manager/workspace-tabs-host.py
··· 153 153 "id": 0, 154 154 "event": "workspace_change", 155 155 "workspace": event.get("new", ""), 156 + "restore": event.get("restore", True), 156 157 } 157 158 ) 158 159 except (OSError, json.JSONDecodeError) as e:
+4 -1
2configs/desktops/sway/default.nix
··· 253 253 bindsym $mod+Shift+space floating toggle 254 254 255 255 # Swap focus between the tiling area and the floating area 256 - bindsym $mod+space focus mode_toggle 256 + bindsym $mod+a focus mode_toggle 257 + 258 + # Workspace manager menu 259 + bindsym $mod+space exec ${lib.getExe self.packages.${pkgs.system}.workspace-menu} 257 260 258 261 # Move focus to the parent container 259 262 bindsym $mod+Shift+a focus parent
+6 -2
tools/workspace-manager/firefox-extension/background.js
··· 63 63 function handleNativeMessage(msg) { 64 64 if (msg.event === "workspace_change") { 65 65 // Push event from workspace-manager — handle workspace switch 66 - onWorkspaceChange(msg.workspace); 66 + // restore flag: true = restore saved tabs, false = start fresh 67 + onWorkspaceChange(msg.workspace, msg.restore !== false); 67 68 return; 68 69 } 69 70 if (msg.id && pendingRequests.has(msg.id)) { ··· 294 295 295 296 // ── Workspace change handling ──────────────────────────────── 296 297 297 - async function onWorkspaceChange(workspace) { 298 + async function onWorkspaceChange(workspace, restore = true) { 298 299 if (!workspace || workspace === lastWorkspace) return; 299 300 lastWorkspace = workspace; 301 + 302 + // If restore is false, the user chose "New session" — don't restore tabs 303 + if (!restore) return; 300 304 301 305 // Check if this workspace has saved tabs but no Firefox window 302 306 const withFirefox = await getWorkspacesWithFirefox();