From cc8f7eb671e4f6761edebbd2adb582a1bd0406c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:22:27 +0000 Subject: [PATCH 1/5] Add macOS workspace restoration plan for multi-project development Covers AeroSpace virtual workspaces, iTerm2 arrangements, shell-based project launcher scripts, and keyboard shortcut integration for instant switching between parallel Claude Code + browser preview setups. https://claude.ai/code/session_019NLEfbmYTkMtdnVYuS6UZM --- workspace-restoration-plan.md | 460 ++++++++++++++++++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 workspace-restoration-plan.md diff --git a/workspace-restoration-plan.md b/workspace-restoration-plan.md new file mode 100644 index 0000000..d126979 --- /dev/null +++ b/workspace-restoration-plan.md @@ -0,0 +1,460 @@ +# macOS Workspace Restoration Plan + +A scriptable system for saving, restoring, and switching between multi-project developer workspaces on macOS — combining terminal sessions, browser previews, and tooling like Claude Code. + +--- + +## Problem + +When working on multiple projects in parallel, each project needs: +- Multiple terminal tabs/panes (Claude Code, dev server, logs, tests) +- A browser window showing a live preview (`localhost:3000`, etc.) +- Possibly other app windows (editor, database GUI, docs) + +Switching between projects means manually rearranging all of this. The goal is **one keystroke** to switch the entire screen to a different project's workspace. + +--- + +## Recommended Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Keyboard Shortcut │ +│ (alt-1, alt-2, alt-3, ...) │ +└────────────────────────┬────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ AeroSpace │ ← virtual workspace manager + │ (tiling WM, TOML) │ no SIP required + └──────────┬──────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌───────▼──────┐ ┌────▼─────┐ ┌──────▼──────┐ + │ iTerm2 │ │ Browser │ │ Other Apps │ + │ (profiles, │ │ (Chrome/ │ │ (editors, │ + │ arrangements│ │ Safari) │ │ DBs, etc) │ + │ per project)│ │ │ │ │ + └──────────────┘ └──────────┘ └─────────────┘ + │ + ┌───────▼──────────────────────┐ + │ Project launcher script │ ← `workspace open project-a` + │ (shell script per project) │ + └──────────────────────────────┘ +``` + +### Why This Stack + +| Component | Role | Why this one | +|-----------|------|-------------| +| **AeroSpace** | Virtual workspace switching | No SIP required, instant switching (no animation), TOML config, CLI-scriptable, works on managed machines | +| **iTerm2** | Terminal sessions | Rich AppleScript/Python API, saved window arrangements, per-project profiles with startup commands | +| **Shell scripts** | Project launchers | Portable, versionable, no dependencies beyond the tools above | +| **Raycast** (optional) | Command palette trigger | Invoke project scripts by name from anywhere | + +### Why Not the Alternatives + +- **Native macOS Spaces**: No public API. Cannot programmatically create/switch spaces. Animation delay. Spaces reorder themselves. +- **Yabai**: Requires disabling SIP on macOS 15.2+ for space manipulation. Security and corporate policy concerns. +- **Hammerspoon alone**: Spaces module is experimental and flaky. Better as a complement than a foundation. + +--- + +## Implementation Plan + +### Phase 1: AeroSpace Setup + +Install and configure AeroSpace as the workspace manager. + +```bash +brew install --cask nikitabobko/tap/aerospace +``` + +Create `~/.config/aerospace/aerospace.toml`: + +```toml +# AeroSpace configuration for multi-project workspaces + +start-at-login = true + +# Disable macOS Spaces animations (they interfere) +[gaps] +inner.horizontal = 8 +inner.vertical = 8 +outer.left = 8 +outer.right = 8 +outer.top = 8 +outer.bottom = 8 + +# Workspace keybindings +[mode.main.binding] +# Project workspaces (one per project) +alt-1 = 'workspace 1' +alt-2 = 'workspace 2' +alt-3 = 'workspace 3' +alt-4 = 'workspace 4' +alt-5 = 'workspace 5' + +# Move focused window to a workspace +alt-shift-1 = 'move-node-to-workspace 1' +alt-shift-2 = 'move-node-to-workspace 2' +alt-shift-3 = 'move-node-to-workspace 3' +alt-shift-4 = 'move-node-to-workspace 4' +alt-shift-5 = 'move-node-to-workspace 5' + +# Tiling layout controls +alt-slash = 'layout tiles horizontal vertical' +alt-comma = 'layout accordion horizontal vertical' +alt-f = 'fullscreen' +alt-shift-f = 'layout floating tiling' + +# Focus movement +alt-h = 'focus left' +alt-j = 'focus down' +alt-k = 'focus up' +alt-l = 'focus right' + +# Resize +alt-shift-h = 'resize width -50' +alt-shift-l = 'resize width +50' +alt-shift-j = 'resize height +50' +alt-shift-k = 'resize height -50' + +# Auto-assign apps to workspaces (optional defaults) +[[on-window-detected]] +if.app-id = 'com.apple.finder' +run = 'layout floating' +``` + +**Key design choice**: Each numbered workspace (1-5) maps to a project. `alt-1` through `alt-5` switch instantly between them. No animation, no SIP, no macOS Spaces involved. + +### Phase 2: iTerm2 Project Profiles + +Create an iTerm2 profile for each project. Each profile sets: +- **Working directory** to the project root +- **Badge text** to the project name (visual identifier) +- **Tab color** unique per project (instant visual feedback) +- **Initial command** (optional, e.g., `claude` or a startup script) + +Profiles can be created in iTerm2 Settings > Profiles, or via the Python API. + +Example profiles: +| Profile Name | Working Dir | Badge | Tab Color | Startup Command | +|-------------|-------------|-------|-----------|-----------------| +| `project-a` | `~/code/project-a` | `PROJECT-A` | Blue | `claude` | +| `project-a-server` | `~/code/project-a` | `PROJECT-A:dev` | Blue | `npm run dev` | +| `project-b` | `~/code/project-b` | `PROJECT-B` | Green | `claude` | +| `project-b-server` | `~/code/project-b` | `PROJECT-B:dev` | `yarn dev` | Green | + +Then save iTerm2 Window Arrangements per project: +1. Open tabs/panes with the right profiles +2. **Window > Save Window Arrangement** as `project-a`, `project-b`, etc. + +### Phase 3: Project Launcher Scripts + +Create a `workspace` CLI tool. This is a shell script that orchestrates everything. + +#### Directory structure + +``` +~/.config/workspaces/ +├── workspace.sh # Main CLI script (symlinked to ~/bin/workspace) +├── projects/ +│ ├── project-a.toml # Project config +│ ├── project-b.toml # Project config +│ └── project-c.toml # Project config +└── lib/ + └── iterm.scpt # Shared AppleScript helpers +``` + +#### Project config format (`projects/project-a.toml`) + +```toml +[project] +name = "project-a" +workspace = 1 # AeroSpace workspace number +directory = "~/code/project-a" + +[terminal] +arrangement = "project-a" # iTerm2 saved arrangement name +# OR define tabs inline: +tabs = [ + { profile = "project-a", command = "claude" }, + { profile = "project-a-server", command = "npm run dev" }, + { profile = "project-a", command = "npm run test -- --watch" }, +] + +[browser] +url = "http://localhost:3000" +app_mode = true # Open as standalone window (no browser chrome) + +[extras] +# Additional apps to open/focus +apps = ["Visual Studio Code"] +``` + +#### Main launcher script (`workspace.sh`) + +```bash +#!/usr/bin/env bash +set -euo pipefail + +WORKSPACES_DIR="${HOME}/.config/workspaces" +PROJECTS_DIR="${WORKSPACES_DIR}/projects" + +usage() { + echo "Usage: workspace [project]" + echo "" + echo "Commands:" + echo " open Switch to project workspace and launch apps" + echo " list List configured projects" + echo " save Save current iTerm2 arrangement for project" + echo " status Show which workspace is active" + echo "" + echo "Examples:" + echo " workspace open project-a" + echo " workspace list" +} + +# Parse TOML value (basic — for simple key = "value" pairs) +toml_get() { + local file="$1" key="$2" + grep "^${key}" "$file" | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' +} + +cmd_open() { + local project="$1" + local config="${PROJECTS_DIR}/${project}.toml" + + if [[ ! -f "$config" ]]; then + echo "Error: No config found for project '${project}'" + echo "Expected: ${config}" + exit 1 + fi + + local ws=$(toml_get "$config" "workspace") + local dir=$(toml_get "$config" "directory") + local url=$(toml_get "$config" "url") + local arrangement=$(toml_get "$config" "arrangement") + dir="${dir/#\~/$HOME}" + + echo "→ Switching to workspace ${ws} (${project})" + + # 1. Switch AeroSpace workspace + aerospace workspace "$ws" 2>/dev/null || true + + # 2. Restore iTerm2 arrangement (if configured) + if [[ -n "$arrangement" ]]; then + osascript </dev/null || echo "unknown") + echo "Active workspace: ${current_ws}" + + for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + local name=$(basename "$f" .toml) + local ws=$(toml_get "$f" "workspace") + if [[ "$ws" == "$current_ws" ]]; then + echo "Active project: ${name}" + return + fi + done + echo "Active project: (none mapped)" +} + +# Main +case "${1:-}" in + open) cmd_open "${2:?project name required}" ;; + list) cmd_list ;; + save) cmd_save "${2:?project name required}" ;; + status) cmd_status ;; + *) usage ;; +esac +``` + +### Phase 4: Keyboard Shortcut Integration + +Bind workspace switching to keyboard shortcuts that also trigger the launcher. Two approaches: + +#### Option A: AeroSpace callbacks (simpler) + +In `aerospace.toml`, combine workspace switch with a script: + +```toml +[mode.main.binding] +# alt-1 switches workspace AND ensures project-a is initialized +alt-1 = ['workspace 1', 'exec-and-forget ~/.config/workspaces/workspace.sh open project-a --no-switch'] +``` + +(Add a `--no-switch` flag to skip the `aerospace workspace` call when triggered from AeroSpace itself.) + +#### Option B: Raycast script commands + +Create Raycast script commands in `~/.config/raycast/scripts/`: + +```bash +#!/bin/bash +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title Project A +# @raycast.mode silent +# @raycast.packageName Workspaces + +~/.config/workspaces/workspace.sh open project-a +``` + +This lets you type "Project A" in Raycast to switch workspaces. + +### Phase 5: Git Worktrees for Parallel Claude Work + +When working on multiple branches of the same repo simultaneously: + +```bash +# Create isolated worktrees for each branch/feature +cd ~/code/my-repo +git worktree add ../my-repo-feature-x feature-x +git worktree add ../my-repo-bugfix-y bugfix-y + +# Each worktree gets its own workspace +# project config points directory to the worktree path +``` + +This pairs with the workspace system — each worktree becomes a separate "project" with its own terminal layout, Claude Code session, and browser preview. + +--- + +## Example: Full Setup for Two Projects + +### Project A: React frontend + +``` +Workspace 1 (alt-1) +┌──────────────────────┬──────────────────────┐ +│ │ │ +│ iTerm2: claude │ Chrome: localhost │ +│ (Claude Code) │ :3000 │ +│ │ │ +│ │ │ +├──────────────────────┤ │ +│ │ │ +│ iTerm2: npm run dev │ │ +│ (dev server) │ │ +│ │ │ +└──────────────────────┴──────────────────────┘ +``` + +Config: `~/.config/workspaces/projects/frontend.toml` +```toml +[project] +name = "frontend" +workspace = 1 +directory = "~/code/frontend" + +[terminal] +arrangement = "frontend" + +[browser] +url = "http://localhost:3000" +``` + +### Project B: Python API + +``` +Workspace 2 (alt-2) +┌──────────────────────┬──────────────────────┐ +│ │ │ +│ iTerm2: claude │ Chrome: localhost │ +│ (Claude Code) │ :8000/docs │ +│ │ (API docs / Swagger)│ +│ │ │ +├──────────────────────┤ │ +│ │ │ +│ iTerm2: uvicorn │ │ +│ (API server) │ │ +│ │ │ +└──────────────────────┴──────────────────────┘ +``` + +Config: `~/.config/workspaces/projects/api.toml` +```toml +[project] +name = "api" +workspace = 2 +directory = "~/code/api" + +[terminal] +arrangement = "api" + +[browser] +url = "http://localhost:8000/docs" +``` + +### Switching between them + +``` +alt-1 → instant switch to frontend (terminal + browser + layout) +alt-2 → instant switch to API (terminal + browser + layout) +``` + +--- + +## Installation Checklist + +1. **Install AeroSpace**: `brew install --cask nikitabobko/tap/aerospace` +2. **Create AeroSpace config**: `~/.config/aerospace/aerospace.toml` (see Phase 1) +3. **Set up iTerm2 profiles**: One profile per project role (claude, server, tests) +4. **Save iTerm2 arrangements**: One arrangement per project +5. **Create workspace configs**: `~/.config/workspaces/projects/.toml` +6. **Install the launcher script**: Copy `workspace.sh` to `~/bin/workspace` and `chmod +x` +7. **Bind shortcuts**: Wire AeroSpace keybindings to workspace launcher (Phase 4) +8. **(Optional) Install Raycast**: For command-palette access to workspace switching + +--- + +## Extensions and Future Ideas + +- **Auto-detect running projects**: Check which `localhost` ports are active and show status +- **Teardown command**: `workspace close project-a` — kill dev servers, close browser tabs, free the workspace +- **Monitor-aware layouts**: Different tiling configurations for laptop-only vs. external monitor +- **Session persistence**: Save/restore Claude Code conversation context per workspace +- **MCP integration**: Use an MCP server to let Claude Code itself trigger workspace switches +- **Dotfiles integration**: Check all configs into a dotfiles repo for portability across machines From e0c21db15d1cfce0c804fb449b093457334f74a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 14:09:51 +0000 Subject: [PATCH 2/5] Add Phase 6: menu bar UI for workspace switching Covers SwiftBar plugin (lowest friction, always-visible dropdown), Raycast extension (searchable list for many projects), and comparison of 6 approaches including Hammerspoon, rumps, Alfred, and SwiftUI. https://claude.ai/code/session_019NLEfbmYTkMtdnVYuS6UZM --- workspace-restoration-plan.md | 152 +++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/workspace-restoration-plan.md b/workspace-restoration-plan.md index d126979..74c0842 100644 --- a/workspace-restoration-plan.md +++ b/workspace-restoration-plan.md @@ -437,6 +437,155 @@ alt-2 → instant switch to API (terminal + browser + layout) --- +## Phase 6: Menu Bar UI for Workspace Switching + +A persistent, clickable UI in the macOS menu bar showing the active workspace and a dropdown to switch between projects. + +### Approach Comparison + +| Approach | Setup | Persistent Menu Bar | Search/Filter | Language | Extra App | +|----------|-------|-------------------|---------------|----------|-----------| +| **SwiftBar plugin** | Low | Yes | No | Bash | SwiftBar | +| **Raycast extension** | Medium | No (invoke to use) | Yes | TypeScript | Raycast | +| **Alfred workflow** | Medium | No (invoke to use) | Yes (fuzzy) | Bash | Alfred | +| **Hammerspoon menubar** | Low-Med | Yes | No | Lua | Hammerspoon | +| **rumps (Python)** | Low-Med | Yes | No | Python | None | +| **SwiftUI MenuBarExtra** | High | Yes | Possible | Swift | None | + +### Recommended: SwiftBar Plugin (lowest friction) + +Install SwiftBar: + +```bash +brew install --cask swiftbar +``` + +Create `~/.config/swiftbar/workspaces.5s.sh` (refreshes every 5 seconds): + +```bash +#!/bin/bash + +# true + +PROJECTS_DIR="${HOME}/.config/workspaces/projects" +CURRENT_WS=$(aerospace list-workspaces --focused 2>/dev/null || echo "?") + +# Find which project maps to the current workspace +CURRENT_PROJECT="" +for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + name=$(basename "$f" .toml) + ws=$(grep "^workspace" "$f" | sed 's/.*= *//;s/"//g' | tr -d ' ') + if [[ "$ws" == "$CURRENT_WS" ]]; then + CURRENT_PROJECT="$name" + break + fi +done + +# Menu bar title: show current project name +if [[ -n "$CURRENT_PROJECT" ]]; then + echo ":desktopcomputer: ${CURRENT_PROJECT} | symbolize=true sfsize=13" +else + echo ":desktopcomputer: ws:${CURRENT_WS} | symbolize=true sfsize=13" +fi + +echo "---" + +# List all projects as menu items +for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + name=$(basename "$f" .toml) + ws=$(grep "^workspace" "$f" | sed 's/.*= *//;s/"//g' | tr -d ' ') + + if [[ "$name" == "$CURRENT_PROJECT" ]]; then + echo ":checkmark.circle.fill: ${name} (workspace ${ws}) | symbolize=true color=systemGreen sfsize=13" + else + echo ":circle: ${name} (workspace ${ws}) | symbolize=true sfsize=13 bash=${HOME}/bin/workspace param1=open param2=${name} terminal=false refresh=true" + fi +done + +echo "---" +echo "Refresh | refresh=true" +``` + +```bash +chmod +x ~/.config/swiftbar/workspaces.5s.sh +``` + +This gives you: +- A menu bar item showing the current project name +- A dropdown listing all configured projects with a checkmark on the active one +- Click any project to switch (runs `workspace open ` silently) +- Auto-refreshes every 5 seconds to reflect keyboard-driven switches + +### Alternative: Raycast Extension (for search-driven switching) + +If you have many projects (6+), a searchable list is better than a dropdown. A Raycast extension provides: +- Fuzzy search across all workspace names +- Status indicators (green dot = active) +- Secondary actions (Cmd+Enter to just switch terminal, Shift+Enter to open browser only) + +```typescript +// src/switch-workspace.tsx — Raycast extension entry point +import { List, ActionPanel, Action, Icon, Color, showToast, Toast } from "@raycast/api"; +import { execSync } from "child_process"; +import { readdirSync } from "fs"; +import { join, basename } from "path"; + +export default function Command() { + const home = process.env.HOME!; + const projectsDir = join(home, ".config/workspaces/projects"); + let currentWs = ""; + try { + currentWs = execSync("aerospace list-workspaces --focused", { encoding: "utf-8" }).trim(); + } catch {} + + const projects = readdirSync(projectsDir) + .filter((f) => f.endsWith(".toml")) + .map((f) => { + const name = basename(f, ".toml"); + const content = require("fs").readFileSync(join(projectsDir, f), "utf-8"); + const wsMatch = content.match(/^workspace\s*=\s*(\S+)/m); + const ws = wsMatch ? wsMatch[1].replace(/"/g, "") : "?"; + return { name, ws, isActive: ws === currentWs }; + }); + + return ( + + {projects.map((p) => ( + + { + execSync(`${home}/bin/workspace open ${p.name}`); + showToast({ style: Toast.Style.Success, title: `Switched to ${p.name}` }); + }} + /> + + } + /> + ))} + + ); +} +``` + +Scaffold with `npx create-raycast-extension`, paste this in, and `npm run dev`. + +### Both Together (recommended for power users) + +SwiftBar for at-a-glance status + click switching, Raycast for keyboard-driven fuzzy search when you have many projects. They complement each other — SwiftBar is always visible, Raycast is always one hotkey away. + +--- + ## Installation Checklist 1. **Install AeroSpace**: `brew install --cask nikitabobko/tap/aerospace` @@ -446,7 +595,8 @@ alt-2 → instant switch to API (terminal + browser + layout) 5. **Create workspace configs**: `~/.config/workspaces/projects/.toml` 6. **Install the launcher script**: Copy `workspace.sh` to `~/bin/workspace` and `chmod +x` 7. **Bind shortcuts**: Wire AeroSpace keybindings to workspace launcher (Phase 4) -8. **(Optional) Install Raycast**: For command-palette access to workspace switching +8. **(Optional) Install SwiftBar**: `brew install --cask swiftbar` for menu bar UI +9. **(Optional) Install Raycast**: For command-palette access to workspace switching --- From f73207bfb96a2bf36544a28edd6822229d8a1067 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 15:25:56 +0000 Subject: [PATCH 3/5] Add Cmd+Tab workspace switching section to restoration plan Covers why native Cmd+Tab doesn't work with AeroSpace workspaces, recommends alt-tab bindings for workspace-back-and-forth and window cycling, and documents the Karabiner-Elements approach for reclaiming literal Cmd+Tab. https://claude.ai/code/session_019NLEfbmYTkMtdnVYuS6UZM --- workspace-restoration-plan.md | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/workspace-restoration-plan.md b/workspace-restoration-plan.md index 74c0842..eb77ceb 100644 --- a/workspace-restoration-plan.md +++ b/workspace-restoration-plan.md @@ -344,6 +344,47 @@ Create Raycast script commands in `~/.config/raycast/scripts/`: This lets you type "Project A" in Raycast to switch workspaces. +### Cmd+Tab and Workspace Switching + +macOS Cmd+Tab shows apps from **all** AeroSpace workspaces (since AeroSpace uses a single macOS Space, not native Spaces). No Cmd+Tab replacement (AltTab, Contexts, Witch) currently supports AeroSpace workspace filtering. + +#### Recommended: Alt+Tab bindings in AeroSpace + +Add these to your `aerospace.toml`: + +```toml +[mode.main.binding] +# Toggle between last two workspaces (feels like Cmd+Tab between projects) +alt-tab = 'workspace-back-and-forth' + +# Cycle windows within the current workspace +alt-grave = 'focus --boundaries-action wrap-around-the-workspace dfs-next' +alt-shift-grave = 'focus --boundaries-action wrap-around-the-workspace dfs-prev' +``` + +This gives you two levels of switching: +- **`alt-tab`**: Flip between your two most recent project workspaces +- **`alt-backtick`**: Cycle through windows within the current workspace + +#### If you want literal Cmd+Tab + +macOS intercepts Cmd+Tab at the system level before AeroSpace sees it. To reclaim it, use [Karabiner-Elements](https://karabiner-elements.pqrs.org/) to remap Cmd+Tab to a key AeroSpace can handle: + +```json +{ + "description": "Remap cmd+tab to alt+tab for AeroSpace", + "manipulators": [{ + "type": "basic", + "from": { "key_code": "tab", "modifiers": { "mandatory": ["command"], "optional": ["shift"] } }, + "to": [{ "key_code": "tab", "modifiers": ["option"] }] + }] +} +``` + +Then AeroSpace's `alt-tab = 'workspace-back-and-forth'` binding handles Cmd+Tab presses. + +> **Note**: This disables the native macOS app switcher. Most AeroSpace users find `alt-tab` sufficient and skip Karabiner. + ### Phase 5: Git Worktrees for Parallel Claude Work When working on multiple branches of the same repo simultaneously: From 0d579187990573495793cba150e25419f637547e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 15:35:07 +0000 Subject: [PATCH 4/5] Implement workspace restoration system - AeroSpace config with workspace switching, tiling, vim-style focus, alt-tab back-and-forth, and window cycling - workspace CLI tool: open/close/list/save/status/note/notes/ensure - Example project configs for frontend and API workspaces - iTerm2 AppleScript helpers for arrangement save/restore/close - SwiftBar menu bar plugin showing active workspace and notes - Raycast script commands for workspace switching and listing - install.sh symlinks everything into ~/.config and ~/bin - Per-workspace editable notes (stored in project TOML, displayed on switch and in menu bar) https://claude.ai/code/session_019NLEfbmYTkMtdnVYuS6UZM --- config/aerospace/aerospace.toml | 93 ++++++ config/raycast/scripts/list-workspaces.sh | 19 ++ config/raycast/scripts/switch-workspace.sh | 18 ++ config/swiftbar/workspaces.5s.sh | 79 +++++ config/workspaces/lib/iterm.scpt | 79 +++++ config/workspaces/projects/api.toml | 15 + config/workspaces/projects/frontend.toml | 15 + config/workspaces/workspace.sh | 360 +++++++++++++++++++++ install.sh | 129 ++++++++ workspace-restoration-plan.md | 44 +++ 10 files changed, 851 insertions(+) create mode 100644 config/aerospace/aerospace.toml create mode 100755 config/raycast/scripts/list-workspaces.sh create mode 100755 config/raycast/scripts/switch-workspace.sh create mode 100755 config/swiftbar/workspaces.5s.sh create mode 100644 config/workspaces/lib/iterm.scpt create mode 100644 config/workspaces/projects/api.toml create mode 100644 config/workspaces/projects/frontend.toml create mode 100755 config/workspaces/workspace.sh create mode 100755 install.sh diff --git a/config/aerospace/aerospace.toml b/config/aerospace/aerospace.toml new file mode 100644 index 0000000..46faa3c --- /dev/null +++ b/config/aerospace/aerospace.toml @@ -0,0 +1,93 @@ +# AeroSpace configuration for multi-project workspaces +# See: https://nikitabobko.github.io/AeroSpace/guide + +start-at-login = true + +# Normalizations +enable-normalization-flatten-containers = true +enable-normalization-opposite-orientation-for-nested-containers = true + +# Mouse follows focus +on-focused-monitor-changed = ['move-mouse monitor-lazy-center'] + +# Run workspace launcher on workspace change +exec-on-workspace-change = [ + '/bin/bash', '-c', + '~/.config/workspaces/workspace.sh ensure "$AEROSPACE_FOCUSED_WORKSPACE" 2>/dev/null &' +] + +[gaps] +inner.horizontal = 8 +inner.vertical = 8 +outer.left = 8 +outer.right = 8 +outer.top = 8 +outer.bottom = 8 + +[mode.main.binding] +# ── Workspace switching ────────────────────────────────────── +alt-1 = 'workspace 1' +alt-2 = 'workspace 2' +alt-3 = 'workspace 3' +alt-4 = 'workspace 4' +alt-5 = 'workspace 5' + +# Toggle between last two workspaces (feels like Cmd+Tab between projects) +alt-tab = 'workspace-back-and-forth' + +# ── Move window to workspace ──────────────────────────────── +alt-shift-1 = 'move-node-to-workspace 1' +alt-shift-2 = 'move-node-to-workspace 2' +alt-shift-3 = 'move-node-to-workspace 3' +alt-shift-4 = 'move-node-to-workspace 4' +alt-shift-5 = 'move-node-to-workspace 5' + +# ── Focus movement (vim-style) ────────────────────────────── +alt-h = 'focus left' +alt-j = 'focus down' +alt-k = 'focus up' +alt-l = 'focus right' + +# Cycle windows within current workspace +alt-grave = 'focus --boundaries-action wrap-around-the-workspace dfs-next' +alt-shift-grave = 'focus --boundaries-action wrap-around-the-workspace dfs-prev' + +# ── Move windows ───────────────────────────────────────────── +alt-shift-h = 'move left' +alt-shift-j = 'move down' +alt-shift-k = 'move up' +alt-shift-l = 'move right' + +# ── Resize ─────────────────────────────────────────────────── +alt-minus = 'resize smart -50' +alt-equal = 'resize smart +50' + +# ── Layout ─────────────────────────────────────────────────── +alt-slash = 'layout tiles horizontal vertical' +alt-comma = 'layout accordion horizontal vertical' +alt-f = 'fullscreen' +alt-shift-f = 'layout floating tiling' + +# ── Service mode (for less common operations) ──────────────── +alt-shift-semicolon = 'mode service' + +[mode.service.binding] +esc = ['reload-config', 'mode main'] +r = ['flatten-workspace-tree', 'mode main'] +alt-shift-h = ['join-with left', 'mode main'] +alt-shift-j = ['join-with down', 'mode main'] +alt-shift-k = ['join-with up', 'mode main'] +alt-shift-l = ['join-with right', 'mode main'] + +# ── Window rules ───────────────────────────────────────────── +[[on-window-detected]] +if.app-id = 'com.apple.finder' +run = 'layout floating' + +[[on-window-detected]] +if.app-id = 'com.apple.systempreferences' +run = 'layout floating' + +[[on-window-detected]] +if.app-id = 'com.apple.calculator' +run = 'layout floating' diff --git a/config/raycast/scripts/list-workspaces.sh b/config/raycast/scripts/list-workspaces.sh new file mode 100755 index 0000000..5670ccc --- /dev/null +++ b/config/raycast/scripts/list-workspaces.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title List Workspaces +# @raycast.mode fullOutput +# @raycast.packageName Workspaces + +# Optional parameters: +# @raycast.icon :desktopcomputer: + +# Documentation: +# @raycast.description List all configured project workspaces +# @raycast.author workspace-restoration +# @raycast.authorURL https://github.com + +~/.config/workspaces/workspace.sh list +echo "" +~/.config/workspaces/workspace.sh status diff --git a/config/raycast/scripts/switch-workspace.sh b/config/raycast/scripts/switch-workspace.sh new file mode 100755 index 0000000..66779a9 --- /dev/null +++ b/config/raycast/scripts/switch-workspace.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title Switch Workspace +# @raycast.mode fullOutput +# @raycast.packageName Workspaces + +# Optional parameters: +# @raycast.icon :desktopcomputer: +# @raycast.argument1 { "type": "text", "placeholder": "project name" } + +# Documentation: +# @raycast.description Switch to a project workspace (AeroSpace + iTerm2 + browser) +# @raycast.author workspace-restoration +# @raycast.authorURL https://github.com + +~/.config/workspaces/workspace.sh open "$1" diff --git a/config/swiftbar/workspaces.5s.sh b/config/swiftbar/workspaces.5s.sh new file mode 100755 index 0000000..8c790e4 --- /dev/null +++ b/config/swiftbar/workspaces.5s.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# SwiftBar plugin: workspace switcher menu bar item +# Refreshes every 5 seconds (per filename convention) +# Install: brew install --cask swiftbar +# Place in SwiftBar plugin directory + +# true + +PROJECTS_DIR="${HOME}/.config/workspaces/projects" +NOTES_DIR="${HOME}/.config/workspaces/.state" +WORKSPACE_BIN="${HOME}/bin/workspace" + +# Get current AeroSpace workspace +CURRENT_WS=$(aerospace list-workspaces --focused 2>/dev/null || echo "?") + +# Find which project maps to the current workspace +CURRENT_PROJECT="" +for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + name=$(basename "$f" .toml) + ws=$(grep "^workspace" "$f" | sed 's/.*= *//;s/"//g' | tr -d ' ') + if [[ "$ws" == "$CURRENT_WS" ]]; then + CURRENT_PROJECT="$name" + break + fi +done + +# ── Menu bar title ─────────────────────────────────────────── + +if [[ -n "$CURRENT_PROJECT" ]]; then + echo ":desktopcomputer: ${CURRENT_PROJECT} | symbolize=true sfsize=13" +else + echo ":desktopcomputer: ws:${CURRENT_WS} | symbolize=true sfsize=13" +fi + +echo "---" + +# ── Show note for current workspace (if any) ──────────────── + +if [[ -n "$CURRENT_PROJECT" ]]; then + note_text="" + in_notes=false + while IFS= read -r line; do + if [[ "$line" =~ ^\[notes\] ]]; then + in_notes=true + continue + fi + if $in_notes; then + [[ "$line" =~ ^\[.*\] ]] && break + if [[ "$line" =~ ^text[[:space:]]*= ]]; then + note_text=$(echo "$line" | sed 's/^[^=]*=[[:space:]]*//;s/^"//;s/"$//') + break + fi + fi + done < "${PROJECTS_DIR}/${CURRENT_PROJECT}.toml" + + if [[ -n "$note_text" ]]; then + echo ":note.text: ${note_text} | symbolize=true sfsize=11 color=systemYellow" + echo "---" + fi +fi + +# ── Project list ───────────────────────────────────────────── + +for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + name=$(basename "$f" .toml) + ws=$(grep "^workspace" "$f" | sed 's/.*= *//;s/"//g' | tr -d ' ') + + if [[ "$name" == "$CURRENT_PROJECT" ]]; then + echo ":checkmark.circle.fill: ${name} (workspace ${ws}) | symbolize=true color=systemGreen sfsize=13" + else + echo ":circle: ${name} (workspace ${ws}) | symbolize=true sfsize=13 bash=${WORKSPACE_BIN} param1=open param2=${name} terminal=false refresh=true" + fi +done + +echo "---" +echo "Refresh | refresh=true" diff --git a/config/workspaces/lib/iterm.scpt b/config/workspaces/lib/iterm.scpt new file mode 100644 index 0000000..4eca5fe --- /dev/null +++ b/config/workspaces/lib/iterm.scpt @@ -0,0 +1,79 @@ +-- iTerm2 AppleScript helpers for workspace management +-- Usage: +-- osascript iterm.scpt restore +-- osascript iterm.scpt save +-- osascript iterm.scpt close + +on run argv + if (count of argv) < 2 then + log "Usage: osascript iterm.scpt " + return + end if + + set cmd to item 1 of argv + set arrangementName to item 2 of argv + + if cmd is "restore" then + restoreArrangement(arrangementName) + else if cmd is "save" then + saveArrangement(arrangementName) + else if cmd is "close" then + closeArrangement(arrangementName) + else + log "Unknown command: " & cmd + end if +end run + +on restoreArrangement(arrangementName) + tell application "iTerm2" + activate + try + -- Check if arrangement exists before restoring + set arrangementNames to name of every window arrangement + if arrangementName is in arrangementNames then + restore window arrangement arrangementName + else + -- Arrangement not saved yet — just open a new window + create window with default profile + end if + on error errMsg + log "Error restoring arrangement: " & errMsg + -- Fallback: just activate iTerm2 + activate + end try + end tell +end restoreArrangement + +on saveArrangement(arrangementName) + tell application "iTerm2" + try + save window arrangement arrangementName + on error errMsg + log "Error saving arrangement: " & errMsg + end try + end tell +end saveArrangement + +on closeArrangement(arrangementName) + tell application "iTerm2" + try + -- Close all windows that belong to this arrangement + -- iTerm2 doesn't track which windows came from which arrangement, + -- so we close windows whose tabs match the arrangement's profile pattern + set windowCount to count of windows + repeat with i from windowCount to 1 by -1 + set w to window i + try + set tabCount to count of tabs of w + set firstTab to tab 1 of w + set sessionName to name of current session of firstTab + if sessionName contains arrangementName then + close w + end if + end try + end repeat + on error errMsg + log "Error closing arrangement: " & errMsg + end try + end tell +end closeArrangement diff --git a/config/workspaces/projects/api.toml b/config/workspaces/projects/api.toml new file mode 100644 index 0000000..b9d95bf --- /dev/null +++ b/config/workspaces/projects/api.toml @@ -0,0 +1,15 @@ +[project] +name = "api" +workspace = 2 +directory = "~/code/api" + +[terminal] +arrangement = "api" + +[browser] +url = "http://localhost:8000/docs" + +[notes] +# Editable notes displayed when switching to this workspace. +# Edit this file or run: workspace note api "your note here" +text = "" diff --git a/config/workspaces/projects/frontend.toml b/config/workspaces/projects/frontend.toml new file mode 100644 index 0000000..75ecdcd --- /dev/null +++ b/config/workspaces/projects/frontend.toml @@ -0,0 +1,15 @@ +[project] +name = "frontend" +workspace = 1 +directory = "~/code/frontend" + +[terminal] +arrangement = "frontend" + +[browser] +url = "http://localhost:3000" + +[notes] +# Editable notes displayed when switching to this workspace. +# Edit this file or run: workspace note frontend "your note here" +text = "" diff --git a/config/workspaces/workspace.sh b/config/workspaces/workspace.sh new file mode 100755 index 0000000..28eba76 --- /dev/null +++ b/config/workspaces/workspace.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKSPACES_DIR="${HOME}/.config/workspaces" +PROJECTS_DIR="${WORKSPACES_DIR}/projects" +STATE_DIR="${WORKSPACES_DIR}/.state" + +# ── Helpers ────────────────────────────────────────────────── + +usage() { + cat <<'USAGE' +Usage: workspace [options] + +Commands: + open Switch to project workspace and launch apps + close Tear down project workspace (kill servers, close windows) + list List configured projects + save Save current iTerm2 window arrangement for project + status Show which workspace/project is active + note [text] View or set a note for a project workspace + notes Show notes for all projects + ensure Ensure workspace has its project initialized (used by AeroSpace callback) + +Options: + --no-switch Skip AeroSpace workspace switch (used when called from AeroSpace binding) + +Examples: + workspace open frontend + workspace close api + workspace list + workspace status + workspace note frontend "TODO: fix auth bug before deploy" + workspace note frontend # view current note + workspace note frontend "" # clear the note + workspace notes # show all project notes +USAGE +} + +# Parse TOML value under a specific section +# Usage: toml_get
+# For top-level keys, use "" as section +toml_get() { + local file="$1" section="$2" key="$3" + local in_section=false result="" + + if [[ -z "$section" ]]; then + # Top-level key (before any section header) + while IFS= read -r line; do + # Stop at first section header + [[ "$line" =~ ^\[.*\] ]] && break + if [[ "$line" =~ ^${key}[[:space:]]*= ]]; then + result=$(echo "$line" | sed 's/^[^=]*=[[:space:]]*//;s/^"//;s/"$//') + break + fi + done < "$file" + else + while IFS= read -r line; do + if [[ "$line" =~ ^\[${section}\] ]]; then + in_section=true + continue + fi + if $in_section; then + # New section starts — stop + [[ "$line" =~ ^\[.*\] ]] && break + if [[ "$line" =~ ^${key}[[:space:]]*= ]]; then + result=$(echo "$line" | sed 's/^[^=]*=[[:space:]]*//;s/^"//;s/"$//') + break + fi + fi + done < "$file" + fi + + echo "$result" +} + +# Find project config by workspace number +find_project_for_workspace() { + local ws="$1" + for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + local project_ws + project_ws=$(toml_get "$f" "project" "workspace") + if [[ "$project_ws" == "$ws" ]]; then + basename "$f" .toml + return 0 + fi + done + return 1 +} + +# ── Commands ───────────────────────────────────────────────── + +cmd_open() { + local project="$1" + local no_switch="${2:-false}" + local config="${PROJECTS_DIR}/${project}.toml" + + if [[ ! -f "$config" ]]; then + echo "Error: No config found for project '${project}'" + echo "Expected: ${config}" + echo "" + echo "Available projects:" + cmd_list + exit 1 + fi + + local ws dir url arrangement + ws=$(toml_get "$config" "project" "workspace") + dir=$(toml_get "$config" "project" "directory") + url=$(toml_get "$config" "browser" "url") + arrangement=$(toml_get "$config" "terminal" "arrangement") + dir="${dir/#\~/$HOME}" + + echo "-> Switching to workspace ${ws} (${project})" + + # 1. Switch AeroSpace workspace (unless called from AeroSpace itself) + if [[ "$no_switch" != "true" ]]; then + if command -v aerospace &>/dev/null; then + aerospace workspace "$ws" 2>/dev/null || true + else + echo " [skip] aerospace not found" + fi + fi + + # 2. Restore iTerm2 arrangement (if configured) + if [[ -n "$arrangement" ]]; then + if command -v osascript &>/dev/null; then + osascript "${WORKSPACES_DIR}/lib/iterm.scpt" restore "$arrangement" 2>/dev/null || true + echo " [ok] iTerm2 arrangement '${arrangement}'" + else + echo " [skip] osascript not available" + fi + fi + + # 3. Open browser preview (if configured) + if [[ -n "$url" ]]; then + if command -v open &>/dev/null; then + open "$url" 2>/dev/null || true + echo " [ok] Browser: ${url}" + else + echo " [skip] 'open' command not available" + fi + fi + + # 4. Mark workspace as initialized + mkdir -p "$STATE_DIR" + echo "$project" > "${STATE_DIR}/ws-${ws}" + + echo "=> Workspace '${project}' active on workspace ${ws}" + + # 5. Show workspace note (if any) + local note + note=$(toml_get "$config" "notes" "text") + if [[ -n "$note" ]]; then + echo "" + echo " Note: ${note}" + fi +} + +cmd_close() { + local project="$1" + local config="${PROJECTS_DIR}/${project}.toml" + + if [[ ! -f "$config" ]]; then + echo "Error: No config found for project '${project}'" + exit 1 + fi + + local ws dir + ws=$(toml_get "$config" "project" "workspace") + dir=$(toml_get "$config" "project" "directory") + dir="${dir/#\~/$HOME}" + + echo "-> Closing workspace ${ws} (${project})" + + # Kill dev server processes running in project directory + if [[ -n "$dir" && -d "$dir" ]]; then + # Find processes with cwd in project directory (common dev server ports) + local pids + pids=$(lsof -ti tcp:3000,3001,4000,5000,5173,8000,8080 2>/dev/null || true) + if [[ -n "$pids" ]]; then + for pid in $pids; do + local proc_cwd + proc_cwd=$(lsof -p "$pid" -Fn 2>/dev/null | grep '^n.*'"$dir" || true) + if [[ -n "$proc_cwd" ]]; then + kill "$pid" 2>/dev/null && echo " [ok] Killed process ${pid}" || true + fi + done + fi + fi + + # Close iTerm2 windows for this arrangement + if command -v osascript &>/dev/null; then + local arrangement + arrangement=$(toml_get "$config" "terminal" "arrangement") + if [[ -n "$arrangement" ]]; then + osascript "${WORKSPACES_DIR}/lib/iterm.scpt" close "$arrangement" 2>/dev/null || true + echo " [ok] Closed iTerm2 windows" + fi + fi + + # Clear state + rm -f "${STATE_DIR}/ws-${ws}" + + echo "=> Workspace '${project}' closed" +} + +cmd_list() { + echo "Configured projects:" + for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + local name ws dir + name=$(basename "$f" .toml) + ws=$(toml_get "$f" "project" "workspace") + dir=$(toml_get "$f" "project" "directory") + echo " ${name} (workspace ${ws}) ${dir}" + done +} + +cmd_save() { + local project="$1" + if command -v osascript &>/dev/null; then + osascript "${WORKSPACES_DIR}/lib/iterm.scpt" save "$project" + echo "Saved iTerm2 arrangement '${project}'" + else + echo "Error: osascript not available (macOS only)" + exit 1 + fi +} + +cmd_status() { + local current_ws + if command -v aerospace &>/dev/null; then + current_ws=$(aerospace list-workspaces --focused 2>/dev/null || echo "unknown") + else + current_ws="unknown" + fi + echo "Active workspace: ${current_ws}" + + for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + local name ws + name=$(basename "$f" .toml) + ws=$(toml_get "$f" "project" "workspace") + if [[ "$ws" == "$current_ws" ]]; then + echo "Active project: ${name}" + return + fi + done + echo "Active project: (none mapped)" +} + +cmd_ensure() { + local ws="$1" + local state_file="${STATE_DIR}/ws-${ws}" + + # Already initialized this session — skip + if [[ -f "$state_file" ]]; then + return 0 + fi + + # Find which project owns this workspace + local project + if project=$(find_project_for_workspace "$ws"); then + cmd_open "$project" "true" + fi +} + +cmd_note() { + local project="$1" + local config="${PROJECTS_DIR}/${project}.toml" + + if [[ ! -f "$config" ]]; then + echo "Error: No config found for project '${project}'" + exit 1 + fi + + if [[ $# -lt 2 ]]; then + # View mode: show current note + local note + note=$(toml_get "$config" "notes" "text") + if [[ -n "$note" ]]; then + echo "${project}: ${note}" + else + echo "${project}: (no note set)" + fi + return + fi + + # Set mode: update the note in the TOML file + local new_note="$2" + + if grep -q '^\[notes\]' "$config"; then + # [notes] section exists — update text line + if grep -q '^text' "$config"; then + sed -i.bak "s|^text[[:space:]]*=.*|text = \"${new_note}\"|" "$config" + else + sed -i.bak "/^\[notes\]/a\\ +text = \"${new_note}\"" "$config" + fi + else + # No [notes] section — append it + printf '\n[notes]\ntext = "%s"\n' "$new_note" >> "$config" + fi + rm -f "${config}.bak" + + if [[ -n "$new_note" ]]; then + echo "Note set for ${project}: ${new_note}" + else + echo "Note cleared for ${project}" + fi +} + +cmd_notes() { + local found=false + for f in "${PROJECTS_DIR}"/*.toml; do + [[ -f "$f" ]] || continue + local name note + name=$(basename "$f" .toml) + note=$(toml_get "$f" "notes" "text") + if [[ -n "$note" ]]; then + echo " ${name}: ${note}" + found=true + fi + done + if ! $found; then + echo " (no notes set for any project)" + fi +} + +# ── Main ───────────────────────────────────────────────────── + +NO_SWITCH=false +ARGS=() +for arg in "$@"; do + case "$arg" in + --no-switch) NO_SWITCH=true ;; + *) ARGS+=("$arg") ;; + esac +done +set -- "${ARGS[@]+"${ARGS[@]}"}" + +case "${1:-}" in + open) cmd_open "${2:?project name required}" "$NO_SWITCH" ;; + close) cmd_close "${2:?project name required}" ;; + list) cmd_list ;; + save) cmd_save "${2:?project name required}" ;; + status) cmd_status ;; + note) + if [[ -z "${3+x}" ]]; then + cmd_note "${2:?project name required}" + else + cmd_note "${2:?project name required}" "$3" + fi + ;; + notes) cmd_notes ;; + ensure) cmd_ensure "${2:?workspace number required}" ;; + -h|--help) usage ;; + *) usage ;; +esac diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a679efb --- /dev/null +++ b/install.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Workspace Restoration System — Installer +# Symlinks config files from this repo into ~/.config and ~/bin + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_SRC="${SCRIPT_DIR}/config" + +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${GREEN}[ok]${NC} $1"; } +warn() { echo -e "${YELLOW}[skip]${NC} $1"; } +err() { echo -e "${RED}[error]${NC} $1"; } + +# Create a symlink, backing up existing files +link_file() { + local src="$1" dst="$2" + + if [[ -L "$dst" ]]; then + local existing + existing=$(readlink "$dst") + if [[ "$existing" == "$src" ]]; then + info "Already linked: ${dst}" + return + fi + rm "$dst" + elif [[ -e "$dst" ]]; then + mv "$dst" "${dst}.backup" + warn "Backed up existing: ${dst} -> ${dst}.backup" + fi + + mkdir -p "$(dirname "$dst")" + ln -s "$src" "$dst" + info "Linked: ${dst} -> ${src}" +} + +echo "=== Workspace Restoration System ===" +echo "" + +# ── 1. AeroSpace config ───────────────────────────────────── + +echo "-- AeroSpace --" +link_file "${CONFIG_SRC}/aerospace/aerospace.toml" "${HOME}/.config/aerospace/aerospace.toml" +echo "" + +# ── 2. Workspace configs + scripts ────────────────────────── + +echo "-- Workspaces --" +link_file "${CONFIG_SRC}/workspaces/workspace.sh" "${HOME}/.config/workspaces/workspace.sh" +chmod +x "${HOME}/.config/workspaces/workspace.sh" +link_file "${CONFIG_SRC}/workspaces/lib/iterm.scpt" "${HOME}/.config/workspaces/lib/iterm.scpt" + +# Link project configs (don't overwrite user's existing ones) +mkdir -p "${HOME}/.config/workspaces/projects" +for f in "${CONFIG_SRC}/workspaces/projects"/*.toml; do + [[ -f "$f" ]] || continue + name=$(basename "$f") + dst="${HOME}/.config/workspaces/projects/${name}" + if [[ -e "$dst" && ! -L "$dst" ]]; then + warn "Keeping existing project config: ${dst}" + else + link_file "$f" "$dst" + fi +done +echo "" + +# ── 3. ~/bin/workspace symlink ─────────────────────────────── + +echo "-- CLI --" +mkdir -p "${HOME}/bin" +link_file "${HOME}/.config/workspaces/workspace.sh" "${HOME}/bin/workspace" +chmod +x "${HOME}/bin/workspace" + +# Check if ~/bin is in PATH +if [[ ":${PATH}:" != *":${HOME}/bin:"* ]]; then + warn "~/bin is not in your PATH. Add this to your shell profile:" + echo " export PATH=\"\$HOME/bin:\$PATH\"" +fi +echo "" + +# ── 4. SwiftBar plugin ────────────────────────────────────── + +echo "-- SwiftBar --" +if command -v swiftbar &>/dev/null || [[ -d "/Applications/SwiftBar.app" ]]; then + # SwiftBar expects plugins in its configured directory + SWIFTBAR_DIR="${HOME}/.config/swiftbar" + mkdir -p "$SWIFTBAR_DIR" + link_file "${CONFIG_SRC}/swiftbar/workspaces.5s.sh" "${SWIFTBAR_DIR}/workspaces.5s.sh" + chmod +x "${SWIFTBAR_DIR}/workspaces.5s.sh" +else + warn "SwiftBar not installed. Install with: brew install --cask swiftbar" +fi +echo "" + +# ── 5. Raycast scripts ────────────────────────────────────── + +echo "-- Raycast --" +RAYCAST_DIR="${HOME}/.config/raycast/scripts" +mkdir -p "$RAYCAST_DIR" +for f in "${CONFIG_SRC}/raycast/scripts"/*.sh; do + [[ -f "$f" ]] || continue + name=$(basename "$f") + link_file "$f" "${RAYCAST_DIR}/${name}" + chmod +x "${RAYCAST_DIR}/${name}" +done +echo "" + +# ── 6. State directory ────────────────────────────────────── + +mkdir -p "${HOME}/.config/workspaces/.state" + +# ── Summary ────────────────────────────────────────────────── + +echo "=== Installation complete ===" +echo "" +echo "Next steps:" +echo " 1. Install AeroSpace: brew install --cask nikitabobko/tap/aerospace" +echo " 2. Edit project configs in: ~/.config/workspaces/projects/" +echo " 3. Save iTerm2 arrangements: workspace save " +echo " 4. Switch workspaces: workspace open " +echo " 5. (Optional) Install SwiftBar: brew install --cask swiftbar" +echo "" +echo "Quick test:" +echo " workspace list" +echo " workspace status" diff --git a/workspace-restoration-plan.md b/workspace-restoration-plan.md index eb77ceb..b953f8a 100644 --- a/workspace-restoration-plan.md +++ b/workspace-restoration-plan.md @@ -641,6 +641,50 @@ SwiftBar for at-a-glance status + click switching, Raycast for keyboard-driven f --- +## Per-Workspace Notes + +Each project config supports a `[notes]` section with editable text that displays when you switch to a workspace. + +### Setting notes via CLI + +```bash +# Set a note +workspace note frontend "TODO: fix auth bug before deploy" + +# View a note +workspace note frontend + +# Clear a note +workspace note frontend "" + +# View all project notes +workspace notes +``` + +### How it works + +Notes are stored directly in the project config TOML file under `[notes]`: + +```toml +[notes] +text = "TODO: fix auth bug before deploy" +``` + +When you run `workspace open frontend`, the note is printed: + +``` +-> Switching to workspace 1 (frontend) + [ok] iTerm2 arrangement 'frontend' + [ok] Browser: http://localhost:3000 +=> Workspace 'frontend' active on workspace 1 + + Note: TODO: fix auth bug before deploy +``` + +The SwiftBar menu bar plugin also shows the current workspace's note, so you always have context visible. + +--- + ## Extensions and Future Ideas - **Auto-detect running projects**: Check which `localhost` ports are active and show status From 42b24666dc55476d7fef890e7c0b7fa7d50b71e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 15:38:16 +0000 Subject: [PATCH 5/5] Add UI editing for workspace notes via SwiftBar Click the note in the menu bar dropdown to open a macOS input dialog where you can edit, save, or clear the note. Also shows "Add note..." when no note is set. https://claude.ai/code/session_019NLEfbmYTkMtdnVYuS6UZM --- config/swiftbar/workspaces.5s.sh | 8 +++++--- config/workspaces/lib/edit-note.sh | 30 ++++++++++++++++++++++++++++++ install.sh | 2 ++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100755 config/workspaces/lib/edit-note.sh diff --git a/config/swiftbar/workspaces.5s.sh b/config/swiftbar/workspaces.5s.sh index 8c790e4..ae593f4 100755 --- a/config/swiftbar/workspaces.5s.sh +++ b/config/swiftbar/workspaces.5s.sh @@ -8,8 +8,8 @@ # true PROJECTS_DIR="${HOME}/.config/workspaces/projects" -NOTES_DIR="${HOME}/.config/workspaces/.state" WORKSPACE_BIN="${HOME}/bin/workspace" +EDIT_NOTE_SCRIPT="${HOME}/.config/workspaces/lib/edit-note.sh" # Get current AeroSpace workspace CURRENT_WS=$(aerospace list-workspaces --focused 2>/dev/null || echo "?") @@ -56,9 +56,11 @@ if [[ -n "$CURRENT_PROJECT" ]]; then done < "${PROJECTS_DIR}/${CURRENT_PROJECT}.toml" if [[ -n "$note_text" ]]; then - echo ":note.text: ${note_text} | symbolize=true sfsize=11 color=systemYellow" - echo "---" + echo ":note.text: ${note_text} | symbolize=true sfsize=11 color=systemYellow bash=${EDIT_NOTE_SCRIPT} param1=${CURRENT_PROJECT} terminal=false refresh=true" + else + echo ":note.text: Add note... | symbolize=true sfsize=11 color=systemSecondaryLabel bash=${EDIT_NOTE_SCRIPT} param1=${CURRENT_PROJECT} terminal=false refresh=true" fi + echo "---" fi # ── Project list ───────────────────────────────────────────── diff --git a/config/workspaces/lib/edit-note.sh b/config/workspaces/lib/edit-note.sh new file mode 100755 index 0000000..c414473 --- /dev/null +++ b/config/workspaces/lib/edit-note.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Edit a workspace note via a macOS input dialog +# Called by SwiftBar menu bar plugin +# Usage: edit-note.sh + +set -euo pipefail + +PROJECT="${1:?project name required}" +WORKSPACE_BIN="${HOME}/bin/workspace" + +# Get current note text +current_note=$("$WORKSPACE_BIN" note "$PROJECT" 2>/dev/null | sed "s/^${PROJECT}: //" | sed 's/(no note set)//') + +# Show macOS input dialog +new_note=$(osascript <