diff --git a/.golangci.yml b/.golangci.yml index 2d28c4c..7decf97 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,17 +65,17 @@ linters: settings: gocognit: - # tkach's clippy.toml: cognitive-complexity-threshold = 7. Same - # metric as Sonar's cognitive complexity — penalizes nested control - # flow more heavily than cyclomatic. 7 is strict; offending files - # are path-excluded below (Bubble Tea reducers, MigrateProject, - # syncProject) where the switch-on-state pattern is the design. - min-complexity: 7 + # Cognitive complexity threshold. AGENTS.md sets the policy: + # functions over 10 split on next touch, functions over 15 + # split now. CI fails at the hard 15 line; the 10-line soft + # rule is enforced by reviewer/agent discipline, not gocognit. + min-complexity: 15 gocyclo: - # Cyclomatic complexity (decision-point count) — a secondary signal - # since gocognit is the primary cognitive-complexity check. - min-complexity: 20 + # Cyclomatic complexity (decision-point count) — a secondary + # signal since gocognit is the primary cognitive-complexity + # check. Aligned with the AGENTS.md 15-hard / 10-soft policy. + min-complexity: 15 funlen: # tkach's clippy.toml: too-many-lines-threshold = 200. @@ -193,7 +193,11 @@ linters: - gocyclo - gocognit - funlen - - path: internal/cli/.*tui.*\.go + # Bubble Tea reducer files under cli/. Covers the original + # *_tui.go files plus the model/view splits (migrate_model.go, + # migrate_view.go, bootstrap_model.go, bootstrap_view.go, + # bootstrap_clone.go). + - path: internal/cli/.*(tui|model|view|clone).*\.go linters: - gocyclo - gocognit @@ -214,15 +218,21 @@ linters: - gocyclo - gocognit - funlen - - path: internal/daemon/reconciler\.go + # Daemon reconciler: the original reconciler.go was split into + # reconciler.go + projects.go + toml.go + conflicts.go + git.go. + # The state-machine fan-out lives in projects.go and toml.go now. + - path: internal/daemon/.*\.go linters: - gocyclo - gocognit - funlen - # Worktree add command resolves three input cases (existing - # worktree / existing branch / fresh) plus collision suffix and - # remote upstream wiring; the switch is the spec. - - path: internal/cli/worktree\.go + # Worktree commands resolve three input cases (existing worktree / + # existing branch / fresh) plus collision suffix and remote + # upstream wiring; the switch is the spec. Glob covers both the + # pre-split worktree.go and the per-subcommand splits + # (worktree_add.go, worktree_list.go, worktree_rm.go, + # worktree_push.go). + - path: internal/cli/worktree.*\.go linters: - gocyclo - gocognit diff --git a/AGENTS.md b/AGENTS.md index 493e0ee..dd23918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,539 @@ # AGENTS.md -Instructions for AI agents (Claude Code, Cursor, OpenAI Codex, etc.) working -on this repository. Human developers can ignore everything except the -"Performance Protocol" section if they choose; agents must read all of it. +The single source of project knowledge and agent operating procedure for +this repository. Read top to bottom before changing anything. + +Instructions are for AI agents (Claude Code, Cursor, OpenAI Codex, etc.) +and human developers alike. Sections are layered: project knowledge first +(what the system is and why), then performance protocol, then agent- +specific obligations. + +## High-level goal + +Personal workspace manager for tracking, syncing, and operating on +development projects across multiple machines. The end goal: the same set +of projects, branches, and works-in-progress is available on every machine +the user sits down at, without losing data and without making destructive +operations behind the user's back. + +The user works on the same projects from multiple machines (e.g. an Asahi +laptop and a desktop). They want: + +1. **One registry of projects** that travels between machines via git, so + adding a project on one machine makes it appear on the other. +2. **Bidirectional, safe sync of feature work** so a branch started on + machine A can be picked up on machine B without manual `git push`/`pull` + gymnastics and without merge conflicts in unrelated branches. +3. **No destructive operations** in project repos. The daemon never runs + `merge`, `rebase`, `reset`, or `force` inside a project. The worst it + can do is decline to act and surface a conflict. +4. **Worktree-first layout** so two machines never fight over the same + checked-out branch — each machine has its own per-branch worktree; + `[[projects.X.branches]]` records which machines hold a copy. Branch + names are repo-native (`feat/foo`, `fix/bar`) from the start; the + legacy `wt//` namespace still resolves but is no + longer the default. + +Many design decisions were deliberate trade-offs and are non-obvious. + +## Architecture + +### Source of truth + +- **`workspace.toml`** at the workspace root is the single source of truth + for project registration. It lists projects, their remotes, status, + category, default branch, and per-project sync flags. It is committed to + git and synced between machines via the workspace's own git repo. +- The reconciler ensures `workspace.toml` is mergeable across machines by + installing a `merge=union` driver in `.gitattributes`. Concurrent + additions of different projects from different machines merge cleanly + without manual intervention. + +### On-disk layout (per project) + +After `ws migrate`, every project lives as a sibling triplet under its +category directory: -This file is the agent-facing complement to `CLAUDE.md`. `CLAUDE.md` is -project knowledge (architecture, invariants, conventions). `AGENTS.md` is -agent operating procedure (what to run, what to gate on, when to escalate). +```text +personal/ +├── myapp/ ← main worktree (project.default_branch) +│ └── .git ← file pointing into ../myapp.bare +├── myapp.bare/ ← bare repo, source of truth for git state +└── myapp-wt--/ ← extra per-feature worktrees, optional +``` + +- `/` keeps its original path so `cd personal/myapp` still drops + the user into a working repo. Tooling that doesn't understand worktrees + generally still works because `.git` is a valid pointer file. +- `.bare/` is the only place git objects live. Worktrees share it. +- `-wt--/` is the convention for extra + worktrees created by `ws worktree add`. The directory name has dashes + only (no slashes); slug collisions get a deterministic `-` + suffix from `SHA-1(branch)`. The underlying branch name is whatever + the user typed (e.g. `feat/auth-refactor`). + +### Branch naming convention + +- Branch names are **literal user input.** `ws worktree add + ` accepts the branch name verbatim — no `wt//` + injection, no slug rewrite, no pattern templating. The CLI validates + with `git check-ref-format --branch` and surfaces git's error on + rejection. Type whatever your project convention is from the start + (`feat/auth-refactor`, `fix/prod-1234`, `chore/cleanup`). +- The reconciler **never auto-pushes project branches.** Pushes are + explicit via `ws worktree push ` (which also stamps + `last_active_*` in `workspace.toml`) or plain `git push` from inside + the worktree (which skips the metadata stamp). +- Per-branch ownership lives in `[[projects.X.branches]]` blocks in + `workspace.toml`. `ws worktree add` appends this machine to the + branch's `machines` slice; `ws worktree rm` removes it. When the + slice becomes empty, the entry is GC'd on the next save — there are + no `[[branches]]` orphan tombstones on disk. +- Legacy `wt//` branches still work: `ws worktree add + myapp wt/linux/legacy-foo` attaches to the existing local branch and + registers it in `[[branches]]`. The reconciler ignores branches that + are not in the registry, so unregistered legacy worktrees keep + functioning without any forced migration. +- `` may still contain slashes (`feat/auth-refactor`). Slashes + are preserved in the branch name; the worktree directory uses + `-wt--` with `/` flattened to `-`. + Distinct branches whose slugs collide get a deterministic `-` + suffix from `SHA-1(branch)`. +- `~/.config/ws/config.toml` field `machine_name` still identifies this + machine in `branches..machines` and `last_active_machine`. The + user is prompted to set it on first use. + +### Reconciler (the daemon's brain) + +`internal/daemon/reconciler.go` is a single state-machine that replaces the +old split Syncer/Poller pair. On each tick (immediate at startup, then on +the configured interval, plus on `config_changed` IPC notifications) it +runs the following sequence: + +**Sidecar pre-check (inline guard, runs before any phase).** Before any +work, the reconciler calls `sidecar.AnyActive(wsRoot)`, which checks every +known sidecar kind (`bootstrap`, `migrate`, `add`, `create`) for the +workspace at `~/.local/state/ws//.toml`. If any sidecar exists +and its recorded pid is alive, the entire tick is skipped for that +workspace — both Phase 1 and Phase 2. This prevents the daemon from +pushing half-completed state upstream and from racing the interactive +command on git operations. Other registered workspaces (each with their +own reconciler goroutine) are unaffected. Stale sidecars (pid dead) are +ignored, and the tick proceeds normally. + +1. **Phase 1 — `syncTOML`.** Commits any local changes to `workspace.toml` + under a `ws: auto-sync workspace.toml from ` message, fetches, + handles every combination of `local_dirty`/`local_ahead`/`remote_ahead` + via a fixed decision matrix, falls back to `pull --rebase` (which is + safe thanks to union-merge), and records `toml-merge`/`toml-push-failed` + conflicts when even rebase fails. + +2. **Phase 2 — `reconcileProjects`.** For every active project: + - If neither `.bare` nor `` exist (project registered in + `workspace.toml` but nothing on disk), and `daemon.auto_bootstrap` is + enabled (default `true`) and `auto_sync != false`, attempt + `clone.CloneIntoLayout` non-interactively. Sequential by construction: + one project per tick. Errors map to: + - `ErrNeedsBootstrap` → conflict `needs-bootstrap` (default branch + ambiguous, user must run `ws bootstrap `) + - `ErrPathBlocked` → conflict `path-blocked` + - network/auth → existing per-project exponential backoff + + `clone-failed` conflict + On success, `default_branch` is persisted back into `workspace.toml` + so other machines pick it up via the next Phase 1 sync. + - If `.bare` is missing but `` exists, record a + `needs-migration` conflict and skip. Plain checkouts are never + auto-migrated — the user runs `ws migrate` explicitly. + - `git fetch --all --prune --tags` in the bare. Failure increments a + per-project exponential backoff (base = poll interval, cap = 1h). + - For each worktree returned by `git worktree list`: + - Skip if `index.lock` is present (the user is mid-edit). + - **Main worktree** (the one at `proj.path`): if clean and only + behind, `git pull --ff-only`. Diverged → record `main-divergence` + and leave it. Dirty → silently skip. + - **Sibling worktrees on a registered branch**: if local is ahead + of origin, stamp `last_active_machine = me` / + `last_active_at = now()` on the `[[branches]]` entry. No push. + - **Sibling worktrees on an unregistered branch** (legacy + `wt//*` checkouts that pre-date the redesign): no-op. + The user can re-register via `ws worktree add `. + - For every `[[branches]]` entry whose `last_pushed_at` is set, + check `refs/remotes/origin/` post-fetch. Missing → record + `branch-orphan` (PR-merge auto-delete is the typical cause; user + resolves via `ws sync resolve`). Re-appearance → clears the + conflict on the next tick. Branches with empty `last_pushed_at` + are local-only (created via `ws worktree add`, never pushed) + and are intentionally skipped — origin's missing ref is expected. + - `ws.Validate()` runs after `config.Load` and emits + `branch-duplicate` for any project that has two `[[branches]]` + entries sharing the same `name` (typical race: two machines did + `ws worktree add` on the same branch in the same Phase 1 cycle). + - If anything changed in-memory during the loop (metadata refresh, + orphan clearing), `config.Save` writes the fresh `workspace.toml` + so Phase 1 of the next tick commits and pushes it. + - `auto_sync = false` on a project limits the work to fetch-only. + +**Conflict bookkeeping (inline).** Conflicts surfaced during Phase 2 are +persisted to `~/.local/state/ws/conflicts.json` (XDG-aware) and the user +is notified via `notify-send` (best-effort, silent fallback). The +reconciler also clears stale entries on each tick when their underlying +condition has been resolved. There is no separate "Phase 3" — recording +happens inline as conflicts are detected. + +The reconciler is **idempotent**: missed ticks and duplicate triggers +never break state, because each tick recomputes desired vs actual from +scratch. + +### Migration (`ws migrate`) + +`internal/migrate/migrate.go` converts a plain `git clone` checkout into +the bare+worktree layout in place. It is intentionally **fail-safe rather +than reversible** — there is no `ws unmigrate`, but every step before the +irreversible final swap preserves the original `.git` so the user can +recover by hand. + +Default UX is the **interactive bubbletea TUI** (`internal/cli/migrate_tui.go`): +scan → plan summary → per-project decision for any project that needs one +(`dirty / stash / detached`) → progress → done. CLI flags (`--all`, +`--check`, `--wip`, `--no-tui`) skip the TUI and run the legacy text flow, +which is also what happens when stdout is not a TTY (pipes, CI). + +Pre-flight handling, in order. Each path that doesn't simply abort +creates an extra side branch that becomes part of the bare clone: + +- **Detached HEAD.** Default: abort. Interactive `[c]` (or + non-interactive `Options.CheckoutDefault=true`): if the current commit + is reachable from any local branch, just `checkout default_branch`. If + it's not reachable, first preserve it on a fresh + `wt//migration-detached-` branch so the orphaned commits + survive into the bare clone. +- **Stash entries.** Default: abort (stash refs are not copied by + `clone --bare`, so they would silently disappear). Interactive `[b]` + (or `Options.StashBranch=true`): walk every entry via + `git stash branch wt//migration-stash--N`, commit the + popped state, and return to the original branch. The new branches are + preserved into the bare like any other local branch. +- **Dirty working tree.** Default: abort. Interactive `[w]` (or + `--wip` / `Options.WIP=true`): commit the dirty state to + `wt//migration-wip-`, then check out the original branch + again so the post-migration main worktree matches the user's + expectation. The WIP branch is attached as a sibling worktree after + migration completes. + +Other invariants: + +- **All local branches are preserved into the bare** via `clone --bare + --no-local` plus belt-and-suspenders `git fetch
` for + any branch the clone missed. +- **Hooks are migrated.** Files in `.git/hooks/` that are not `*.sample` + and have an executable bit get copied to `/hooks/`. +- **No upstream tracking is restored.** Bare repos clone with the mirror + refspec `+refs/heads/*:refs/heads/*` and have no `refs/remotes/origin/*` + refs at all, so `branch --set-upstream-to=origin/X` always fails. The + worktree layout doesn't need it: the reconciler only auto-pushes + `wt//*` branches, and ordinary `git pull` in a worktree + resolves its upstream lazily. +- **Worktree attach via --no-checkout + pointer swap.** `git worktree + add --force ` does NOT attach to a directory + that already has files — `--force` only relaxes the + "branch-already-checked-out" and "registered-but-missing" checks, not + the path-existence check. Migrate's working strategy: + 1. Move existing `.git` aside to `.git.migrating-` (recoverable). + 2. `git worktree add --no-checkout
.wt-tmp ` — git + writes the worktree's `.git` pointer file to the tmp dir but no + working-tree files. + 3. `mv
.wt-tmp/.git
/.git` — pointer file lands in the + existing main path, on top of the user's untouched files. + 4. `rm -rf
.wt-tmp` (now empty). + 5. `git worktree repair
` so the bare's `worktrees//gitdir` + points at `
` instead of the tmp location. + 6. Verify HEAD didn't shift. + Any failure between steps 2–5 restores `.git.migrating-` and tears + down the bare. Step 6 is the last point a rollback is feasible. + +`ws migrate --check` reports state without changing anything. `ws migrate +--all` walks every active project, skipping already-migrated ones and +projects that are not cloned on this machine. + +The migration process is coordinated with the daemon via a sidecar at +`~/.local/state/ws/migrate/.toml`. While migrate is running with a +live pid, the reconciler skips its tick entirely for the affected +workspace — both Phase 1 (workspace.toml git sync) and Phase 2 (project +reconcile) — preventing races on git operations and half-migrated state +being pushed upstream. Stale sidecars (crashed run) trigger a resume +prompt on the next `ws migrate` invocation. + +### Conflict store and `ws sync resolve` + +`internal/conflict/conflict.go` owns `~/.local/state/ws/conflicts.json`. +The reconciler is the only writer; `ws sync resolve` is the only reader +that mutates entries. Coordination is via the file alone (atomic write +via tmp+rename); there is no IPC between them. The store deduplicates +on `(workspace, project, branch, kind)` so a recurring condition does +not produce duplicate entries on every tick. + +`ws sync resolve` is a prompt-based CLI (intentionally not a TUI in v1). +It lists conflicts, lets the user open a shell in the affected worktree +or workspace repo, shows `git log local..remote` and `remote..local`, +and clears entries when the user confirms a fix. **It never auto-rebases +or auto-merges anything** — every action that modifies git state is +explicitly the user's choice via the spawned shell. + +## Project statuses + +- `active` — cloned locally, actively developed +- `dormant` — still cloned but no recent activity (detected by daemon) + +## Categories + +- `personal` — user's own repos +- `work` — organization repos + +## Workspace-wide fields (`workspace.toml`) + +The `[agent]` top-level block holds user preferences for `ws agent`. +Synced across machines via `workspace.toml`. Per-machine preferences +would live in `~/.config/ws/config.toml` instead — `[agent]` is +intentionally cross-machine. + +```toml +[agent] +default_view = "favorites" # "all" (default) | "favorites" + # toggled by `space v` in `ws agent` +``` + +## Per-project fields (`workspace.toml`) + +```toml +[projects.myapp] +remote = "git@github.com:user/myapp.git" +path = "personal/myapp" # main worktree, relative to ws root +status = "active" +category = "personal" +default_branch = "main" # determined at migrate time, prompt fallback +auto_sync = true # default true; false = fetch only +favorite = true # pinned to Favorites section of `ws agent` +group = "..." # optional grouping + +# One [[branches]] block per branch this project knows about, populated +# by `ws worktree add`, `ws agent` launch stamps, and `ws worktree +# push` / the reconciler's metadata refresh. Empty-machines entries +# never persist across saves. +# +# `ws agent` activity stamps create a minimal entry for the project's +# default branch the first time the user opens a shell or claude +# session on it — CreatedBy / CreatedAt stay empty in this case +# because the launcher is not a user-driven act of branch creation, +# unlike `ws worktree add`. +[[projects.myapp.branches]] + name = "feat/auth-refactor" + machines = ["linux", "archlinux"] # who currently has a worktree + last_active_machine = "linux" # last to push, commit, or launch + last_active_at = "2026-05-08T12:00:00Z" + last_pushed_machine = "linux" # last to ws worktree push + last_pushed_at = "2026-05-07T16:30:00Z" # absent until first push + created_by = "linux" # original creator (empty for launch-stamped) + created_at = "2026-04-08T13:59:04Z" # original create time (empty for launch-stamped) +``` -## Performance Protocol +Legacy `[[autopush.owned]]` and `autopush.branches []string` from +pre-0.7.0 workspace.toml files are auto-migrated on `config.Load` and +removed on the next `config.Save`. No manual edit is required. + +## Commands + +### Project management + +| Command | Purpose | +|---|---| +| `ws add [remote-url...]` | Register and clone one or more new repos into `workspace.toml`, directly into the bare+worktree layout (no follow-up `ws migrate` needed). Accepts positional URLs, `-` for stdin (one URL per line, `#` comments allowed), and the legacy single-URL invocation. Flags: `-c`/`--category` personal\|work, `-g`/`--group`, `-n`/`--name` (single-URL only), `--no-clone` register-only, `--no-tui` force headless, `--tui` force TUI (Phase 3). Crash-safe via a sidecar at `~/.local/state/ws/add/`; daemon pauses while running. | +| `ws create` | Create a new GitHub repository in any accessible owner (personal account or org via `gh api user/orgs`), then register it in `workspace.toml` and clone it as bare+worktree — same end state as `ws add`. Default: interactive single-screen TUI (owner selector, name, visibility, description, category, group). Repo is always created with `--add-readme` so the default branch + first commit exist before clone runs. Headless mode via `--owner --name [--public] [--description ...]`. Requires `gh auth login`. Crash-safe via a sidecar at `~/.local/state/ws/create/`; daemon pauses while running. | +| `ws bootstrap [name]` | Interactive TUI: clone projects listed in `workspace.toml` that are missing on this machine, directly into the bare+worktree layout. Crash-safe via a sidecar at `~/.local/state/ws/bootstrap/`. While running, the daemon pauses all sync for this workspace. `--dry-run` shows the plan without cloning. | +| `ws migrate [name]` | Convert plain checkouts into the bare+worktree layout. Default: interactive TUI with per-project decisions for `dirty / stash / detached HEAD`. Pass any flag (`--all`, `--check`, `--wip`, `--no-tui`) or run without a TTY to switch to non-interactive mode. Crash-safe via a sidecar at `~/.local/state/ws/migrate/`; daemon pauses while running. See "Migration" above for the worktree-attach strategy. | +| `ws sync` | Run **one reconciler tick** in the foreground (commit/push/pull `workspace.toml`, fetch every bare, ff-pull main worktrees, refresh `last_active_*` for branches with local-ahead commits, detect origin-deleted branches as `branch-orphan`). The reconciler does NOT push project branches — `ws worktree push` is the user-driven path. Same work as a daemon tick. | +| `ws sync resolve` | Inspect and act on unresolved conflicts from `~/.local/state/ws/conflicts.json`. Prompt-based; never auto-merges. | +| `ws status` | Table: PROJECT / GROUP / STATUS / BRANCH / LAST COMMIT / LAYOUT. The LAYOUT column reads `plain`, `worktree`, `worktree+N` (where N is the count of extra worktrees), or `missing`. | +| `ws scan` | Find git repos under `personal/`, `work/`, `playground/`, `researches/`, `tools/` that are not in `workspace.toml`. **Ignores `*.bare/` and `*-wt-*/` siblings** so the worktree layout doesn't show up as orphans. | +| `ws doctor [name] [--fix] [--json] [--skip-remote]` | Run unified health check across system (daemon, stale sidecars, active conflicts, config validity) and per-project state (layout, fetch refspec, remote URL, reachability, default branch, branch upstream, index locks). `--fix` applies all safe auto-fixes in batch; conflicts and index-locks are intentionally never auto-fixed. Exit codes: `0` clean, `1` issues found, `2` --fix applied. | +| `ws favorite add/rm/list ` | Pin / unpin projects to the Favorites section of `ws agent`. Sets `[projects.X].favorite = true` in `workspace.toml`, which syncs across machines. Same toggle is available in the TUI via the `f` hotkey. | + +### Worktree layout + +| Command | Purpose | +|---|---| +| `ws migrate ` | Convert a plain checkout to the bare+worktree layout in place. Verify-before-delete; preserves all local branches and active hooks. | +| `ws migrate --all` | Migrate every active project. Skips already-migrated. | +| `ws migrate --check [name...]` | Preview without changes. Shows state and any blockers (dirty, stash, detached HEAD, hook count). | +| `ws migrate --wip` | Snapshot dirty working tree to a `wt//migration-wip-` branch and attach as a sibling worktree. | +| `ws worktree add [--from ]` | Create or attach a worktree for the literal ``. Auto-detects existing remote (fetches and checks out) and existing local-only branches (attaches; covers legacy `wt//*` re-registration). Records this machine in `[[branches]].machines` and stamps `last_active_*`. Slug collisions get `-` deterministic suffix. | +| `ws worktree list [project]` | Table: PROJECT / WORKTREE / BRANCH / STATE. STATE includes clean/dirty, ahead/behind, ownership (`main`, `mine`, `shared with `, `remote`, `legacy-wt`), and `last: ` from the registry. | +| `ws worktree rm [--force]` | Remove a worktree and release this machine from `[[branches]].machines`. Refuses dirty or unpushed unless `--force`. Empty `machines` causes the entry to be GC'd on save. | +| `ws worktree push [--force-dirty]` | Push the branch to origin via `git push -u origin ` and stamp `last_pushed_*` (and bump `last_active_*`) in `workspace.toml`. Refuses dirty without `--force-dirty`; refuses branches missing from `[[branches]]` (sign of out-of-band creation). | +| `ws wt …` | Alias for `ws worktree`. | + +### Aliases + +| Command | Purpose | +|---|---| +| `ws alias list` | Show configured shell aliases. | +| `ws alias add ` | Add an alias. | +| `ws alias rm ` | Remove an alias. | +| `ws alias init [shell]` | Generate alias init code for the user's shell (zsh). | +| `ws alias install` | Install the hook into `~/.zshrc`. | + +### Daemon + +| Command | Purpose | +|---|---| +| `ws daemon run` | Foreground (used by `start` after fork). | +| `ws daemon start` | Background-spawn the daemon. | +| `ws daemon stop` | Stop the daemon. | +| `ws daemon restart` | Stop + start. | +| `ws daemon status` | PID + running state. | +| `ws daemon register [path]` | Add a workspace to the daemon's config so it gets reconciled on every tick. | +| `ws daemon unregister [path]` | Remove a workspace from the daemon config. | +| `ws daemon install-service` | Install systemd unit for the daemon. | + +### GitHub auth (for repo discovery) + +| Command | Purpose | +|---|---| +| `ws auth login` | Device flow or PAT. | +| `ws auth logout` | Remove the stored token. | +| `ws auth status` | Token status. | + +### Setup + +| Command | Purpose | +|---|---| +| `ws setup` | Interactive bootstrap of a new workspace directory. | + +## Files the CLI relies on + +- `/workspace.toml` — project registry, single source of truth. +- `/.gitattributes` — `workspace.toml merge=union` (created by reconciler). +- `~/.config/ws/config.toml` — `machine_name` for branch namespacing. +- `~/.config/ws/daemon.toml` — list of workspaces watched by the daemon plus socket path. +- `~/.config/ws/daemon.{sock,pid,log}` — daemon runtime files. +- `~/.local/state/ws/conflicts.json` — unresolved sync conflicts. Honors `$XDG_STATE_HOME`. +- `~/.local/state/ws/bootstrap/.toml` — per-workspace bootstrap progress sidecar. Created by `ws bootstrap`, deleted on success. While present with a live pid, the daemon skips its tick for that workspace. Honors `$XDG_STATE_HOME`. +- `~/.local/state/ws/migrate/.toml` — per-workspace migrate progress sidecar. Created by `ws migrate`, deleted on success. Same daemon-skip semantics. Honors `$XDG_STATE_HOME`. All four sidecar kinds (`bootstrap`, `migrate`, `add`, `create`) share `internal/sidecar` which centralizes file/lock/pid mechanics; command-specific value types live in their own packages and round-trip through `json.RawMessage`. +- `~/.local/state/ws/add/.toml` — per-workspace `ws add` session sidecar. Created when `ws add` starts (any mode), deleted on success/error/panic via `defer`. While present with a live pid, the daemon skips its tick for that workspace and a second `ws add` invocation refuses with an "is running" error. Honors `$XDG_STATE_HOME`. +- `~/.local/state/ws/create/.toml` — per-workspace `ws create` session sidecar. Same lifecycle and contract as the `add` sidecar; the kind name differs so concurrent `ws add` and `ws create` runs do not stomp each other. Honors `$XDG_STATE_HOME`. + +## Conventions + +- All paths in `workspace.toml` are relative to the workspace root. +- Scripts and reconciler logic must be idempotent — safe to re-run. +- No secrets in this repo. +- `workspace.toml` is the only file that changes during normal operation + (plus `.gitattributes` once, on the reconciler's first run). +- The daemon **never** runs `merge`, `rebase`, `reset`, `force`, **or + `push`** inside a project repo. The worst it does is record a + conflict and stop. Project pushes are user-driven via + `ws worktree push` or plain `git push`. +- Do **not** hand-edit `[[projects.X.branches]]` blocks unless you are + reconciling a `branch-duplicate` conflict. The CLI helpers + (`ClaimBranch` / `ReleaseBranch` / `TouchActive` / `StampActivity`) + are the only sanctioned writers; manual edits race against the + reconciler's metadata refresh. + +## Commits + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` — new feature (bumps minor pre-1.0, would be minor post-1.0) +- `fix:` — bug fix (bumps patch) +- `feat!:` or `fix!:` with `BREAKING CHANGE:` footer — breaking change + (bumps minor pre-1.0, major post-1.0) +- `chore:`, `docs:`, `refactor:`, `test:`, `ci:`, `style:`, `perf:` — no release + +Scope is optional: `feat(alias): ...`, `fix(sync): ...`. + +Never add `Co-Authored-By` or attribution footers. + +## Release process + +Automated via [release-please](https://github.com/googleapis/release-please). + +**Flow:** conventional commits land on `main` → release-please opens/updates +a Release PR with bumped version + CHANGELOG → merge the PR → tag `vX.Y.Z` +is pushed → existing `release.yml` builds binaries and publishes the GitHub +Release. + +**Do NOT** manually edit `CHANGELOG.md`, bump versions, or create `vX.Y.Z` +tags by hand — release-please owns all of it. + +## Tests + +The project uses **real git in temp dirs** rather than mocks. Every test +spins up its own ephemeral git repos under `t.TempDir()` and runs real +`git` commands. This catches the kinds of bugs (the +`git worktree add --force` regression that motivated the migrate rewrite, +for example) that mock-based tests would happily lie about. + +`internal/testutil/gitfixture.go` provides the shared helpers: + +- `InitFakeRemote(t, name, defaultBranch) string` — creates a bare repo + with a seed commit; usable as `proj.Remote` for clone/bootstrap tests. +- `InitFakePlainCheckout(t, parent, name, branches) string` — creates a + non-bare git repo with N branches, each carrying one unique commit. + Used as the input for migrate tests. +- `RunGit(t, dir, args...)` / `RunGitTry` — wraps `exec.Command("git", ...)` + with a deterministic env (no global config, no GPG, fixed identity). +- `AddDirty`, `AddStash` — push the working tree into the dirty/stash + states needed by migrate's pre-flight tests. + +Test files live next to the code they cover, in `_test` packages: + +- `internal/clone/clone_test.go` — happy path, ErrAlreadyCloned, + ErrNeedsMigration, ErrPathBlocked, default_branch resolution. +- `internal/migrate/migrate_test.go` — happy path **(regression test for + the worktree-attach bug)**, dirty + WIP, stash + branch conversion, + detached HEAD with and without orphan preservation, ErrAlreadyMigrated. +- `internal/bootstrap/bootstrap_test.go` — `ScanPlan` classification of + every project state, only-filter restriction. +- `internal/sidecar/sidecar_test.go` — Save/Load round-trip, + Delete-is-idempotent, IsAlive with self/dead/zero pids, AnyActive + finds either kind, AnyActive ignores stale entries. +- `internal/doctor/*_test.go` — per-check tests for the `ws doctor` + catalog: happy-path runner, stale-sidecar auto-fix, conflict scoping, + config validation, fetch-refspec/remote-URL/default-branch/branch- + upstream fixes on real bare+worktree fixtures, index-lock detection. +- `internal/agent/header_test.go` — sort/cap/tie-breaking for the + Favorites + Recent shortcut header above the workspace tree. +- `internal/agent/stamp_test.go` — `StampLaunchFromPath` smoke tests + (default branch entry creation, subpath resolution, no-op outside + any workspace, idempotent re-stamp). + +Run everything: `go test ./...`. CI runs `go test -race -timeout 5m ./...` +on every push to main and on every PR via `.github/workflows/test.yml`. + +When adding new git-touching code: write a real-git test for it. The +testutil helpers cover ~95% of fixture needs; extend them rather than +inlining `exec.Command` in tests. + +## Known follow-ups (not yet implemented) + +These were deliberately deferred during the worktree refactor and are open +for future work: + +- **`ws worktree gc`** to clean up old WIP branches and orphaned worktrees. +- **fsnotify on `workspace.toml`** to remove the dependency on IPC + notifications from CLI commands. +- **Real TUI for `ws sync resolve`** instead of the prompt-based v1. +- **Per-machine `default_branch` override** for the rare case of different + default branches across machines for the same project. + +--- + +# Performance Protocol This project ships a tiered benchmark protocol designed to keep the binary fast (cold-start) and resource-efficient (memory, allocations) without CI infrastructure. The agent is the gate. -### Three tiers +## Three tiers - **L1 — microbenchmarks** (`just bench-l1`) - Per-package, `go test -bench` on the hot paths. @@ -33,7 +552,7 @@ infrastructure. The agent is the gate. Captures cold/warm wall, peak RSS, binary size, init() trace. - Wall: ~10-15min. Trend-only — never gates. -### Priority axis: CLI cold-start +## Priority axis: CLI cold-start This protocol was designed with CLI cold-start as the optimization priority. Every `ws ` invocation pays init() + config.Load + cobra @@ -47,7 +566,7 @@ toward functions on the cold-start path: Allocation rate matters more than raw CPU here — GC pauses inflate p99 of a 100ms invocation visibly. -### Per-machine baselines +## Per-machine baselines Without CI, each developer machine has its own baseline. Layout: @@ -63,7 +582,7 @@ bench/ GATE_ACTIVATION ← timestamp; hard gate engages 14d later ``` -### Gate activation lifecycle +## Gate activation lifecycle ```text day 0: soft mode (no gating) @@ -74,11 +593,13 @@ day 14: hard mode — gate exits non-zero on regress Soft → hard transition is automatic based on the file timestamp. There is no "switch to hard" command — only the activation point. -## Agent obligations +--- + +# Agent obligations Read top to bottom; follow in order. -### Before opening a PR (mandatory) +## Before opening a PR (mandatory) 1. **Run `just bench-pr-gate`.** Always. Even for "trivial" changes — TOML tweaks have surprised us before. The gate runs L1, compares against the @@ -99,7 +620,7 @@ Read top to bottom; follow in order. `## Performance` section with `bench-skip: ` and a follow-up issue link or TODO. Skipping silently is a forbidden action. -### After merging a perf-relevant PR (mandatory) +## After merging a perf-relevant PR (mandatory) If the PR was specifically about performance — optimization, refactor of a hot path, dependency bump that touches reconciler or config — refresh @@ -116,7 +637,7 @@ git push If the PR was not perf-relevant, do nothing — baselines are sticky on purpose. -### When adding new code on a hot path +## When adding new code on a hot path If the new code lives in any of these packages, add at least one microbenchmark in a `*_bench_test.go` file: @@ -132,13 +653,13 @@ Bench naming convention: `BenchmarkFooSmall` / `FooMedium` / `FooLarge` when the input scales meaningfully (e.g. workspace size). Otherwise just `BenchmarkFoo`. -### When NOT to add a benchmark +## When NOT to add a benchmark - TUI render code (bubbletea Update loops) — human-timescale, irrelevant - One-shot interactive commands (setup, auth) — not on hot paths - Test-only helpers — defeats the point -### Threshold violations — diagnostic playbook +## Threshold violations — diagnostic playbook When `bench-pr-gate` reports a regression: @@ -154,7 +675,7 @@ When `bench-pr-gate` reports a regression: - new validation rule with O(N²) scan instead of map lookup - regex compiled inside loop instead of `var pattern = regexp.MustCompile` -### When in doubt +## When in doubt Do not invent. Ask the human: - "Is this regression intentional?" — when the change is functional and @@ -164,6 +685,94 @@ Do not invent. Ask the human: - "Activate gate now?" — when the project hasn't activated yet but the PR is foundational enough that establishing a baseline matters. +## Mechanical rules — apply proactively, no permission needed + +### 1. No big files + +A `.go` file beyond ~500 lines is a signal to split. Hard thresholds: + +- **> 500 lines**: extract on the next touch — find the cohesive + cluster inside that wants its own file/package and pull it out. +- **> 800 lines**: extract *now*, before adding the change that + brought you here. Do not append to a file already that large. + +Tests count, but the `_test.go` sibling is one unit — split the +production file first; tests usually follow naturally. + +### 2. No decorative section separators + +Never write `// ─── X ───`, `// --- X ---`, `// === X ===`, or any +other in-file visual delimiter to break up a single `.go` file. If +you reach for one, the chunk underneath is asking to be its own +file or package. Extract instead. + +Not this rule: godoc headings on exported symbols, `// Package foo` +comments, and license headers at file top. + +### 3. Function complexity + +Cyclomatic complexity (gocyclo) thresholds for production `.go`: + +- **> 15**: extract *now* — pull state branches into their own + dispatch (one handler per case, a table, or a sub-state file). + Do this before adding the change that brought you here. +- **> 10**: extract on the next touch — when you edit a function + already over 10, split before adding more branches. + +Bubbletea `Update` and cobra builders are naturally branchy; the rule +trusts the writer to recognize when the switch is masking a real +sub-machine that deserves its own file. Check with: + +``` +go run github.com/fzipp/gocyclo/cmd/gocyclo@latest -over 10 -ignore '_test\.go' . +``` + +### 4. Comments are a last resort + +Default to no comment. Before adding one, try a clearer name or a +small extraction so the code carries the meaning on its own. A +comment is justified when it captures a non-obvious *why* the reader +cannot derive from the code (workaround for a specific bug, invariant +maintained off-screen, deliberate inefficiency, external protocol +nuance). + +Not justified: "// init defaults", "// loop over projects", "// +returns the count", paraphrasing an obvious branch, or marking +sections with `// ── header ──` (see rule 2). + +## Architectural changes — ask first + +The agent does not decide module boundaries, new abstractions, +provider/transport contracts, or any cross-cutting structural change +on its own. For these: + +1. State the proposed shape (ASCII diagram, file list, dependency + direction, blast radius, what stays the same). +2. List one or two rejected alternatives with why. +3. Wait for human approval before writing code. + +The reason is signal, not capability. The human grows this system +over time and needs to know what's happening at the architecture +level to keep later decisions consistent. The agent implementing an +architectural choice without surfacing it short-circuits that loop. + +**Counter-examples (agent does NOT ask first):** +- Splitting a 800-line file along an obviously cohesive seam + (mechanical rule 1) — just do it. +- Pulling a `// ─── helpers ───` chunk into its own `helpers.go` + (mechanical rule 2) — just do it. +- Renaming a local variable for clarity, deleting dead branches, + inlining a single-use helper — just do it. + +**Examples (agent asks first):** +- Introducing a new interface or abstraction layer. +- Moving a cluster of files under a new parent package. +- Changing the daemon ↔ CLI IPC contract or socket shape. +- Reconciler phase semantics (what each phase owns, what it touches). +- Sidecar protocol additions (new kinds, new fields, lifecycle changes). +- New feature flags or build-time toggles. +- Anything the agent would label "architecture" in a PR title. + ## Other agent conventions - Use `ws` for workspace operations: `ws status`, `ws sync`, @@ -182,4 +791,3 @@ Do not invent. Ask the human: - PRs: open as **draft** by default (`gh pr create --draft`). Only the human flips to ready. - No `Co-Authored-By` footers in commits. -- See `CLAUDE.md` for the full project conventions. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6766d27..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,490 +0,0 @@ -# Workspace - -Personal workspace manager for tracking, syncing and operating on development -projects across multiple machines. The end goal is that the same set of -projects, branches, and even works-in-progress is available on every machine -the user sits down at, without losing data and without making destructive -operations behind the user's back. - -## High-level goal - -The user works on the same projects from multiple machines (e.g. an Asahi -laptop and a desktop). They want: - -1. **One registry of projects** that travels between machines via git, so - adding a project on one machine makes it appear on the other. -2. **Bidirectional, safe sync of feature work** so a branch started on - machine A can be picked up on machine B without manual `git push`/`pull` - gymnastics and without merge conflicts in unrelated branches. -3. **No destructive operations** in project repos. The daemon never runs - `merge`, `rebase`, `reset`, or `force` inside a project. The worst it - can do is decline to act and surface a conflict. -4. **Worktree-first layout** so two machines never fight over the same - checked-out branch — each machine has its own per-branch worktree; - `[[projects.X.branches]]` records which machines hold a copy. Branch - names are repo-native (`feat/foo`, `fix/bar`) from the start; the - legacy `wt//` namespace still resolves but is no - longer the default. - -If you are an agent picking this up: read this whole file before changing -anything. Many design decisions were deliberate trade-offs and are non-obvious. - -## Architecture - -### Source of truth - -- **`workspace.toml`** at the workspace root is the single source of truth - for project registration. It lists projects, their remotes, status, - category, default branch, and per-project sync flags. It is committed to - git and synced between machines via the workspace's own git repo. -- The reconciler ensures `workspace.toml` is mergeable across machines by - installing a `merge=union` driver in `.gitattributes`. Concurrent - additions of different projects from different machines merge cleanly - without manual intervention. - -### On-disk layout (per project) - -After `ws migrate`, every project lives as a sibling triplet under its -category directory: - -``` -personal/ -├── myapp/ ← main worktree (project.default_branch) -│ └── .git ← file pointing into ../myapp.bare -├── myapp.bare/ ← bare repo, source of truth for git state -└── myapp-wt--/ ← extra per-feature worktrees, optional -``` - -- `/` keeps its original path so `cd personal/myapp` still drops - the user into a working repo. Tooling that doesn't understand worktrees - generally still works because `.git` is a valid pointer file. -- `.bare/` is the only place git objects live. Worktrees share it. -- `-wt--/` is the convention for extra - worktrees created by `ws worktree add`. The directory name has dashes - only (no slashes); slug collisions get a deterministic `-` - suffix from `SHA-1(branch)`. The underlying branch name is whatever - the user typed (e.g. `feat/auth-refactor`). - -### Branch naming convention - -- Branch names are **literal user input.** `ws worktree add - ` accepts the branch name verbatim — no `wt//` - injection, no slug rewrite, no pattern templating. The CLI validates - with `git check-ref-format --branch` and surfaces git's error on - rejection. Type whatever your project convention is from the start - (`feat/auth-refactor`, `fix/prod-1234`, `chore/cleanup`). -- The reconciler **never auto-pushes project branches.** Pushes are - explicit via `ws worktree push ` (which also stamps - `last_active_*` in `workspace.toml`) or plain `git push` from inside - the worktree (which skips the metadata stamp). -- Per-branch ownership lives in `[[projects.X.branches]]` blocks in - `workspace.toml`. `ws worktree add` appends this machine to the - branch's `machines` slice; `ws worktree rm` removes it. When the - slice becomes empty, the entry is GC'd on the next save — there are - no `[[branches]]` orphan tombstones on disk. -- Legacy `wt//` branches still work: `ws worktree add - myapp wt/linux/legacy-foo` attaches to the existing local branch and - registers it in `[[branches]]`. The reconciler ignores branches that - are not in the registry, so unregistered legacy worktrees keep - functioning without any forced migration. -- `` may still contain slashes (`feat/auth-refactor`). Slashes - are preserved in the branch name; the worktree directory uses - `-wt--` with `/` flattened to `-`. - Distinct branches whose slugs collide get a deterministic `-` - suffix from `SHA-1(branch)`. -- `~/.config/ws/config.toml` field `machine_name` still identifies this - machine in `branches..machines` and `last_active_machine`. The - user is prompted to set it on first use. - -### Reconciler (the daemon's brain) - -`internal/daemon/reconciler.go` is a single state-machine that replaces the -old split Syncer/Poller pair. On each tick (immediate at startup, then on -the configured interval, plus on `config_changed` IPC notifications) it: - -0. **Sidecar pre-check.** Before any work, the reconciler calls - `sidecar.AnyActive(wsRoot)`, which checks every known sidecar kind - (`bootstrap`, `migrate`, `add`) for the workspace at - `~/.local/state/ws//.toml`. If any sidecar exists and its - recorded pid is alive, the entire tick is skipped for that workspace — - both Phase 1 and Phase 2. This prevents the daemon from pushing - half-completed state upstream and from racing the interactive command - on git operations. Other registered workspaces (each with their own - reconciler goroutine) are unaffected. Stale sidecars (pid dead) are - ignored, and the tick proceeds normally. - -1. **Phase 1 — `syncTOML`.** Commits any local changes to `workspace.toml` - under a `ws: auto-sync workspace.toml from ` message, fetches, - handles every combination of `local_dirty`/`local_ahead`/`remote_ahead` - via a fixed decision matrix, falls back to `pull --rebase` (which is - safe thanks to union-merge), and records `toml-merge`/`toml-push-failed` - conflicts when even rebase fails. - -2. **Phase 2 — `reconcileProjects`.** For every active project: - - If neither `.bare` nor `` exist (project registered in - `workspace.toml` but nothing on disk), and `daemon.auto_bootstrap` is - enabled (default `true`) and `auto_sync != false`, attempt - `clone.CloneIntoLayout` non-interactively. Sequential by construction: - one project per tick. Errors map to: - - `ErrNeedsBootstrap` → conflict `needs-bootstrap` (default branch - ambiguous, user must run `ws bootstrap `) - - `ErrPathBlocked` → conflict `path-blocked` - - network/auth → existing per-project exponential backoff + - `clone-failed` conflict - On success, `default_branch` is persisted back into `workspace.toml` - so other machines pick it up via the next Phase 1 sync. - - If `.bare` is missing but `` exists, record a - `needs-migration` conflict and skip. Plain checkouts are never - auto-migrated — the user runs `ws migrate` explicitly. - - `git fetch --all --prune --tags` in the bare. Failure increments a - per-project exponential backoff (base = poll interval, cap = 1h). - - For each worktree returned by `git worktree list`: - - Skip if `index.lock` is present (the user is mid-edit). - - **Main worktree** (the one at `proj.path`): if clean and only - behind, `git pull --ff-only`. Diverged → record `main-divergence` - and leave it. Dirty → silently skip. - - **Sibling worktrees on a registered branch**: if local is ahead - of origin, stamp `last_active_machine = me` / - `last_active_at = now()` on the `[[branches]]` entry. No push. - - **Sibling worktrees on an unregistered branch** (legacy - `wt//*` checkouts that pre-date the redesign): no-op. - The user can re-register via `ws worktree add `. - - For every `[[branches]]` entry whose `last_pushed_at` is set, - check `refs/remotes/origin/` post-fetch. Missing → record - `branch-orphan` (PR-merge auto-delete is the typical cause; user - resolves via `ws sync resolve`). Re-appearance → clears the - conflict on the next tick. Branches with empty `last_pushed_at` - are local-only (created via `ws worktree add`, never pushed) - and are intentionally skipped — origin's missing ref is expected. - - `ws.Validate()` runs after `config.Load` and emits - `branch-duplicate` for any project that has two `[[branches]]` - entries sharing the same `name` (typical race: two machines did - `ws worktree add` on the same branch in the same Phase 1 cycle). - - If anything changed in-memory during the loop (metadata refresh, - orphan clearing), `config.Save` writes the fresh `workspace.toml` - so Phase 1 of the next tick commits and pushes it. - - `auto_sync = false` on a project limits the work to fetch-only. - -3. **Phase 3 — conflict bookkeeping.** New conflicts are persisted to - `~/.local/state/ws/conflicts.json` (XDG-aware) and surfaced via - `notify-send` (best-effort, silent fallback). The reconciler also - clears stale entries on each tick when their underlying condition - has been resolved. - -The reconciler is **idempotent**: missed ticks and duplicate triggers -never break state, because each tick recomputes desired vs actual from -scratch. - -### Migration (`ws migrate`) - -`internal/migrate/migrate.go` converts a plain `git clone` checkout into -the bare+worktree layout in place. It is intentionally **fail-safe rather -than reversible** — there is no `ws unmigrate`, but every step before the -irreversible final swap preserves the original `.git` so the user can -recover by hand. - -Default UX is the **interactive bubbletea TUI** (`internal/cli/migrate_tui.go`): -scan → plan summary → per-project decision for any project that needs one -(`dirty / stash / detached`) → progress → done. CLI flags (`--all`, -`--check`, `--wip`, `--no-tui`) skip the TUI and run the legacy text flow, -which is also what happens when stdout is not a TTY (pipes, CI). - -Pre-flight handling, in order. Each path that doesn't simply abort -creates an extra side branch that becomes part of the bare clone: - -- **Detached HEAD.** Default: abort. Interactive `[c]` (or - non-interactive `Options.CheckoutDefault=true`): if the current commit - is reachable from any local branch, just `checkout default_branch`. If - it's not reachable, first preserve it on a fresh - `wt//migration-detached-` branch so the orphaned commits - survive into the bare clone. -- **Stash entries.** Default: abort (stash refs are not copied by - `clone --bare`, so they would silently disappear). Interactive `[b]` - (or `Options.StashBranch=true`): walk every entry via - `git stash branch wt//migration-stash--N`, commit the - popped state, and return to the original branch. The new branches are - preserved into the bare like any other local branch. -- **Dirty working tree.** Default: abort. Interactive `[w]` (or - `--wip` / `Options.WIP=true`): commit the dirty state to - `wt//migration-wip-`, then check out the original branch - again so the post-migration main worktree matches the user's - expectation. The WIP branch is attached as a sibling worktree after - migration completes. - -Other invariants: - -- **All local branches are preserved into the bare** via `clone --bare - --no-local` plus belt-and-suspenders `git fetch
` for - any branch the clone missed. -- **Hooks are migrated.** Files in `.git/hooks/` that are not `*.sample` - and have an executable bit get copied to `/hooks/`. -- **No upstream tracking is restored.** Bare repos clone with the mirror - refspec `+refs/heads/*:refs/heads/*` and have no `refs/remotes/origin/*` - refs at all, so `branch --set-upstream-to=origin/X` always fails. The - worktree layout doesn't need it: the reconciler only auto-pushes - `wt//*` branches, and ordinary `git pull` in a worktree - resolves its upstream lazily. -- **Worktree attach via --no-checkout + pointer swap.** `git worktree - add --force ` does NOT attach to a directory - that already has files — `--force` only relaxes the - "branch-already-checked-out" and "registered-but-missing" checks, not - the path-existence check. Migrate's working strategy: - 1. Move existing `.git` aside to `.git.migrating-` (recoverable). - 2. `git worktree add --no-checkout
.wt-tmp ` — git - writes the worktree's `.git` pointer file to the tmp dir but no - working-tree files. - 3. `mv
.wt-tmp/.git
/.git` — pointer file lands in the - existing main path, on top of the user's untouched files. - 4. `rm -rf
.wt-tmp` (now empty). - 5. `git worktree repair
` so the bare's `worktrees//gitdir` - points at `
` instead of the tmp location. - 6. Verify HEAD didn't shift. - Any failure between steps 2–5 restores `.git.migrating-` and tears - down the bare. Step 6 is the last point a rollback is feasible. - -`ws migrate --check` reports state without changing anything. `ws migrate ---all` walks every active project, skipping already-migrated ones and -projects that are not cloned on this machine. - -The migration process is coordinated with the daemon via a sidecar at -`~/.local/state/ws/migrate/.toml`. While migrate is running with a -live pid, the reconciler skips its tick entirely for the affected -workspace — both Phase 1 (workspace.toml git sync) and Phase 2 (project -reconcile) — preventing races on git operations and half-migrated state -being pushed upstream. Stale sidecars (crashed run) trigger a resume -prompt on the next `ws migrate` invocation. - -### Conflict store and `ws sync resolve` - -`internal/conflict/conflict.go` owns `~/.local/state/ws/conflicts.json`. -The reconciler is the only writer; `ws sync resolve` is the only reader -that mutates entries. Coordination is via the file alone (atomic write -via tmp+rename); there is no IPC between them. The store deduplicates -on `(workspace, project, branch, kind)` so a recurring condition does -not produce duplicate entries on every tick. - -`ws sync resolve` is a prompt-based CLI (intentionally not a TUI in v1). -It lists conflicts, lets the user open a shell in the affected worktree -or workspace repo, shows `git log local..remote` and `remote..local`, -and clears entries when the user confirms a fix. **It never auto-rebases -or auto-merges anything** — every action that modifies git state is -explicitly the user's choice via the spawned shell. - -## Project statuses - -- `active` — cloned locally, actively developed -- `dormant` — still cloned but no recent activity (detected by daemon) - -## Categories - -- `personal` — user's own repos -- `work` — organization repos - -## Per-project fields (`workspace.toml`) - -```toml -[projects.myapp] -remote = "git@github.com:user/myapp.git" -path = "personal/myapp" # main worktree, relative to ws root -status = "active" -category = "personal" -default_branch = "main" # determined at migrate time, prompt fallback -auto_sync = true # default true; false = fetch only -group = "..." # optional grouping - -# One [[branches]] block per branch this project knows about, populated -# by `ws worktree add` and updated by `ws worktree push` / the reconciler's -# metadata refresh. Empty-machines entries never persist across saves. -[[projects.myapp.branches]] - name = "feat/auth-refactor" - machines = ["linux", "archlinux"] # who currently has a worktree - last_active_machine = "linux" # last to push or commit - last_active_at = "2026-05-08T12:00:00Z" - last_pushed_machine = "linux" # last to ws worktree push - last_pushed_at = "2026-05-07T16:30:00Z" # absent until first push - created_by = "linux" # original creator - created_at = "2026-04-08T13:59:04Z" -``` - -Legacy `[[autopush.owned]]` and `autopush.branches []string` from -pre-0.7.0 workspace.toml files are auto-migrated on `config.Load` and -removed on the next `config.Save`. No manual edit is required. - -## Commands - -### Project management - -| Command | Purpose | -|---|---| -| `ws add [remote-url...]` | Register and clone one or more new repos into `workspace.toml`, directly into the bare+worktree layout (no follow-up `ws migrate` needed). Accepts positional URLs, `-` for stdin (one URL per line, `#` comments allowed), and the legacy single-URL invocation. Flags: `-c`/`--category` personal\|work, `-g`/`--group`, `-n`/`--name` (single-URL only), `--no-clone` register-only, `--no-tui` force headless, `--tui` force TUI (Phase 3). Crash-safe via a sidecar at `~/.local/state/ws/add/`; daemon pauses while running. | -| `ws create` | Create a new GitHub repository in any accessible owner (personal account or org via `gh api user/orgs`), then register it in `workspace.toml` and clone it as bare+worktree — same end state as `ws add`. Default: interactive single-screen TUI (owner selector, name, visibility, description, category, group). Repo is always created with `--add-readme` so the default branch + first commit exist before clone runs. Headless mode via `--owner --name [--public] [--description ...]`. Requires `gh auth login`. Crash-safe via a sidecar at `~/.local/state/ws/create/`; daemon pauses while running. | -| `ws bootstrap [name]` | Interactive TUI: clone projects listed in `workspace.toml` that are missing on this machine, directly into the bare+worktree layout. Crash-safe via a sidecar at `~/.local/state/ws/bootstrap/`. While running, the daemon pauses all sync for this workspace. `--dry-run` shows the plan without cloning. | -| `ws migrate [name]` | Convert plain checkouts into the bare+worktree layout. Default: interactive TUI with per-project decisions for `dirty / stash / detached HEAD`. Pass any flag (`--all`, `--check`, `--wip`, `--no-tui`) or run without a TTY to switch to non-interactive mode. Crash-safe via a sidecar at `~/.local/state/ws/migrate/`; daemon pauses while running. See "Migration" below for the worktree-attach strategy. | -| `ws sync` | Run **one reconciler tick** in the foreground (commit/push/pull `workspace.toml`, fetch every bare, ff-pull main worktrees, refresh `last_active_*` for branches with local-ahead commits, detect origin-deleted branches as `branch-orphan`). The reconciler does NOT push project branches — `ws worktree push` is the user-driven path. Same work as a daemon tick. | -| `ws sync resolve` | Inspect and act on unresolved conflicts from `~/.local/state/ws/conflicts.json`. Prompt-based; never auto-merges. | -| `ws status` | Table: PROJECT / GROUP / STATUS / BRANCH / LAST COMMIT / LAYOUT. The LAYOUT column reads `plain`, `worktree`, `worktree+N` (where N is the count of extra worktrees), or `missing`. | -| `ws scan` | Find git repos under `personal/`, `work/`, `playground/`, `researches/`, `tools/` that are not in `workspace.toml`. **Ignores `*.bare/` and `*-wt-*/` siblings** so the worktree layout doesn't show up as orphans. | -| `ws doctor [name] [--fix] [--json] [--skip-remote]` | Run unified health check across system (daemon, stale sidecars, active conflicts, config validity) and per-project state (layout, fetch refspec, remote URL, reachability, default branch, branch upstream, index locks). `--fix` applies all safe auto-fixes in batch; conflicts and index-locks are intentionally never auto-fixed. Exit codes: `0` clean, `1` issues found, `2` --fix applied. | - -### Worktree layout - -| Command | Purpose | -|---|---| -| `ws migrate ` | Convert a plain checkout to the bare+worktree layout in place. Verify-before-delete; preserves all local branches and active hooks. | -| `ws migrate --all` | Migrate every active project. Skips already-migrated. | -| `ws migrate --check [name...]` | Preview without changes. Shows state and any blockers (dirty, stash, detached HEAD, hook count). | -| `ws migrate --wip` | Snapshot dirty working tree to a `wt//migration-wip-` branch and attach as a sibling worktree. | -| `ws worktree add [--from ]` | Create or attach a worktree for the literal ``. Auto-detects existing remote (fetches and checks out) and existing local-only branches (attaches; covers legacy `wt//*` re-registration). Records this machine in `[[branches]].machines` and stamps `last_active_*`. Slug collisions get `-` deterministic suffix. | -| `ws worktree list [project]` | Table: PROJECT / WORKTREE / BRANCH / STATE. STATE includes clean/dirty, ahead/behind, ownership (`main`, `mine`, `shared with `, `remote`, `legacy-wt`), and `last: ` from the registry. | -| `ws worktree rm [--force]` | Remove a worktree and release this machine from `[[branches]].machines`. Refuses dirty or unpushed unless `--force`. Empty `machines` causes the entry to be GC'd on save. | -| `ws worktree push [--force-dirty]` | Push the branch to origin via `git push -u origin ` and stamp `last_pushed_*` (and bump `last_active_*`) in `workspace.toml`. Refuses dirty without `--force-dirty`; refuses branches missing from `[[branches]]` (sign of out-of-band creation). | -| `ws wt …` | Alias for `ws worktree`. | - -### Aliases - -| Command | Purpose | -|---|---| -| `ws alias list` | Show configured shell aliases. | -| `ws alias add ` | Add an alias. | -| `ws alias rm ` | Remove an alias. | -| `ws alias init [shell]` | Generate alias init code for the user's shell (zsh). | -| `ws alias install` | Install the hook into `~/.zshrc`. | - -### Daemon - -| Command | Purpose | -|---|---| -| `ws daemon run` | Foreground (used by `start` after fork). | -| `ws daemon start` | Background-spawn the daemon. | -| `ws daemon stop` | Stop the daemon. | -| `ws daemon restart` | Stop + start. | -| `ws daemon status` | PID + running state. | -| `ws daemon register [path]` | Add a workspace to the daemon's config so it gets reconciled on every tick. | -| `ws daemon unregister [path]` | Remove a workspace from the daemon config. | -| `ws daemon install-service` | Install systemd unit for the daemon. | - -### GitHub auth (for repo discovery) - -| Command | Purpose | -|---|---| -| `ws auth login` | Device flow or PAT. | -| `ws auth logout` | Remove the stored token. | -| `ws auth status` | Token status. | - -### Setup - -| Command | Purpose | -|---|---| -| `ws setup` | Interactive bootstrap of a new workspace directory. | - -## Files the CLI relies on - -- `/workspace.toml` — project registry, single source of truth. -- `/.gitattributes` — `workspace.toml merge=union` (created by reconciler). -- `~/.config/ws/config.toml` — `machine_name` for branch namespacing. -- `~/.config/ws/daemon.toml` — list of workspaces watched by the daemon plus socket path. -- `~/.config/ws/daemon.{sock,pid,log}` — daemon runtime files. -- `~/.local/state/ws/conflicts.json` — unresolved sync conflicts. Honors `$XDG_STATE_HOME`. -- `~/.local/state/ws/bootstrap/.toml` — per-workspace bootstrap progress sidecar. Created by `ws bootstrap`, deleted on success. While present with a live pid, the daemon skips its tick for that workspace. Honors `$XDG_STATE_HOME`. -- `~/.local/state/ws/migrate/.toml` — per-workspace migrate progress sidecar. Created by `ws migrate`, deleted on success. Same daemon-skip semantics. Honors `$XDG_STATE_HOME`. All four sidecar kinds (`bootstrap`, `migrate`, `add`, `create`) share `internal/sidecar` which centralizes file/lock/pid mechanics; command-specific value types live in their own packages and round-trip through `json.RawMessage`. -- `~/.local/state/ws/add/.toml` — per-workspace `ws add` session sidecar. Created when `ws add` starts (any mode), deleted on success/error/panic via `defer`. While present with a live pid, the daemon skips its tick for that workspace and a second `ws add` invocation refuses with an "is running" error. Honors `$XDG_STATE_HOME`. -- `~/.local/state/ws/create/.toml` — per-workspace `ws create` session sidecar. Same lifecycle and contract as the `add` sidecar; the kind name differs so concurrent `ws add` and `ws create` runs do not stomp each other. Honors `$XDG_STATE_HOME`. - -## Conventions - -- All paths in `workspace.toml` are relative to the workspace root. -- Scripts and reconciler logic must be idempotent — safe to re-run. -- No secrets in this repo. -- `workspace.toml` is the only file that changes during normal operation - (plus `.gitattributes` once, on the reconciler's first run). -- The daemon **never** runs `merge`, `rebase`, `reset`, `force`, **or - `push`** inside a project repo. The worst it does is record a - conflict and stop. Project pushes are user-driven via - `ws worktree push` or plain `git push`. -- Do **not** hand-edit `[[projects.X.branches]]` blocks unless you are - reconciling a `branch-duplicate` conflict. The CLI helpers - (`ClaimBranch` / `ReleaseBranch` / `TouchActive`) are the only - sanctioned writers; manual edits race against the reconciler's - metadata refresh. - -## Commits - -Use [Conventional Commits](https://www.conventionalcommits.org/): - -- `feat:` — new feature (bumps minor pre-1.0, would be minor post-1.0) -- `fix:` — bug fix (bumps patch) -- `feat!:` or `fix!:` with `BREAKING CHANGE:` footer — breaking change - (bumps minor pre-1.0, major post-1.0) -- `chore:`, `docs:`, `refactor:`, `test:`, `ci:`, `style:`, `perf:` — no release - -Scope is optional: `feat(alias): ...`, `fix(sync): ...`. - -Never add `Co-Authored-By` or attribution footers. - -## Release process - -Automated via [release-please](https://github.com/googleapis/release-please). - -**Flow:** conventional commits land on `main` → release-please opens/updates -a Release PR with bumped version + CHANGELOG → merge the PR → tag `vX.Y.Z` -is pushed → existing `release.yml` builds binaries and publishes the GitHub -Release. - -**Do NOT** manually edit `CHANGELOG.md`, bump versions, or create `vX.Y.Z` -tags by hand — release-please owns all of it. - -## Tests - -The project uses **real git in temp dirs** rather than mocks. Every test -spins up its own ephemeral git repos under `t.TempDir()` and runs real -`git` commands. This catches the kinds of bugs (the -`git worktree add --force` regression that motivated the migrate rewrite, -for example) that mock-based tests would happily lie about. - -`internal/testutil/gitfixture.go` provides the shared helpers: - -- `InitFakeRemote(t, name, defaultBranch) string` — creates a bare repo - with a seed commit; usable as `proj.Remote` for clone/bootstrap tests. -- `InitFakePlainCheckout(t, parent, name, branches) string` — creates a - non-bare git repo with N branches, each carrying one unique commit. - Used as the input for migrate tests. -- `RunGit(t, dir, args...)` / `RunGitTry` — wraps `exec.Command("git", ...)` - with a deterministic env (no global config, no GPG, fixed identity). -- `AddDirty`, `AddStash` — push the working tree into the dirty/stash - states needed by migrate's pre-flight tests. - -Test files live next to the code they cover, in `_test` packages: - -- `internal/clone/clone_test.go` — happy path, ErrAlreadyCloned, - ErrNeedsMigration, ErrPathBlocked, default_branch resolution. -- `internal/migrate/migrate_test.go` — happy path **(regression test for - the worktree-attach bug)**, dirty + WIP, stash + branch conversion, - detached HEAD with and without orphan preservation, ErrAlreadyMigrated. -- `internal/bootstrap/bootstrap_test.go` — `ScanPlan` classification of - every project state, only-filter restriction. -- `internal/sidecar/sidecar_test.go` — Save/Load round-trip, - Delete-is-idempotent, IsAlive with self/dead/zero pids, AnyActive - finds either kind, AnyActive ignores stale entries. -- `internal/doctor/*_test.go` — per-check tests for the `ws doctor` - catalog: happy-path runner, stale-sidecar auto-fix, conflict scoping, - config validation, fetch-refspec/remote-URL/default-branch/branch- - upstream fixes on real bare+worktree fixtures, index-lock detection. - -Run everything: `go test ./...`. CI runs `go test -race -timeout 5m ./...` -on every push to main and on every PR via `.github/workflows/test.yml`. - -When adding new git-touching code: write a real-git test for it. The -testutil helpers cover ~95% of fixture needs; extend them rather than -inlining `exec.Command` in tests. - -## Known follow-ups (not yet implemented) - -These were deliberately deferred during the worktree refactor and are open -for future work: - -- **`ws worktree gc`** to clean up old WIP branches and orphaned worktrees. -- **fsnotify on `workspace.toml`** to remove the dependency on IPC - notifications from CLI commands. -- **Real TUI for `ws sync resolve`** instead of the prompt-based v1. -- **Per-machine `default_branch` override** for the rare case of different - default branches across machines for the same project. diff --git a/docs/agent-tui.md b/docs/explorer.md similarity index 53% rename from docs/agent-tui.md rename to docs/explorer.md index 05b7519..66ad43e 100644 --- a/docs/agent-tui.md +++ b/docs/explorer.md @@ -1,14 +1,14 @@ -# Agent TUI +# Explorer TUI -`ws` (run with no arguments in a TTY) — or `ws agent` explicitly — -opens a Bubble Tea TUI nested-list launcher across every workspace -the daemon knows about. It is the fastest path from "I want to work -on something" to a shell or a Claude Code session in the right -directory. +`ws` (run with no arguments in a TTY) — or `ws explorer` explicitly — +opens a Bubble Tea TUI explorer across every workspace the daemon +knows about. It is the fastest path from "I want to work on something" +to a shell or a Claude Code session in the right directory. ```sh -ws # bare invocation; same as `ws agent` -ws agent # explicit +ws # bare invocation; same as `ws explorer` +ws explorer # explicit +ws agent # legacy alias, still works ``` When stdout is not a TTY, `ws` falls through to `cmd.Help()` so @@ -16,26 +16,43 @@ piping / scripts get help instead of a TUI prompt. ## What you see -The agent reads `~/.config/ws/daemon.toml` to find every registered +The explorer reads `~/.config/ws/daemon.toml` to find every registered workspace, walks each one for projects / groups / worktrees / Claude -sessions, and renders a single nested list: +sessions, and renders a pinned quick-nav header above a scrollable +tree. ```text +*1.myapp 2m 2.api 1h 3.docs 3h 4.experiments 1d 5.utils 2d +6.proj-a 5m 7.proj-b 1h 8.proj-c 4h 9.proj-d 1d + ~/dev — workspace -├── personal -│ ├── dotfiles -│ ├── ws (workspace itself) -│ │ ├── main -│ │ ├── feat/foo (mine, ↑2) -│ │ └── feat/auth-refactor (shared with archlinux) -│ └── … -└── work - └── api-gateway - └── … + personal + dotfiles + workspace + main + feat/foo (mine, ↑2) + feat/auth-refactor (shared with archlinux) + work + api-gateway ``` -Group / project rows expand and collapse. Worktrees show the same -ownership tags as `ws worktree list` (`main`, `mine`, +### Pinned chip header + +Up to nine numbered chips, sorted favorites-first then +recently-touched. The leading `*` marks favorited projects. Each chip +shows `N.name age` — press the digit `1`-`9` to launch the matching +project immediately (claude in its directory). The chip row stays +pinned above the tree while you scroll, so the shortcuts never +disappear off the top. + +A project icon is rendered per ecosystem (Go, Rust, Python, Node, TS, +Java, Ruby, C#, Shell, Docker) based on marker files (`go.mod`, +`Cargo.toml`, `pyproject.toml`, etc.) in the project directory. + +### Tree + +Group / project rows expand and collapse with `tab`. Worktrees show +the same ownership tags as `ws worktree list` (`main`, `mine`, `shared with `, `legacy-wt`). ## Keys @@ -47,6 +64,7 @@ Navigation: - `h` / `←` — collapse one level. Smart: from a worktree row it closes the parent project; from a project row under a group it closes the group. +- `1`-`9` — launch the matching chip (claude in its directory) - `q` — quit Per-row actions: @@ -62,6 +80,10 @@ Per-row actions: - `d` — on a non-main worktree row, prompt for delete (with registry release; releases this machine from `[[branches]].machines`). +- `f` — on a project row, toggle favorite. Equivalent to + `ws favorite add` / `ws favorite rm` from the CLI. The new flag is + persisted to `workspace.toml` and synced across machines via the + reconciler. Search: @@ -74,8 +96,8 @@ Help: ## Worktree creation from the TUI -Press `w` on a project row → "Branch name" input → confirm. The TUI -runs the same path as `ws worktree add `: +Press `w` on a project row → "Branch name" input → confirm. The +explorer runs the same path as `ws worktree add `: - Auto-detects an existing remote ref and checks it out. - Auto-detects an existing local-only ref and attaches. @@ -84,15 +106,15 @@ runs the same path as `ws worktree add `: without creating a duplicate. - Otherwise creates a fresh branch from the project's default branch. -After the form closes, the agent invalidates its worktree cache and -re-renders so the new entry appears immediately. +After the form closes, the explorer invalidates its worktree cache +and re-renders so the new entry appears immediately. ## Project edit Press `e` on a project row → group / category form. Edits update `workspace.toml` directly (Phase 1 of the next reconciler tick commits + pushes the change). Useful when reorganizing the layout -without leaving the launcher. +without leaving the explorer. ## Sessions @@ -105,10 +127,10 @@ at the session's recorded `cwd`. The session cache is shared with Three reasons it earns its keep: -- **One key per worktree.** Beats remembering aliases for branches - that come and go. +- **One key per pinned project.** Number hotkeys 1-9 beat + remembering aliases for branches that come and go. - **Cross-workspace.** If you have several `ws daemon register`'d directories, they all show up in one list. -- **Claude integration.** The launcher is the primary way to drop +- **Claude integration.** The explorer is the primary way to drop into a Claude session that already has the right `cwd` and an optional resume target. diff --git a/internal/add/browse.go b/internal/add/browse.go new file mode 100644 index 0000000..32500cf --- /dev/null +++ b/internal/add/browse.go @@ -0,0 +1,463 @@ +package add + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/config" +) + +func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.filterMode { + switch key.String() { + case "esc": + m.filterMode = false + m.filterInput.SetValue("") + m.filterInput.Blur() + return m, nil + case "enter": + m.filterMode = false + m.filterInput.Blur() + m.cursor = 0 + return m, nil + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.cursor = 0 + return m, cmd + } + + view := m.filteredView() + + switch key.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(view)-1 { + m.cursor++ + } + case "i": + m.transitionTo(addStateManual) + m.manualInput.SetValue("") + m.manualErr = "" + return m, m.manualInput.Focus() + case "/": + m.filterMode = true + return m, m.filterInput.Focus() + case "enter": + if len(view) == 0 { + return m, nil + } + // Bulk path: any URLs marked → confirm them all at once. + if len(m.selectedURLs) > 0 { + m.transitionTo(addStateBulkConfirm) + return m, nil + } + // Single path: edit the cursor row. + s := view[m.cursor] + m.editFields = m.editFromSuggestion(s) + m.editFocus = 0 + m.editErr = "" + m.transitionTo(addStateEdit) + return m, nil + case " ": + // Toggle the cursor row in the bulk-select set. The selection + // is keyed by RemoteURL so it survives filter changes and + // re-sorts. + if len(view) == 0 { + return m, nil + } + s := view[m.cursor] + if s.RemoteURL == "" { + return m, nil + } + if m.selectedURLs == nil { + m.selectedURLs = make(map[string]bool) + } + if m.selectedURLs[s.RemoteURL] { + delete(m.selectedURLs, s.RemoteURL) + } else { + m.selectedURLs[s.RemoteURL] = true + } + return m, nil + case "a": + // Mark every visible (filtered) suggestion. Toggle: if all + // visible are already selected, clear them. + if len(view) == 0 { + return m, nil + } + if m.selectedURLs == nil { + m.selectedURLs = make(map[string]bool) + } + allMarked := true + for _, s := range view { + if !m.selectedURLs[s.RemoteURL] { + allMarked = false + break + } + } + if allMarked { + for _, s := range view { + delete(m.selectedURLs, s.RemoteURL) + } + } else { + for _, s := range view { + if s.RemoteURL != "" { + m.selectedURLs[s.RemoteURL] = true + } + } + } + return m, nil + case "esc": + // Esc with selections clears them; esc on a clean browse exits. + if len(m.selectedURLs) > 0 { + m.selectedURLs = nil + return m, nil + } + done := m.toDone() + if m.standalone { + return done, tea.Sequence(emit(m.doneMsg()), tea.Quit) + } + return done, emit(m.doneMsg()) + } + return m, nil +} + +func (m AddModel) viewBrowse() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Add project ")) + b.WriteString("\n\n") + + view := m.filteredView() + if len(view) == 0 { + b.WriteString(addDim.Render(" No suggestions found.\n\n")) + b.WriteString(" " + addHelp.Render("[i] enter URL manually [esc] quit")) + return b.String() + } + + // Per-source diagnostics. Each chip reflects the status of one + // source as of "now": completed (with count), pending (spinner), + // or errored (with hint). Updates each frame as new sources land. + if len(m.sources) > 0 { + b.WriteString(" ") + b.WriteString(renderSourceChipsLive(m.sourceOutcomes)) + if m.sourcesDone < len(m.sources) { + fmt.Fprintf(&b, " %s", + addDim.Render(fmt.Sprintf("%s loading %d more...", + m.spinner.View(), len(m.sources)-m.sourcesDone))) + } + b.WriteString("\n\n") + } + + if m.filterInput.Value() != "" { + fmt.Fprintf(&b, " search: %s\n\n", addAccent.Render(m.filterInput.Value())) + } + + // Build the tree: group suggestions by owner / kind. The cursor + // (m.cursor) still indexes the flat filtered slice; the tree is a + // pure rendering concern. We compute which "rendered row" the + // cursor maps to and crop a window around it. + rows := buildBrowseRows(view) + cursorRow := -1 + itemSeen := 0 + for i, r := range rows { + if r.kind == rowItem { + if itemSeen == m.cursor { + cursorRow = i + } + itemSeen++ + } + } + + const visibleRows = 16 + start, end := windowAround(cursorRow, len(rows), visibleRows) + for i := start; i < end; i++ { + r := rows[i] + switch r.kind { + case rowGroup: + fmt.Fprintf(&b, " %s\n", r.text) + case rowItem: + s := r.suggestion + selected := i == cursorRow + marked := m.selectedURLs[s.RemoteURL] + cursor := " " + if selected && marked { + cursor = " " + addCursor.Render("▸") + addAccent.Render("●") + } else if selected { + cursor = " " + addCursor.Render("▸ ") + } else if marked { + cursor = " " + addAccent.Render("● ") + } + line := strings.TrimRight(renderItemLine(cursor, s), "\n") + if selected { + // Pad the line out to terminal width and apply a + // background highlight so the entire row reads as + // "this is what Enter will select". Width(0) is a + // no-op when m.width hasn't been seen yet (pre + // WindowSizeMsg) — falls back to natural length. + rs := addCursorRow + if m.width > 0 { + rs = rs.Width(m.width) + } + line = rs.Render(line) + } + b.WriteString(line + "\n") + } + } + if start > 0 || end < len(rows) { + fmt.Fprintf(&b, "\n %s\n", + addDim.Render(fmt.Sprintf("(scrolled %d/%d items)", m.cursor+1, len(view)))) + } + + // Selected-item preview: description + repo metadata. Always + // rendered when a row is highlighted so the visible height stays + // stable as the cursor moves. + if cursorRow >= 0 && cursorRow < len(rows) && rows[cursorRow].kind == rowItem { + b.WriteString("\n") + b.WriteString(renderSelectionPreview(rows[cursorRow].suggestion)) + } + + b.WriteString("\n") + if m.filterMode { + b.WriteString(" search: " + m.filterInput.View() + "\n") + b.WriteString(" " + addHelp.Render("[enter] commit [esc] cancel")) + } else if n := len(m.selectedURLs); n > 0 { + fmt.Fprintf(&b, " %s %s\n", + addAccent.Render(fmt.Sprintf("● %d marked", n)), + addHelp.Render("[⏎] confirm bulk add [space] toggle [a] all [esc] clear")) + b.WriteString(" " + addHelp.Render("[↑↓] navigate [/] search [i] manual URL")) + } else { + b.WriteString(" " + addHelp.Render("[↑↓] navigate [⏎] select [space] mark [a] all [/] search [i] manual URL [esc] quit")) + } + return b.String() +} + +// renderSelectionPreview shows the currently-selected suggestion's +// description and metadata (last push, activity, sources, paths). +// Always emits at least 2 lines so the screen height stays constant +// as the cursor moves between described and undescribed repos — +// otherwise the help line jumps. +func renderSelectionPreview(s *Suggestion) string { + var b strings.Builder + // Title line: name + URL. + b.WriteString(" " + addPreviewName.Render(s.Name)) + if u := shortURL(*s); u != "" { + b.WriteString(" " + addDim.Render(u)) + } + b.WriteString("\n") + + // Description, or a placeholder so the layout doesn't shift. + desc := strings.TrimSpace(s.Description) + if desc == "" { + desc = "(no description)" + b.WriteString(" " + addDim.Render(truncate(desc, 100)) + "\n") + } else { + // Replace newlines so multi-line descriptions don't blow out + // the layout. Truncate at ~100 chars for the same reason. + desc = strings.ReplaceAll(desc, "\n", " ") + b.WriteString(" " + truncate(desc, 100) + "\n") + } + + // Optional metadata: pushed timestamp, activity count, registered + // or local-disk hint repeated here for visibility (they're also + // rendered as inline tags on the row, but the preview is where + // the user looks for context after selecting). + var meta []string + if !s.PushedAt.IsZero() && s.PushedAt.Year() > 1 { + meta = append(meta, "pushed "+relativeTime(s.PushedAt)) + } + if s.GhActivity > 0 { + meta = append(meta, fmt.Sprintf("%d events", s.GhActivity)) + } + if s.RegisteredPath != "" { + meta = append(meta, "● already at "+s.RegisteredPath) + } else if s.DiskPath != "" { + meta = append(meta, "● local at "+s.DiskPath) + } + if len(meta) > 0 { + b.WriteString(" " + addDim.Render(strings.Join(meta, " · ")) + "\n") + } + return b.String() +} + +// browseRowKind tags a rendered line so the windowing math can tell +// group headers (which the cursor cannot land on) from item rows +// (which it can). +type browseRowKind int + +const ( + rowGroup browseRowKind = iota + rowItem +) + +type browseRow struct { + kind browseRowKind + text string // pre-formatted header text; empty for items + suggestion *Suggestion // non-nil for items +} + +// buildBrowseRows walks an already-sorted view (sortByRelevance puts +// it in group → in-group order) and emits a header row each time the +// group key changes. This keeps m.cursor's view-index aligned with +// the position of the matching item row in the rendered tree — +// critical for the cursor marker and Enter to point at the same +// suggestion. +func buildBrowseRows(view []Suggestion) []browseRow { + if len(view) == 0 { + return nil + } + + // First pass: count items per group key for the header counts. + // Cheap because the view is small (≤ low hundreds even at scale). + groupCounts := map[string]int{} + for i := range view { + k, _, _ := groupKey(view[i]) + groupCounts[k]++ + } + + var rows []browseRow + var lastKey string + for i := range view { + s := &view[i] + key, label, _ := groupKey(*s) + if key != lastKey { + header := fmt.Sprintf("%s %s", + addGroupHdr.Render(label), + addDim.Render(fmt.Sprintf("(%d)", groupCounts[key]))) + rows = append(rows, browseRow{kind: rowGroup, text: header}) + lastKey = key + } + rows = append(rows, browseRow{kind: rowItem, suggestion: s}) + } + return rows +} + +// groupKey returns (key, displayLabel, sortOrder) for a Suggestion. +// Sort order pins Clipboard at the top (most recent intent), then +// any disk-only entries (acting on what's already on the user's +// machine), then GitHub owners alphabetically. Mixed sources fall +// into the GitHub bucket because that's where they came from +// originally — the disk presence becomes a row-level highlight, not +// a separate bucket. +func groupKey(s Suggestion) (key, label string, order int) { + hasGh := hasSource(s.Sources, SourceGitHub) + hasClip := hasSource(s.Sources, SourceClipboard) + hasDisk := hasSource(s.Sources, SourceDisk) + hasManual := hasSource(s.Sources, SourceManual) + + switch { + case hasClip && !hasGh: + return "_clip", "Clipboard", 0 + case hasManual && !hasGh: + return "_manual", "Manual", 0 + case hasDisk && !hasGh: + return "_disk", "Local (unregistered)", 1 + case hasGh && s.InferredGrp != "": + return "gh:" + strings.ToLower(s.InferredGrp), s.InferredGrp, 2 + default: + return "_other", "Other", 3 + } +} + +// windowAround crops [0, total) to a visible-size window centered +// around `cursor`. Used by viewBrowse to keep the cursor in view +// without scrolling the entire 300-row tree. +func windowAround(cursor, total, size int) (start, end int) { + if total <= size { + return 0, total + } + if cursor < 0 { + return 0, size + } + half := size / 2 + start = cursor - half + if start < 0 { + start = 0 + } + end = start + size + if end > total { + end = total + start = end - size + } + return start, end +} + +// renderItemLine produces one suggestion-row in the browse list, +// applying the "already cloned" highlight when the suggestion has a +// disk path or a registered-path match. The cursor argument is the +// pre-rendered prefix (" ▸ " for the selected row, " " otherwise). +func renderItemLine(cursor string, s *Suggestion) string { + nameStyle := addItemName + suffix := "" + urlStyle := addDim + + switch { + case s.RegisteredPath != "": + // Already in workspace.toml — would create a duplicate. The + // highlight is loud enough to warn the user but the row + // stays selectable so they can intentionally make a copy. + nameStyle = addExists + suffix = " " + addExistsTag.Render( + fmt.Sprintf("● cloned at %s", s.RegisteredPath)) + case s.DiskPath != "": + // Found on disk but not registered — selecting will + // register the existing path (no clone). + nameStyle = addExists + suffix = " " + addExistsTag.Render( + fmt.Sprintf("● local: %s", s.DiskPath)) + } + + url := shortURL(*s) + return fmt.Sprintf("%s%s %s %s%s\n", + cursor, + nameStyle.Render(addPad(s.Name, 24)), + renderSourceChips(s.Sources), + urlStyle.Render(url), + suffix) +} + +func (m AddModel) filteredView() []Suggestion { + q := strings.ToLower(strings.TrimSpace(m.filterInput.Value())) + if q == "" { + return m.allSuggestions + } + var out []Suggestion + for _, s := range m.allSuggestions { + // Search across name, URL, owner/group, and the repo + // description so the user can find a repo by what it does + // (e.g. typing "graphql" matches any repo whose description + // mentions GraphQL), not just by name. + hay := strings.ToLower(s.Name + " " + s.RemoteURL + " " + s.InferredGrp + " " + s.Description) + if strings.Contains(hay, q) { + out = append(out, s) + } + } + return out +} + +func (m AddModel) editFromSuggestion(s Suggestion) editFields { + cat := config.CategoryPersonal + // Crude heuristic: if the inferred group looks like a work org + // (anything other than the user's GitHub login or "personal"), + // default to Work. The user can flip on the edit screen. + grp := s.InferredGrp + if grp != "" && grp != "personal" { + cat = config.CategoryWork + } + return editFields{ + Name: s.Name, + URL: s.RemoteURL, + Category: cat, + Group: grp, + Path: buildPath(grp, cat, s.Name), + FromDisk: s.DiskPath, + } +} diff --git a/internal/add/clone.go b/internal/add/clone.go new file mode 100644 index 0000000..4127ab8 --- /dev/null +++ b/internal/add/clone.go @@ -0,0 +1,177 @@ +package add + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/branchprompt" + "github.com/kuchmenko/workspace/internal/clone" +) + +func (m AddModel) startCloneJob(idx int) tea.Cmd { + if idx >= len(m.queue) { + return func() tea.Msg { return allClonesDoneMsg{} } + } + job := m.queue[idx] + return func() tea.Msg { + // Build a per-iteration Options for Register. Disk-found + // suggestions register-only (NoClone) since the repo is + // already on the user's machine; everything else clones into + // the bare+worktree layout via Register → CloneIntoLayout. + // + // Register is non-interactive: if the clone returns + // ErrNeedsBootstrap, we surface it as a per-job error and + // the user is told to run `ws bootstrap ` afterwards. + // The branchPrompt sub-state in the TUI is wired to handle + // a future needsBranchMsg flow if we ever decide to plumb + // the prompt through (the same answer-channel pattern + // bootstrap uses). + opts := Options{ + URLs: []string{job.URL}, + Name: job.Name, + Category: job.Category, + Group: job.Group, + WsRoot: m.wsRoot, + Workspace: m.ws, + Save: m.saveFn, + Mode: ModeHeadless, + NoClone: job.FromDisk != "", // disk-found → register only + } + + regRes, err := Register(opts, job.URL) + out := cloneDoneMsg{idx: idx} + if err != nil { + if errors.Is(err, ErrAlreadyRegistered) { + out.skipped = &SkipReason{URL: job.URL, Reason: err.Error()} + } else if errors.Is(err, clone.ErrNeedsBootstrap) { + out.err = fmt.Errorf("%s: default branch ambiguous (run `ws bootstrap %s` after add)", job.Name, job.Name) + } else { + out.err = err + } + } else if regRes != nil { + out.project = regRes.Project + } + return out + } +} + +func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case cloneDoneMsg: + switch { + case msg.err != nil: + m.errors = append(m.errors, msg.err) + case msg.skipped != nil: + m.skipped = append(m.skipped, *msg.skipped) + default: + m.added = append(m.added, msg.project) + } + m.currentIdx = msg.idx + 1 + if m.currentIdx >= len(m.queue) { + m.transitionTo(addStateDone) + if m.standalone { + return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) + } + return m, emit(m.doneMsg()) + } + return m, m.startCloneJob(m.currentIdx) + case needsBranchMsg: + // Wired but unreachable today: no clone path emits + // needsBranchMsg. Kept so a future caller that wants to + // route clone.ErrNeedsBootstrap through the TUI prompt + // has the plumbing ready (same answer-channel pattern as + // bootstrap). + m.branchPrompt = branchprompt.NewModel(msg.project, msg.candidates) + m.branchAnswer = msg.answer + m.transitionTo(addStateBranchPrompt) + return m, nil + case allClonesDoneMsg: + m.transitionTo(addStateDone) + if m.standalone { + return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) + } + return m, emit(m.doneMsg()) + } + return m, nil +} + +func (m AddModel) viewCloning() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Cloning ")) + b.WriteString("\n\n") + total := len(m.queue) + done := m.currentIdx + fmt.Fprintf(&b, " %d / %d\n\n", done, total) + if m.currentIdx < total { + j := m.queue[m.currentIdx] + fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), j.Name) + fmt.Fprintf(&b, " %s\n", addDim.Render(j.Path)) + } + if len(m.errors) > 0 { + fmt.Fprintf(&b, "\n %s %d failed\n", addErr.Render("✗"), len(m.errors)) + } + b.WriteString("\n " + addHelp.Render("[ctrl+c] abort")) + return b.String() +} + +// Branch prompt: plumbing for routing clone.ErrNeedsBootstrap through +// the branchprompt sub-state. Currently unreachable — no clone path +// emits needsBranchMsg — but the wiring is complete so a future +// caller can hook it up without restructuring the state machine. +func (m AddModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case branchprompt.PickedMsg: + m.resolveBranch(msg.Branch, nil) + m.transitionTo(addStateCloning) + return m, nil + case branchprompt.CancelledMsg: + m.resolveBranch("", errors.New("user canceled branch selection")) + m.transitionTo(addStateCloning) + return m, nil + } + var cmd tea.Cmd + m.branchPrompt, cmd = m.branchPrompt.Update(msg) + return m, cmd +} + +func (m *AddModel) resolveBranch(branch string, err error) { + if m.branchAnswer != nil { + m.branchAnswer <- branchAnswer{branch: branch, err: err} + m.branchAnswer = nil + } +} + +func (m AddModel) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + if m.standalone { + return m, tea.Quit + } + } + return m, nil +} + +func (m AddModel) viewDone() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Done ")) + b.WriteString("\n\n") + fmt.Fprintf(&b, " %s %d added\n", addCheck.Render("✓"), len(m.added)) + if len(m.skipped) > 0 { + fmt.Fprintf(&b, " %s %d skipped\n", addDim.Render("⊘"), len(m.skipped)) + } + if len(m.errors) > 0 { + fmt.Fprintf(&b, " %s %d errored\n", addErr.Render("✗"), len(m.errors)) + b.WriteString("\n") + for _, e := range m.errors { + fmt.Fprintf(&b, " %s\n", addDim.Render(e.Error())) + } + } + b.WriteString("\n " + addHelp.Render("[any key] exit")) + return b.String() +} diff --git a/internal/add/edit.go b/internal/add/edit.go new file mode 100644 index 0000000..82c24a8 --- /dev/null +++ b/internal/add/edit.go @@ -0,0 +1,250 @@ +package add + +import ( + "errors" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/config" +) + +func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "tab", "down": + m.editFocus = (m.editFocus + 1) % 4 // 0=Name 1=URL 2=Category 3=Group + case "shift+tab", "up": + m.editFocus = (m.editFocus + 3) % 4 + case "enter": + // Validate & advance to confirm. + if err := m.validateEdit(); err != nil { + m.editErr = err.Error() + return m, nil + } + m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) + m.transitionTo(addStateConfirm) + return m, nil + case "esc": + m.transitionTo(addStateBrowse) + return m, nil + default: + // Plain typing edits the focused field. + s := key.String() + // Filter to printable rune-ish keys. + if key.Type == tea.KeyRunes { + runes := key.Runes + m.applyEditRunes(runes) + return m, nil + } + if s == "backspace" { + m.applyEditBackspace() + return m, nil + } + } + return m, nil +} + +func (m *AddModel) applyEditRunes(runes []rune) { + r := string(runes) + switch m.editFocus { + case 0: + m.editFields.Name += r + case 1: + m.editFields.URL += r + case 2: + // Category: cycle on space, otherwise ignore alphabetic input + // — only personal|work allowed. + if r == " " { + if m.editFields.Category == config.CategoryPersonal { + m.editFields.Category = config.CategoryWork + } else { + m.editFields.Category = config.CategoryPersonal + } + } + case 3: + m.editFields.Group += r + } + m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) +} + +func (m *AddModel) applyEditBackspace() { + switch m.editFocus { + case 0: + if len(m.editFields.Name) > 0 { + m.editFields.Name = m.editFields.Name[:len(m.editFields.Name)-1] + } + case 1: + if len(m.editFields.URL) > 0 { + m.editFields.URL = m.editFields.URL[:len(m.editFields.URL)-1] + } + case 3: + if len(m.editFields.Group) > 0 { + m.editFields.Group = m.editFields.Group[:len(m.editFields.Group)-1] + } + } + m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) +} + +func (m AddModel) validateEdit() error { + if strings.TrimSpace(m.editFields.Name) == "" { + return errors.New("name is required") + } + if strings.TrimSpace(m.editFields.URL) == "" { + return errors.New("URL is required") + } + if m.editFields.Category != config.CategoryPersonal && m.editFields.Category != config.CategoryWork { + return errors.New("category must be personal or work") + } + if _, exists := m.ws.Projects[m.editFields.Name]; exists { + return fmt.Errorf("name %q is already registered", m.editFields.Name) + } + return nil +} + +func (m AddModel) viewEdit() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Edit project ")) + b.WriteString("\n\n") + + rows := []struct{ label, value string }{ + {"Name", m.editFields.Name}, + {"URL", m.editFields.URL}, + {"Category", string(m.editFields.Category) + addDim.Render(" (space to toggle: personal | work)")}, + {"Group", m.editFields.Group + addDim.Render(" (auto-inferred; empty → category)")}, + } + for i, r := range rows { + marker := " " + label := r.label + if i == m.editFocus { + marker = addCursor.Render("▸ ") + label = addAccent.Render(r.label) + } + fmt.Fprintf(&b, " %s%s: %s\n", marker, addPad(label, 12), r.value) + } + fmt.Fprintf(&b, "\n %s: %s\n", addPad("Path", 12), addDim.Render(m.editFields.Path)) + + if m.editErr != "" { + b.WriteString("\n " + addErr.Render(m.editErr) + "\n") + } + b.WriteString("\n " + addHelp.Render("[tab/↑↓] field [⏎] confirm [esc] back")) + return b.String() +} + +func (m AddModel) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "y", "Y", "enter": + m.queue = append(m.queue, m.editFields) + m.currentIdx = 0 + m.transitionTo(addStateCloning) + return m, tea.Batch(m.spinner.Tick, m.startCloneJob(0)) + case "n", "N", "esc": + m.transitionTo(addStateBrowse) + return m, nil + } + } + return m, nil +} + +func (m AddModel) viewConfirm() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Confirm ")) + b.WriteString("\n\n") + fmt.Fprintf(&b, " Add %s\n", addAccent.Render(m.editFields.Name)) + fmt.Fprintf(&b, " %s\n", addDim.Render(m.editFields.URL)) + fmt.Fprintf(&b, " %s → %s\n\n", + string(m.editFields.Category), + addDim.Render(m.editFields.Path)) + if m.editFields.FromDisk != "" { + b.WriteString(" " + addDim.Render("(disk) repo already at "+m.editFields.FromDisk+ + " — register only, no clone\n")) + b.WriteString("\n") + } + b.WriteString(" " + addHelp.Render("[y/⏎] add [n/esc] back")) + return b.String() +} + +// updateBulkConfirm handles the multi-add confirmation screen reached +// from browse when the user pressed `enter` with one or more URLs +// marked. Confirming queues every marked suggestion via +// editFromSuggestion (default category/group inferred from owner) and +// transitions to the existing cloning loop unchanged. +func (m AddModel) updateBulkConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "y", "Y", "enter": + queue := m.buildBulkQueue() + if len(queue) == 0 { + m.transitionTo(addStateBrowse) + return m, nil + } + m.queue = queue + m.currentIdx = 0 + m.selectedURLs = nil + m.transitionTo(addStateCloning) + return m, tea.Batch(m.spinner.Tick, m.startCloneJob(0)) + case "n", "N", "esc": + m.transitionTo(addStateBrowse) + return m, nil + } + return m, nil +} + +// buildBulkQueue resolves the marked URLs to editFields, preserving +// the order they appear in allSuggestions (alphabetised by group → +// name). Skips URLs that no longer exist in allSuggestions and URLs +// already registered in workspace.toml so a stale selection cannot +// accidentally re-clone an existing project. +func (m AddModel) buildBulkQueue() []editFields { + if len(m.selectedURLs) == 0 { + return nil + } + var out []editFields + for i := range m.allSuggestions { + s := m.allSuggestions[i] + if !m.selectedURLs[s.RemoteURL] { + continue + } + if s.RegisteredPath != "" { + continue + } + out = append(out, m.editFromSuggestion(s)) + } + return out +} + +func (m AddModel) viewBulkConfirm() string { + queue := m.buildBulkQueue() + var b strings.Builder + b.WriteString(addTitle.Render(" Bulk add ")) + b.WriteString("\n\n") + if len(queue) == 0 { + b.WriteString(" " + addDim.Render("(no eligible URLs — every selection is already registered)\n")) + b.WriteString("\n " + addHelp.Render("[esc] back")) + return b.String() + } + fmt.Fprintf(&b, " Will add %s repos:\n\n", addAccent.Render(fmt.Sprintf("%d", len(queue)))) + const max = 10 + shown := queue + if len(shown) > max { + shown = shown[:max] + } + for _, ef := range shown { + fmt.Fprintf(&b, " • %s %s %s\n", + addItemName.Render(addPad(ef.Name, 24)), + addDim.Render(fmt.Sprintf("[%s]", ef.Category)), + addDim.Render(ef.URL)) + } + if len(queue) > max { + fmt.Fprintf(&b, " %s\n", addDim.Render(fmt.Sprintf("…and %d more", len(queue)-max))) + } + b.WriteString("\n " + addHelp.Render("[y/⏎] confirm [n/esc] back")) + return b.String() +} diff --git a/internal/add/format.go b/internal/add/format.go new file mode 100644 index 0000000..3127b66 --- /dev/null +++ b/internal/add/format.go @@ -0,0 +1,169 @@ +package add + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// truncate caps s at n characters with a trailing ellipsis when +// truncation occurs. Operates on bytes, which is wrong for any +// non-ASCII repo description but acceptable as a stop-gap; the +// fallout (a cut mid-rune) is cosmetic only. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 3 { + return s[:n] + } + return s[:n-1] + "…" +} + +// relativeTime renders a time.Time as a short "Nd ago" string. Used in +// the selection preview to give a quick "is this repo active" cue. +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) + default: + return fmt.Sprintf("%dy ago", int(d.Hours()/(24*365))) + } +} + +func emit(msg tea.Msg) tea.Cmd { + return func() tea.Msg { return msg } +} + +func parseRepoNameFromURL(url string) string { + // Lightweight wrapper around git.ParseRepoName to avoid a dep + // loop into internal/git for code that doesn't otherwise need it. + url = strings.TrimSpace(url) + url = strings.TrimSuffix(url, ".git") + url = strings.TrimSuffix(url, "/") + if i := strings.LastIndexAny(url, "/:"); i >= 0 { + return url[i+1:] + } + return url +} + +func addPad(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +func renderSourceChips(srcs []SourceKind) string { + if len(srcs) == 0 { + return "" + } + var parts []string + for _, k := range srcs { + parts = append(parts, addChip.Render("["+k.String()+"]")) + } + return strings.Join(parts, " ") +} + +func shortURL(s Suggestion) string { + if s.RemoteURL != "" { + return s.RemoteURL + } + if s.DiskPath != "" { + return s.DiskPath + } + return "" +} + +// renderSourceChipsLive turns the model's accumulated per-source +// outcomes into a single status line. Used both in the gathering +// view (when the user is staring at the spinner) and in the browse +// view (where it lives above the tree as a status bar). +// +// Color rules: +// +// green (2): source returned a non-empty result +// dim (8): source returned 0 (empty but successful) +// amber (3): source errored +func renderSourceChipsLive(outcomes []SourceOutcome) string { + var chips []string + for _, o := range outcomes { + var color string + var label string + switch { + case o.Err != nil: + color = "3" + label = fmt.Sprintf("%s:err (%s)", o.Name, sourceErrHint(o.Err)) + case o.Count == 0: + color = "8" + label = fmt.Sprintf("%s:0", o.Name) + default: + color = "2" + label = fmt.Sprintf("%s:%d", o.Name, o.Count) + } + chips = append(chips, lipgloss.NewStyle(). + Foreground(lipgloss.Color(color)).Render(label)) + } + return strings.Join(chips, " ") +} + +// sourceErrHint summarizes a per-source error into a one-or-two-word +// chip suffix. Keeps the gather chips readable on narrow terminals +// without burying the user in stack-trace prose. +// +// Errors in the source pipeline are wrapped as `: ` or +// even `: : ` (clipboard wraps the binary path, +// github wraps "github source", etc). The fallback strips those +// prefixes and shows the deepest cause — that's the actionable bit +// the user wants to read. +func sourceErrHint(err error) string { + if err == nil { + return "" + } + msg := err.Error() + switch { + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "canceled" + case strings.Contains(msg, "ErrNotAuthed"), strings.Contains(msg, "not authed"): + return "no auth" + case strings.Contains(strings.ToLower(msg), "rate limit"), + strings.Contains(msg, "API rate limit"): + return "rate-limited" + case strings.Contains(strings.ToLower(msg), "401"), + strings.Contains(strings.ToLower(msg), "unauthorized"): + return "401 expired?" + case strings.Contains(msg, "Nothing is copied"), + strings.Contains(msg, "No selection"): + return "empty" + } + // Fallback: drop everything up to and including the LAST `: ` so + // "/sbin/wl-paste: failed to bind" → "failed to bind". Cap at 24 + // chars, single line. + tail := msg + if i := strings.LastIndex(msg, ": "); i >= 0 { + tail = strings.TrimSpace(msg[i+2:]) + } + tail = strings.ReplaceAll(tail, "\n", " ") + if len(tail) > 24 { + tail = tail[:24] + } + return tail +} diff --git a/internal/add/gather.go b/internal/add/gather.go new file mode 100644 index 0000000..2e195a3 --- /dev/null +++ b/internal/add/gather.go @@ -0,0 +1,88 @@ +package add + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// handleSourceDone folds one source's FetchSuggestions outcome into +// the model. Called for every source as it completes (sources run in +// parallel via separate tea.Cmds from Init), so this runs ~N times +// per session where N == len(m.sources). +// +// State transitions: +// - First source with results → addStateGathering → addStateBrowse +// (user sees something the moment any source finishes) +// - Last source done with no cumulative results → addStateBrowseEmpty +// - Subsequent sources after browse is reached → silently fold in; +// the rendered tree updates next frame +func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tea.Model, tea.Cmd) { + m.sourcesDone++ + m.sourceOutcomes = append(m.sourceOutcomes, SourceOutcome{ + Name: msg.name, + Count: len(msg.items), + Duration: msg.took, + Err: msg.err, + }) + if msg.err == nil && len(msg.items) > 0 { + // Re-run dedup against the existing list so a repo that + // shows up in two sources merges into one row even if the + // sources finish on different ticks. + merged := mergeSuggestions([][]Suggestion{m.allSuggestions, msg.items}) + sortByRelevance(merged) + m.allSuggestions = merged + // Cursor may need clamping if the dedup pass shrank an + // already-rendered list (rare but possible if a clipboard + // suggestion arrives last and merges with an existing GH + // suggestion). + if m.cursor >= len(m.allSuggestions) && len(m.allSuggestions) > 0 { + m.cursor = len(m.allSuggestions) - 1 + } + } + + // State decisions only apply while we're still on the gathering + // screen — sources finishing after the user has already entered + // manual/edit/confirm don't yank them back. + if m.state == addStateGathering { + switch { + case len(m.allSuggestions) > 0: + m.transitionTo(addStateBrowse) + case m.sourcesDone >= len(m.sources): + m.transitionTo(addStateBrowseEmpty) + } + } + return m, nil +} + +func (m AddModel) updateGathering(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(spinner.TickMsg); ok { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + return m, nil +} + +func (m AddModel) viewGathering() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Add project — gathering ")) + b.WriteString("\n\n") + b.WriteString(" " + m.spinner.View() + " probing sources") + if m.sourcesDone > 0 { + // Show progress so the user can tell we haven't hung — e.g. + // "(2/3 sources done)". + fmt.Fprintf(&b, " %s", addDim.Render(fmt.Sprintf("(%d/%d done)", m.sourcesDone, len(m.sources)))) + } + b.WriteString("\n\n") + // Per-source progress chips — same look as the in-browse line. + if len(m.sourceOutcomes) > 0 { + b.WriteString(" ") + b.WriteString(renderSourceChipsLive(m.sourceOutcomes)) + b.WriteString("\n\n") + } + b.WriteString(" " + addHelp.Render("[ctrl+c] cancel")) + return b.String() +} diff --git a/internal/add/manual.go b/internal/add/manual.go new file mode 100644 index 0000000..dc94bc2 --- /dev/null +++ b/internal/add/manual.go @@ -0,0 +1,53 @@ +package add + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/config" +) + +func (m AddModel) updateManual(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "enter": + val := strings.TrimSpace(m.manualInput.Value()) + if val == "" { + m.manualErr = "URL is required" + return m, nil + } + // Build editFields from the bare URL. + name := parseRepoNameFromURL(val) + m.editFields = editFields{ + Name: name, + URL: val, + Category: config.CategoryPersonal, + Group: "", + Path: buildPath("", config.CategoryPersonal, name), + } + m.editFocus = 0 + m.editErr = "" + m.transitionTo(addStateEdit) + return m, nil + case "esc": + m.transitionTo(addStateBrowse) + m.manualInput.Blur() + return m, nil + } + } + var cmd tea.Cmd + m.manualInput, cmd = m.manualInput.Update(msg) + return m, cmd +} + +func (m AddModel) viewManual() string { + var b strings.Builder + b.WriteString(addTitle.Render(" Manual URL ")) + b.WriteString("\n\n") + b.WriteString(" " + m.manualInput.View() + "\n") + if m.manualErr != "" { + b.WriteString("\n " + addErr.Render(m.manualErr) + "\n") + } + b.WriteString("\n " + addHelp.Render("[⏎] continue [esc] back")) + return b.String() +} diff --git a/internal/add/msg.go b/internal/add/msg.go new file mode 100644 index 0000000..5b4ffda --- /dev/null +++ b/internal/add/msg.go @@ -0,0 +1,46 @@ +package add + +import ( + "time" + + "github.com/kuchmenko/workspace/internal/config" +) + +// AddDoneMsg signals that the model has finished its work. Standalone +// callers consume this and quit; embedded callers consume it to +// transition back to their parent state. +type AddDoneMsg struct { + Added []config.Project + Skipped []SkipReason + Errors []error +} + +// cloneDoneMsg is posted after each Register call in the cloning queue. +type cloneDoneMsg struct { + idx int + project config.Project + skipped *SkipReason + err error +} + +// allClonesDoneMsg signals the cloning loop reached the end of the queue. +type allClonesDoneMsg struct{} + +// needsBranchMsg is the bridge from a clone goroutine that hit +// clone.ErrNeedsBootstrap. The TUI switches into branchPrompt state, +// the user picks, and the answer flows back via the channel. +type needsBranchMsg struct { + project string + candidates []string + answer chan branchAnswer +} + +// sourceDoneMsg lands on AddModel.Update each time a single source +// finishes its FetchSuggestions call. Multiple sourceDoneMsgs are +// expected per session (one per source). +type sourceDoneMsg struct { + name string + items []Suggestion + err error + took time.Duration +} diff --git a/internal/add/styles.go b/internal/add/styles.go new file mode 100644 index 0000000..5e1bab7 --- /dev/null +++ b/internal/add/styles.go @@ -0,0 +1,70 @@ +package add + +import "github.com/charmbracelet/lipgloss" + +var ( + addTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("6")). + Padding(0, 1) + + addDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + addHelp = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + addCursor = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true) + + addAccent = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true) + + addErr = lipgloss.NewStyle(). + Foreground(lipgloss.Color("1")). + Bold(true) + + addCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + + addChip = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) + + // Group header: bright magenta + bold so org names stand out + // against the muted body. Underline gives a clear visual break + // between groups in dense lists. + addGroupHdr = lipgloss.NewStyle(). + Foreground(lipgloss.Color("5")). + Bold(true). + Underline(true) + + // Default item-name color for fresh suggestions. + addItemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + + // "Already cloned" highlight for items that map to a registered + // project or an unregistered local clone. Yellow so it screams + // "look at me" without going full red, since picking the row is + // still allowed (creates a copy after rename). + addExists = lipgloss.NewStyle(). + Foreground(lipgloss.Color("3")). + Bold(true) + + // Tag suffix that follows the item name, with a slightly dimmer + // shade so it reads as metadata not part of the name. + addExistsTag = lipgloss.NewStyle(). + Foreground(lipgloss.Color("3")). + Italic(true) + + // Selection-preview header: bright cyan + bold, distinct from the + // row's name color so the preview reads as separate panel. + addPreviewName = lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Bold(true) + + // Cursor-row highlight: dark gray background, applied to the + // entire selected row (padded to terminal width). Lipgloss + // re-applies the bg around any inner ANSI sequences so chip + // colors, dim URLs, and the cursor arrow keep their fg styling + // while the bg stays continuous across the line. + addCursorRow = lipgloss.NewStyle(). + Background(lipgloss.Color("237")) +) diff --git a/internal/add/tui.go b/internal/add/tui.go index b860a02..bc56ca3 100644 --- a/internal/add/tui.go +++ b/internal/add/tui.go @@ -2,9 +2,6 @@ package add import ( "context" - "errors" - "fmt" - "strings" "time" "github.com/charmbracelet/bubbles/spinner" @@ -12,7 +9,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/branchprompt" - "github.com/kuchmenko/workspace/internal/clone" "github.com/kuchmenko/workspace/internal/config" ) @@ -190,39 +186,6 @@ type AddModelOptions struct { PreURLs []string } -// AddDoneMsg signals that the model has finished its work. Standalone -// callers consume this and quit; embedded callers consume it to -// transition back to their parent state. -type AddDoneMsg struct { - Added []config.Project - Skipped []SkipReason - Errors []error -} - -// cloneDoneMsg is posted after each Register call in the cloning queue. -type cloneDoneMsg struct { - idx int - project config.Project - skipped *SkipReason - err error -} - -// allClonesDoneMsg signals the cloning loop reached the end of the queue. -type allClonesDoneMsg struct{} - -// needsBranchMsg is the bridge from a clone goroutine that hit -// clone.ErrNeedsBootstrap. The TUI switches into branchPrompt state, -// the user picks, and the answer flows back via the channel. -type needsBranchMsg struct { - project string - candidates []string - answer chan branchAnswer -} - -// ============================================================================= -// tea.Model interface -// ============================================================================= - func (m AddModel) Init() tea.Cmd { // Streaming gather: each source runs as its own tea.Cmd so its // result lands on the bubbletea event loop the moment the source @@ -262,16 +225,6 @@ func (m AddModel) startSource(src Source) tea.Cmd { } } -// sourceDoneMsg lands on AddModel.Update each time a single source -// finishes its FetchSuggestions call. Multiple sourceDoneMsgs are -// expected per session (one per source). -type sourceDoneMsg struct { - name string - items []Suggestion - err error - took time.Duration -} - func (m AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -347,1066 +300,6 @@ func (m AddModel) View() string { return "" } -// ============================================================================= -// Gathering -// ============================================================================= - -// handleSourceDone folds one source's FetchSuggestions outcome into -// the model. Called for every source as it completes (sources run in -// parallel via separate tea.Cmds from Init), so this runs ~N times -// per session where N == len(m.sources). -// -// State transitions: -// - First source with results → addStateGathering → addStateBrowse -// (user sees something the moment any source finishes) -// - Last source done with no cumulative results → addStateBrowseEmpty -// - Subsequent sources after browse is reached → silently fold in; -// the rendered tree updates next frame -func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tea.Model, tea.Cmd) { - m.sourcesDone++ - m.sourceOutcomes = append(m.sourceOutcomes, SourceOutcome{ - Name: msg.name, - Count: len(msg.items), - Duration: msg.took, - Err: msg.err, - }) - if msg.err == nil && len(msg.items) > 0 { - // Re-run dedup against the existing list so a repo that - // shows up in two sources merges into one row even if the - // sources finish on different ticks. - merged := mergeSuggestions([][]Suggestion{m.allSuggestions, msg.items}) - sortByRelevance(merged) - m.allSuggestions = merged - // Cursor may need clamping if the dedup pass shrank an - // already-rendered list (rare but possible if a clipboard - // suggestion arrives last and merges with an existing GH - // suggestion). - if m.cursor >= len(m.allSuggestions) && len(m.allSuggestions) > 0 { - m.cursor = len(m.allSuggestions) - 1 - } - } - - // State decisions only apply while we're still on the gathering - // screen — sources finishing after the user has already entered - // manual/edit/confirm don't yank them back. - if m.state == addStateGathering { - switch { - case len(m.allSuggestions) > 0: - m.transitionTo(addStateBrowse) - case m.sourcesDone >= len(m.sources): - m.transitionTo(addStateBrowseEmpty) - } - } - return m, nil -} - -func (m AddModel) updateGathering(msg tea.Msg) (tea.Model, tea.Cmd) { - if msg, ok := msg.(spinner.TickMsg); ok { - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - return m, nil -} - -func (m AddModel) viewGathering() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Add project — gathering ")) - b.WriteString("\n\n") - b.WriteString(" " + m.spinner.View() + " probing sources") - if m.sourcesDone > 0 { - // Show progress so the user can tell we haven't hung — e.g. - // "(2/3 sources done)". - fmt.Fprintf(&b, " %s", addDim.Render(fmt.Sprintf("(%d/%d done)", m.sourcesDone, len(m.sources)))) - } - b.WriteString("\n\n") - // Per-source progress chips — same look as the in-browse line. - if len(m.sourceOutcomes) > 0 { - b.WriteString(" ") - b.WriteString(renderSourceChipsLive(m.sourceOutcomes)) - b.WriteString("\n\n") - } - b.WriteString(" " + addHelp.Render("[ctrl+c] cancel")) - return b.String() -} - -// ============================================================================= -// Browse -// ============================================================================= - -func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil - } - - if m.filterMode { - switch key.String() { - case "esc": - m.filterMode = false - m.filterInput.SetValue("") - m.filterInput.Blur() - return m, nil - case "enter": - m.filterMode = false - m.filterInput.Blur() - m.cursor = 0 - return m, nil - } - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(msg) - m.cursor = 0 - return m, cmd - } - - view := m.filteredView() - - switch key.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(view)-1 { - m.cursor++ - } - case "i": - m.transitionTo(addStateManual) - m.manualInput.SetValue("") - m.manualErr = "" - return m, m.manualInput.Focus() - case "/": - m.filterMode = true - return m, m.filterInput.Focus() - case "enter": - if len(view) == 0 { - return m, nil - } - // Bulk path: any URLs marked → confirm them all at once. - if len(m.selectedURLs) > 0 { - m.transitionTo(addStateBulkConfirm) - return m, nil - } - // Single path: edit the cursor row. - s := view[m.cursor] - m.editFields = m.editFromSuggestion(s) - m.editFocus = 0 - m.editErr = "" - m.transitionTo(addStateEdit) - return m, nil - case " ": - // Toggle the cursor row in the bulk-select set. The selection - // is keyed by RemoteURL so it survives filter changes and - // re-sorts. - if len(view) == 0 { - return m, nil - } - s := view[m.cursor] - if s.RemoteURL == "" { - return m, nil - } - if m.selectedURLs == nil { - m.selectedURLs = make(map[string]bool) - } - if m.selectedURLs[s.RemoteURL] { - delete(m.selectedURLs, s.RemoteURL) - } else { - m.selectedURLs[s.RemoteURL] = true - } - return m, nil - case "a": - // Mark every visible (filtered) suggestion. Toggle: if all - // visible are already selected, clear them. - if len(view) == 0 { - return m, nil - } - if m.selectedURLs == nil { - m.selectedURLs = make(map[string]bool) - } - allMarked := true - for _, s := range view { - if !m.selectedURLs[s.RemoteURL] { - allMarked = false - break - } - } - if allMarked { - for _, s := range view { - delete(m.selectedURLs, s.RemoteURL) - } - } else { - for _, s := range view { - if s.RemoteURL != "" { - m.selectedURLs[s.RemoteURL] = true - } - } - } - return m, nil - case "esc": - // Esc with selections clears them; esc on a clean browse exits. - if len(m.selectedURLs) > 0 { - m.selectedURLs = nil - return m, nil - } - done := m.toDone() - if m.standalone { - return done, tea.Sequence(emit(m.doneMsg()), tea.Quit) - } - return done, emit(m.doneMsg()) - } - return m, nil -} - -func (m AddModel) viewBrowse() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Add project ")) - b.WriteString("\n\n") - - view := m.filteredView() - if len(view) == 0 { - b.WriteString(addDim.Render(" No suggestions found.\n\n")) - b.WriteString(" " + addHelp.Render("[i] enter URL manually [esc] quit")) - return b.String() - } - - // Per-source diagnostics. Each chip reflects the status of one - // source as of "now": completed (with count), pending (spinner), - // or errored (with hint). Updates each frame as new sources land. - if len(m.sources) > 0 { - b.WriteString(" ") - b.WriteString(renderSourceChipsLive(m.sourceOutcomes)) - if m.sourcesDone < len(m.sources) { - fmt.Fprintf(&b, " %s", - addDim.Render(fmt.Sprintf("%s loading %d more...", - m.spinner.View(), len(m.sources)-m.sourcesDone))) - } - b.WriteString("\n\n") - } - - if m.filterInput.Value() != "" { - fmt.Fprintf(&b, " search: %s\n\n", addAccent.Render(m.filterInput.Value())) - } - - // Build the tree: group suggestions by owner / kind. The cursor - // (m.cursor) still indexes the flat filtered slice; the tree is a - // pure rendering concern. We compute which "rendered row" the - // cursor maps to and crop a window around it. - rows := buildBrowseRows(view) - cursorRow := -1 - itemSeen := 0 - for i, r := range rows { - if r.kind == rowItem { - if itemSeen == m.cursor { - cursorRow = i - } - itemSeen++ - } - } - - const visibleRows = 16 - start, end := windowAround(cursorRow, len(rows), visibleRows) - for i := start; i < end; i++ { - r := rows[i] - switch r.kind { - case rowGroup: - fmt.Fprintf(&b, " %s\n", r.text) - case rowItem: - s := r.suggestion - selected := i == cursorRow - marked := m.selectedURLs[s.RemoteURL] - cursor := " " - if selected && marked { - cursor = " " + addCursor.Render("▸") + addAccent.Render("●") - } else if selected { - cursor = " " + addCursor.Render("▸ ") - } else if marked { - cursor = " " + addAccent.Render("● ") - } - line := strings.TrimRight(renderItemLine(cursor, s), "\n") - if selected { - // Pad the line out to terminal width and apply a - // background highlight so the entire row reads as - // "this is what Enter will select". Width(0) is a - // no-op when m.width hasn't been seen yet (pre - // WindowSizeMsg) — falls back to natural length. - rs := addCursorRow - if m.width > 0 { - rs = rs.Width(m.width) - } - line = rs.Render(line) - } - b.WriteString(line + "\n") - } - } - if start > 0 || end < len(rows) { - fmt.Fprintf(&b, "\n %s\n", - addDim.Render(fmt.Sprintf("(scrolled %d/%d items)", m.cursor+1, len(view)))) - } - - // Selected-item preview: description + repo metadata. Always - // rendered when a row is highlighted so the visible height stays - // stable as the cursor moves. - if cursorRow >= 0 && cursorRow < len(rows) && rows[cursorRow].kind == rowItem { - b.WriteString("\n") - b.WriteString(renderSelectionPreview(rows[cursorRow].suggestion)) - } - - b.WriteString("\n") - if m.filterMode { - b.WriteString(" search: " + m.filterInput.View() + "\n") - b.WriteString(" " + addHelp.Render("[enter] commit [esc] cancel")) - } else if n := len(m.selectedURLs); n > 0 { - fmt.Fprintf(&b, " %s %s\n", - addAccent.Render(fmt.Sprintf("● %d marked", n)), - addHelp.Render("[⏎] confirm bulk add [space] toggle [a] all [esc] clear")) - b.WriteString(" " + addHelp.Render("[↑↓] navigate [/] search [i] manual URL")) - } else { - b.WriteString(" " + addHelp.Render("[↑↓] navigate [⏎] select [space] mark [a] all [/] search [i] manual URL [esc] quit")) - } - return b.String() -} - -// renderSelectionPreview shows the currently-selected suggestion's -// description and metadata (last push, activity, sources, paths). -// Always emits at least 2 lines so the screen height stays constant -// as the cursor moves between described and undescribed repos — -// otherwise the help line jumps. -func renderSelectionPreview(s *Suggestion) string { - var b strings.Builder - // Title line: name + URL. - b.WriteString(" " + addPreviewName.Render(s.Name)) - if u := shortURL(*s); u != "" { - b.WriteString(" " + addDim.Render(u)) - } - b.WriteString("\n") - - // Description, or a placeholder so the layout doesn't shift. - desc := strings.TrimSpace(s.Description) - if desc == "" { - desc = "(no description)" - b.WriteString(" " + addDim.Render(truncate(desc, 100)) + "\n") - } else { - // Replace newlines so multi-line descriptions don't blow out - // the layout. Truncate at ~100 chars for the same reason. - desc = strings.ReplaceAll(desc, "\n", " ") - b.WriteString(" " + truncate(desc, 100) + "\n") - } - - // Optional metadata: pushed timestamp, activity count, registered - // or local-disk hint repeated here for visibility (they're also - // rendered as inline tags on the row, but the preview is where - // the user looks for context after selecting). - var meta []string - if !s.PushedAt.IsZero() && s.PushedAt.Year() > 1 { - meta = append(meta, "pushed "+relativeTime(s.PushedAt)) - } - if s.GhActivity > 0 { - meta = append(meta, fmt.Sprintf("%d events", s.GhActivity)) - } - if s.RegisteredPath != "" { - meta = append(meta, "● already at "+s.RegisteredPath) - } else if s.DiskPath != "" { - meta = append(meta, "● local at "+s.DiskPath) - } - if len(meta) > 0 { - b.WriteString(" " + addDim.Render(strings.Join(meta, " · ")) + "\n") - } - return b.String() -} - -// truncate caps s at n characters with a trailing ellipsis when -// truncation occurs. Operates on bytes, which is wrong for any -// non-ASCII repo description but acceptable as a stop-gap; the -// fallout (a cut mid-rune) is cosmetic only. -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - if n <= 3 { - return s[:n] - } - return s[:n-1] + "…" -} - -// relativeTime renders a time.Time as a short "Nd ago" string. Used in -// the selection preview to give a quick "is this repo active" cue. -func relativeTime(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - case d < 7*24*time.Hour: - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) - case d < 30*24*time.Hour: - return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) - case d < 365*24*time.Hour: - return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) - default: - return fmt.Sprintf("%dy ago", int(d.Hours()/(24*365))) - } -} - -// browseRowKind tags a rendered line so the windowing math can tell -// group headers (which the cursor cannot land on) from item rows -// (which it can). -type browseRowKind int - -const ( - rowGroup browseRowKind = iota - rowItem -) - -type browseRow struct { - kind browseRowKind - text string // pre-formatted header text; empty for items - suggestion *Suggestion // non-nil for items -} - -// buildBrowseRows walks an already-sorted view (sortByRelevance puts -// it in group → in-group order) and emits a header row each time the -// group key changes. This keeps m.cursor's view-index aligned with -// the position of the matching item row in the rendered tree — -// critical for the cursor marker and Enter to point at the same -// suggestion. -func buildBrowseRows(view []Suggestion) []browseRow { - if len(view) == 0 { - return nil - } - - // First pass: count items per group key for the header counts. - // Cheap because the view is small (≤ low hundreds even at scale). - groupCounts := map[string]int{} - for i := range view { - k, _, _ := groupKey(view[i]) - groupCounts[k]++ - } - - var rows []browseRow - var lastKey string - for i := range view { - s := &view[i] - key, label, _ := groupKey(*s) - if key != lastKey { - header := fmt.Sprintf("%s %s", - addGroupHdr.Render(label), - addDim.Render(fmt.Sprintf("(%d)", groupCounts[key]))) - rows = append(rows, browseRow{kind: rowGroup, text: header}) - lastKey = key - } - rows = append(rows, browseRow{kind: rowItem, suggestion: s}) - } - return rows -} - -// groupKey returns (key, displayLabel, sortOrder) for a Suggestion. -// Sort order pins Clipboard at the top (most recent intent), then -// any disk-only entries (acting on what's already on the user's -// machine), then GitHub owners alphabetically. Mixed sources fall -// into the GitHub bucket because that's where they came from -// originally — the disk presence becomes a row-level highlight, not -// a separate bucket. -func groupKey(s Suggestion) (key, label string, order int) { - hasGh := hasSource(s.Sources, SourceGitHub) - hasClip := hasSource(s.Sources, SourceClipboard) - hasDisk := hasSource(s.Sources, SourceDisk) - hasManual := hasSource(s.Sources, SourceManual) - - switch { - case hasClip && !hasGh: - return "_clip", "Clipboard", 0 - case hasManual && !hasGh: - return "_manual", "Manual", 0 - case hasDisk && !hasGh: - return "_disk", "Local (unregistered)", 1 - case hasGh && s.InferredGrp != "": - return "gh:" + strings.ToLower(s.InferredGrp), s.InferredGrp, 2 - default: - return "_other", "Other", 3 - } -} - -// windowAround crops [0, total) to a visible-size window centered -// around `cursor`. Used by viewBrowse to keep the cursor in view -// without scrolling the entire 300-row tree. -func windowAround(cursor, total, size int) (start, end int) { - if total <= size { - return 0, total - } - if cursor < 0 { - return 0, size - } - half := size / 2 - start = cursor - half - if start < 0 { - start = 0 - } - end = start + size - if end > total { - end = total - start = end - size - } - return start, end -} - -// renderItemLine produces one suggestion-row in the browse list, -// applying the "already cloned" highlight when the suggestion has a -// disk path or a registered-path match. The cursor argument is the -// pre-rendered prefix (" ▸ " for the selected row, " " otherwise). -func renderItemLine(cursor string, s *Suggestion) string { - nameStyle := addItemName - suffix := "" - urlStyle := addDim - - switch { - case s.RegisteredPath != "": - // Already in workspace.toml — would create a duplicate. The - // highlight is loud enough to warn the user but the row - // stays selectable so they can intentionally make a copy. - nameStyle = addExists - suffix = " " + addExistsTag.Render( - fmt.Sprintf("● cloned at %s", s.RegisteredPath)) - case s.DiskPath != "": - // Found on disk but not registered — selecting will - // register the existing path (no clone). - nameStyle = addExists - suffix = " " + addExistsTag.Render( - fmt.Sprintf("● local: %s", s.DiskPath)) - } - - url := shortURL(*s) - return fmt.Sprintf("%s%s %s %s%s\n", - cursor, - nameStyle.Render(addPad(s.Name, 24)), - renderSourceChips(s.Sources), - urlStyle.Render(url), - suffix) -} - -func (m AddModel) filteredView() []Suggestion { - q := strings.ToLower(strings.TrimSpace(m.filterInput.Value())) - if q == "" { - return m.allSuggestions - } - var out []Suggestion - for _, s := range m.allSuggestions { - // Search across name, URL, owner/group, and the repo - // description so the user can find a repo by what it does - // (e.g. typing "graphql" matches any repo whose description - // mentions GraphQL), not just by name. - hay := strings.ToLower(s.Name + " " + s.RemoteURL + " " + s.InferredGrp + " " + s.Description) - if strings.Contains(hay, q) { - out = append(out, s) - } - } - return out -} - -func (m AddModel) editFromSuggestion(s Suggestion) editFields { - cat := config.CategoryPersonal - // Crude heuristic: if the inferred group looks like a work org - // (anything other than the user's GitHub login or "personal"), - // default to Work. The user can flip on the edit screen. - grp := s.InferredGrp - if grp != "" && grp != "personal" { - cat = config.CategoryWork - } - return editFields{ - Name: s.Name, - URL: s.RemoteURL, - Category: cat, - Group: grp, - Path: buildPath(grp, cat, s.Name), - FromDisk: s.DiskPath, - } -} - -// ============================================================================= -// Manual URL input -// ============================================================================= - -func (m AddModel) updateManual(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "enter": - val := strings.TrimSpace(m.manualInput.Value()) - if val == "" { - m.manualErr = "URL is required" - return m, nil - } - // Build editFields from the bare URL. - name := parseRepoNameFromURL(val) - m.editFields = editFields{ - Name: name, - URL: val, - Category: config.CategoryPersonal, - Group: "", - Path: buildPath("", config.CategoryPersonal, name), - } - m.editFocus = 0 - m.editErr = "" - m.transitionTo(addStateEdit) - return m, nil - case "esc": - m.transitionTo(addStateBrowse) - m.manualInput.Blur() - return m, nil - } - } - var cmd tea.Cmd - m.manualInput, cmd = m.manualInput.Update(msg) - return m, cmd -} - -func (m AddModel) viewManual() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Manual URL ")) - b.WriteString("\n\n") - b.WriteString(" " + m.manualInput.View() + "\n") - if m.manualErr != "" { - b.WriteString("\n " + addErr.Render(m.manualErr) + "\n") - } - b.WriteString("\n " + addHelp.Render("[⏎] continue [esc] back")) - return b.String() -} - -// ============================================================================= -// Edit -// ============================================================================= - -func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil - } - switch key.String() { - case "tab", "down": - m.editFocus = (m.editFocus + 1) % 4 // 0=Name 1=URL 2=Category 3=Group - case "shift+tab", "up": - m.editFocus = (m.editFocus + 3) % 4 - case "enter": - // Validate & advance to confirm. - if err := m.validateEdit(); err != nil { - m.editErr = err.Error() - return m, nil - } - m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) - m.transitionTo(addStateConfirm) - return m, nil - case "esc": - m.transitionTo(addStateBrowse) - return m, nil - default: - // Plain typing edits the focused field. - s := key.String() - // Filter to printable rune-ish keys. - if key.Type == tea.KeyRunes { - runes := key.Runes - m.applyEditRunes(runes) - return m, nil - } - if s == "backspace" { - m.applyEditBackspace() - return m, nil - } - } - return m, nil -} - -func (m *AddModel) applyEditRunes(runes []rune) { - r := string(runes) - switch m.editFocus { - case 0: - m.editFields.Name += r - case 1: - m.editFields.URL += r - case 2: - // Category: cycle on space, otherwise ignore alphabetic input - // — only personal|work allowed. - if r == " " { - if m.editFields.Category == config.CategoryPersonal { - m.editFields.Category = config.CategoryWork - } else { - m.editFields.Category = config.CategoryPersonal - } - } - case 3: - m.editFields.Group += r - } - m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) -} - -func (m *AddModel) applyEditBackspace() { - switch m.editFocus { - case 0: - if len(m.editFields.Name) > 0 { - m.editFields.Name = m.editFields.Name[:len(m.editFields.Name)-1] - } - case 1: - if len(m.editFields.URL) > 0 { - m.editFields.URL = m.editFields.URL[:len(m.editFields.URL)-1] - } - case 3: - if len(m.editFields.Group) > 0 { - m.editFields.Group = m.editFields.Group[:len(m.editFields.Group)-1] - } - } - m.editFields.Path = buildPath(m.editFields.Group, m.editFields.Category, m.editFields.Name) -} - -func (m AddModel) validateEdit() error { - if strings.TrimSpace(m.editFields.Name) == "" { - return errors.New("name is required") - } - if strings.TrimSpace(m.editFields.URL) == "" { - return errors.New("URL is required") - } - if m.editFields.Category != config.CategoryPersonal && m.editFields.Category != config.CategoryWork { - return errors.New("category must be personal or work") - } - if _, exists := m.ws.Projects[m.editFields.Name]; exists { - return fmt.Errorf("name %q is already registered", m.editFields.Name) - } - return nil -} - -func (m AddModel) viewEdit() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Edit project ")) - b.WriteString("\n\n") - - rows := []struct{ label, value string }{ - {"Name", m.editFields.Name}, - {"URL", m.editFields.URL}, - {"Category", string(m.editFields.Category) + addDim.Render(" (space to toggle: personal | work)")}, - {"Group", m.editFields.Group + addDim.Render(" (auto-inferred; empty → category)")}, - } - for i, r := range rows { - marker := " " - label := r.label - if i == m.editFocus { - marker = addCursor.Render("▸ ") - label = addAccent.Render(r.label) - } - fmt.Fprintf(&b, " %s%s: %s\n", marker, addPad(label, 12), r.value) - } - fmt.Fprintf(&b, "\n %s: %s\n", addPad("Path", 12), addDim.Render(m.editFields.Path)) - - if m.editErr != "" { - b.WriteString("\n " + addErr.Render(m.editErr) + "\n") - } - b.WriteString("\n " + addHelp.Render("[tab/↑↓] field [⏎] confirm [esc] back")) - return b.String() -} - -// ============================================================================= -// Confirm -// ============================================================================= - -func (m AddModel) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "y", "Y", "enter": - m.queue = append(m.queue, m.editFields) - m.currentIdx = 0 - m.transitionTo(addStateCloning) - return m, tea.Batch(m.spinner.Tick, m.startCloneJob(0)) - case "n", "N", "esc": - m.transitionTo(addStateBrowse) - return m, nil - } - } - return m, nil -} - -func (m AddModel) viewConfirm() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Confirm ")) - b.WriteString("\n\n") - fmt.Fprintf(&b, " Add %s\n", addAccent.Render(m.editFields.Name)) - fmt.Fprintf(&b, " %s\n", addDim.Render(m.editFields.URL)) - fmt.Fprintf(&b, " %s → %s\n\n", - string(m.editFields.Category), - addDim.Render(m.editFields.Path)) - if m.editFields.FromDisk != "" { - b.WriteString(" " + addDim.Render("(disk) repo already at "+m.editFields.FromDisk+ - " — register only, no clone\n")) - b.WriteString("\n") - } - b.WriteString(" " + addHelp.Render("[y/⏎] add [n/esc] back")) - return b.String() -} - -// ============================================================================= -// Bulk confirm -// ============================================================================= - -// updateBulkConfirm handles the multi-add confirmation screen reached -// from browse when the user pressed `enter` with one or more URLs -// marked. Confirming queues every marked suggestion via -// editFromSuggestion (default category/group inferred from owner) and -// transitions to the existing cloning loop unchanged. -func (m AddModel) updateBulkConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil - } - switch key.String() { - case "y", "Y", "enter": - queue := m.buildBulkQueue() - if len(queue) == 0 { - m.transitionTo(addStateBrowse) - return m, nil - } - m.queue = queue - m.currentIdx = 0 - m.selectedURLs = nil - m.transitionTo(addStateCloning) - return m, tea.Batch(m.spinner.Tick, m.startCloneJob(0)) - case "n", "N", "esc": - m.transitionTo(addStateBrowse) - return m, nil - } - return m, nil -} - -// buildBulkQueue resolves the marked URLs to editFields, preserving -// the order they appear in allSuggestions (alphabetised by group → -// name). Skips URLs that no longer exist in allSuggestions and URLs -// already registered in workspace.toml so a stale selection cannot -// accidentally re-clone an existing project. -func (m AddModel) buildBulkQueue() []editFields { - if len(m.selectedURLs) == 0 { - return nil - } - var out []editFields - for i := range m.allSuggestions { - s := m.allSuggestions[i] - if !m.selectedURLs[s.RemoteURL] { - continue - } - if s.RegisteredPath != "" { - continue - } - out = append(out, m.editFromSuggestion(s)) - } - return out -} - -func (m AddModel) viewBulkConfirm() string { - queue := m.buildBulkQueue() - var b strings.Builder - b.WriteString(addTitle.Render(" Bulk add ")) - b.WriteString("\n\n") - if len(queue) == 0 { - b.WriteString(" " + addDim.Render("(no eligible URLs — every selection is already registered)\n")) - b.WriteString("\n " + addHelp.Render("[esc] back")) - return b.String() - } - fmt.Fprintf(&b, " Will add %s repos:\n\n", addAccent.Render(fmt.Sprintf("%d", len(queue)))) - const max = 10 - shown := queue - if len(shown) > max { - shown = shown[:max] - } - for _, ef := range shown { - fmt.Fprintf(&b, " • %s %s %s\n", - addItemName.Render(addPad(ef.Name, 24)), - addDim.Render(fmt.Sprintf("[%s]", ef.Category)), - addDim.Render(ef.URL)) - } - if len(queue) > max { - fmt.Fprintf(&b, " %s\n", addDim.Render(fmt.Sprintf("…and %d more", len(queue)-max))) - } - b.WriteString("\n " + addHelp.Render("[y/⏎] confirm [n/esc] back")) - return b.String() -} - -// ============================================================================= -// Cloning -// ============================================================================= - -func (m AddModel) startCloneJob(idx int) tea.Cmd { - if idx >= len(m.queue) { - return func() tea.Msg { return allClonesDoneMsg{} } - } - job := m.queue[idx] - return func() tea.Msg { - // Build a per-iteration Options for Register. Disk-found - // suggestions register-only (NoClone) since the repo is - // already on the user's machine; everything else clones into - // the bare+worktree layout via Register → CloneIntoLayout. - // - // Register is non-interactive: if the clone returns - // ErrNeedsBootstrap, we surface it as a per-job error and - // the user is told to run `ws bootstrap ` afterwards. - // The branchPrompt sub-state in the TUI is wired to handle - // a future needsBranchMsg flow if we ever decide to plumb - // the prompt through (the same answer-channel pattern - // bootstrap uses). - opts := Options{ - URLs: []string{job.URL}, - Name: job.Name, - Category: job.Category, - Group: job.Group, - WsRoot: m.wsRoot, - Workspace: m.ws, - Save: m.saveFn, - Mode: ModeHeadless, - NoClone: job.FromDisk != "", // disk-found → register only - } - - regRes, err := Register(opts, job.URL) - out := cloneDoneMsg{idx: idx} - if err != nil { - if errors.Is(err, ErrAlreadyRegistered) { - out.skipped = &SkipReason{URL: job.URL, Reason: err.Error()} - } else if errors.Is(err, clone.ErrNeedsBootstrap) { - out.err = fmt.Errorf("%s: default branch ambiguous (run `ws bootstrap %s` after add)", job.Name, job.Name) - } else { - out.err = err - } - } else if regRes != nil { - out.project = regRes.Project - } - return out - } -} - -func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case cloneDoneMsg: - switch { - case msg.err != nil: - m.errors = append(m.errors, msg.err) - case msg.skipped != nil: - m.skipped = append(m.skipped, *msg.skipped) - default: - m.added = append(m.added, msg.project) - } - m.currentIdx = msg.idx + 1 - if m.currentIdx >= len(m.queue) { - m.transitionTo(addStateDone) - if m.standalone { - return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) - } - return m, emit(m.doneMsg()) - } - return m, m.startCloneJob(m.currentIdx) - case needsBranchMsg: - // Wired but unreachable today: no clone path emits - // needsBranchMsg. Kept so a future caller that wants to - // route clone.ErrNeedsBootstrap through the TUI prompt - // has the plumbing ready (same answer-channel pattern as - // bootstrap). - m.branchPrompt = branchprompt.NewModel(msg.project, msg.candidates) - m.branchAnswer = msg.answer - m.transitionTo(addStateBranchPrompt) - return m, nil - case allClonesDoneMsg: - m.transitionTo(addStateDone) - if m.standalone { - return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) - } - return m, emit(m.doneMsg()) - } - return m, nil -} - -func (m AddModel) viewCloning() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Cloning ")) - b.WriteString("\n\n") - total := len(m.queue) - done := m.currentIdx - fmt.Fprintf(&b, " %d / %d\n\n", done, total) - if m.currentIdx < total { - j := m.queue[m.currentIdx] - fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), j.Name) - fmt.Fprintf(&b, " %s\n", addDim.Render(j.Path)) - } - if len(m.errors) > 0 { - fmt.Fprintf(&b, "\n %s %d failed\n", addErr.Render("✗"), len(m.errors)) - } - b.WriteString("\n " + addHelp.Render("[ctrl+c] abort")) - return b.String() -} - -// ============================================================================= -// Branch prompt -// -// Plumbing for routing clone.ErrNeedsBootstrap through the -// branchprompt sub-state. Currently unreachable — no clone path -// emits needsBranchMsg — but the wiring is complete so a future -// caller can hook it up without restructuring the state machine. -// ============================================================================= - -func (m AddModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case branchprompt.PickedMsg: - m.resolveBranch(msg.Branch, nil) - m.transitionTo(addStateCloning) - return m, nil - case branchprompt.CancelledMsg: - m.resolveBranch("", errors.New("user canceled branch selection")) - m.transitionTo(addStateCloning) - return m, nil - } - var cmd tea.Cmd - m.branchPrompt, cmd = m.branchPrompt.Update(msg) - return m, cmd -} - -func (m *AddModel) resolveBranch(branch string, err error) { - if m.branchAnswer != nil { - m.branchAnswer <- branchAnswer{branch: branch, err: err} - m.branchAnswer = nil - } -} - -// ============================================================================= -// Done -// ============================================================================= - -func (m AddModel) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { - if m.standalone { - return m, tea.Quit - } - } - return m, nil -} - -func (m AddModel) viewDone() string { - var b strings.Builder - b.WriteString(addTitle.Render(" Done ")) - b.WriteString("\n\n") - fmt.Fprintf(&b, " %s %d added\n", addCheck.Render("✓"), len(m.added)) - if len(m.skipped) > 0 { - fmt.Fprintf(&b, " %s %d skipped\n", addDim.Render("⊘"), len(m.skipped)) - } - if len(m.errors) > 0 { - fmt.Fprintf(&b, " %s %d errored\n", addErr.Render("✗"), len(m.errors)) - b.WriteString("\n") - for _, e := range m.errors { - fmt.Fprintf(&b, " %s\n", addDim.Render(e.Error())) - } - } - b.WriteString("\n " + addHelp.Render("[any key] exit")) - return b.String() -} - -// ============================================================================= -// Helpers -// ============================================================================= - func (m *AddModel) transitionTo(s addState) { m.state = s m.stateChangedAt = time.Now() @@ -1421,195 +314,3 @@ func (m AddModel) toDone() AddModel { func (m AddModel) doneMsg() AddDoneMsg { return AddDoneMsg{Added: m.added, Skipped: m.skipped, Errors: m.errors} } - -func emit(msg tea.Msg) tea.Cmd { - return func() tea.Msg { return msg } -} - -func parseRepoNameFromURL(url string) string { - // Lightweight wrapper around git.ParseRepoName to avoid a dep - // loop into internal/git for code that doesn't otherwise need it. - url = strings.TrimSpace(url) - url = strings.TrimSuffix(url, ".git") - url = strings.TrimSuffix(url, "/") - if i := strings.LastIndexAny(url, "/:"); i >= 0 { - return url[i+1:] - } - return url -} - -func addPad(s string, n int) string { - if len(s) >= n { - return s - } - return s + strings.Repeat(" ", n-len(s)) -} - -func renderSourceChips(srcs []SourceKind) string { - if len(srcs) == 0 { - return "" - } - var parts []string - for _, k := range srcs { - parts = append(parts, addChip.Render("["+k.String()+"]")) - } - return strings.Join(parts, " ") -} - -func shortURL(s Suggestion) string { - if s.RemoteURL != "" { - return s.RemoteURL - } - if s.DiskPath != "" { - return s.DiskPath - } - return "" -} - -// renderSourceChipsLive turns the model's accumulated per-source -// outcomes into a single status line. Used both in the gathering -// view (when the user is staring at the spinner) and in the browse -// view (where it lives above the tree as a status bar). -// -// Color rules: -// -// green (2): source returned a non-empty result -// dim (8): source returned 0 (empty but successful) -// amber (3): source errored -func renderSourceChipsLive(outcomes []SourceOutcome) string { - var chips []string - for _, o := range outcomes { - var color string - var label string - switch { - case o.Err != nil: - color = "3" - label = fmt.Sprintf("%s:err (%s)", o.Name, sourceErrHint(o.Err)) - case o.Count == 0: - color = "8" - label = fmt.Sprintf("%s:0", o.Name) - default: - color = "2" - label = fmt.Sprintf("%s:%d", o.Name, o.Count) - } - chips = append(chips, lipgloss.NewStyle(). - Foreground(lipgloss.Color(color)).Render(label)) - } - return strings.Join(chips, " ") -} - -// sourceErrHint summarizes a per-source error into a one-or-two-word -// chip suffix. Keeps the gather chips readable on narrow terminals -// without burying the user in stack-trace prose. -// -// Errors in the source pipeline are wrapped as `: ` or -// even `: : ` (clipboard wraps the binary path, -// github wraps "github source", etc). The fallback strips those -// prefixes and shows the deepest cause — that's the actionable bit -// the user wants to read. -func sourceErrHint(err error) string { - if err == nil { - return "" - } - msg := err.Error() - switch { - case errors.Is(err, context.DeadlineExceeded): - return "timeout" - case errors.Is(err, context.Canceled): - return "canceled" - case strings.Contains(msg, "ErrNotAuthed"), strings.Contains(msg, "not authed"): - return "no auth" - case strings.Contains(strings.ToLower(msg), "rate limit"), - strings.Contains(msg, "API rate limit"): - return "rate-limited" - case strings.Contains(strings.ToLower(msg), "401"), - strings.Contains(strings.ToLower(msg), "unauthorized"): - return "401 expired?" - case strings.Contains(msg, "Nothing is copied"), - strings.Contains(msg, "No selection"): - return "empty" - } - // Fallback: drop everything up to and including the LAST `: ` so - // "/sbin/wl-paste: failed to bind" → "failed to bind". Cap at 24 - // chars, single line. - tail := msg - if i := strings.LastIndex(msg, ": "); i >= 0 { - tail = strings.TrimSpace(msg[i+2:]) - } - tail = strings.ReplaceAll(tail, "\n", " ") - if len(tail) > 24 { - tail = tail[:24] - } - return tail -} - -// ============================================================================= -// Styles -// ============================================================================= - -var ( - addTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). - Padding(0, 1) - - addDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - addHelp = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - addCursor = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). - Bold(true) - - addAccent = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). - Bold(true) - - addErr = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). - Bold(true) - - addCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - - addChip = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) - - // Group header: bright magenta + bold so org names stand out - // against the muted body. Underline gives a clear visual break - // between groups in dense lists. - addGroupHdr = lipgloss.NewStyle(). - Foreground(lipgloss.Color("5")). - Bold(true). - Underline(true) - - // Default item-name color for fresh suggestions. - addItemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - - // "Already cloned" highlight for items that map to a registered - // project or an unregistered local clone. Yellow so it screams - // "look at me" without going full red, since picking the row is - // still allowed (creates a copy after rename). - addExists = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")). - Bold(true) - - // Tag suffix that follows the item name, with a slightly dimmer - // shade so it reads as metadata not part of the name. - addExistsTag = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")). - Italic(true) - - // Selection-preview header: bright cyan + bold, distinct from the - // row's name color so the preview reads as separate panel. - addPreviewName = lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). - Bold(true) - - // Cursor-row highlight: dark gray background, applied to the - // entire selected row (padded to terminal width). Lipgloss - // re-applies the bg around any inner ANSI sequences so chip - // colors, dim URLs, and the cursor arrow keep their fg styling - // while the bg stays continuous across the line. - addCursorRow = lipgloss.NewStyle(). - Background(lipgloss.Color("237")) -) diff --git a/internal/agent/chip_action.go b/internal/agent/chip_action.go new file mode 100644 index 0000000..518237e --- /dev/null +++ b/internal/agent/chip_action.go @@ -0,0 +1,89 @@ +package agent + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// updateChipAction handles the modal opened by 1-9 on a header chip. +// The user picks the action: c = claude, p = claude+prompt, s = shell, +// w = new worktree (project chips only), esc = cancel. +func (m *Model) updateChipAction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.chipTarget == nil { + m.mode = viewList + return m, nil + } + target := *m.chipTarget + switch msg.String() { + case "esc", "q": + m.chipTarget = nil + m.mode = viewList + return m, nil + case "c", "enter": + m.Launch = &LaunchRequest{Cwd: target.Path} + return m, tea.Quit + case "s", "l": + m.Launch = &LaunchRequest{Cwd: target.Path, ShellOnly: true} + return m, tea.Quit + case "p": + m.pendingLaunch = &LaunchRequest{Cwd: target.Path} + m.promptInput = "" + m.chipTarget = nil + m.mode = viewPromptInput + return m, nil + case "w": + // Worktree creation is project-only — groups have no bare repo. + if target.Kind == KindProject && target.Project != nil { + m.popupProj = target.Project + m.wtBranch = "" + m.wtField = 0 + m.wtNoLaunch = true + m.chipTarget = nil + m.mode = viewNewWorktree + return m, nil + } + } + return m, nil +} + +// viewChipAction renders the modal asking what to do with the picked +// chip. Centered, narrow, with a compact action list. The chip +// reference stays valid across redraws because chipTarget is a copy. +func (m *Model) viewChipAction() string { + if m.chipTarget == nil { + return m.viewList() + } + target := *m.chipTarget + popupW := 44 + if m.width < 50 { + popupW = m.width - 6 + } + innerW := popupW - 6 + + kindLabel := "project" + if target.Kind == KindGroup { + kindLabel = "group" + } + + var lines []string + lines = append(lines, popupTitleStyle.Width(innerW).Render(fmt.Sprintf("Launch %s", kindLabel))) + lines = append(lines, popupDimStyle.Width(innerW).Render(target.Name)) + lines = append(lines, popupDimStyle.Width(innerW).Render(target.Path)) + lines = append(lines, "") + lines = append(lines, popupItemStyle.Width(innerW).Render(" c / ⏎ claude")) + lines = append(lines, popupItemStyle.Width(innerW).Render(" p claude + prompt")) + lines = append(lines, popupItemStyle.Width(innerW).Render(" s / l shell")) + if target.Kind == KindProject { + lines = append(lines, popupItemStyle.Width(innerW).Render(" w new worktree")) + } + lines = append(lines, "") + lines = append(lines, popupDimStyle.Width(innerW).Render(" esc cancel")) + + content := strings.Join(lines, "\n") + popup := popupBorderStyle.Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup, + lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) +} diff --git a/internal/agent/flash.go b/internal/agent/flash.go new file mode 100644 index 0000000..284d342 --- /dev/null +++ b/internal/agent/flash.go @@ -0,0 +1,179 @@ +package agent + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// jumpLabels is the alphabet used for flash jump labels. +const jumpLabels = "asdfghjklqwertyuiopzxcvbnm" + +func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.exitFlash(false) + case "backspace": + if len(m.flashQuery) > 0 { + m.flashQuery = m.flashQuery[:len(m.flashQuery)-1] + m.recomputeFlash() + } else { + m.exitFlash(false) + } + case "enter": + // Jump to first match. + if len(m.flashMatches) > 0 { + m.cursor = m.flashMatches[0] + m.ensureVisible() + } + m.exitFlash(true) + default: + if len(key) == 1 && key[0] >= 32 && key[0] < 127 { + ch := rune(key[0]) + // Check if this character is a non-conflicting jump label. + // Labels are only assigned from characters that would NOT + // match if appended to the query, so this is unambiguous. + if m.flashQuery != "" { + for i, label := range m.flashLabels { + if label != 0 && ch == label && i < len(m.flashMatches) { + m.cursor = m.flashMatches[i] + m.ensureVisible() + m.exitFlash(true) + return m, nil + } + } + } + // Not a label — append to query to narrow results. + m.flashQuery += key + m.recomputeFlash() + } + } + return m, nil +} + +// exitFlash leaves flash mode. For global search (S), if the user +// canceled (jumped=false), restore the original expansion state. +// If they jumped to an item, keep expansions so the target is visible. +func (m *Model) exitFlash(jumped bool) { + m.mode = viewList + if m.flashGlobal && !jumped && m.savedExpanded != nil { + m.expanded = m.savedExpanded + m.savedExpanded = nil + m.rebuildItems() + m.ensureVisible() + } + m.flashGlobal = false +} + +func (m *Model) recomputeFlash() { + query := strings.ToLower(m.flashQuery) + m.flashMatches = nil + m.flashLabels = nil + + // Collect matches. Section rows are non-selectable and must never + // appear in the flash match list — pressing a label that targets + // a section row would be a no-op and confuse the user. + for i, item := range m.items { + name := m.itemSearchName(item) + if query == "" || strings.Contains(strings.ToLower(name), query) { + m.flashMatches = append(m.flashMatches, i) + } + } + + // Compute non-conflicting labels: only use characters that, when + // appended to the current query, would NOT match any item. This + // makes label presses unambiguous — they can never be mistaken for + // "continue typing to narrow results". + available := m.availableJumpLabels() + for i := 0; i < len(m.flashMatches); i++ { + if i < len(available) { + m.flashLabels = append(m.flashLabels, available[i]) + } else { + m.flashLabels = append(m.flashLabels, 0) // no label — need more query chars + } + } +} + +// availableJumpLabels returns characters safe to use as jump labels: +// letters that, if appended to the current query, would produce zero +// matches. This guarantees pressing a label always means "jump", never +// "keep filtering". +func (m *Model) availableJumpLabels() []rune { + query := strings.ToLower(m.flashQuery) + if query == "" { + return nil // no labels until user types at least one char + } + var available []rune + for _, r := range jumpLabels { + extended := query + string(r) + productive := false + for _, item := range m.items { + name := strings.ToLower(m.itemSearchName(item)) + if strings.Contains(name, extended) { + productive = true + break + } + } + if !productive { + available = append(available, r) + } + } + return available +} + +// itemSearchName returns the searchable text for a list item. +func (m *Model) itemSearchName(item listItem) string { + switch item.kind { + case KindGroup: + return item.group + case KindProject: + return item.project.Name + case KindWorktree: + return item.group // display name + case KindPortal: + if item.session != nil { + return item.session.Title + } + } + return "" +} + +// flashInlineLabel highlights the query match in a name and, when a +// non-zero label is available, overlays it on the character after the +// match. When label is 0 (no label assigned yet), only the match is +// highlighted — the user needs to type more chars. +func flashInlineLabel(name, query string, label rune) string { + if query == "" { + return name + } + lower := strings.ToLower(name) + q := strings.ToLower(query) + idx := strings.Index(lower, q) + if idx < 0 { + return name + } + matchEnd := idx + len(q) + runes := []rune(name) + + var b strings.Builder + if idx > 0 { + b.WriteString(string(runes[:idx])) + } + b.WriteString(flashMatchStyle.Render(string(runes[idx:matchEnd]))) + if label != 0 { + // Overlay label on the next character. + b.WriteString(flashLabelStyle.Render(string(label))) + if matchEnd+1 < len(runes) { + b.WriteString(string(runes[matchEnd+1:])) + } + } else { + // No label — just show the rest of the name. + if matchEnd < len(runes) { + b.WriteString(string(runes[matchEnd:])) + } + } + return b.String() +} diff --git a/internal/agent/forms.go b/internal/agent/forms.go new file mode 100644 index 0000000..6d5c9f6 --- /dev/null +++ b/internal/agent/forms.go @@ -0,0 +1,181 @@ +package agent + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/layout" +) + +func (m *Model) updateNewWorktree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + switch key { + case "esc": + m.mode = viewList + return m, nil + case "tab", "down": + m.wtField = (m.wtField + 1) % 2 + return m, nil + case "shift+tab", "up": + m.wtField = (m.wtField + 1) % 2 + return m, nil + case "enter": + if m.wtField == 1 { // confirm + return m.executeNewWorktree() + } + m.wtField = (m.wtField + 1) % 2 + return m, nil + case "backspace": + if m.wtField == 0 && len(m.wtBranch) > 0 { + m.wtBranch = m.wtBranch[:len(m.wtBranch)-1] + } + return m, nil + default: + if m.wtField == 0 && len(key) == 1 && key[0] >= 32 && key[0] < 127 { + m.wtBranch += key + } + } + return m, nil +} + +func (m *Model) executeNewWorktree() (tea.Model, tea.Cmd) { + branch := strings.TrimSpace(m.wtBranch) + if branch == "" { + return m, nil + } + + wsRoot := m.workspaceRootFor(m.popupProj) + result, err := CreateWorktree(m.popupProj, branch, wsRoot, m.popupProj.ID) + if err != nil { + m.statusMsg = err.Error() + m.mode = viewList + return m, nil + } + m.wtCache.Invalidate(m.popupProj.Path) + + // If "create worktree only" (w key), go back to list. + if m.wtNoLaunch { + m.wtNoLaunch = false + m.mode = viewList + m.rebuildItems() + m.ensureVisible() + m.statusMsg = "worktree created" + return m, nil + } + + // Go to prompt input before launching. + m.pendingLaunch = &LaunchRequest{Cwd: result.Path} + m.promptInput = "" + m.mode = viewPromptInput + return m, nil +} + +func (m *Model) viewNewWorktree() string { + p := m.popupProj + popupW := 50 + if m.width < 56 { + popupW = m.width - 6 + } + innerW := popupW - 6 + + var lines []string + lines = append(lines, popupTitleStyle.Width(innerW).Render(fmt.Sprintf("%s New worktree for %s", iconWorktree, p.Name))) + lines = append(lines, "") + + // Field 0: branch (single input — user types the literal branch name). + branchLabel := " Branch name:" + branchVal := m.wtBranch + "█" + if m.wtField != 0 { + branchVal = m.wtBranch + if branchVal == "" { + branchVal = "(required)" + } + } + if m.wtField == 0 { + lines = append(lines, popupSelectedStyle.Width(innerW).Render(branchLabel)) + lines = append(lines, popupSelectedStyle.Width(innerW).Render(" "+branchVal)) + } else { + lines = append(lines, popupItemStyle.Width(innerW).Render(branchLabel)) + lines = append(lines, popupDimStyle.Width(innerW).Render(" "+branchVal)) + } + if branch := strings.TrimSpace(m.wtBranch); branch != "" { + pathPreview := fmt.Sprintf(" → dir: %s-wt--%s", p.Name, layout.SlugifyBranch(branch)) + lines = append(lines, popupDimStyle.Width(innerW).Render(pathPreview)) + } + lines = append(lines, "") + + // Field 1: confirm button + confirmLabel := " → Create worktree" + if m.wtField == 1 { + lines = append(lines, popupSelectedStyle.Width(innerW).Render(confirmLabel)) + } else { + lines = append(lines, popupItemStyle.Width(innerW).Render(confirmLabel)) + } + + lines = append(lines, "") + lines = append(lines, popupDimStyle.Width(innerW).Render("tab:next enter:confirm esc:back")) + + content := strings.Join(lines, "\n") + popup := popupBorderStyle.Render(content) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup, + lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) +} + +func (m *Model) updatePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "esc": + m.mode = viewList + m.pendingLaunch = nil + case "enter": + // Launch with or without prompt. + m.pendingLaunch.Prompt = strings.TrimSpace(m.promptInput) + m.Launch = m.pendingLaunch + m.pendingLaunch = nil + return m, tea.Quit + case "backspace": + if len(m.promptInput) > 0 { + m.promptInput = m.promptInput[:len(m.promptInput)-1] + } + default: + if len(key) == 1 && key[0] >= 32 { + m.promptInput += key + } else if key == "space" || key == " " { + m.promptInput += " " + } + } + return m, nil +} + +func (m *Model) viewPromptInput() string { + if m.pendingLaunch == nil { + m.mode = viewList + return m.viewList() + } + popupW := 56 + if m.width < 62 { + popupW = m.width - 6 + } + innerW := popupW - 6 + + var lines []string + lines = append(lines, popupTitleStyle.Width(innerW).Render("Launch claude")) + lines = append(lines, popupDimStyle.Width(innerW).Render(fmt.Sprintf("in: %s", m.pendingLaunch.Cwd))) + lines = append(lines, "") + lines = append(lines, popupItemStyle.Width(innerW).Render(" Initial prompt (optional):")) + + input := m.promptInput + "█" + lines = append(lines, popupSelectedStyle.Width(innerW).Render(" "+input)) + lines = append(lines, "") + lines = append(lines, popupDimStyle.Width(innerW).Render(" Enter: launch (empty = interactive)")) + lines = append(lines, popupDimStyle.Width(innerW).Render(" Esc: back")) + + content := strings.Join(lines, "\n") + popup := popupBorderStyle.Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup, + lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) +} diff --git a/internal/agent/header.go b/internal/agent/header.go new file mode 100644 index 0000000..dd4c92a --- /dev/null +++ b/internal/agent/header.go @@ -0,0 +1,247 @@ +package agent + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// HeaderCap is the maximum number of chips that share the pinned +// quick-nav header. Nine because chips are numbered 1-9 for direct +// keyboard launch — adding more would need shift+digit and isn't +// worth the cognitive cost. +const HeaderCap = 9 + +// buildHeaderChips returns the ordered list of chips rendered in the +// pinned quick-nav. Favorites come first (groups and projects merged, +// sorted by activity desc with name asc tiebreak; groups carry zero +// activity so they sort last among favs), then non-favorite +// recently-touched projects. Capped at HeaderCap so chips fit in the +// 1-9 hotkey range. +func buildHeaderChips(workspaces []WorkspaceData) []Chip { + var favs, recent []Chip + for i := range workspaces { + ws := &workspaces[i] + for j := range ws.Projects { + p := &ws.Projects[j] + c := Chip{ + Kind: KindProject, + Name: p.Name, + Path: p.Path, + Favorite: p.Favorite, + LastActiveAt: p.LastActiveAt, + Project: p, + WorkspaceRoot: ws.Root, + } + if p.Favorite { + favs = append(favs, c) + } else if !p.LastActiveAt.IsZero() { + recent = append(recent, c) + } + } + for _, g := range ws.Groups { + if !ws.FavoriteGroups[g] { + continue + } + favs = append(favs, Chip{ + Kind: KindGroup, + Name: g, + Path: GroupPath(ws.Root, g), + Favorite: true, + WorkspaceRoot: ws.Root, + }) + } + } + sortChipsByActivity(favs) + sortChipsByActivity(recent) + merged := append(favs, recent...) + if len(merged) > HeaderCap { + merged = merged[:HeaderCap] + } + return merged +} + +func sortChipsByActivity(cs []Chip) { + sort.Slice(cs, func(i, j int) bool { + ai, aj := cs[i].LastActiveAt, cs[j].LastActiveAt + if !ai.Equal(aj) { + return ai.After(aj) + } + return cs[i].Name < cs[j].Name + }) +} + +// humanizeAge returns a short human-readable age for the activity +// column, e.g. "2m", "3h", "yday", "5d", "3w", "2mo", "1y". Returns +// the empty string when t is zero (no activity recorded). +func humanizeAge(t time.Time) string { + if t.IsZero() { + return "" + } + return humanizeAgeAt(t, time.Now()) +} + +func humanizeAgeAt(t, now time.Time) string { + d := now.Sub(t) + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return formatInt(int(d.Minutes())) + "m" + case d < 24*time.Hour: + return formatInt(int(d.Hours())) + "h" + case d < 48*time.Hour: + return "yday" + case d < 7*24*time.Hour: + return formatInt(int(d.Hours()/24)) + "d" + case d < 30*24*time.Hour: + return formatInt(int(d.Hours()/(24*7))) + "w" + case d < 365*24*time.Hour: + return formatInt(int(d.Hours()/(24*30))) + "mo" + default: + return formatInt(int(d.Hours()/(24*365))) + "y" + } +} + +// renderHeaderChips formats `chips` as numbered hotkey chips packed +// into at most `maxLines` lines of width `w`. Each chip is rendered +// as `1.name 2m` (project) or `1.@group` (group, with `@` prefix to +// disambiguate at a glance). A leading `*` marks favorites. Chips +// that wouldn't fit in `maxLines` are dropped — HeaderCap=9 keeps +// the count small enough that this is rare. +// +// Returns nil on an empty input so callers omit the header rows +// entirely; an idle workspace doesn't burn vertical space on chrome. +func renderHeaderChips(chips []Chip, w, maxLines int) []string { + if len(chips) == 0 || w <= 0 || maxLines <= 0 { + return nil + } + tokens := make([]string, len(chips)) + for i, c := range chips { + tokens[i] = formatChip(i+1, c) + } + return packChips(tokens, w, maxLines) +} + +// formatChip builds the chip token: `*N.name age` for projects and +// `*N.@group` for groups. The age column is omitted when LastActiveAt +// is zero (favorited but never stamped) so chips stay compact on a +// fresh install. +func formatChip(num int, c Chip) string { + star := "" + if c.Favorite { + star = "*" + } + body := c.Name + if c.Kind == KindGroup { + body = "@" + c.Name + } + age := humanizeAge(c.LastActiveAt) + if age == "" { + return fmt.Sprintf("%s%d.%s", star, num, body) + } + return fmt.Sprintf("%s%d.%s %s", star, num, body, age) +} + +// packChips greedily fills lines with chips separated by two spaces, +// breaking to a new line whenever appending the next chip would push +// the running width past w. Stops once maxLines is reached, dropping +// the remaining chips silently. +func packChips(chips []string, w, maxLines int) []string { + var lines []string + cur := "" + for _, c := range chips { + next := c + if cur != "" { + next = cur + " " + c + } + if lipgloss.Width(next) > w { + if cur != "" { + lines = append(lines, cur) + if len(lines) >= maxLines { + return lines + } + } + cur = c + continue + } + cur = next + } + if cur != "" && len(lines) < maxLines { + lines = append(lines, cur) + } + return lines +} + +// styleHeaderLines applies the chip palette to packed header lines: +// favorites get a brighter star, the leading `N.` digit is dimmed so +// the name reads first, and the trailing age column is dim. Operates +// on the raw `1.name 2m`-style strings produced by packChips by +// re-tokenizing on the chip boundary (two spaces). Keep style logic +// confined here so header.go owns the look end-to-end. +func styleHeaderLines(lines []string) []string { + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = styleChipLine(line) + } + return out +} + +func styleChipLine(line string) string { + chips := strings.Split(line, " ") + for i, c := range chips { + chips[i] = styleChip(c) + } + return strings.Join(chips, " ") +} + +// styleChip splits one chip into (star?)(N.)(name)( age?) and paints +// each piece. The age separator is a single space; if absent the chip +// ends after the name. +func styleChip(c string) string { + hasStar := strings.HasPrefix(c, "*") + if hasStar { + c = c[1:] + } + dot := strings.Index(c, ".") + if dot < 0 { + return c + } + num := c[:dot] + rest := c[dot+1:] + name, age, _ := strings.Cut(rest, " ") + + var b strings.Builder + if hasStar { + b.WriteString(favoriteStarStyle.Render("*")) + } + b.WriteString(chipNumberStyle.Render(num + ".")) + b.WriteString(chipNameStyle.Render(name)) + if age != "" { + b.WriteString(" ") + b.WriteString(activityAgeStyle.Render(age)) + } + return b.String() +} + +// formatInt avoids pulling in fmt for the hot path; the values are +// always small non-negative ints (max ~24-30 in practice). +func formatInt(n int) string { + if n == 0 { + return "0" + } + if n < 0 { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/internal/agent/header_test.go b/internal/agent/header_test.go new file mode 100644 index 0000000..0316c59 --- /dev/null +++ b/internal/agent/header_test.go @@ -0,0 +1,127 @@ +package agent + +import ( + "reflect" + "testing" + "time" +) + +func TestBuildHeaderChips_FavoritesFirstThenRecent(t *testing.T) { + now := time.Now().UTC() + ws := []WorkspaceData{{ + Root: "/ws", + Projects: []Project{ + {Name: "fav-old", Favorite: true, LastActiveAt: now.Add(-48 * time.Hour)}, + {Name: "recent-new", Favorite: false, LastActiveAt: now.Add(-5 * time.Minute)}, + {Name: "fav-new", Favorite: true, LastActiveAt: now.Add(-1 * time.Minute)}, + {Name: "stale", Favorite: false, LastActiveAt: time.Time{}}, + {Name: "recent-old", Favorite: false, LastActiveAt: now.Add(-3 * time.Hour)}, + }, + }} + + got := chipNames(buildHeaderChips(ws)) + want := []string{"fav-new", "fav-old", "recent-new", "recent-old"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v (favs first by activity desc, then recent by activity desc; zero-activity non-favs excluded)", got, want) + } +} + +func TestBuildHeaderChips_IncludesFavoriteGroups(t *testing.T) { + now := time.Now().UTC() + ws := []WorkspaceData{{ + Root: "/ws", + Groups: []string{"work", "personal"}, + FavoriteGroups: map[string]bool{"work": true}, + Projects: []Project{ + {Name: "active", Favorite: false, LastActiveAt: now.Add(-10 * time.Minute)}, + }, + }} + chips := buildHeaderChips(ws) + got := chipNames(chips) + // fav group `work` is favorited with zero activity; sorted last + // among favs (none here), then non-favorite recent project. + want := []string{"work", "active"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v (fav group first, then recent project)", got, want) + } + // Verify the chip is marked as a group. + if chips[0].Kind != KindGroup { + t.Errorf("first chip should be KindGroup, got %v", chips[0].Kind) + } + if chips[1].Kind != KindProject { + t.Errorf("second chip should be KindProject, got %v", chips[1].Kind) + } +} + +func TestBuildHeaderChips_CappedAtNine(t *testing.T) { + now := time.Now().UTC() + var projects []Project + for i := 0; i < 8; i++ { + projects = append(projects, Project{ + Name: "fav-" + formatInt(i), + Favorite: true, + LastActiveAt: now.Add(time.Duration(-i) * time.Hour), + }) + projects = append(projects, Project{ + Name: "recent-" + formatInt(i), + Favorite: false, + LastActiveAt: now.Add(time.Duration(-i-100) * time.Hour), + }) + } + ws := []WorkspaceData{{Root: "/ws", Projects: projects}} + got := buildHeaderChips(ws) + if len(got) != HeaderCap { + t.Errorf("expected cap of %d, got %d", HeaderCap, len(got)) + } +} + +func TestBuildHeaderChips_TiesByName(t *testing.T) { + t0 := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC) + ws := []WorkspaceData{{ + Root: "/ws", + Projects: []Project{ + {Name: "z-app", Favorite: false, LastActiveAt: t0}, + {Name: "a-app", Favorite: false, LastActiveAt: t0}, + {Name: "m-app", Favorite: false, LastActiveAt: t0}, + }, + }} + got := chipNames(buildHeaderChips(ws)) + want := []string{"a-app", "m-app", "z-app"} + if !reflect.DeepEqual(got, want) { + t.Errorf("equal-activity tie should sort by name asc: got %v, want %v", got, want) + } +} + +func TestHumanizeAgeAt(t *testing.T) { + t0 := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + cases := []struct { + offset time.Duration + want string + }{ + {30 * time.Second, "now"}, + {5 * time.Minute, "5m"}, + {2 * time.Hour, "2h"}, + {36 * time.Hour, "yday"}, + {3 * 24 * time.Hour, "3d"}, + {10 * 24 * time.Hour, "1w"}, + {60 * 24 * time.Hour, "2mo"}, + {800 * 24 * time.Hour, "2y"}, + } + for _, tc := range cases { + got := humanizeAgeAt(t0.Add(-tc.offset), t0) + if got != tc.want { + t.Errorf("humanizeAgeAt offset=%v: got %q, want %q", tc.offset, got, tc.want) + } + } + if humanizeAge(time.Time{}) != "" { + t.Errorf("zero time should produce empty string, not a humanized value") + } +} + +func chipNames(cs []Chip) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = c.Name + } + return out +} diff --git a/internal/agent/items.go b/internal/agent/items.go new file mode 100644 index 0000000..f405ece --- /dev/null +++ b/internal/agent/items.go @@ -0,0 +1,89 @@ +package agent + +// rebuildItems flattens the workspace tree into the scrollable item +// list. The pinned quick-nav header is rendered separately (see +// renderHeaderChips) and never enters m.items — keeping the header +// fixed above the scroll viewport requires it to be outside the +// scrollable region. +func (m *Model) rebuildItems() { + m.items = nil + m.headerChips = buildHeaderChips(m.workspaces) + + for _, ws := range m.workspaces { + // Ungrouped projects first. + for i := range ws.Projects { + p := &ws.Projects[i] + if p.Group == "" { + m.addProjectItem(p, 0) + } + } + // Then groups. + for _, g := range ws.Groups { + m.items = append(m.items, listItem{kind: KindGroup, group: g, indent: 0, path: GroupPath(ws.Root, g)}) + if m.expanded[g] { + for i := range ws.Projects { + p := &ws.Projects[i] + if p.Group == g { + m.addProjectItem(p, 1) + } + } + } + } + } + m.clampCursor() +} + +// clampCursor keeps m.cursor inside the items range. Every row in +// m.items is selectable now that section headers live outside the +// scroll list, but we still bracket-clamp the index for safety after +// rebuilds that may have shrunk the list. +func (m *Model) clampCursor() { + if len(m.items) == 0 { + m.cursor = 0 + return + } + if m.cursor >= len(m.items) { + m.cursor = len(m.items) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +func (m *Model) addProjectItem(p *Project, indent int) { + m.items = append(m.items, listItem{kind: KindProject, project: p, indent: indent, path: p.Path}) + + // If project is expanded (tab), show worktrees + sessions inline. + if !m.expanded["proj:"+p.ID] { + return + } + + wts := m.wtCache.Get(p.Path) + for i := range wts { + wt := &wts[i] + name := worktreeDisplayName(*wt) + m.items = append(m.items, listItem{ + kind: KindWorktree, + worktree: wt, + indent: indent + 1, + path: wt.Path, + parentProj: p, + group: name, + }) + } + + sessions := m.sessCache.Get(p.Path) + if len(sessions) > 5 { + sessions = sessions[:5] + } + for i := range sessions { + s := &sessions[i] + m.items = append(m.items, listItem{ + kind: KindPortal, + session: s, + indent: indent + 1, + path: s.Cwd, + parentProj: p, + }) + } +} diff --git a/internal/agent/lang.go b/internal/agent/lang.go new file mode 100644 index 0000000..a03b0d6 --- /dev/null +++ b/internal/agent/lang.go @@ -0,0 +1,134 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +// Language icons (Nerd Font codepoints). Kept in one block so they +// scan as a palette when reading the file. +const ( + iconGo = "" // nf-seti-go + iconRust = "" // nf-dev-rust + iconPython = "" // nf-seti-python + iconNode = "" // nf-dev-nodejs_small + iconTypeScript = "" // nf-seti-typescript + iconJavaScript = "" // nf-dev-javascript_badge + iconRuby = "" // nf-dev-ruby + iconJava = "" // nf-dev-java + iconCSharp = "" // nf-seti-c_sharp + iconDocker = "" // nf-linux-docker + iconShell = "" // nf-oct-terminal + iconMarkdown = "" // nf-seti-markdown +) + +// projectIconCache memoizes DetectLanguage per absolute project path. +// The detection walks the project dir once at first render and is +// stable for the session — language doesn't change between tree +// refreshes. Invalidation isn't wired yet because the rare +// "added go.mod mid-session" case isn't worth the extra plumbing. +var projectIconCache sync.Map // map[string]string + +// DetectIcon returns the Nerd Font glyph that best matches the +// project at `path`. Detection prefers ecosystem marker files in a +// fixed priority order (go.mod beats Dockerfile beats *.sh fallback) +// so a Go project that also ships a Dockerfile reads as Go. Returns +// the generic iconProject when no marker fires. +func DetectIcon(path string) string { + if path == "" { + return iconProject + } + if v, ok := projectIconCache.Load(path); ok { + return v.(string) + } + icon := detectIconUncached(path) + projectIconCache.Store(path, icon) + return icon +} + +// markerFiles is the priority-ordered list of marker file → icon +// mappings. First hit wins. Multi-marker languages list every +// canonical file (pyproject.toml AND requirements.txt for Python, +// package.json AND yarn.lock for Node) so neither order nor presence +// of a specific tooling flavor changes detection. +var markerFiles = []struct { + file string + icon string +}{ + {"go.mod", iconGo}, + {"Cargo.toml", iconRust}, + {"pyproject.toml", iconPython}, + {"requirements.txt", iconPython}, + {"setup.py", iconPython}, + {"tsconfig.json", iconTypeScript}, + {"Gemfile", iconRuby}, + {"pom.xml", iconJava}, + {"build.gradle", iconJava}, + {"build.gradle.kts", iconJava}, + {"package.json", iconNode}, // after tsconfig so TS wins over JS+TS +} + +// suffixIcons is the fallback scan: when no marker file fires, we +// look for the first top-level file with a recognized extension. +// Keeps the loop cheap — the project dir is read once at most. +var suffixIcons = []struct { + suffix string + icon string +}{ + {".csproj", iconCSharp}, + {".sln", iconCSharp}, + {".rs", iconRust}, + {".go", iconGo}, + {".ts", iconTypeScript}, + {".tsx", iconTypeScript}, + {".js", iconJavaScript}, + {".py", iconPython}, + {".rb", iconRuby}, + {".java", iconJava}, + {".cs", iconCSharp}, + {".sh", iconShell}, + {".bash", iconShell}, + {".zsh", iconShell}, +} + +func detectIconUncached(path string) string { + // Pass 1: marker files in priority order. cheap (one stat per + // marker) and disambiguates polyglot repos correctly. + for _, m := range markerFiles { + if _, err := os.Stat(filepath.Join(path, m.file)); err == nil { + return m.icon + } + } + // Pass 2: Dockerfile and Markdown are weak signals — they show + // up as the project icon only when no real language marker exists. + if _, err := os.Stat(filepath.Join(path, "Dockerfile")); err == nil { + return iconDocker + } + + // Pass 3: scan the top-level directory once for known extensions. + // Bail out the moment we hit the first match — order in suffixIcons + // determines tie-breaks. + entries, err := os.ReadDir(path) + if err != nil { + return iconProject + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + for _, s := range suffixIcons { + if strings.HasSuffix(name, s.suffix) { + return s.icon + } + } + } + + // Pass 4: a lonely README.md is at least *something* recognizable. + if _, err := os.Stat(filepath.Join(path, "README.md")); err == nil { + return iconMarkdown + } + return iconProject +} diff --git a/internal/agent/list.go b/internal/agent/list.go new file mode 100644 index 0000000..87d7787 --- /dev/null +++ b/internal/agent/list.go @@ -0,0 +1,249 @@ +package agent + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/git" +) + +func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle pending delete confirmation. + if m.pendingDelete { + m.pendingDelete = false + if msg.String() == "y" && m.deleteItem != nil { + it := m.deleteItem + m.deleteItem = nil + // Use the registry-aware variant so the [[branches]] entry + // is released alongside the worktree directory; otherwise + // this machine stays listed as owner with stale + // last_pushed_* and the reconciler keeps recreating + // branch-orphan after the on-disk worktree is gone. + projID := "" + if it.parentProj != nil { + projID = it.parentProj.ID + } + wsRoot := m.workspaceRootFor(it.parentProj) + if err := DeleteWorktreeWithRegistry(it.parentProj.Path, it.worktree.Path, false, wsRoot, projID, it.worktree.Branch); err != nil { + m.statusMsg = err.Error() + return m, nil + } + m.wtCache.Invalidate(it.parentProj.Path) + m.rebuildItems() + m.ensureVisible() + m.statusMsg = "worktree deleted" + return m, nil + } + m.deleteItem = nil + m.statusMsg = "" + return m, nil + } + + m.statusMsg = "" // clear status on any key + item := m.currentItem() + + // Number hotkeys 1-9 open the chip-action modal for the + // corresponding chip. Handled before the navigation switch so the + // digits never collide with future bindings. + if s := msg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { + idx := int(s[0] - '1') + if idx < len(m.headerChips) { + c := m.headerChips[idx] + m.chipTarget = &c + m.mode = viewChipAction + return m, nil + } + } + + switch msg.String() { + case "q": + return m, tea.Quit + case "j", "down": + if m.cursor+1 < len(m.items) { + m.cursor++ + m.ensureVisible() + } + case "k", "up": + if m.cursor > 0 { + m.cursor-- + m.ensureVisible() + } + + case "enter": + if item == nil { + break + } + switch item.kind { + case KindGroup: + m.Launch = &LaunchRequest{Cwd: item.path} + return m, tea.Quit + case KindProject: + m.Launch = &LaunchRequest{Cwd: item.path} + return m, tea.Quit + case KindWorktree: + m.Launch = &LaunchRequest{Cwd: item.path} + return m, tea.Quit + case KindPortal: + if item.session != nil { + m.Launch = &LaunchRequest{Cwd: item.session.Cwd, ResumeID: item.session.ID} + return m, tea.Quit + } + } + + case "p": + // Claude with prompt — available on group, project, worktree. + if item != nil && item.path != "" && (item.kind == KindGroup || item.kind == KindProject || item.kind == KindWorktree) { + m.pendingLaunch = &LaunchRequest{Cwd: item.path} + m.promptInput = "" + m.mode = viewPromptInput + return m, nil + } + // Prompt resume for sessions. + if item != nil && item.kind == KindPortal && item.session != nil { + m.pendingLaunch = &LaunchRequest{Cwd: item.session.Cwd, ResumeID: item.session.ID} + m.promptInput = "" + m.mode = viewPromptInput + return m, nil + } + + case "w": + // New worktree — only on projects. + if item != nil && item.kind == KindProject { + m.wtNoLaunch = true + m.wtBranch = "" + m.wtField = 0 + m.popupProj = item.project + m.mode = viewNewWorktree + return m, nil + } + + case "e": + // Edit project metadata (group / category) — only on projects. + if item != nil && item.kind == KindProject && item.project != nil { + m.popupProj = item.project + m.editGroup = item.project.Group + m.editCategory = config.Category(item.project.Category) + if m.editCategory == "" { + m.editCategory = config.CategoryPersonal + } + m.editField = 0 + m.editErr = "" + m.mode = viewEditProject + return m, nil + } + + case "l", "right": + if item != nil && item.path != "" { + m.Launch = &LaunchRequest{Cwd: item.path, ShellOnly: true} + return m, tea.Quit + } + + case "f": + // Toggle favorite on the cursor row. Works on projects and on + // groups; both kinds appear as chips in the pinned header. + // Persists the new flag to workspace.toml and refreshes the + // in-memory model so the new state is visible without a TUI + // restart. + if item != nil && item.kind == KindProject && item.project != nil { + m.toggleFavoriteFor(item.project) + } + if item != nil && item.kind == KindGroup && item.group != "" { + m.toggleFavoriteGroup(item.group) + } + + case "h", "left": + if item != nil { + switch { + case item.kind == KindProject && m.expanded["proj:"+item.project.ID]: + m.expanded["proj:"+item.project.ID] = false + m.rebuildItems() + m.ensureVisible() + case item.kind == KindProject && item.project.Group != "": + m.expanded[item.project.Group] = false + m.rebuildItems() + m.jumpToGroup(item.project.Group) + case (item.kind == KindWorktree || item.kind == KindPortal) && item.parentProj != nil: + m.expanded["proj:"+item.parentProj.ID] = false + m.rebuildItems() + m.jumpToProject(item.parentProj.ID) + case item.kind == KindGroup && m.expanded[item.group]: + m.expanded[item.group] = false + m.rebuildItems() + m.ensureVisible() + } + } + + case "tab": + // Expand/collapse — groups and projects. + if item != nil { + switch item.kind { + case KindGroup: + m.toggleExpand(item.group) + case KindProject: + key := "proj:" + item.project.ID + m.expanded[key] = !m.expanded[key] + m.rebuildItems() + m.ensureVisible() + } + } + + case "d": + if item != nil && item.kind == KindWorktree && item.worktree != nil && !item.worktree.IsMain && item.parentProj != nil { + wt := item.worktree + if git.IsDirty(wt.Path) { + m.statusMsg = "cannot delete: uncommitted changes" + break + } + ahead, _, hasUpstream := git.AheadBehind(wt.Path, wt.Branch) + if hasUpstream && ahead > 0 { + m.statusMsg = fmt.Sprintf("cannot delete: %d unpushed commit(s)", ahead) + break + } + // Ask for confirmation. + name := worktreeDisplayName(*wt) + m.statusMsg = fmt.Sprintf("delete %s? y to confirm", name) + m.pendingDelete = true + m.deleteItem = item + } + + case "s", "/": + m.flashGlobal = false + m.mode = viewFlash + m.flashQuery = "" + m.recomputeFlash() + + case "S": + // Global search — expand everything, search all items. + m.flashGlobal = true + m.savedExpanded = make(map[string]bool) + for k, v := range m.expanded { + m.savedExpanded[k] = v + } + // Expand all groups and projects. + for _, ws := range m.workspaces { + for _, g := range ws.Groups { + m.expanded[g] = true + } + for i := range ws.Projects { + m.expanded["proj:"+ws.Projects[i].ID] = true + } + } + m.rebuildItems() + m.mode = viewFlash + m.flashQuery = "" + m.recomputeFlash() + + case "?", " ": + m.whichKeyLevel = 0 + m.mode = viewWhichKey + + case "G": + m.cursor = len(m.items) - 1 + m.ensureVisible() + case "g": + m.cursor = 0 + m.scroll = 0 + } + return m, nil +} diff --git a/internal/agent/persist.go b/internal/agent/persist.go new file mode 100644 index 0000000..2219a0c --- /dev/null +++ b/internal/agent/persist.go @@ -0,0 +1,32 @@ +package agent + +import ( + "github.com/kuchmenko/workspace/internal/config" +) + +// MutateAndSave runs `apply` against a freshly loaded Workspace, then +// persists the result if `apply` reports a change. The daemon is +// notified best-effort so the next reconciler tick observes the new +// state immediately. Used by `ws agent` to flip favorites and view +// preference without leaking the load/save dance into the TUI layer. +// +// `apply` returns true to signal "in-memory state moved, please save". +// Returning false skips the write entirely — a clean no-op. +// +// Errors from Load and Save propagate. The daemon notify is best-effort +// and never surfaces (a missing daemon is a recoverable, expected state +// during fresh installs and tests). +func MutateAndSave(wsRoot string, apply func(*config.Workspace) bool) error { + ws, err := config.Load(wsRoot) + if err != nil { + return err + } + if !apply(ws) { + return nil + } + if err := config.Save(wsRoot, ws); err != nil { + return err + } + notifyDaemon(wsRoot) + return nil +} diff --git a/internal/agent/render.go b/internal/agent/render.go new file mode 100644 index 0000000..3a3694e --- /dev/null +++ b/internal/agent/render.go @@ -0,0 +1,331 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m *Model) renderListRows(listW int, dimAll bool) []string { + var rows []string + inFlash := m.mode == viewFlash + + maxH := m.listHeight() + end := m.scroll + maxH + if end > len(m.items) { + end = len(m.items) + } + + // Track group boundaries for visual spacing. + prevGroup := "" + for i := m.scroll; i < end; i++ { + item := m.items[i] + selected := i == m.cursor + + // Inject empty line between groups. + curGroup := m.itemGroupKey(item) + if prevGroup != "" && curGroup != prevGroup { + rows = append(rows, strings.Repeat(" ", listW)) + } + prevGroup = curGroup + + // In flash mode: check if this item is in the match set. + isMatch := false + flashLabel := rune(0) + if inFlash { + for mi, idx := range m.flashMatches { + if idx == i { + isMatch = true + if mi < len(m.flashLabels) { + flashLabel = m.flashLabels[mi] + } + break + } + } + } + + var line string + switch item.kind { + case KindGroup: + line = m.renderGroup(item, selected, inFlash, isMatch, flashLabel, listW, dimAll) + case KindProject: + line = m.renderProject(item, selected, inFlash, isMatch, flashLabel, listW, dimAll) + case KindWorktree: + line = m.renderWorktree(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) + case KindPortal: + line = m.renderSession(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) + } + + rows = append(rows, line) + } + return rows +} + +// itemGroupKey returns a key that identifies the visual group boundary +// for inserting blank lines between groups. +func (m *Model) itemGroupKey(item listItem) string { + switch item.kind { + case KindGroup: + return "g:" + item.group + case KindProject: + if item.project.Group != "" { + return "g:" + item.project.Group + } + return "ungrouped" + case KindWorktree, KindPortal: + if item.parentProj != nil && item.parentProj.Group != "" { + return "g:" + item.parentProj.Group + } + return "ungrouped" + } + return "" +} + +func (m *Model) renderGroup(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { + arrow := "▸" + if m.expanded[item.group] { + arrow = "▾" + } + name := item.group + if inFlash && isMatch { + name = flashInlineLabel(name, m.flashQuery, flashLabel) + } + label := fmt.Sprintf(" %s %s", arrow, name) + + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(label) + } + if selected { + return m.renderSelected(label, groupStyle, w) + } + return groupStyle.Width(w).Render(label) +} + +func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { + p := item.project + indent := strings.Repeat(" ", item.indent) + + name := p.Name + if inFlash && isMatch { + name = flashInlineLabel(name, m.flashQuery, flashLabel) + } + + // Build left part: indent + icon + name. Icon is language-detected + // via marker files so a Go project shows the Go glyph, a Rust + // project shows the Rust glyph, etc. + icon := DetectIcon(p.Path) + left := fmt.Sprintf(" %s%s %s", indent, icon, name) + + // Build right part: badges (right-aligned). Worktree count gets a + // lightning-bolt prefix so it reads as "branches in flight" at a + // glance; session count keeps the unprefixed `Ns` form. + var badgeParts []string + if p.WorktreeCount > 1 { + badgeParts = append(badgeParts, fmt.Sprintf("⚡%d", p.WorktreeCount)) + } + if p.SessionCount > 0 { + badgeParts = append(badgeParts, fmt.Sprintf("%ds", p.SessionCount)) + } + badges := strings.Join(badgeParts, " · ") + + // Pad between left and right to fill width. + line := m.padRight(left, badges, w) + + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(line) + } + if selected { + return m.renderSelected(line, itemStyle, w) + } + // Render with styled badges. + if badges != "" { + leftPart := fmt.Sprintf(" %s%s %s", indent, icon, name) + padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 + if padding < 1 { + padding = 1 + } + return itemStyle.Render(leftPart) + strings.Repeat(" ", padding) + badgeStyle.Render(badges) + } + return itemStyle.Width(w).Render(line) +} + +// renderHeaderProject draws a project row inside the Favorites/Recent +// shortcut section: `*` star for favorites, project icon, name, and a +// right-aligned `2m linux` activity column. The row is fully selectable +func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { + indent := strings.Repeat(" ", item.indent) + name := item.group // worktreeDisplayName stored in group field + if name == "" { + name = "worktree" + } + if inFlash && isMatch { + name = flashInlineLabel(name, m.flashQuery, flashLabel) + } + + // Status indicators: * for dirty, ↑N for ahead. + var status string + if item.worktree != nil { + if item.worktree.Dirty { + status += "*" + } + if item.worktree.Ahead > 0 { + status += fmt.Sprintf(" ↑%d", item.worktree.Ahead) + } + status = strings.TrimSpace(status) + } + + prefix := fmt.Sprintf(" %s%s ", indent, iconWorktree) + // Truncate name to fit available width. + maxName := w - lipgloss.Width(prefix) - lipgloss.Width(status) - 2 + if maxName > 0 && !inFlash { + name = truncateStr(name, maxName) + } + + left := prefix + name + if status != "" { + line := m.padRight(left, status+" ", w) + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(line) + } + if selected { + return m.renderSelected(line, wtStyle, w) + } + leftRendered := wtStyle.Render(left) + padding := w - lipgloss.Width(left) - lipgloss.Width(status) - 1 + if padding < 1 { + padding = 1 + } + return leftRendered + strings.Repeat(" ", padding) + wtStatusStyle.Render(status) + } + + label := left + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(label) + } + if selected { + return m.renderSelected(label, wtStyle, w) + } + return wtStyle.Width(w).Render(label) +} + +func (m *Model) renderSession(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { + indent := strings.Repeat(" ", item.indent) + title := "(session)" + if item.session != nil { + title = fmt.Sprintf("%s %s", TimeAgo(item.session.Updated), item.session.Title) + } + if inFlash && isMatch && item.session != nil { + title = fmt.Sprintf("%s %s", TimeAgo(item.session.Updated), + flashInlineLabel(item.session.Title, m.flashQuery, flashLabel)) + } + + // Truncate to prevent multiline wrapping. + prefix := fmt.Sprintf(" %s%s ", indent, iconSession) + maxTitle := w - len([]rune(prefix)) - 1 + if maxTitle > 0 { + title = truncateStr(title, maxTitle) + } + label := prefix + title + + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(label) + } + if selected { + return m.renderSelected(label, sessionStyle, w) + } + return sessionStyle.Width(w).Render(label) +} + +// truncateStr truncates a string to maxLen runes, adding … if needed. +func truncateStr(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + if maxLen <= 1 { + return "…" + } + return string(runes[:maxLen-1]) + "…" +} + +// renderSelected renders a line with the amber ▌ selection bar. +func (m *Model) renderSelected(content string, base lipgloss.Style, w int) string { + bar := accentBarStyle.Render("▌") + // Render content with selected style, leave room for the bar. + rest := selectedStyle.Width(w - 1).Render(content) + return bar + rest +} + +// padRight fills space between left content and right badges. +func (m *Model) padRight(left, right string, w int) string { + lw := lipgloss.Width(left) + rw := lipgloss.Width(right) + gap := w - lw - rw - 1 + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + +func (m *Model) viewList() string { + listW := 60 + if m.width > 80 { + listW = 70 + } + if m.width < 66 { + listW = m.width - 6 + } + + var rows []string + + // Pinned quick-nav chips: up to two lines of numbered 1-9 hotkeys + // above the breadcrumb. They never scroll — the chip row stays put + // while the tree below scrolls under them. + chipLines := renderHeaderChips(m.headerChips, listW-2, 2) + rows = append(rows, styleHeaderLines(chipLines)...) + if len(chipLines) > 0 { + rows = append(rows, strings.Repeat(" ", listW)) + } + + // Header — breadcrumb + position. + inFlash := m.mode == viewFlash + if inFlash { + prefix := iconSearch + if m.flashGlobal { + prefix = iconSearch + " all" + } + searchLine := fmt.Sprintf(" %s %s█", prefix, m.flashQuery) + rows = append(rows, flashSearchStyle.Width(listW).Render(searchLine)) + } else { + bc := m.breadcrumb() + pos := fmt.Sprintf("%d/%d", m.cursor+1, len(m.items)) + hdr := m.padRight(" "+bc, pos+" ", listW) + rows = append(rows, headerStyle.Width(listW).Render(hdr)) + } + + // List items. + rows = append(rows, m.renderListRows(listW, false)...) + + // Footer — status message or context-sensitive hints. + if m.statusMsg != "" && !inFlash { + rows = append(rows, statusMsgStyle.Width(listW).Render(" "+m.statusMsg)) + } else if inFlash { + matchInfo := fmt.Sprintf(" %d matches", len(m.flashMatches)) + hint := "letter to jump · esc cancel" + footer := m.padRight(matchInfo, hint+" ", listW) + rows = append(rows, footerStyle.Width(listW).Render(footer)) + } else { + actions, nav := m.footerHints() + rows = append(rows, footerStyle.Width(listW).Render(" "+actions)) + rows = append(rows, footerStyle.Width(listW).Render(" "+nav)) + } + + panel := lipgloss.JoinVertical(lipgloss.Left, rows...) + + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + panel, + ) +} diff --git a/internal/agent/source.go b/internal/agent/source.go index d350fc2..ff1b4d6 100644 --- a/internal/agent/source.go +++ b/internal/agent/source.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "time" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/daemon" @@ -45,11 +46,14 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s } ws := &WorkspaceData{ - Name: filepath.Base(root), - Root: root, + Name: filepath.Base(root), + Root: root, + FavoriteGroups: map[string]bool{}, } - // Collect groups. + // Collect groups. A group is registered when at least one project + // references it OR when it has an explicit [groups.] entry + // (the explicit entry is what carries the Favorite flag). groupSet := map[string]bool{} names := make([]string, 0, len(w.Projects)) for n, p := range w.Projects { @@ -61,9 +65,15 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s groupSet[p.Group] = true } } + for g := range w.Groups { + groupSet[g] = true + } sort.Strings(names) for g := range groupSet { ws.Groups = append(ws.Groups, g) + if entry, ok := w.Groups[g]; ok && entry.Favorite { + ws.FavoriteGroups[g] = true + } } sort.Strings(ws.Groups) @@ -71,13 +81,17 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s for _, name := range names { p := w.Projects[name] mainPath := filepath.Join(root, p.Path) + lastAt, lastMachine := projectActivity(p.Branches) proj := Project{ - ID: name, - Name: name, - Group: p.Group, - Category: string(p.Category), - Path: mainPath, - DefaultBranch: p.DefaultBranch, + ID: name, + Name: name, + Group: p.Group, + Category: string(p.Category), + Path: mainPath, + DefaultBranch: p.DefaultBranch, + Favorite: p.Favorite, + LastActiveAt: lastAt, + LastActiveMachine: lastMachine, } // Count worktrees. @@ -103,6 +117,29 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s return ws, diagnostics } +// projectActivity returns the most recent (LastActiveAt, LastActiveMachine) +// across the project's [[branches]] entries. Used to sort projects in the +// Favorites and Recent sections of `ws agent`. Returns zero time when no +// branch has ever been stamped — such projects never bubble up into Recent. +func projectActivity(branches []config.BranchMeta) (time.Time, string) { + var best time.Time + var machine string + for _, b := range branches { + if b.LastActiveAt == "" { + continue + } + t, err := time.Parse(time.RFC3339, b.LastActiveAt) + if err != nil { + continue + } + if t.After(best) { + best = t + machine = b.LastActiveMachine + } + } + return best, machine +} + func workspaceRoots(fallback string) []string { seen := map[string]bool{} var out []string diff --git a/internal/agent/stamp.go b/internal/agent/stamp.go new file mode 100644 index 0000000..b47f808 --- /dev/null +++ b/internal/agent/stamp.go @@ -0,0 +1,120 @@ +package agent + +import ( + "path/filepath" + "strings" + "time" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/daemon" + "github.com/kuchmenko/workspace/internal/git" +) + +// StampLaunchFromPath records "this machine just launched into a +// project at `cwd`" by bumping the per-branch activity timestamp in +// workspace.toml. Called immediately before syscall.Exec into claude +// or $SHELL, both from the TUI and from the non-interactive `ws agent +// launch / shell / resume` subcommands. +// +// All failures are best-effort: the launch never fails because of a +// stamp error. The caller logs to stderr and proceeds with exec — +// activity tracking is a UX nicety, never a hard requirement. +// +// Resolution sequence: +// +// 1. Walk up from cwd to find workspace.toml. +// 2. Find the project whose path matches cwd (main worktree exact +// match OR sibling worktree under `-wt--...`). +// 3. Read the current branch at cwd via `git rev-parse`. +// 4. Project.StampActivity(branch, machine, now); Save; notify daemon. +// +// Returns nil unless something genuinely surprising fails (a Save +// error after a successful Load). Returning nil does NOT mean a +// stamp happened — many launches are no-ops (e.g. shell into a +// non-workspace path, unrecognized branch). +func StampLaunchFromPath(cwd string) error { + abs, err := filepath.Abs(cwd) + if err != nil { + return nil + } + wsRoot, ok := config.FindRootFrom(abs) + if !ok { + return nil + } + ws, err := config.Load(wsRoot) + if err != nil { + return nil + } + projID, proj := findProjectByPath(ws, wsRoot, abs) + if proj == nil { + return nil + } + branch, err := git.CurrentBranch(abs) + if err != nil || branch == "" { + return nil + } + machine := loadMachineName() + if machine == "" { + return nil + } + if !proj.StampActivity(branch, machine, time.Now()) { + return nil + } + ws.Projects[projID] = *proj + if err := config.Save(wsRoot, ws); err != nil { + return err + } + notifyDaemon(wsRoot) + return nil +} + +// loadMachineName returns the configured machine name, or "" if +// unconfigured. We never prompt here — a missing machine_name means +// the user has not yet run any interactive ws command that would set +// it, and we simply skip the stamp rather than block the launch. +func loadMachineName() string { + mc, err := config.LoadMachineConfig() + if err != nil || mc == nil { + return "" + } + return mc.MachineName +} + +// findProjectByPath returns the project whose worktree (main or +// sibling) contains `abs`. The match is structural: main worktree if +// abs == join(wsRoot, p.Path) or a subpath of it; sibling worktree +// if abs starts with `-wt-`. Returns (id, *Project) on hit, +// ("", nil) on miss. The returned pointer aliases a freshly copied +// Project so the caller can mutate it before writing back to the map. +func findProjectByPath(ws *config.Workspace, wsRoot, abs string) (string, *config.Project) { + abs = filepath.Clean(abs) + for id, p := range ws.Projects { + projPath := filepath.Clean(filepath.Join(wsRoot, p.Path)) + if abs == projPath || strings.HasPrefix(abs, projPath+string(filepath.Separator)) { + cp := p + return id, &cp + } + wtPrefix := projPath + "-wt-" + if abs == strings.TrimSuffix(wtPrefix, "-") { + continue + } + if strings.HasPrefix(abs, wtPrefix) { + cp := p + return id, &cp + } + } + return "", nil +} + +// notifyDaemon shortens the wait until the reconciler observes the +// new workspace.toml. Best-effort: if the daemon is down or the IPC +// socket is missing, the next scheduled tick still picks up the +// change from disk. +func notifyDaemon(wsRoot string) { + c, err := daemon.Dial() + if err != nil { + return + } + defer c.Close() + _ = c.Notify(wsRoot, "config_changed") +} diff --git a/internal/agent/stamp_test.go b/internal/agent/stamp_test.go new file mode 100644 index 0000000..52b47aa --- /dev/null +++ b/internal/agent/stamp_test.go @@ -0,0 +1,185 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/testutil" +) + +// TestStampLaunchFromPath_BumpsActivityOnMainBranch confirms that a +// launch into the main worktree, where the default branch has never +// been registered in [[branches]], creates a minimal branch entry and +// stamps last_active_at. This is the common case: the user opens a +// claude session on `main` and expects the project to show up in +// Recent on the next ws agent invocation. +func TestStampLaunchFromPath_BumpsActivityOnMainBranch(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + wsRoot := t.TempDir() + // Plain checkout serves as the project's main worktree. The + // stamper only reads HEAD with `git rev-parse`; it does not care + // about the bare/worktree layout. Path is registered as just + // "alpha" so cwd == wsRoot/alpha. + mainPath := testutil.InitFakePlainCheckout(t, wsRoot, "alpha", []string{"main"}) + + seedWorkspace(t, wsRoot, map[string]config.Project{ + "alpha": { + Remote: "git@github.com:user/alpha.git", + Path: "alpha", + Status: config.StatusActive, + Category: config.CategoryPersonal, + DefaultBranch: "main", + }, + }) + + seedMachine(t, "linux") + + if err := StampLaunchFromPath(mainPath); err != nil { + t.Fatalf("StampLaunchFromPath: %v", err) + } + + got, err := config.Load(wsRoot) + if err != nil { + t.Fatalf("reload: %v", err) + } + alpha := got.Projects["alpha"] + if len(alpha.Branches) != 1 { + t.Fatalf("expected 1 branch entry after stamp, got %d: %+v", len(alpha.Branches), alpha.Branches) + } + b := alpha.Branches[0] + if b.Name != "main" { + t.Errorf("stamped wrong branch: got %q, want %q", b.Name, "main") + } + if b.LastActiveMachine != "linux" { + t.Errorf("LastActiveMachine: got %q, want %q", b.LastActiveMachine, "linux") + } + if b.LastActiveAt == "" { + t.Error("LastActiveAt should be non-empty after stamp") + } + // Auto-created entries must leave CreatedBy/CreatedAt empty — + // the launcher is NOT a creation event, unlike `ws worktree add`. + if b.CreatedBy != "" || b.CreatedAt != "" { + t.Errorf("auto-created stamp must not set CreatedBy/CreatedAt: got %+v", b) + } +} + +// TestStampLaunchFromPath_OutsideWorkspace_NoOp confirms the stamper +// is silent when the path does not belong to any workspace project. +// This is a hot path — every `ws agent shell ` invocation +// must not error out. +func TestStampLaunchFromPath_OutsideWorkspace_NoOp(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + other := t.TempDir() + if err := StampLaunchFromPath(other); err != nil { + t.Errorf("stamping outside any workspace must not error, got %v", err) + } +} + +// TestStampLaunchFromPath_UpdatesExistingBranch confirms that a +// second launch on the same branch bumps the timestamp in-place +// without producing duplicate [[branches]] entries. +func TestStampLaunchFromPath_UpdatesExistingBranch(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + wsRoot := t.TempDir() + mainPath := testutil.InitFakePlainCheckout(t, wsRoot, "alpha", []string{"main"}) + + seedWorkspace(t, wsRoot, map[string]config.Project{ + "alpha": { + Path: "alpha", Status: config.StatusActive, + Category: config.CategoryPersonal, DefaultBranch: "main", + Branches: []config.BranchMeta{{ + Name: "main", + Machines: []string{"linux"}, + LastActiveMachine: "linux", + LastActiveAt: "2026-04-01T00:00:00Z", + CreatedBy: "linux", + CreatedAt: "2026-04-01T00:00:00Z", + }}, + }, + }) + seedMachine(t, "linux") + + if err := StampLaunchFromPath(mainPath); err != nil { + t.Fatalf("StampLaunchFromPath: %v", err) + } + + got, _ := config.Load(wsRoot) + alpha := got.Projects["alpha"] + if len(alpha.Branches) != 1 { + t.Fatalf("expected branch count unchanged, got %d: %+v", len(alpha.Branches), alpha.Branches) + } + b := alpha.Branches[0] + if b.LastActiveAt == "2026-04-01T00:00:00Z" { + t.Error("LastActiveAt should have been bumped past the seeded 2026-04-01") + } + if b.CreatedAt != "2026-04-01T00:00:00Z" { + t.Errorf("CreatedAt must not be modified by stamp: got %q", b.CreatedAt) + } +} + +// TestStampLaunchFromPath_FindRootFrom_HandlesSubpath confirms the +// stamper walks up from a sub-directory of the project to locate +// workspace.toml — important because the user often launches from +// some path beneath the worktree root. +func TestStampLaunchFromPath_FindRootFrom_HandlesSubpath(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + wsRoot := t.TempDir() + mainPath := testutil.InitFakePlainCheckout(t, wsRoot, "alpha", []string{"main"}) + + seedWorkspace(t, wsRoot, map[string]config.Project{ + "alpha": { + Path: "alpha", Status: config.StatusActive, + Category: config.CategoryPersonal, DefaultBranch: "main", + }, + }) + seedMachine(t, "linux") + + // Launch from a real subdir inside the worktree to exercise the + // walk-up logic in FindRootFrom — using mainPath itself or "." on + // it would skip the walk entirely. + deep := filepath.Join(mainPath, "src", "deep") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatalf("mkdir deep: %v", err) + } + if err := StampLaunchFromPath(deep); err != nil { + t.Fatalf("StampLaunchFromPath: %v", err) + } + got, _ := config.Load(wsRoot) + if len(got.Projects["alpha"].Branches) != 1 { + t.Errorf("expected branch entry, got %+v", got.Projects["alpha"].Branches) + } +} + +// seedWorkspace writes a minimal workspace.toml at root with the given +// projects map. Other fields are left at zero/defaults; tests assert +// only on what they set. +func seedWorkspace(t *testing.T, root string, projects map[string]config.Project) { + t.Helper() + ws := &config.Workspace{ + Meta: config.Meta{Version: 1, Root: root}, + Projects: projects, + } + if err := config.Save(root, ws); err != nil { + t.Fatalf("seed Save: %v", err) + } +} + +// seedMachine writes ~/.config/ws/config.toml under the test's +// XDG_CONFIG_HOME so loadMachineName returns deterministically. +func seedMachine(t *testing.T, name string) { + t.Helper() + cfg := &config.MachineConfig{MachineName: name} + if err := config.SaveMachineConfig(cfg); err != nil { + t.Fatalf("seed machine: %v", err) + } +} diff --git a/internal/agent/styles.go b/internal/agent/styles.go new file mode 100644 index 0000000..4b0bd3a --- /dev/null +++ b/internal/agent/styles.go @@ -0,0 +1,125 @@ +package agent + +import "github.com/charmbracelet/lipgloss" + +// Warm amber "command post" palette. +var ( + // Header / footer bars. + headerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")). // amber dim — breadcrumb + Background(lipgloss.Color("235")) + + footerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Background(lipgloss.Color("235")) + + // Selection: amber accent bar. + accentBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")) // warm amber ▌ + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("254")). // bright text + Background(lipgloss.Color("236")). // subtle dark bg + Bold(true) + + // Type colors. + groupStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("182")). // soft mauve + Bold(true) + + itemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("254")) // white — primary items + + wtStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("108")) // muted sage — git/branch + + sessionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("110")) // cool steel — history + + badgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) // subtle + + wtStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")) // warm amber dim — dirty/ahead indicators + + statusMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")). // amber + Bold(true) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + // favoriteStarStyle paints the leading `*` indicator placed + // before favorited projects in the header section. + favoriteStarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")) // amber, slightly brighter than section + + // activityAgeStyle is the right-aligned " 2m linux" column on + // header-section rows. + activityAgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + // chipNumberStyle paints the leading "1." part of a header chip. + // Dimmer than the project name so the eye reads the name first; + // the digit is still picked up at a glance for hotkey use. + chipNumberStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + // chipNameStyle paints the project name inside a header chip. + chipNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("254")). + Bold(true) + + // Flash search. + flashSearchStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("215")). // amber + Background(lipgloss.Color("235")) + + flashLabelStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("235")). // dark on amber + Background(lipgloss.Color("215")) + + flashMatchStyle = lipgloss.NewStyle(). + Underline(true). + Foreground(lipgloss.Color("215")) // amber underlined match + + // Popup forms. + popupBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("173")). + Padding(1, 1) + + popupTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("215")) // amber + + popupSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("215")). + Background(lipgloss.Color("237")) + + popupItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("254")) + + popupDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + // Which-key panel. + whichKeyBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("173")). + Padding(0, 1) + + whichKeyTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")). + Bold(true) + + whichKeyKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")). // amber key + Bold(true) + + whichKeyDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) // secondary text +) diff --git a/internal/agent/tui.go b/internal/agent/tui.go index e745666..fb76ce3 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -1,14 +1,8 @@ package agent import ( - "fmt" - "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/config" - "github.com/kuchmenko/workspace/internal/git" - "github.com/kuchmenko/workspace/internal/layout" ) // View mode. @@ -21,17 +15,20 @@ const ( viewPromptInput // optional prompt input before launching claude viewWhichKey // which-key action panel (? or space) viewEditProject // edit project group/category + viewChipAction // chip launch modal (c/p/s/esc) ) // Nerd Font icons. const ( - iconProject = "\uf487" // nf-oct-package - iconWorktree = "\ue725" // nf-dev-git_branch - iconSession = "\uf4a6" // nf-md-message_text_outline - iconSearch = "\uf002" // nf-fa-search + iconProject = "" // nf-oct-package + iconWorktree = "" // nf-dev-git_branch + iconSession = "" // nf-md-message_text_outline + iconSearch = "" // nf-fa-search ) -// listItem is one row in the nested list. +// listItem is one row in the scrollable nested list. Header chips +// (Favorites/Recent quick-nav) live outside m.items and are rendered +// directly by viewList — they are not items the cursor can land on. type listItem struct { kind NodeKind group string // group name (for KindGroup rows) @@ -57,11 +54,22 @@ type LaunchRequest struct { type Model struct { workspaces []WorkspaceData mode viewMode - items []listItem // flattened visible items + items []listItem // flattened scrollable tree items (no header) cursor int expanded map[string]bool // group/project name → expanded scroll int // scroll offset for long lists + // headerChips is the ordered list of project-or-group chips + // rendered in the pinned quick-nav above the tree. Recomputed in + // rebuildItems from favorited groups + favorite/recent projects. + headerChips []Chip + + // chipAction modal state: when the user presses 1-9 to pick a + // chip, we open a small action modal asking what to do (claude / + // prompt / shell / etc.). chipTarget holds the picked chip until + // the modal resolves. + chipTarget *Chip + // Caches — loaded lazily, invalidated after mutations. sessCache *SessionCache wtCache *WorktreeCache @@ -168,375 +176,34 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.mode == viewEditProject { return m.updateEditProject(msg) } - return m.updateList(msg) - } - return m, nil -} - -func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Handle pending delete confirmation. - if m.pendingDelete { - m.pendingDelete = false - if msg.String() == "y" && m.deleteItem != nil { - it := m.deleteItem - m.deleteItem = nil - // Use the registry-aware variant so the [[branches]] entry - // is released alongside the worktree directory; otherwise - // this machine stays listed as owner with stale - // last_pushed_* and the reconciler keeps recreating - // branch-orphan after the on-disk worktree is gone. - projID := "" - if it.parentProj != nil { - projID = it.parentProj.ID - } - wsRoot := m.workspaceRootFor(it.parentProj) - if err := DeleteWorktreeWithRegistry(it.parentProj.Path, it.worktree.Path, false, wsRoot, projID, it.worktree.Branch); err != nil { - m.statusMsg = err.Error() - return m, nil - } - m.wtCache.Invalidate(it.parentProj.Path) - m.rebuildItems() - m.ensureVisible() - m.statusMsg = "worktree deleted" - return m, nil - } - m.deleteItem = nil - m.statusMsg = "" - return m, nil - } - - m.statusMsg = "" // clear status on any key - item := m.currentItem() - - switch msg.String() { - case "q": - return m, tea.Quit - case "j", "down": - if m.cursor < len(m.items)-1 { - m.cursor++ - m.ensureVisible() - } - case "k", "up": - if m.cursor > 0 { - m.cursor-- - m.ensureVisible() - } - - case "enter": - if item == nil { - break - } - switch item.kind { - case KindGroup: - m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit - case KindProject: - m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit - case KindWorktree: - m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit - case KindPortal: - if item.session != nil { - m.Launch = &LaunchRequest{Cwd: item.session.Cwd, ResumeID: item.session.ID} - return m, tea.Quit - } + if m.mode == viewChipAction { + return m.updateChipAction(msg) } - - case "p": - // Claude with prompt — available on group, project, worktree. - if item != nil && item.path != "" && (item.kind == KindGroup || item.kind == KindProject || item.kind == KindWorktree) { - m.pendingLaunch = &LaunchRequest{Cwd: item.path} - m.promptInput = "" - m.mode = viewPromptInput - return m, nil - } - // Prompt resume for sessions. - if item != nil && item.kind == KindPortal && item.session != nil { - m.pendingLaunch = &LaunchRequest{Cwd: item.session.Cwd, ResumeID: item.session.ID} - m.promptInput = "" - m.mode = viewPromptInput - return m, nil - } - - case "w": - // New worktree — only on projects. - if item != nil && item.kind == KindProject { - m.wtNoLaunch = true - m.wtBranch = "" - m.wtField = 0 - m.popupProj = item.project - m.mode = viewNewWorktree - return m, nil - } - - case "e": - // Edit project metadata (group / category) — only on projects. - if item != nil && item.kind == KindProject && item.project != nil { - m.popupProj = item.project - m.editGroup = item.project.Group - m.editCategory = config.Category(item.project.Category) - if m.editCategory == "" { - m.editCategory = config.CategoryPersonal - } - m.editField = 0 - m.editErr = "" - m.mode = viewEditProject - return m, nil - } - - case "l", "right": - if item != nil && item.path != "" { - m.Launch = &LaunchRequest{Cwd: item.path, ShellOnly: true} - return m, tea.Quit - } - - case "h", "left": - if item != nil { - switch { - case item.kind == KindProject && m.expanded["proj:"+item.project.ID]: - m.expanded["proj:"+item.project.ID] = false - m.rebuildItems() - m.ensureVisible() - case item.kind == KindProject && item.project.Group != "": - m.expanded[item.project.Group] = false - m.rebuildItems() - m.jumpToGroup(item.project.Group) - case (item.kind == KindWorktree || item.kind == KindPortal) && item.parentProj != nil: - m.expanded["proj:"+item.parentProj.ID] = false - m.rebuildItems() - m.jumpToProject(item.parentProj.ID) - case item.kind == KindGroup && m.expanded[item.group]: - m.expanded[item.group] = false - m.rebuildItems() - m.ensureVisible() - } - } - - case "tab": - // Expand/collapse — groups and projects. - if item != nil { - switch item.kind { - case KindGroup: - m.toggleExpand(item.group) - case KindProject: - key := "proj:" + item.project.ID - m.expanded[key] = !m.expanded[key] - m.rebuildItems() - m.ensureVisible() - } - } - - case "d": - if item != nil && item.kind == KindWorktree && item.worktree != nil && !item.worktree.IsMain && item.parentProj != nil { - wt := item.worktree - if git.IsDirty(wt.Path) { - m.statusMsg = "cannot delete: uncommitted changes" - break - } - ahead, _, hasUpstream := git.AheadBehind(wt.Path, wt.Branch) - if hasUpstream && ahead > 0 { - m.statusMsg = fmt.Sprintf("cannot delete: %d unpushed commit(s)", ahead) - break - } - // Ask for confirmation. - name := worktreeDisplayName(*wt) - m.statusMsg = fmt.Sprintf("delete %s? y to confirm", name) - m.pendingDelete = true - m.deleteItem = item - } - - case "s", "/": - m.flashGlobal = false - m.mode = viewFlash - m.flashQuery = "" - m.recomputeFlash() - - case "S": - // Global search — expand everything, search all items. - m.flashGlobal = true - m.savedExpanded = make(map[string]bool) - for k, v := range m.expanded { - m.savedExpanded[k] = v - } - // Expand all groups and projects. - for _, ws := range m.workspaces { - for _, g := range ws.Groups { - m.expanded[g] = true - } - for i := range ws.Projects { - m.expanded["proj:"+ws.Projects[i].ID] = true - } - } - m.rebuildItems() - m.mode = viewFlash - m.flashQuery = "" - m.recomputeFlash() - - case "?", " ": - m.whichKeyLevel = 0 - m.mode = viewWhichKey - - case "G": - m.cursor = len(m.items) - 1 - m.ensureVisible() - case "g": - m.cursor = 0 - m.scroll = 0 + return m.updateList(msg) } return m, nil } -// --- which-key action panel --- - -type whichKeyAction struct { - key string - desc string -} - -func (m *Model) whichKeyActions() []whichKeyAction { - item := m.currentItem() - if item == nil { - return nil - } - - if m.whichKeyLevel == 1 { - // Worktree sub-menu. - return []whichKeyAction{ - {"n", "new worktree"}, - {"", ""}, - {"esc", "back"}, - } - } - - switch item.kind { - case KindGroup: - return []whichKeyAction{ - {"\u23ce", "open claude"}, - {"p", "+prompt"}, - {"l", "shell"}, - {"tab", "expand"}, - {"", ""}, - {"esc", "close"}, - } - case KindProject: - return []whichKeyAction{ - {"\u23ce", "open claude"}, - {"p", "+prompt"}, - {"w", "worktree \u203a"}, - {"e", "edit"}, - {"l", "shell"}, - {"tab", "expand"}, - {"", ""}, - {"esc", "close"}, - } - case KindWorktree: - actions := []whichKeyAction{ - {"\u23ce", "open claude"}, - {"p", "+prompt"}, - {"l", "shell"}, - } - if item.worktree != nil && !item.worktree.IsMain { - actions = append(actions, whichKeyAction{"d", "delete"}) - } - actions = append(actions, whichKeyAction{"", ""}) - actions = append(actions, whichKeyAction{"esc", "close"}) - return actions - case KindPortal: - return []whichKeyAction{ - {"\u23ce", "resume"}, - {"p", "resume +prompt"}, - {"", ""}, - {"esc", "close"}, - } +func (m *Model) View() string { + if m.width == 0 { + return "loading…" } - return nil -} - -func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - item := m.currentItem() - - // Handle worktree sub-level. - if m.whichKeyLevel == 1 { - switch key { - case "esc": - m.whichKeyLevel = 0 - return m, nil - case "n": - if item != nil && item.kind == KindProject { - m.wtNoLaunch = true - m.wtBranch = "" - m.wtField = 0 - m.popupProj = item.project - m.mode = viewNewWorktree - return m, nil - } - } - return m, nil + if m.mode == viewPromptInput { + return m.viewPromptInput() } - - // Root level — dispatch action. - switch key { - case "esc": - m.mode = viewList - return m, nil - case "enter": - m.mode = viewList - return m.updateList(msg) - case "p": - m.mode = viewList - return m.updateList(msg) - case "w": - if item != nil && item.kind == KindProject { - m.whichKeyLevel = 1 - return m, nil - } - m.mode = viewList - case "l": - m.mode = viewList - return m.updateList(msg) - case "d": - m.mode = viewList - return m.updateList(msg) - case "m": - m.mode = viewList - return m.updateList(msg) - case "e": - m.mode = viewList - return m.updateList(msg) - case "tab": - m.mode = viewList - return m.updateList(msg) + if m.mode == viewNewWorktree { + return m.viewNewWorktree() } - return m, nil -} - -func (m *Model) whichKeyTitle() string { - item := m.currentItem() - if item == nil { - return "actions" + if m.mode == viewEditProject { + return m.viewEditProject() } - if m.whichKeyLevel == 1 { - return "worktree" + if m.mode == viewChipAction { + return m.viewChipAction() } - switch item.kind { - case KindGroup: - return item.group - case KindProject: - return item.project.Name - case KindWorktree: - return item.group // display name - case KindPortal: - if item.session != nil { - t := item.session.Title - if len(t) > 16 { - t = t[:16] + "\u2026" - } - return t - } + if m.mode == viewWhichKey { + return m.viewWhichKey() } - return "actions" + return m.viewList() } func (m *Model) currentItem() *listItem { @@ -585,295 +252,6 @@ func (m *Model) jumpToProject(projID string) { m.ensureVisible() } -func (m *Model) updateNewWorktree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - - switch key { - case "esc": - m.mode = viewList - return m, nil - case "tab", "down": - m.wtField = (m.wtField + 1) % 2 - return m, nil - case "shift+tab", "up": - m.wtField = (m.wtField + 1) % 2 - return m, nil - case "enter": - if m.wtField == 1 { // confirm - return m.executeNewWorktree() - } - m.wtField = (m.wtField + 1) % 2 - return m, nil - case "backspace": - if m.wtField == 0 && len(m.wtBranch) > 0 { - m.wtBranch = m.wtBranch[:len(m.wtBranch)-1] - } - return m, nil - default: - if m.wtField == 0 && len(key) == 1 && key[0] >= 32 && key[0] < 127 { - m.wtBranch += key - } - } - return m, nil -} - -func (m *Model) executeNewWorktree() (tea.Model, tea.Cmd) { - branch := strings.TrimSpace(m.wtBranch) - if branch == "" { - return m, nil - } - - wsRoot := m.workspaceRootFor(m.popupProj) - result, err := CreateWorktree(m.popupProj, branch, wsRoot, m.popupProj.ID) - if err != nil { - m.statusMsg = err.Error() - m.mode = viewList - return m, nil - } - m.wtCache.Invalidate(m.popupProj.Path) - - // If "create worktree only" (w key), go back to list. - if m.wtNoLaunch { - m.wtNoLaunch = false - m.mode = viewList - m.rebuildItems() - m.ensureVisible() - m.statusMsg = "worktree created" - return m, nil - } - - // Go to prompt input before launching. - m.pendingLaunch = &LaunchRequest{Cwd: result.Path} - m.promptInput = "" - m.mode = viewPromptInput - return m, nil -} - -func (m *Model) updatePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - switch key { - case "esc": - m.mode = viewList - m.pendingLaunch = nil - case "enter": - // Launch with or without prompt. - m.pendingLaunch.Prompt = strings.TrimSpace(m.promptInput) - m.Launch = m.pendingLaunch - m.pendingLaunch = nil - return m, tea.Quit - case "backspace": - if len(m.promptInput) > 0 { - m.promptInput = m.promptInput[:len(m.promptInput)-1] - } - default: - if len(key) == 1 && key[0] >= 32 { - m.promptInput += key - } else if key == "space" || key == " " { - m.promptInput += " " - } - } - return m, nil -} - -func (m *Model) viewPromptInput() string { - if m.pendingLaunch == nil { - m.mode = viewList - return m.viewList() - } - popupW := 56 - if m.width < 62 { - popupW = m.width - 6 - } - innerW := popupW - 6 - - var lines []string - lines = append(lines, popupTitleStyle.Width(innerW).Render("Launch claude")) - lines = append(lines, popupDimStyle.Width(innerW).Render(fmt.Sprintf("in: %s", m.pendingLaunch.Cwd))) - lines = append(lines, "") - lines = append(lines, popupItemStyle.Width(innerW).Render(" Initial prompt (optional):")) - - input := m.promptInput + "\u2588" - lines = append(lines, popupSelectedStyle.Width(innerW).Render(" "+input)) - lines = append(lines, "") - lines = append(lines, popupDimStyle.Width(innerW).Render(" Enter: launch (empty = interactive)")) - lines = append(lines, popupDimStyle.Width(innerW).Render(" Esc: back")) - - content := strings.Join(lines, "\n") - popup := popupBorderStyle.Render(content) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup, - lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) -} - -// jumpLabels is the alphabet used for flash jump labels. -const jumpLabels = "asdfghjklqwertyuiopzxcvbnm" - -func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - key := msg.String() - switch key { - case "ctrl+c": - return m, tea.Quit - case "esc": - m.exitFlash(false) - case "backspace": - if len(m.flashQuery) > 0 { - m.flashQuery = m.flashQuery[:len(m.flashQuery)-1] - m.recomputeFlash() - } else { - m.exitFlash(false) - } - case "enter": - // Jump to first match. - if len(m.flashMatches) > 0 { - m.cursor = m.flashMatches[0] - m.ensureVisible() - } - m.exitFlash(true) - default: - if len(key) == 1 && key[0] >= 32 && key[0] < 127 { - ch := rune(key[0]) - // Check if this character is a non-conflicting jump label. - // Labels are only assigned from characters that would NOT - // match if appended to the query, so this is unambiguous. - if m.flashQuery != "" { - for i, label := range m.flashLabels { - if label != 0 && ch == label && i < len(m.flashMatches) { - m.cursor = m.flashMatches[i] - m.ensureVisible() - m.exitFlash(true) - return m, nil - } - } - } - // Not a label — append to query to narrow results. - m.flashQuery += key - m.recomputeFlash() - } - } - return m, nil -} - -// exitFlash leaves flash mode. For global search (S), if the user -// canceled (jumped=false), restore the original expansion state. -// If they jumped to an item, keep expansions so the target is visible. -func (m *Model) exitFlash(jumped bool) { - m.mode = viewList - if m.flashGlobal && !jumped && m.savedExpanded != nil { - m.expanded = m.savedExpanded - m.savedExpanded = nil - m.rebuildItems() - m.ensureVisible() - } - m.flashGlobal = false -} - -func (m *Model) recomputeFlash() { - query := strings.ToLower(m.flashQuery) - m.flashMatches = nil - m.flashLabels = nil - - // Collect matches. - for i, item := range m.items { - name := m.itemSearchName(item) - if query == "" || strings.Contains(strings.ToLower(name), query) { - m.flashMatches = append(m.flashMatches, i) - } - } - - // Compute non-conflicting labels: only use characters that, when - // appended to the current query, would NOT match any item. This - // makes label presses unambiguous — they can never be mistaken for - // "continue typing to narrow results". - available := m.availableJumpLabels() - for i := 0; i < len(m.flashMatches); i++ { - if i < len(available) { - m.flashLabels = append(m.flashLabels, available[i]) - } else { - m.flashLabels = append(m.flashLabels, 0) // no label — need more query chars - } - } -} - -// availableJumpLabels returns characters safe to use as jump labels: -// letters that, if appended to the current query, would produce zero -// matches. This guarantees pressing a label always means "jump", never -// "keep filtering". -func (m *Model) availableJumpLabels() []rune { - query := strings.ToLower(m.flashQuery) - if query == "" { - return nil // no labels until user types at least one char - } - var available []rune - for _, r := range jumpLabels { - extended := query + string(r) - productive := false - for _, item := range m.items { - name := strings.ToLower(m.itemSearchName(item)) - if strings.Contains(name, extended) { - productive = true - break - } - } - if !productive { - available = append(available, r) - } - } - return available -} - -// itemSearchName returns the searchable text for a list item. -func (m *Model) itemSearchName(item listItem) string { - switch item.kind { - case KindGroup: - return item.group - case KindProject: - return item.project.Name - case KindWorktree: - return item.group // display name - case KindPortal: - if item.session != nil { - return item.session.Title - } - } - return "" -} - -// flashInlineLabel highlights the query match in a name and, when a -// non-zero label is available, overlays it on the character after the -// match. When label is 0 (no label assigned yet), only the match is -// highlighted — the user needs to type more chars. -func flashInlineLabel(name, query string, label rune) string { - if query == "" { - return name - } - lower := strings.ToLower(name) - q := strings.ToLower(query) - idx := strings.Index(lower, q) - if idx < 0 { - return name - } - matchEnd := idx + len(q) - runes := []rune(name) - - var b strings.Builder - if idx > 0 { - b.WriteString(string(runes[:idx])) - } - b.WriteString(flashMatchStyle.Render(string(runes[idx:matchEnd]))) - if label != 0 { - // Overlay label on the next character. - b.WriteString(flashLabelStyle.Render(string(label))) - if matchEnd+1 < len(runes) { - b.WriteString(string(runes[matchEnd+1:])) - } - } else { - // No label — just show the rest of the name. - if matchEnd < len(runes) { - b.WriteString(string(runes[matchEnd:])) - } - } - return b.String() -} - func (m *Model) ensureVisible() { // Keep cursor pinned to the vertical center of the viewport. maxVisible := m.listHeight() @@ -890,7 +268,16 @@ func (m *Model) ensureVisible() { } func (m *Model) listHeight() int { - h := m.height - 5 // header + 2 footer lines + borders + // 5 = breadcrumb (1) + 2 footer lines + borders (2). Add room for + // the pinned chip header when present: up to 2 chip lines plus a + // 1-line separator below them. headerProjects may be empty (idle + // workspace) — listHeight then matches the pre-rework value so a + // fresh install has the same vertical density. + chrome := 5 + if len(m.headerChips) > 0 { + chrome += 3 + } + h := m.height - chrome if h < 3 { h = 3 } @@ -901,26 +288,26 @@ func (m *Model) listHeight() int { // Line 1: actions available for the currently selected item type. // Line 2: universal navigation shortcuts. func (m *Model) footerHints() (actions, nav string) { - nav = "j/k:\u2195 tab:expand s:find S:all ?:more" + nav = "j/k:↕ tab:expand s:find S:all ?:more" item := m.currentItem() if item == nil { - return "\u23ce:open s:find S:all", nav + return "⏎:open s:find S:all", nav } switch item.kind { case KindGroup: - actions = "\u23ce:claude p:+prompt l:shell" + actions = "⏎:claude p:+prompt l:shell" case KindProject: - actions = "\u23ce:claude p:+prompt w:worktree e:edit l:shell" + actions = "⏎:claude p:+prompt w:worktree e:edit l:shell" case KindWorktree: if item.worktree != nil && !item.worktree.IsMain { - actions = "\u23ce:claude p:+prompt l:shell m:promote d:delete" + actions = "⏎:claude p:+prompt l:shell m:promote d:delete" } else { - actions = "\u23ce:claude p:+prompt l:shell" + actions = "⏎:claude p:+prompt l:shell" } case KindPortal: - actions = "\u23ce:resume p:+prompt" + actions = "⏎:resume p:+prompt" default: - actions = "\u23ce:open" + actions = "⏎:open" } return actions, nav } @@ -933,16 +320,16 @@ func (m *Model) breadcrumb() string { } switch item.kind { case KindGroup: - return item.group + " \u203a" + return item.group + " ›" case KindProject: if item.project.Group != "" { - return item.project.Group + " \u203a" + return item.project.Group + " ›" } return "ws" case KindWorktree, KindPortal: if item.parentProj != nil { if item.parentProj.Group != "" { - return item.parentProj.Group + " \u203a " + item.parentProj.Name + return item.parentProj.Group + " › " + item.parentProj.Name } return item.parentProj.Name } @@ -950,627 +337,3 @@ func (m *Model) breadcrumb() string { } return "ws" } - -// rebuildItems flattens the workspace tree into a visible list, -// respecting group expansion state. -func (m *Model) rebuildItems() { - m.items = nil - for _, ws := range m.workspaces { - // Ungrouped projects first. - for i := range ws.Projects { - p := &ws.Projects[i] - if p.Group == "" { - m.addProjectItem(p, 0) - } - } - // Then groups. - for _, g := range ws.Groups { - m.items = append(m.items, listItem{kind: KindGroup, group: g, indent: 0, path: GroupPath(ws.Root, g)}) - if m.expanded[g] { - for i := range ws.Projects { - p := &ws.Projects[i] - if p.Group == g { - m.addProjectItem(p, 1) - } - } - } - } - } - if m.cursor >= len(m.items) { - m.cursor = len(m.items) - 1 - } - if m.cursor < 0 { - m.cursor = 0 - } -} - -func (m *Model) addProjectItem(p *Project, indent int) { - m.items = append(m.items, listItem{kind: KindProject, project: p, indent: indent, path: p.Path}) - - // If project is expanded (tab), show worktrees + sessions inline. - if !m.expanded["proj:"+p.ID] { - return - } - - wts := m.wtCache.Get(p.Path) - for i := range wts { - wt := &wts[i] - name := worktreeDisplayName(*wt) - m.items = append(m.items, listItem{ - kind: KindWorktree, - worktree: wt, - indent: indent + 1, - path: wt.Path, - parentProj: p, - group: name, - }) - } - - sessions := m.sessCache.Get(p.Path) - if len(sessions) > 5 { - sessions = sessions[:5] - } - for i := range sessions { - s := &sessions[i] - m.items = append(m.items, listItem{ - kind: KindPortal, - session: s, - indent: indent + 1, - path: s.Cwd, - parentProj: p, - }) - } -} - -func (m *Model) View() string { - if m.width == 0 { - return "loading\u2026" - } - if m.mode == viewPromptInput { - return m.viewPromptInput() - } - if m.mode == viewNewWorktree { - return m.viewNewWorktree() - } - if m.mode == viewEditProject { - return m.viewEditProject() - } - if m.mode == viewWhichKey { - return m.viewWhichKey() - } - return m.viewList() -} - -// --- list rendering --- - -func (m *Model) renderListRows(listW int, dimAll bool) []string { - var rows []string - inFlash := m.mode == viewFlash - - maxH := m.listHeight() - end := m.scroll + maxH - if end > len(m.items) { - end = len(m.items) - } - - // Track group boundaries for visual spacing. - prevGroup := "" - for i := m.scroll; i < end; i++ { - item := m.items[i] - selected := i == m.cursor - - // Inject empty line between groups. - curGroup := m.itemGroupKey(item) - if prevGroup != "" && curGroup != prevGroup { - rows = append(rows, strings.Repeat(" ", listW)) - } - prevGroup = curGroup - - // In flash mode: check if this item is in the match set. - isMatch := false - flashLabel := rune(0) - if inFlash { - for mi, idx := range m.flashMatches { - if idx == i { - isMatch = true - if mi < len(m.flashLabels) { - flashLabel = m.flashLabels[mi] - } - break - } - } - } - - var line string - switch item.kind { - case KindGroup: - line = m.renderGroup(item, selected, inFlash, isMatch, flashLabel, listW, dimAll) - case KindProject: - line = m.renderProject(item, selected, inFlash, isMatch, flashLabel, listW, dimAll) - case KindWorktree: - line = m.renderWorktree(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) - case KindPortal: - line = m.renderSession(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) - } - - rows = append(rows, line) - } - return rows -} - -// itemGroupKey returns a key that identifies the visual group boundary -// for inserting blank lines between groups. -func (m *Model) itemGroupKey(item listItem) string { - switch item.kind { - case KindGroup: - return "g:" + item.group - case KindProject: - if item.project.Group != "" { - return "g:" + item.project.Group - } - return "ungrouped" - case KindWorktree, KindPortal: - if item.parentProj != nil && item.parentProj.Group != "" { - return "g:" + item.parentProj.Group - } - return "ungrouped" - } - return "" -} - -func (m *Model) renderGroup(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { - arrow := "\u25b8" - if m.expanded[item.group] { - arrow = "\u25be" - } - name := item.group - if inFlash && isMatch { - name = flashInlineLabel(name, m.flashQuery, flashLabel) - } - label := fmt.Sprintf(" %s %s", arrow, name) - - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(label) - } - if selected { - return m.renderSelected(label, groupStyle, w) - } - return groupStyle.Width(w).Render(label) -} - -func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { - p := item.project - indent := strings.Repeat(" ", item.indent) - - expandMark := "" - if p.WorktreeCount > 1 || p.SessionCount > 0 { - if m.expanded["proj:"+p.ID] { - expandMark = "\u25be " - } else { - expandMark = "\u25b8 " - } - } - - name := p.Name - if inFlash && isMatch { - name = flashInlineLabel(name, m.flashQuery, flashLabel) - } - - // Build left part: indent + expand + icon + name - left := fmt.Sprintf(" %s%s%s %s", indent, expandMark, iconProject, name) - - // Build right part: badges (right-aligned) - var badgeParts []string - if p.WorktreeCount > 1 { - badgeParts = append(badgeParts, fmt.Sprintf("%dwt", p.WorktreeCount)) - } - if p.SessionCount > 0 { - badgeParts = append(badgeParts, fmt.Sprintf("%ds", p.SessionCount)) - } - badges := strings.Join(badgeParts, " \u00b7 ") - - // Pad between left and right to fill width. - line := m.padRight(left, badges, w) - - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(line) - } - if selected { - return m.renderSelected(line, itemStyle, w) - } - // Render with styled badges. - if badges != "" { - leftPart := fmt.Sprintf(" %s%s%s %s", indent, expandMark, iconProject, name) - padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 - if padding < 1 { - padding = 1 - } - return itemStyle.Render(leftPart) + strings.Repeat(" ", padding) + badgeStyle.Render(badges) - } - return itemStyle.Width(w).Render(line) -} - -func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { - indent := strings.Repeat(" ", item.indent) - name := item.group // worktreeDisplayName stored in group field - if name == "" { - name = "worktree" - } - if inFlash && isMatch { - name = flashInlineLabel(name, m.flashQuery, flashLabel) - } - - // Status indicators: * for dirty, ↑N for ahead. - var status string - if item.worktree != nil { - if item.worktree.Dirty { - status += "*" - } - if item.worktree.Ahead > 0 { - status += fmt.Sprintf(" \u2191%d", item.worktree.Ahead) - } - status = strings.TrimSpace(status) - } - - prefix := fmt.Sprintf(" %s%s ", indent, iconWorktree) - // Truncate name to fit available width. - maxName := w - lipgloss.Width(prefix) - lipgloss.Width(status) - 2 - if maxName > 0 && !inFlash { - name = truncateStr(name, maxName) - } - - left := prefix + name - if status != "" { - line := m.padRight(left, status+" ", w) - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(line) - } - if selected { - return m.renderSelected(line, wtStyle, w) - } - leftRendered := wtStyle.Render(left) - padding := w - lipgloss.Width(left) - lipgloss.Width(status) - 1 - if padding < 1 { - padding = 1 - } - return leftRendered + strings.Repeat(" ", padding) + wtStatusStyle.Render(status) - } - - label := left - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(label) - } - if selected { - return m.renderSelected(label, wtStyle, w) - } - return wtStyle.Width(w).Render(label) -} - -func (m *Model) renderSession(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { - indent := strings.Repeat(" ", item.indent) - title := "(session)" - if item.session != nil { - title = fmt.Sprintf("%s %s", TimeAgo(item.session.Updated), item.session.Title) - } - if inFlash && isMatch && item.session != nil { - title = fmt.Sprintf("%s %s", TimeAgo(item.session.Updated), - flashInlineLabel(item.session.Title, m.flashQuery, flashLabel)) - } - - // Truncate to prevent multiline wrapping. - prefix := fmt.Sprintf(" %s%s ", indent, iconSession) - maxTitle := w - len([]rune(prefix)) - 1 - if maxTitle > 0 { - title = truncateStr(title, maxTitle) - } - label := prefix + title - - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(label) - } - if selected { - return m.renderSelected(label, sessionStyle, w) - } - return sessionStyle.Width(w).Render(label) -} - -// truncateStr truncates a string to maxLen runes, adding … if needed. -func truncateStr(s string, maxLen int) string { - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - if maxLen <= 1 { - return "\u2026" - } - return string(runes[:maxLen-1]) + "\u2026" -} - -// renderSelected renders a line with the amber ▌ selection bar. -func (m *Model) renderSelected(content string, base lipgloss.Style, w int) string { - bar := accentBarStyle.Render("\u258c") - // Render content with selected style, leave room for the bar. - rest := selectedStyle.Width(w - 1).Render(content) - return bar + rest -} - -// padRight fills space between left content and right badges. -func (m *Model) padRight(left, right string, w int) string { - lw := lipgloss.Width(left) - rw := lipgloss.Width(right) - gap := w - lw - rw - 1 - if gap < 1 { - gap = 1 - } - return left + strings.Repeat(" ", gap) + right -} - -func (m *Model) viewList() string { - listW := 60 - if m.width > 80 { - listW = 70 - } - if m.width < 66 { - listW = m.width - 6 - } - - var rows []string - - // Header — breadcrumb + position. - inFlash := m.mode == viewFlash - if inFlash { - prefix := iconSearch - if m.flashGlobal { - prefix = iconSearch + " all" - } - searchLine := fmt.Sprintf(" %s %s\u2588", prefix, m.flashQuery) - rows = append(rows, flashSearchStyle.Width(listW).Render(searchLine)) - } else { - bc := m.breadcrumb() - pos := fmt.Sprintf("%d/%d", m.cursor+1, len(m.items)) - hdr := m.padRight(" "+bc, pos+" ", listW) - rows = append(rows, headerStyle.Width(listW).Render(hdr)) - } - - // List items. - rows = append(rows, m.renderListRows(listW, false)...) - - // Footer — status message or context-sensitive hints. - if m.statusMsg != "" && !inFlash { - rows = append(rows, statusMsgStyle.Width(listW).Render(" "+m.statusMsg)) - } else if inFlash { - matchInfo := fmt.Sprintf(" %d matches", len(m.flashMatches)) - hint := "letter to jump \u00b7 esc cancel" - footer := m.padRight(matchInfo, hint+" ", listW) - rows = append(rows, footerStyle.Width(listW).Render(footer)) - } else { - actions, nav := m.footerHints() - rows = append(rows, footerStyle.Width(listW).Render(" "+actions)) - rows = append(rows, footerStyle.Width(listW).Render(" "+nav)) - } - - panel := lipgloss.JoinVertical(lipgloss.Left, rows...) - - return lipgloss.Place( - m.width, m.height, - lipgloss.Center, lipgloss.Center, - panel, - ) -} - -// --- which-key panel rendering --- - -func (m *Model) viewWhichKey() string { - listW := 48 - if m.width < 72 { - listW = m.width - 28 - if listW < 30 { - listW = 30 - } - } - - // Render the list (dimmed). - var rows []string - bc := m.breadcrumb() - pos := fmt.Sprintf("%d/%d", m.cursor+1, len(m.items)) - hdr := m.padRight(" "+bc, pos+" ", listW) - rows = append(rows, headerStyle.Width(listW).Render(hdr)) - rows = append(rows, m.renderListRows(listW, true)...) - rows = append(rows, footerStyle.Width(listW).Render(" press a key or esc")) - - listPanel := lipgloss.JoinVertical(lipgloss.Left, rows...) - - // Render the action panel. - actions := m.whichKeyActions() - title := m.whichKeyTitle() - - panelW := 20 - var actionLines []string - actionLines = append(actionLines, whichKeyTitleStyle.Width(panelW-4).Render(title)) - actionLines = append(actionLines, "") - - for _, a := range actions { - if a.key == "" { - actionLines = append(actionLines, "") - continue - } - keyPart := whichKeyKeyStyle.Render(a.key) - descPart := whichKeyDescStyle.Render(" " + a.desc) - actionLines = append(actionLines, " "+keyPart+descPart) - } - - actionContent := strings.Join(actionLines, "\n") - actionPanel := whichKeyBorderStyle.Width(panelW).Render(actionContent) - - // Position the action panel vertically aligned with the cursor. - listH := lipgloss.Height(listPanel) - panelH := lipgloss.Height(actionPanel) - topPad := (listH - panelH) / 2 - if topPad < 0 { - topPad = 0 - } - paddedPanel := strings.Repeat("\n", topPad) + actionPanel - - combined := lipgloss.JoinHorizontal(lipgloss.Top, listPanel, " ", paddedPanel) - - return lipgloss.Place( - m.width, m.height, - lipgloss.Center, lipgloss.Center, - combined, - ) -} - -func (m *Model) viewNewWorktree() string { - p := m.popupProj - popupW := 50 - if m.width < 56 { - popupW = m.width - 6 - } - innerW := popupW - 6 - - var lines []string - lines = append(lines, popupTitleStyle.Width(innerW).Render(fmt.Sprintf("%s New worktree for %s", iconWorktree, p.Name))) - lines = append(lines, "") - - // Field 0: branch (single input \u2014 user types the literal branch name). - branchLabel := " Branch name:" - branchVal := m.wtBranch + "\u2588" - if m.wtField != 0 { - branchVal = m.wtBranch - if branchVal == "" { - branchVal = "(required)" - } - } - if m.wtField == 0 { - lines = append(lines, popupSelectedStyle.Width(innerW).Render(branchLabel)) - lines = append(lines, popupSelectedStyle.Width(innerW).Render(" "+branchVal)) - } else { - lines = append(lines, popupItemStyle.Width(innerW).Render(branchLabel)) - lines = append(lines, popupDimStyle.Width(innerW).Render(" "+branchVal)) - } - if branch := strings.TrimSpace(m.wtBranch); branch != "" { - pathPreview := fmt.Sprintf(" \u2192 dir: %s-wt--%s", p.Name, layout.SlugifyBranch(branch)) - lines = append(lines, popupDimStyle.Width(innerW).Render(pathPreview)) - } - lines = append(lines, "") - - // Field 1: confirm button - confirmLabel := " \u2192 Create worktree" - if m.wtField == 1 { - lines = append(lines, popupSelectedStyle.Width(innerW).Render(confirmLabel)) - } else { - lines = append(lines, popupItemStyle.Width(innerW).Render(confirmLabel)) - } - - lines = append(lines, "") - lines = append(lines, popupDimStyle.Width(innerW).Render("tab:next enter:confirm esc:back")) - - content := strings.Join(lines, "\n") - popup := popupBorderStyle.Render(content) - - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup, - lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) -} - -// ---- styles ---- -// Warm amber "command post" palette. - -var ( - // Header / footer bars. - headerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")). // amber dim — breadcrumb - Background(lipgloss.Color("235")) - - footerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Background(lipgloss.Color("235")) - - // Selection: amber accent bar. - accentBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")) // warm amber ▌ - - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")). // bright text - Background(lipgloss.Color("236")). // subtle dark bg - Bold(true) - - // Type colors. - groupStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("182")). // soft mauve - Bold(true) - - itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")) // white — primary items - - wtStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("108")) // muted sage — git/branch - - sessionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("110")) // cool steel — history - - badgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) // subtle - - wtStatusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")) // warm amber dim — dirty/ahead indicators - - statusMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). // amber - Bold(true) - - dimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - // Flash search. - flashSearchStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("215")). // amber - Background(lipgloss.Color("235")) - - flashLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("235")). // dark on amber - Background(lipgloss.Color("215")) - - flashMatchStyle = lipgloss.NewStyle(). - Underline(true). - Foreground(lipgloss.Color("215")) // amber underlined match - - // Popup forms. - popupBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("173")). - Padding(1, 1) - - popupTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("215")) // amber - - popupSelectedStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("215")). - Background(lipgloss.Color("237")) - - popupItemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")) - - popupDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - // Which-key panel. - whichKeyBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("173")). - Padding(0, 1) - - whichKeyTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). - Bold(true) - - whichKeyKeyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). // amber key - Bold(true) - - whichKeyDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) // secondary text -) diff --git a/internal/agent/types.go b/internal/agent/types.go index 65dce4c..04cc502 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -4,7 +4,10 @@ // launching. package agent -import "path/filepath" +import ( + "path/filepath" + "time" +) // NodeKind classifies an item in the workspace tree. type NodeKind int @@ -18,15 +21,25 @@ const ( ) // Project is one navigable project in the workspace tree. +// +// Favorite / LastActiveAt / LastActiveMachine are populated from +// workspace.toml at LoadWorkspaces time. LastActiveAt is the most +// recent timestamp across all of the project's [[branches]] entries +// (which currently includes the project's default branch once the +// `ws agent` launcher has stamped it). Zero time = no activity ever +// recorded — such projects never appear in the Recent header section. type Project struct { - ID string - Name string - Group string - Category string - Path string - DefaultBranch string - WorktreeCount int - SessionCount int + ID string + Name string + Group string + Category string + Path string + DefaultBranch string + WorktreeCount int + SessionCount int + Favorite bool + LastActiveAt time.Time + LastActiveMachine string } // GroupPath returns the filesystem directory for a group under a @@ -39,8 +52,28 @@ func GroupPath(wsRoot, group string) string { // Workspace is the top-level data structure loaded from workspace.toml // and daemon.toml, used by the TUI. type WorkspaceData struct { - Name string - Root string - Groups []string - Projects []Project + Name string + Root string + Groups []string + Projects []Project + FavoriteGroups map[string]bool // group name → pinned to header chips +} + +// Chip is one entry in the pinned quick-nav header. Either a project +// (Project != nil) or a group (Group != ""); never both. Chips can +// represent favorites from either kind, plus recently-touched +// non-favorite projects. +type Chip struct { + Kind NodeKind // KindProject or KindGroup + Name string // display name + Path string // cwd to launch in + Favorite bool + LastActiveAt time.Time + // Project is set when Kind == KindProject. Groups do not carry + // per-row metadata beyond name and path so this is nil for them. + Project *Project + // WorkspaceRoot is the root of the workspace this chip belongs to. + // Needed so toggleFavoriteFor can resolve which workspace.toml to + // mutate when the chip is a group. + WorkspaceRoot string } diff --git a/internal/agent/whichkey.go b/internal/agent/whichkey.go new file mode 100644 index 0000000..78c8502 --- /dev/null +++ b/internal/agent/whichkey.go @@ -0,0 +1,350 @@ +package agent + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/config" +) + +type whichKeyAction struct { + key string + desc string +} + +func (m *Model) whichKeyActions() []whichKeyAction { + item := m.currentItem() + if item == nil { + return nil + } + + if m.whichKeyLevel == 1 { + // Worktree sub-menu. + return []whichKeyAction{ + {"n", "new worktree"}, + {"", ""}, + {"esc", "back"}, + } + } + + switch item.kind { + case KindGroup: + return []whichKeyAction{ + {"⏎", "open claude"}, + {"p", "+prompt"}, + {"f", m.favoriteToggleLabelGroup(item.group)}, + {"l", "shell"}, + {"tab", "expand"}, + {"", ""}, + {"esc", "close"}, + } + case KindProject: + return []whichKeyAction{ + {"⏎", "open claude"}, + {"p", "+prompt"}, + {"f", m.favoriteToggleLabel(item)}, + {"w", "worktree ›"}, + {"e", "edit"}, + {"l", "shell"}, + {"tab", "expand"}, + {"", ""}, + {"esc", "close"}, + } + case KindWorktree: + actions := []whichKeyAction{ + {"⏎", "open claude"}, + {"p", "+prompt"}, + {"l", "shell"}, + } + if item.worktree != nil && !item.worktree.IsMain { + actions = append(actions, whichKeyAction{"d", "delete"}) + } + actions = append(actions, whichKeyAction{"", ""}) + actions = append(actions, whichKeyAction{"esc", "close"}) + return actions + case KindPortal: + return []whichKeyAction{ + {"⏎", "resume"}, + {"p", "resume +prompt"}, + {"", ""}, + {"esc", "close"}, + } + } + return nil +} + +// favoriteToggleLabel describes the `f` action target: "favorite" if +// the project is currently unfavorited, "unfavorite" if it already is. +func (m *Model) favoriteToggleLabel(it *listItem) string { + if it != nil && it.project != nil && it.project.Favorite { + return "unfavorite" + } + return "favorite" +} + +// favoriteToggleLabelGroup is the group-row variant. Reads the +// favorite flag from the in-memory WorkspaceData.FavoriteGroups set +// for whichever workspace owns this group. +func (m *Model) favoriteToggleLabelGroup(group string) string { + for _, ws := range m.workspaces { + if ws.FavoriteGroups[group] { + return "unfavorite" + } + } + return "favorite" +} + +func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + item := m.currentItem() + + // Handle worktree sub-level. + if m.whichKeyLevel == 1 { + switch key { + case "esc": + m.whichKeyLevel = 0 + return m, nil + case "n": + if item != nil && item.kind == KindProject { + m.wtNoLaunch = true + m.wtBranch = "" + m.wtField = 0 + m.popupProj = item.project + m.mode = viewNewWorktree + return m, nil + } + } + return m, nil + } + + // Root level — dispatch action. + switch key { + case "esc": + m.mode = viewList + return m, nil + case "enter": + m.mode = viewList + return m.updateList(msg) + case "p": + m.mode = viewList + return m.updateList(msg) + case "w": + if item != nil && item.kind == KindProject { + m.whichKeyLevel = 1 + return m, nil + } + m.mode = viewList + case "l": + m.mode = viewList + return m.updateList(msg) + case "d": + m.mode = viewList + return m.updateList(msg) + case "m": + m.mode = viewList + return m.updateList(msg) + case "e": + m.mode = viewList + return m.updateList(msg) + case "f": + // Favorite toggle is per-row: project rows toggle the project, + // group rows toggle the group. Either way close the panel so + // the user sees the result immediately. + m.mode = viewList + if item != nil && item.kind == KindProject && item.project != nil { + m.toggleFavoriteFor(item.project) + } + if item != nil && item.kind == KindGroup && item.group != "" { + m.toggleFavoriteGroup(item.group) + } + return m, nil + case "tab": + m.mode = viewList + return m.updateList(msg) + } + return m, nil +} + +// toggleFavoriteGroup flips the favorite flag on the named group in +// the workspace that owns the current cursor row. The in-memory +// WorkspaceData is updated so the chip header repaint picks up the +// change without a TUI restart. Symmetric to toggleFavoriteFor. +func (m *Model) toggleFavoriteGroup(group string) { + root := m.workspaceRootForGroup(group) + if root == "" { + m.statusMsg = "cannot resolve workspace for group" + return + } + current := false + for i := range m.workspaces { + if m.workspaces[i].Root == root { + current = m.workspaces[i].FavoriteGroups[group] + break + } + } + target := !current + err := MutateAndSave(root, func(ws *config.Workspace) bool { + return ws.SetGroupFavorite(group, target) + }) + if err != nil { + m.statusMsg = "favorite: " + err.Error() + return + } + for i := range m.workspaces { + if m.workspaces[i].Root != root { + continue + } + if m.workspaces[i].FavoriteGroups == nil { + m.workspaces[i].FavoriteGroups = map[string]bool{} + } + if target { + m.workspaces[i].FavoriteGroups[group] = true + m.statusMsg = "* favorited @" + group + } else { + delete(m.workspaces[i].FavoriteGroups, group) + m.statusMsg = "unfavorited @" + group + } + break + } + m.rebuildItems() + m.clampCursor() + m.ensureVisible() +} + +// workspaceRootForGroup finds the workspace whose Groups slice +// contains `name`. Returns "" when unmatched (e.g. group was just +// removed in a parallel save). +func (m *Model) workspaceRootForGroup(name string) string { + for _, ws := range m.workspaces { + for _, g := range ws.Groups { + if g == name { + return ws.Root + } + } + } + return "" +} + +// toggleFavoriteFor flips the favorite flag on `proj` and persists the +// change. The in-memory pointer is mutated so the row repaint picks +// up the new state without needing a TUI restart. The header section +// is rebuilt: a freshly favorited project may need to leave Recent +// and appear in Favorites, and vice versa. +func (m *Model) toggleFavoriteFor(proj *Project) { + root := m.workspaceRootFor(proj) + if root == "" { + m.statusMsg = "cannot resolve workspace for project" + return + } + target := !proj.Favorite + err := MutateAndSave(root, func(ws *config.Workspace) bool { + p := ws.Projects[proj.ID] + if !p.SetFavorite(target) { + return false + } + ws.Projects[proj.ID] = p + return true + }) + if err != nil { + m.statusMsg = "favorite: " + err.Error() + return + } + proj.Favorite = target + if target { + m.statusMsg = "* favorited " + proj.Name + } else { + m.statusMsg = "unfavorited " + proj.Name + } + m.rebuildItems() + m.clampCursor() + m.ensureVisible() +} + +func (m *Model) whichKeyTitle() string { + item := m.currentItem() + if item == nil { + return "actions" + } + if m.whichKeyLevel == 1 { + return "worktree" + } + switch item.kind { + case KindGroup: + return item.group + case KindProject: + return item.project.Name + case KindWorktree: + return item.group // display name + case KindPortal: + if item.session != nil { + t := item.session.Title + if len(t) > 16 { + t = t[:16] + "…" + } + return t + } + } + return "actions" +} + +func (m *Model) viewWhichKey() string { + listW := 48 + if m.width < 72 { + listW = m.width - 28 + if listW < 30 { + listW = 30 + } + } + + // Render the list (dimmed). + var rows []string + bc := m.breadcrumb() + pos := fmt.Sprintf("%d/%d", m.cursor+1, len(m.items)) + hdr := m.padRight(" "+bc, pos+" ", listW) + rows = append(rows, headerStyle.Width(listW).Render(hdr)) + rows = append(rows, m.renderListRows(listW, true)...) + rows = append(rows, footerStyle.Width(listW).Render(" press a key or esc")) + + listPanel := lipgloss.JoinVertical(lipgloss.Left, rows...) + + // Render the action panel. + actions := m.whichKeyActions() + title := m.whichKeyTitle() + + panelW := 20 + var actionLines []string + actionLines = append(actionLines, whichKeyTitleStyle.Width(panelW-4).Render(title)) + actionLines = append(actionLines, "") + + for _, a := range actions { + if a.key == "" { + actionLines = append(actionLines, "") + continue + } + keyPart := whichKeyKeyStyle.Render(a.key) + descPart := whichKeyDescStyle.Render(" " + a.desc) + actionLines = append(actionLines, " "+keyPart+descPart) + } + + actionContent := strings.Join(actionLines, "\n") + actionPanel := whichKeyBorderStyle.Width(panelW).Render(actionContent) + + // Position the action panel vertically aligned with the cursor. + listH := lipgloss.Height(listPanel) + panelH := lipgloss.Height(actionPanel) + topPad := (listH - panelH) / 2 + if topPad < 0 { + topPad = 0 + } + paddedPanel := strings.Repeat("\n", topPad) + actionPanel + + combined := lipgloss.JoinHorizontal(lipgloss.Top, listPanel, " ", paddedPanel) + + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + combined, + ) +} diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 5df3f28..348dd59 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -7,12 +7,8 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/bootstrap" - "github.com/kuchmenko/workspace/internal/branchprompt" - "github.com/kuchmenko/workspace/internal/clone" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/conflict" "github.com/spf13/cobra" @@ -223,453 +219,3 @@ func printPlanText(plan *bootstrap.Plan) { } } } - -// ============================================================================= -// Bubbletea model -// ============================================================================= - -type bootstrapStep int - -const ( - bsStepPlan bootstrapStep = iota - bsStepCloning - bsStepBranchPrompt - bsStepDone -) - -type bootstrapError struct { - project string - err error -} - -type bootstrapModel struct { - step bootstrapStep - stepChangedAt time.Time - width int - height int - - plan *bootstrap.Plan - toClone []bootstrap.PlanItem - current int // index into toClone - successes []string - errors []bootstrapError - canceled bool - - spinner spinner.Model - sidecar *bootstrap.Sidecar - - // Branch-prompt sub-state. The UI is owned by internal/branchprompt; - // branchAnswer is how we unblock the worker goroutine waiting on the - // channel passed into clone.Options.PromptDefaultBranch. - branchPrompt branchprompt.Model - branchAnswer chan branchAnswer -} - -type branchAnswer struct { - branch string - err error -} - -// Custom messages for the async clone loop. -type cloneDoneMsg struct { - index int - project string - res *clone.Result - err error -} -type needsBranchMsg struct { - project string - candidates []string - answer chan branchAnswer -} - -func newBootstrapModel(plan *bootstrap.Plan, toClone []bootstrap.PlanItem, resume map[string]bootstrap.DoneEntry) bootstrapModel { - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - - // Initialize sidecar (in-memory only — written to disk after first - // successful clone, so a Ctrl+C on the plan screen leaves no trace). - sc := bootstrap.New(wsRoot) - for k, v := range resume { - _ = sc.Set(k, v) - } - - return bootstrapModel{ - step: bsStepPlan, - plan: plan, - toClone: toClone, - spinner: sp, - sidecar: sc, - } -} - -func (m bootstrapModel) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m bootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.KeyMsg: - // Debounce immediately after step transitions to avoid phantom inputs. - if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { - return m, nil - } - if msg.String() == "ctrl+c" { - m.canceled = true - return m, tea.Quit - } - } - - switch m.step { - case bsStepPlan: - return m.updatePlan(msg) - case bsStepCloning: - return m.updateCloning(msg) - case bsStepBranchPrompt: - return m.updateBranchPrompt(msg) - case bsStepDone: - if _, ok := msg.(tea.KeyMsg); ok { - return m, tea.Quit - } - } - return m, nil -} - -func (m bootstrapModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "y", "Y", "enter": - if len(m.toClone) == 0 { - m.step = bsStepDone - return m, tea.Quit - } - // Persist sidecar with our pid before any clone runs. - if err := bootstrap.Save(m.sidecar); err != nil { - m.errors = append(m.errors, bootstrapError{project: "", err: err}) - return m, tea.Quit - } - conflict.Notify("ws: bootstrap started", - fmt.Sprintf("%s: cloning %d projects", wsRoot, len(m.toClone))) - m.step = bsStepCloning - m.stepChangedAt = time.Now() - return m, tea.Batch(m.spinner.Tick, m.startClone(0)) - case "n", "N", "escape": - m.canceled = true - return m, tea.Quit - } - } - return m, nil -} - -// startClone returns a tea.Cmd that runs CloneIntoLayout for toClone[index] -// in a goroutine and emits cloneDoneMsg when finished. Branch prompts during -// the clone are routed back through needsBranchMsg → updateBranchPrompt and -// resolved via a channel. -func (m bootstrapModel) startClone(index int) tea.Cmd { - if index >= len(m.toClone) { - return func() tea.Msg { return allDoneMsg{} } - } - item := m.toClone[index] - return func() tea.Msg { - proj := item.Project - // PromptDefaultBranch bridges into the TUI: send a needsBranchMsg - // from inside the goroutine using p.Send via the global program? - // We don't have that handle here, so use a channel-based approach: - // the prompt callback parks on a channel, the TUI replies via the - // same channel after the user picks a branch. - ch := make(chan branchAnswer, 1) - opts := clone.Options{ - Logf: func(format string, args ...interface{}) { - // no-op; TUI shows progress, full log goes to debug if needed - }, - PromptDefaultBranch: func(name string, candidates []string) (string, error) { - // Send a request into the bubbletea queue and block until - // the model writes back into ch. - program.Send(needsBranchMsg{ - project: name, - candidates: candidates, - answer: ch, - }) - ans := <-ch - return ans.branch, ans.err - }, - } - res, err := clone.CloneIntoLayout(wsRoot, item.Name, &proj, opts) - // proj is local to this goroutine; the resolved default_branch is - // returned via res for the main loop to record into the sidecar. - return cloneDoneMsg{index: index, project: item.Name, res: res, err: err} - } -} - -type allDoneMsg struct{} - -// program is the running tea.Program. We need a global handle to it so the -// PromptDefaultBranch callback (running in a worker goroutine) can post -// messages back into the TUI loop. Set in runBootstrap before p.Run(). -var program *tea.Program - -func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - - case needsBranchMsg: - // Pause clone progress and switch to the branch-prompt sub-step. - // The UI (candidate list, free-text input, styling) is owned by - // internal/branchprompt; we keep only the answer channel that - // unblocks the clone goroutine. - m.step = bsStepBranchPrompt - m.stepChangedAt = time.Now() - m.branchPrompt = branchprompt.NewModel(msg.project, msg.candidates) - m.branchAnswer = msg.answer - return m, m.branchPrompt.Init() - - case cloneDoneMsg: - if msg.err != nil { - m.errors = append(m.errors, bootstrapError{project: msg.project, err: msg.err}) - } else { - m.successes = append(m.successes, msg.project) - // Persist progress immediately so a crash doesn't lose work. - if msg.res != nil { - _ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) - _ = bootstrap.Save(m.sidecar) - } - } - m.current = msg.index + 1 - // Periodic notify-send progress (every 5 clones). - if m.current > 0 && m.current%5 == 0 && m.current < len(m.toClone) { - conflict.Notify("ws: bootstrap progress", - fmt.Sprintf("%d/%d cloned", m.current, len(m.toClone))) - } - if m.current >= len(m.toClone) { - m.step = bsStepDone - return m, tea.Quit - } - return m, m.startClone(m.current) - - case allDoneMsg: - m.step = bsStepDone - return m, tea.Quit - } - return m, nil -} - -func (m bootstrapModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { - // Terminal messages from the branchprompt model take priority: a pick - // or cancel ends the sub-step and unblocks the clone worker. - switch msg := msg.(type) { - case branchprompt.PickedMsg: - m.resolveBranch(msg.Branch, nil) - m.step = bsStepCloning - m.stepChangedAt = time.Now() - return m, nil - case branchprompt.CancelledMsg: - // User refuses to pick → treat as error for this project. - m.resolveBranch("", errors.New("user canceled branch selection")) - m.step = bsStepCloning - m.stepChangedAt = time.Now() - return m, nil - } - - // Otherwise delegate to the embedded model and let it produce the - // terminal messages above on the next key event. - var cmd tea.Cmd - m.branchPrompt, cmd = m.branchPrompt.Update(msg) - return m, cmd -} - -// resolveBranch unblocks the worker goroutine waiting for a branch answer. -func (m *bootstrapModel) resolveBranch(branch string, err error) { - if m.branchAnswer == nil { - return - } - m.branchAnswer <- branchAnswer{branch: branch, err: err} - m.branchAnswer = nil -} - -// ============================================================================= -// Views -// ============================================================================= - -func (m bootstrapModel) View() string { - switch m.step { - case bsStepPlan: - return m.viewPlan() - case bsStepCloning: - return m.viewCloning() - case bsStepBranchPrompt: - return m.viewBranchPrompt() - case bsStepDone: - return m.viewDone() - } - return "" -} - -func (m bootstrapModel) viewPlan() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Bootstrap plan ")) - b.WriteString("\n\n") - b.WriteString(bsDimStyle.Render(wsRoot)) - b.WriteString("\n\n") - - rows := []struct { - state bootstrap.State - label string - mark string - }{ - {bootstrap.StateMissing, "will clone", bsArrowStyle.Render("→")}, - {bootstrap.StatePresent, "already present", bsCheckStyle.Render("✓")}, - {bootstrap.StateNeedsMigrate, "needs migration", bsWarnStyle.Render("⚠")}, - {bootstrap.StateBlocked, "path blocked", bsErrStyle.Render("✗")}, - {bootstrap.StateSelf, "self (skipped)", bsDimStyle.Render("⊘")}, - } - for _, row := range rows { - items := m.plan.Bucket(row.state) - if len(items) == 0 { - continue - } - fmt.Fprintf(&b, " %s %s (%d)\n", row.mark, bsHeaderStyle.Render(row.label), len(items)) - // Truncate large lists in the TUI; full list still shown in dry-run. - max := len(items) - if max > 8 { - max = 8 - } - for i := 0; i < max; i++ { - fmt.Fprintf(&b, " %s\n", items[i].Name) - } - if len(items) > max { - fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(fmt.Sprintf("… and %d more", len(items)-max))) - } - } - - b.WriteString("\n") - if len(m.toClone) == 0 { - b.WriteString(bsDimStyle.Render("Nothing to clone.")) - b.WriteString("\n") - } - b.WriteString(bsHelpStyle.Render("[Y] proceed [n/esc] cancel")) - return b.String() -} - -func (m bootstrapModel) viewCloning() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Cloning ")) - b.WriteString("\n\n") - b.WriteString(bsDimStyle.Render(wsRoot)) - b.WriteString("\n\n") - - total := len(m.toClone) - done := m.current - bar := renderProgressBar(done, total, 30) - fmt.Fprintf(&b, " %s %d / %d\n\n", bar, done, total) - - if m.current < total { - current := m.toClone[m.current] - fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), current.Name) - fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(current.Project.Path)) - } - - if len(m.errors) > 0 { - fmt.Fprintf(&b, "\n%s %d failed (full errors after exit)\n", - bsErrStyle.Render("✗"), len(m.errors)) - } - - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("[ctrl+c] abort")) - return b.String() -} - -func (m bootstrapModel) viewBranchPrompt() string { - return m.branchPrompt.View() -} - -func (m bootstrapModel) viewDone() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Bootstrap finished ")) - b.WriteString("\n\n") - fmt.Fprintf(&b, " %s %d cloned\n", bsCheckStyle.Render("✓"), len(m.successes)) - if len(m.errors) > 0 { - fmt.Fprintf(&b, " %s %d failed\n", bsErrStyle.Render("✗"), len(m.errors)) - b.WriteString("\n") - b.WriteString(bsDimStyle.Render(" Full errors will be printed after exit.")) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("[any key] exit")) - return b.String() -} - -// renderProgressBar draws a simple [█████░░░░░] bar. -func renderProgressBar(done, total, width int) string { - if total <= 0 { - return strings.Repeat("░", width) - } - filled := done * width / total - if filled > width { - filled = width - } - return bsBarFilledStyle.Render(strings.Repeat("█", filled)) + - bsBarEmptyStyle.Render(strings.Repeat("░", width-filled)) -} - -// indent prefixes every line of s with prefix. Used for nesting git stderr -// inside the post-exit error report. -func indent(s, prefix string) string { - lines := strings.Split(s, "\n") - for i, l := range lines { - lines[i] = prefix + l - } - return strings.Join(lines, "\n") -} - -// ============================================================================= -// Styles -// ============================================================================= - -var ( - bsTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). - Padding(0, 1) - - bsHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). - Bold(true) - - bsDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) - - bsHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) - - bsCheckStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("2")) - - bsWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")) - - bsErrStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")) - - bsArrowStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) - - bsBarFilledStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) - - bsBarEmptyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) - - errorBannerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). - Bold(true) -) diff --git a/internal/cli/bootstrap_model.go b/internal/cli/bootstrap_model.go new file mode 100644 index 0000000..0f84a0a --- /dev/null +++ b/internal/cli/bootstrap_model.go @@ -0,0 +1,282 @@ +package cli + +import ( + "errors" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/bootstrap" + "github.com/kuchmenko/workspace/internal/branchprompt" + "github.com/kuchmenko/workspace/internal/clone" + "github.com/kuchmenko/workspace/internal/conflict" +) + +type bootstrapStep int + +const ( + bsStepPlan bootstrapStep = iota + bsStepCloning + bsStepBranchPrompt + bsStepDone +) + +type bootstrapError struct { + project string + err error +} + +type bootstrapModel struct { + step bootstrapStep + stepChangedAt time.Time + width int + height int + + plan *bootstrap.Plan + toClone []bootstrap.PlanItem + current int // index into toClone + successes []string + errors []bootstrapError + canceled bool + + spinner spinner.Model + sidecar *bootstrap.Sidecar + + // Branch-prompt sub-state. The UI is owned by internal/branchprompt; + // branchAnswer is how we unblock the worker goroutine waiting on the + // channel passed into clone.Options.PromptDefaultBranch. + branchPrompt branchprompt.Model + branchAnswer chan branchAnswer +} + +type branchAnswer struct { + branch string + err error +} + +// Custom messages for the async clone loop. +type cloneDoneMsg struct { + index int + project string + res *clone.Result + err error +} +type needsBranchMsg struct { + project string + candidates []string + answer chan branchAnswer +} + +type allDoneMsg struct{} + +// program is the running tea.Program. We need a global handle to it so the +// PromptDefaultBranch callback (running in a worker goroutine) can post +// messages back into the TUI loop. Set in runBootstrap before p.Run(). +var program *tea.Program + +func newBootstrapModel(plan *bootstrap.Plan, toClone []bootstrap.PlanItem, resume map[string]bootstrap.DoneEntry) bootstrapModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + + // Initialize sidecar (in-memory only — written to disk after first + // successful clone, so a Ctrl+C on the plan screen leaves no trace). + sc := bootstrap.New(wsRoot) + for k, v := range resume { + _ = sc.Set(k, v) + } + + return bootstrapModel{ + step: bsStepPlan, + plan: plan, + toClone: toClone, + spinner: sp, + sidecar: sc, + } +} + +func (m bootstrapModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m bootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + // Debounce immediately after step transitions to avoid phantom inputs. + if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { + return m, nil + } + if msg.String() == "ctrl+c" { + m.canceled = true + return m, tea.Quit + } + } + + switch m.step { + case bsStepPlan: + return m.updatePlan(msg) + case bsStepCloning: + return m.updateCloning(msg) + case bsStepBranchPrompt: + return m.updateBranchPrompt(msg) + case bsStepDone: + if _, ok := msg.(tea.KeyMsg); ok { + return m, tea.Quit + } + } + return m, nil +} + +func (m bootstrapModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "y", "Y", "enter": + if len(m.toClone) == 0 { + m.step = bsStepDone + return m, tea.Quit + } + // Persist sidecar with our pid before any clone runs. + if err := bootstrap.Save(m.sidecar); err != nil { + m.errors = append(m.errors, bootstrapError{project: "", err: err}) + return m, tea.Quit + } + conflict.Notify("ws: bootstrap started", + fmt.Sprintf("%s: cloning %d projects", wsRoot, len(m.toClone))) + m.step = bsStepCloning + m.stepChangedAt = time.Now() + return m, tea.Batch(m.spinner.Tick, m.startClone(0)) + case "n", "N", "escape": + m.canceled = true + return m, tea.Quit + } + } + return m, nil +} + +// startClone returns a tea.Cmd that runs CloneIntoLayout for toClone[index] +// in a goroutine and emits cloneDoneMsg when finished. Branch prompts during +// the clone are routed back through needsBranchMsg → updateBranchPrompt and +// resolved via a channel. +func (m bootstrapModel) startClone(index int) tea.Cmd { + if index >= len(m.toClone) { + return func() tea.Msg { return allDoneMsg{} } + } + item := m.toClone[index] + return func() tea.Msg { + proj := item.Project + // PromptDefaultBranch bridges into the TUI: send a needsBranchMsg + // from inside the goroutine using p.Send via the global program? + // We don't have that handle here, so use a channel-based approach: + // the prompt callback parks on a channel, the TUI replies via the + // same channel after the user picks a branch. + ch := make(chan branchAnswer, 1) + opts := clone.Options{ + Logf: func(format string, args ...interface{}) { + // no-op; TUI shows progress, full log goes to debug if needed + }, + PromptDefaultBranch: func(name string, candidates []string) (string, error) { + // Send a request into the bubbletea queue and block until + // the model writes back into ch. + program.Send(needsBranchMsg{ + project: name, + candidates: candidates, + answer: ch, + }) + ans := <-ch + return ans.branch, ans.err + }, + } + res, err := clone.CloneIntoLayout(wsRoot, item.Name, &proj, opts) + // proj is local to this goroutine; the resolved default_branch is + // returned via res for the main loop to record into the sidecar. + return cloneDoneMsg{index: index, project: item.Name, res: res, err: err} + } +} + +func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case needsBranchMsg: + // Pause clone progress and switch to the branch-prompt sub-step. + // The UI (candidate list, free-text input, styling) is owned by + // internal/branchprompt; we keep only the answer channel that + // unblocks the clone goroutine. + m.step = bsStepBranchPrompt + m.stepChangedAt = time.Now() + m.branchPrompt = branchprompt.NewModel(msg.project, msg.candidates) + m.branchAnswer = msg.answer + return m, m.branchPrompt.Init() + + case cloneDoneMsg: + if msg.err != nil { + m.errors = append(m.errors, bootstrapError{project: msg.project, err: msg.err}) + } else { + m.successes = append(m.successes, msg.project) + // Persist progress immediately so a crash doesn't lose work. + if msg.res != nil { + _ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) + _ = bootstrap.Save(m.sidecar) + } + } + m.current = msg.index + 1 + // Periodic notify-send progress (every 5 clones). + if m.current > 0 && m.current%5 == 0 && m.current < len(m.toClone) { + conflict.Notify("ws: bootstrap progress", + fmt.Sprintf("%d/%d cloned", m.current, len(m.toClone))) + } + if m.current >= len(m.toClone) { + m.step = bsStepDone + return m, tea.Quit + } + return m, m.startClone(m.current) + + case allDoneMsg: + m.step = bsStepDone + return m, tea.Quit + } + return m, nil +} + +func (m bootstrapModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { + // Terminal messages from the branchprompt model take priority: a pick + // or cancel ends the sub-step and unblocks the clone worker. + switch msg := msg.(type) { + case branchprompt.PickedMsg: + m.resolveBranch(msg.Branch, nil) + m.step = bsStepCloning + m.stepChangedAt = time.Now() + return m, nil + case branchprompt.CancelledMsg: + // User refuses to pick → treat as error for this project. + m.resolveBranch("", errors.New("user canceled branch selection")) + m.step = bsStepCloning + m.stepChangedAt = time.Now() + return m, nil + } + + // Otherwise delegate to the embedded model and let it produce the + // terminal messages above on the next key event. + var cmd tea.Cmd + m.branchPrompt, cmd = m.branchPrompt.Update(msg) + return m, cmd +} + +// resolveBranch unblocks the worker goroutine waiting for a branch answer. +func (m *bootstrapModel) resolveBranch(branch string, err error) { + if m.branchAnswer == nil { + return + } + m.branchAnswer <- branchAnswer{branch: branch, err: err} + m.branchAnswer = nil +} diff --git a/internal/cli/bootstrap_view.go b/internal/cli/bootstrap_view.go new file mode 100644 index 0000000..a0d7ef5 --- /dev/null +++ b/internal/cli/bootstrap_view.go @@ -0,0 +1,180 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/bootstrap" +) + +func (m bootstrapModel) View() string { + switch m.step { + case bsStepPlan: + return m.viewPlan() + case bsStepCloning: + return m.viewCloning() + case bsStepBranchPrompt: + return m.viewBranchPrompt() + case bsStepDone: + return m.viewDone() + } + return "" +} + +func (m bootstrapModel) viewPlan() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Bootstrap plan ")) + b.WriteString("\n\n") + b.WriteString(bsDimStyle.Render(wsRoot)) + b.WriteString("\n\n") + + rows := []struct { + state bootstrap.State + label string + mark string + }{ + {bootstrap.StateMissing, "will clone", bsArrowStyle.Render("→")}, + {bootstrap.StatePresent, "already present", bsCheckStyle.Render("✓")}, + {bootstrap.StateNeedsMigrate, "needs migration", bsWarnStyle.Render("⚠")}, + {bootstrap.StateBlocked, "path blocked", bsErrStyle.Render("✗")}, + {bootstrap.StateSelf, "self (skipped)", bsDimStyle.Render("⊘")}, + } + for _, row := range rows { + items := m.plan.Bucket(row.state) + if len(items) == 0 { + continue + } + fmt.Fprintf(&b, " %s %s (%d)\n", row.mark, bsHeaderStyle.Render(row.label), len(items)) + // Truncate large lists in the TUI; full list still shown in dry-run. + max := len(items) + if max > 8 { + max = 8 + } + for i := 0; i < max; i++ { + fmt.Fprintf(&b, " %s\n", items[i].Name) + } + if len(items) > max { + fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(fmt.Sprintf("… and %d more", len(items)-max))) + } + } + + b.WriteString("\n") + if len(m.toClone) == 0 { + b.WriteString(bsDimStyle.Render("Nothing to clone.")) + b.WriteString("\n") + } + b.WriteString(bsHelpStyle.Render("[Y] proceed [n/esc] cancel")) + return b.String() +} + +func (m bootstrapModel) viewCloning() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Cloning ")) + b.WriteString("\n\n") + b.WriteString(bsDimStyle.Render(wsRoot)) + b.WriteString("\n\n") + + total := len(m.toClone) + done := m.current + bar := renderProgressBar(done, total, 30) + fmt.Fprintf(&b, " %s %d / %d\n\n", bar, done, total) + + if m.current < total { + current := m.toClone[m.current] + fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), current.Name) + fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(current.Project.Path)) + } + + if len(m.errors) > 0 { + fmt.Fprintf(&b, "\n%s %d failed (full errors after exit)\n", + bsErrStyle.Render("✗"), len(m.errors)) + } + + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("[ctrl+c] abort")) + return b.String() +} + +func (m bootstrapModel) viewBranchPrompt() string { + return m.branchPrompt.View() +} + +func (m bootstrapModel) viewDone() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Bootstrap finished ")) + b.WriteString("\n\n") + fmt.Fprintf(&b, " %s %d cloned\n", bsCheckStyle.Render("✓"), len(m.successes)) + if len(m.errors) > 0 { + fmt.Fprintf(&b, " %s %d failed\n", bsErrStyle.Render("✗"), len(m.errors)) + b.WriteString("\n") + b.WriteString(bsDimStyle.Render(" Full errors will be printed after exit.")) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("[any key] exit")) + return b.String() +} + +// renderProgressBar draws a simple [█████░░░░░] bar. +func renderProgressBar(done, total, width int) string { + if total <= 0 { + return strings.Repeat("░", width) + } + filled := done * width / total + if filled > width { + filled = width + } + return bsBarFilledStyle.Render(strings.Repeat("█", filled)) + + bsBarEmptyStyle.Render(strings.Repeat("░", width-filled)) +} + +// indent prefixes every line of s with prefix. Used for nesting git stderr +// inside the post-exit error report. +func indent(s, prefix string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = prefix + l + } + return strings.Join(lines, "\n") +} + +var ( + bsTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("6")). + Padding(0, 1) + + bsHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true) + + bsDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + bsHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + bsCheckStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("2")) + + bsWarnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("3")) + + bsErrStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("1")) + + bsArrowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")) + + bsBarFilledStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")) + + bsBarEmptyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + errorBannerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("1")). + Bold(true) +) diff --git a/internal/cli/agent.go b/internal/cli/explorer.go similarity index 64% rename from internal/cli/agent.go rename to internal/cli/explorer.go index a0ae0c5..4e96abe 100644 --- a/internal/cli/agent.go +++ b/internal/cli/explorer.go @@ -9,33 +9,37 @@ import ( "github.com/spf13/cobra" ) -func newAgentCmd() *cobra.Command { +func newExplorerCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "agent", - Short: "TUI launcher for Claude Code sessions across workspaces", + Use: "explorer", + Aliases: []string{"agent"}, // backwards-compat: ws agent still works + Short: "TUI explorer for projects, worktrees, and Claude sessions", Annotations: map[string]string{ - "capability": "agent", - "agent:when": "Browse workspaces and projects, then launch or resume Claude Code sessions", + "capability": "explorer", + "agent:when": "Browse workspaces, projects, and worktrees, then launch or resume Claude Code sessions", "agent:safety": "Interactive TUI. Use subcommands (launch, shell, resume) for non-interactive access.", }, - Long: `Launch an interactive TUI that lets you browse workspaces, projects, -and worktrees, then start or resume Claude Code sessions. + Long: `Launch the interactive TUI explorer over every registered workspace. +The pinned quick-nav header shows up to nine numbered chips (favorites ++ recently-touched) — press 1-9 to launch the matching project. Below +the header, the full project tree scrolls with j/k navigation. Navigation: j/k to move, Enter to open, h/Esc to go back, q to quit. -Subcommands provide non-interactive access to the same actions.`, +1-9 to launch a chip directly. Subcommands provide non-interactive +access to the same actions.`, RunE: func(cmd *cobra.Command, args []string) error { - return runAgentTUI() + return runExplorerTUI() }, } cmd.AddCommand( - newAgentLaunchCmd(), - newAgentShellCmd(), - newAgentResumeCmd(), + newExplorerLaunchCmd(), + newExplorerShellCmd(), + newExplorerResumeCmd(), ) return cmd } -func newAgentLaunchCmd() *cobra.Command { +func newExplorerLaunchCmd() *cobra.Command { var prompt string cmd := &cobra.Command{ Use: "launch ", @@ -46,6 +50,7 @@ func newAgentLaunchCmd() *cobra.Command { }, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + stampLaunchActivity(args[0]) return agent.LaunchClaude(args[0], "", prompt) }, } @@ -53,7 +58,7 @@ func newAgentLaunchCmd() *cobra.Command { return cmd } -func newAgentShellCmd() *cobra.Command { +func newExplorerShellCmd() *cobra.Command { return &cobra.Command{ Use: "shell ", Short: "Open shell in a directory (non-interactive)", @@ -63,12 +68,13 @@ func newAgentShellCmd() *cobra.Command { }, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + stampLaunchActivity(args[0]) return agent.LaunchShell(args[0]) }, } } -func newAgentResumeCmd() *cobra.Command { +func newExplorerResumeCmd() *cobra.Command { var prompt string cmd := &cobra.Command{ Use: "resume ", @@ -84,6 +90,7 @@ func newAgentResumeCmd() *cobra.Command { if session == nil { return fmt.Errorf("session %s not found", sessionID) } + stampLaunchActivity(session.Cwd) return agent.LaunchClaude(session.Cwd, session.ID, prompt) }, } @@ -91,7 +98,7 @@ func newAgentResumeCmd() *cobra.Command { return cmd } -func runAgentTUI() error { +func runExplorerTUI() error { cwd, _ := os.Getwd() workspaces, sessCache, diagnostics := agent.LoadWorkspaces(cwd) for _, d := range diagnostics { @@ -111,6 +118,7 @@ func runAgentTUI() error { // If the user selected a launch action, exec into claude now. // bubbletea has already restored the terminal at this point. if final, ok := finalModel.(*agent.Model); ok && final.Launch != nil { + stampLaunchActivity(final.Launch.Cwd) if final.Launch.ShellOnly { return agent.LaunchShell(final.Launch.Cwd) } @@ -118,3 +126,13 @@ func runAgentTUI() error { } return nil } + +// stampLaunchActivity runs StampLaunchFromPath synchronously and +// writes any error to stderr without failing the launch. Activity +// stamping is UX-only: an unwritable workspace.toml or down daemon +// must not prevent the user from getting into their shell. +func stampLaunchActivity(cwd string) { + if err := agent.StampLaunchFromPath(cwd); err != nil { + fmt.Fprintf(os.Stderr, "ws agent: stamp activity: %v\n", err) + } +} diff --git a/internal/cli/favorite.go b/internal/cli/favorite.go new file mode 100644 index 0000000..f3a9069 --- /dev/null +++ b/internal/cli/favorite.go @@ -0,0 +1,183 @@ +package cli + +import ( + "fmt" + "os" + "sort" + "text/tabwriter" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/spf13/cobra" +) + +// newFavoriteCmd builds the `ws favorite` command tree. Mirrors the +// `ws alias` shape (add/rm/list) so the two project-pinning surfaces +// — aliases for cd, favorites for `ws agent` — read consistently. +// +// Favorites are stored as `[projects.].favorite = true` in +// workspace.toml, which means they sync across machines via the +// reconciler. The TUI hotkey `f` is the interactive equivalent. +func newFavoriteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "favorite", + Short: "Pin projects to the Favorites section of `ws agent`", + Long: `Manage the project favorites shown at the top of ` + "`" + `ws agent` + "`" + `. + +Favorites are stored in workspace.toml and sync across machines via the +reconciler. The same toggle is available in the TUI as the f hotkey on +any project row.`, + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Pin / unpin projects shown in the Favorites section of `ws agent`", + }, + } + cmd.AddCommand( + newFavoriteAddCmd(), + newFavoriteRmCmd(), + newFavoriteListCmd(), + ) + return cmd +} + +func newFavoriteAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Mark a project or group as favorite", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Pin a project or group to the quick-nav chips of `ws explorer`", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return setFavorite(args[0], true) + }, + } +} + +func newFavoriteRmCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm ", + Short: "Unmark a favorite project or group", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Unpin a project or group from the quick-nav chips of `ws explorer`", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return setFavorite(args[0], false) + }, + } +} + +// setFavorite dispatches by `@`-prefix: `@group` toggles a group +// favorite, anything else toggles a project favorite. Keeps the CLI +// surface symmetric with the TUI hotkey, which uses the cursor's +// row type to decide. +func setFavorite(arg string, fav bool) error { + if len(arg) > 1 && arg[0] == '@' { + return setGroupFavoriteCLI(arg[1:], fav) + } + return setProjectFavorite(arg, fav) +} + +func setGroupFavoriteCLI(name string, fav bool) error { + if _, ok := ws.Groups[name]; !ok { + // Auto-register the group so the favorite flag has somewhere + // to live. Empty Group{} is fine — the user can fill it later. + if ws.Groups == nil { + ws.Groups = map[string]config.Group{} + } + ws.Groups[name] = config.Group{} + } + if !ws.SetGroupFavorite(name, fav) { + if fav { + fmt.Printf("@%s is already a favorite.\n", name) + } else { + fmt.Printf("@%s is not a favorite.\n", name) + } + return nil + } + if err := saveWorkspace(); err != nil { + return err + } + if fav { + fmt.Printf("Added @%s to favorites.\n", name) + } else { + fmt.Printf("Removed @%s from favorites.\n", name) + } + return nil +} + +func newFavoriteListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List favorite projects", + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Print favorited projects with their category and group", + }, + RunE: func(cmd *cobra.Command, args []string) error { + var projNames, groupNames []string + for n, p := range ws.Projects { + if p.Favorite { + projNames = append(projNames, n) + } + } + for n, g := range ws.Groups { + if g.Favorite { + groupNames = append(groupNames, n) + } + } + if len(projNames)+len(groupNames) == 0 { + fmt.Println("No favorites. Use `ws favorite add ` to pin one.") + return nil + } + sort.Strings(projNames) + sort.Strings(groupNames) + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tKIND\tCATEGORY\tGROUP") + for _, n := range groupNames { + fmt.Fprintf(tw, "@%s\tgroup\t-\t-\n", n) + } + for _, n := range projNames { + p := ws.Projects[n] + group := p.Group + if group == "" { + group = "-" + } + fmt.Fprintf(tw, "%s\tproject\t%s\t%s\n", n, p.Category, group) + } + return tw.Flush() + }, + } +} + +// setProjectFavorite updates the favorite flag on `name` and persists +// the workspace. Returns an error if the project is unknown or the +// save fails. No-op (with a printed notice) when the flag is already +// at the requested value — keeps the command idempotent for shell +// scripts that don't want to track current state. +func setProjectFavorite(name string, fav bool) error { + p, ok := ws.Projects[name] + if !ok { + return fmt.Errorf("unknown project %q (see `ws status`)", name) + } + if !p.SetFavorite(fav) { + if fav { + fmt.Printf("%s is already a favorite.\n", name) + } else { + fmt.Printf("%s is not a favorite.\n", name) + } + return nil + } + ws.Projects[name] = p + if err := saveWorkspace(); err != nil { + return err + } + if fav { + fmt.Printf("Added %s to favorites.\n", name) + } else { + fmt.Printf("Removed %s from favorites.\n", name) + } + return nil +} diff --git a/internal/cli/migrate_model.go b/internal/cli/migrate_model.go new file mode 100644 index 0000000..ad867be --- /dev/null +++ b/internal/cli/migrate_model.go @@ -0,0 +1,275 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/conflict" + "github.com/kuchmenko/workspace/internal/migrate" +) + +type migrateStep int + +const ( + mStepPlan migrateStep = iota + mStepDecision // per-project decision (dirty/stash/detached) + mStepMigrating // running migrate.MigrateProject + mStepDone +) + +type migrateError struct { + project string + err error +} + +type migrateModel struct { + step migrateStep + stepChangedAt time.Time + + machine string + plan *migratePlan + queue []migratePlanItem // projects pending action, in order + cursor int // index into queue + current migratePlanItem // active project + + // Decisions accumulated per project before the migration runs. + decisions map[string]migrateDecision + + successes []string + errors []migrateError + skipped int + canceled bool + + spinner spinner.Model + sidecar *migrate.Sidecar +} + +// migrateDecision captures the user's per-project answer to a state-specific +// prompt. Empty fields default to "abort" semantics. +type migrateDecision struct { + WIP bool + StashBranch bool + CheckoutDefault bool + Skip bool +} + +type migrateDoneMsg struct { + index int + project string + res *migrate.Result + err error +} + +type migrateAllDoneMsg struct{} + +func newMigrateModel(plan *migratePlan, machine string, resume map[string]migrate.DoneEntry) migrateModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + + sc := migrate.New(wsRoot) + for k, v := range resume { + _ = sc.Set(k, v) + } + + return migrateModel{ + step: mStepPlan, + machine: machine, + plan: plan, + decisions: make(map[string]migrateDecision), + spinner: sp, + sidecar: sc, + } +} + +func (m migrateModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { + return m, nil + } + if msg.String() == "ctrl+c" { + m.canceled = true + return m, tea.Quit + } + } + + switch m.step { + case mStepPlan: + return m.updatePlan(msg) + case mStepDecision: + return m.updateDecision(msg) + case mStepMigrating: + return m.updateMigrating(msg) + case mStepDone: + if _, ok := msg.(tea.KeyMsg); ok { + return m, tea.Quit + } + } + return m, nil +} + +func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "y", "Y", "enter": + // Build queue: ready + dirty + stash + detached, in that order. + // already/missing/not-a-repo are skipped silently. + for _, s := range []migrateState{mstReady, mstDirty, mstStash, mstDetached} { + m.queue = append(m.queue, m.plan.Bucket(s)...) + } + if len(m.queue) == 0 { + m.step = mStepDone + return m, tea.Quit + } + // Persist sidecar with our pid before any migrate runs. + if err := migrate.Save(m.sidecar); err != nil { + m.errors = append(m.errors, migrateError{project: "", err: err}) + return m, tea.Quit + } + conflict.Notify("ws: migrate started", + fmt.Sprintf("%s: %d projects", wsRoot, len(m.queue))) + return m.advance() + case "n", "N", "escape": + m.canceled = true + return m, tea.Quit + } + } + return m, nil +} + +// advance moves from one queue item to the next. If the next item needs a +// per-project decision, switch to mStepDecision; otherwise kick off +// migration directly. +func (m migrateModel) advance() (tea.Model, tea.Cmd) { + if m.cursor >= len(m.queue) { + m.step = mStepDone + return m, tea.Quit + } + m.current = m.queue[m.cursor] + switch m.current.State { + case mstReady: + // No decision needed. Migrate immediately. + m.step = mStepMigrating + m.stepChangedAt = time.Now() + return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) + case mstDirty, mstStash, mstDetached: + m.step = mStepDecision + m.stepChangedAt = time.Now() + return m, nil + } + // Unknown — skip. + m.skipped++ + m.cursor++ + return m.advance() +} + +func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + dec := migrateDecision{} + resolved := false + switch m.current.State { + case mstDirty: + switch key.String() { + case "w", "W": + dec.WIP = true + resolved = true + case "s", "S": + dec.Skip = true + resolved = true + case "a", "A": + m.canceled = true + return m, tea.Quit + } + case mstStash: + switch key.String() { + case "b", "B": + dec.StashBranch = true + resolved = true + case "s", "S": + dec.Skip = true + resolved = true + case "a", "A": + m.canceled = true + return m, tea.Quit + } + case mstDetached: + switch key.String() { + case "c", "C": + dec.CheckoutDefault = true + resolved = true + case "s", "S": + dec.Skip = true + resolved = true + case "a", "A": + m.canceled = true + return m, tea.Quit + } + } + if !resolved { + return m, nil + } + m.decisions[m.current.Name] = dec + if dec.Skip { + m.skipped++ + m.cursor++ + return m.advance() + } + m.step = mStepMigrating + m.stepChangedAt = time.Now() + return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) +} + +// startMigrate runs MigrateProject in a goroutine and returns a tea.Cmd that +// emits migrateDoneMsg on completion. +func (m migrateModel) startMigrate(index int) tea.Cmd { + item := m.queue[index] + dec := m.decisions[item.Name] + machine := m.machine + return func() tea.Msg { + proj := item.Project + opts := migrate.Options{ + WIP: dec.WIP, + StashBranch: dec.StashBranch, + CheckoutDefault: dec.CheckoutDefault, + Machine: machine, + } + res, err := migrate.MigrateProject(wsRoot, item.Name, &proj, opts) + return migrateDoneMsg{index: index, project: item.Name, res: res, err: err} + } +} + +func (m migrateModel) updateMigrating(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case migrateDoneMsg: + if msg.err != nil { + m.errors = append(m.errors, migrateError{project: msg.project, err: msg.err}) + } else { + m.successes = append(m.successes, msg.project) + if msg.res != nil { + _ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) + _ = migrate.Save(m.sidecar) + } + } + m.cursor++ + return m.advance() + case migrateAllDoneMsg: + m.step = mStepDone + return m, tea.Quit + } + return m, nil +} diff --git a/internal/cli/migrate_tui.go b/internal/cli/migrate_tui.go index dee165e..f245e5c 100644 --- a/internal/cli/migrate_tui.go +++ b/internal/cli/migrate_tui.go @@ -8,9 +8,7 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/conflict" "github.com/kuchmenko/workspace/internal/migrate" @@ -192,10 +190,6 @@ func commitMigrate(sc *migrate.Sidecar) error { return saveWorkspace() } -// ============================================================================= -// Plan model -// ============================================================================= - type migrateState int const ( @@ -248,406 +242,3 @@ func (p *migratePlan) Bucket(s migrateState) []migratePlanItem { } return out } - -// ============================================================================= -// Bubbletea model -// ============================================================================= - -type migrateStep int - -const ( - mStepPlan migrateStep = iota - mStepDecision // per-project decision (dirty/stash/detached) - mStepMigrating // running migrate.MigrateProject - mStepDone -) - -type migrateError struct { - project string - err error -} - -type migrateModel struct { - step migrateStep - stepChangedAt time.Time - - machine string - plan *migratePlan - queue []migratePlanItem // projects pending action, in order - cursor int // index into queue - current migratePlanItem // active project - - // Decisions accumulated per project before the migration runs. - decisions map[string]migrateDecision - - successes []string - errors []migrateError - skipped int - canceled bool - - spinner spinner.Model - sidecar *migrate.Sidecar -} - -// migrateDecision captures the user's per-project answer to a state-specific -// prompt. Empty fields default to "abort" semantics. -type migrateDecision struct { - WIP bool - StashBranch bool - CheckoutDefault bool - Skip bool -} - -type migrateDoneMsg struct { - index int - project string - res *migrate.Result - err error -} - -type migrateAllDoneMsg struct{} - -func newMigrateModel(plan *migratePlan, machine string, resume map[string]migrate.DoneEntry) migrateModel { - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - - sc := migrate.New(wsRoot) - for k, v := range resume { - _ = sc.Set(k, v) - } - - return migrateModel{ - step: mStepPlan, - machine: machine, - plan: plan, - decisions: make(map[string]migrateDecision), - spinner: sp, - sidecar: sc, - } -} - -func (m migrateModel) Init() tea.Cmd { - return m.spinner.Tick -} - -func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { - return m, nil - } - if msg.String() == "ctrl+c" { - m.canceled = true - return m, tea.Quit - } - } - - switch m.step { - case mStepPlan: - return m.updatePlan(msg) - case mStepDecision: - return m.updateDecision(msg) - case mStepMigrating: - return m.updateMigrating(msg) - case mStepDone: - if _, ok := msg.(tea.KeyMsg); ok { - return m, tea.Quit - } - } - return m, nil -} - -func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - switch key.String() { - case "y", "Y", "enter": - // Build queue: ready + dirty + stash + detached, in that order. - // already/missing/not-a-repo are skipped silently. - for _, s := range []migrateState{mstReady, mstDirty, mstStash, mstDetached} { - m.queue = append(m.queue, m.plan.Bucket(s)...) - } - if len(m.queue) == 0 { - m.step = mStepDone - return m, tea.Quit - } - // Persist sidecar with our pid before any migrate runs. - if err := migrate.Save(m.sidecar); err != nil { - m.errors = append(m.errors, migrateError{project: "", err: err}) - return m, tea.Quit - } - conflict.Notify("ws: migrate started", - fmt.Sprintf("%s: %d projects", wsRoot, len(m.queue))) - return m.advance() - case "n", "N", "escape": - m.canceled = true - return m, tea.Quit - } - } - return m, nil -} - -// advance moves from one queue item to the next. If the next item needs a -// per-project decision, switch to mStepDecision; otherwise kick off -// migration directly. -func (m migrateModel) advance() (tea.Model, tea.Cmd) { - if m.cursor >= len(m.queue) { - m.step = mStepDone - return m, tea.Quit - } - m.current = m.queue[m.cursor] - switch m.current.State { - case mstReady: - // No decision needed. Migrate immediately. - m.step = mStepMigrating - m.stepChangedAt = time.Now() - return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) - case mstDirty, mstStash, mstDetached: - m.step = mStepDecision - m.stepChangedAt = time.Now() - return m, nil - } - // Unknown — skip. - m.skipped++ - m.cursor++ - return m.advance() -} - -func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil - } - dec := migrateDecision{} - resolved := false - switch m.current.State { - case mstDirty: - switch key.String() { - case "w", "W": - dec.WIP = true - resolved = true - case "s", "S": - dec.Skip = true - resolved = true - case "a", "A": - m.canceled = true - return m, tea.Quit - } - case mstStash: - switch key.String() { - case "b", "B": - dec.StashBranch = true - resolved = true - case "s", "S": - dec.Skip = true - resolved = true - case "a", "A": - m.canceled = true - return m, tea.Quit - } - case mstDetached: - switch key.String() { - case "c", "C": - dec.CheckoutDefault = true - resolved = true - case "s", "S": - dec.Skip = true - resolved = true - case "a", "A": - m.canceled = true - return m, tea.Quit - } - } - if !resolved { - return m, nil - } - m.decisions[m.current.Name] = dec - if dec.Skip { - m.skipped++ - m.cursor++ - return m.advance() - } - m.step = mStepMigrating - m.stepChangedAt = time.Now() - return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) -} - -// startMigrate runs MigrateProject in a goroutine and returns a tea.Cmd that -// emits migrateDoneMsg on completion. -func (m migrateModel) startMigrate(index int) tea.Cmd { - item := m.queue[index] - dec := m.decisions[item.Name] - machine := m.machine - return func() tea.Msg { - proj := item.Project - opts := migrate.Options{ - WIP: dec.WIP, - StashBranch: dec.StashBranch, - CheckoutDefault: dec.CheckoutDefault, - Machine: machine, - } - res, err := migrate.MigrateProject(wsRoot, item.Name, &proj, opts) - return migrateDoneMsg{index: index, project: item.Name, res: res, err: err} - } -} - -func (m migrateModel) updateMigrating(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case migrateDoneMsg: - if msg.err != nil { - m.errors = append(m.errors, migrateError{project: msg.project, err: msg.err}) - } else { - m.successes = append(m.successes, msg.project) - if msg.res != nil { - _ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) - _ = migrate.Save(m.sidecar) - } - } - m.cursor++ - return m.advance() - case migrateAllDoneMsg: - m.step = mStepDone - return m, tea.Quit - } - return m, nil -} - -// ============================================================================= -// Views -// ============================================================================= - -func (m migrateModel) View() string { - switch m.step { - case mStepPlan: - return m.viewPlan() - case mStepDecision: - return m.viewDecision() - case mStepMigrating: - return m.viewMigrating() - case mStepDone: - return m.viewDone() - } - return "" -} - -func (m migrateModel) viewPlan() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Migrate plan ")) - b.WriteString("\n\n") - b.WriteString(bsDimStyle.Render(wsRoot)) - b.WriteString("\n\n") - - rows := []struct { - state migrateState - mark string - }{ - {mstReady, bsArrowStyle.Render("→")}, - {mstDirty, bsWarnStyle.Render("●")}, - {mstStash, bsWarnStyle.Render("●")}, - {mstDetached, bsWarnStyle.Render("●")}, - {mstAlready, bsCheckStyle.Render("✓")}, - {mstMissing, bsDimStyle.Render("⊘")}, - {mstNotRepo, bsErrStyle.Render("✗")}, - } - for _, row := range rows { - items := m.plan.Bucket(row.state) - if len(items) == 0 { - continue - } - fmt.Fprintf(&b, " %s %s (%d)\n", row.mark, bsHeaderStyle.Render(row.state.label()), len(items)) - max := len(items) - if max > 8 { - max = 8 - } - for i := 0; i < max; i++ { - fmt.Fprintf(&b, " %s\n", items[i].Name) - } - if len(items) > max { - fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(fmt.Sprintf("… and %d more", len(items)-max))) - } - } - - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("[Y] proceed [n/esc] cancel")) - return b.String() -} - -func (m migrateModel) viewDecision() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Decision needed ")) - b.WriteString("\n\n") - fmt.Fprintf(&b, " Project: %s\n", bsHeaderStyle.Render(m.current.Name)) - fmt.Fprintf(&b, " State: %s\n\n", bsWarnStyle.Render(m.current.State.label())) - - switch m.current.State { - case mstDirty: - b.WriteString(" Working tree has uncommitted changes.\n\n") - b.WriteString(" [w] snapshot to wt/" + m.machine + "/migration-wip- and migrate\n") - b.WriteString(" [s] skip this project\n") - b.WriteString(" [a] abort migrate\n") - case mstStash: - b.WriteString(" Repository has stash entries (would be lost on bare clone).\n\n") - b.WriteString(" [b] convert each stash to wt/" + m.machine + "/migration-stash--N branch and migrate\n") - b.WriteString(" [s] skip this project\n") - b.WriteString(" [a] abort migrate\n") - case mstDetached: - b.WriteString(" HEAD is detached. Migration needs to attach to a branch.\n\n") - b.WriteString(" [c] checkout default_branch (orphaned commits saved to wt/" + m.machine + "/migration-detached-)\n") - b.WriteString(" [s] skip this project\n") - b.WriteString(" [a] abort migrate\n") - } - - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("press the bracketed letter to choose")) - return b.String() -} - -func (m migrateModel) viewMigrating() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Migrating ")) - b.WriteString("\n\n") - b.WriteString(bsDimStyle.Render(wsRoot)) - b.WriteString("\n\n") - - total := len(m.queue) - done := m.cursor - bar := renderProgressBar(done, total, 30) - fmt.Fprintf(&b, " %s %d / %d\n\n", bar, done, total) - - if m.cursor < total { - fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), m.current.Name) - fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(m.current.Project.Path)) - } - - if len(m.errors) > 0 { - fmt.Fprintf(&b, "\n%s %d failed (full errors after exit)\n", - bsErrStyle.Render("✗"), len(m.errors)) - } - - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("[ctrl+c] abort")) - return b.String() -} - -func (m migrateModel) viewDone() string { - var b strings.Builder - b.WriteString(bsTitleStyle.Render(" Migrate finished ")) - b.WriteString("\n\n") - fmt.Fprintf(&b, " %s %d migrated\n", bsCheckStyle.Render("✓"), len(m.successes)) - if m.skipped > 0 { - fmt.Fprintf(&b, " %s %d skipped\n", bsDimStyle.Render("⊘"), m.skipped) - } - if len(m.errors) > 0 { - fmt.Fprintf(&b, " %s %d failed\n", bsErrStyle.Render("✗"), len(m.errors)) - b.WriteString("\n") - b.WriteString(bsDimStyle.Render(" Full errors will be printed after exit.")) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(bsHelpStyle.Render("[any key] exit")) - return b.String() -} diff --git a/internal/cli/migrate_view.go b/internal/cli/migrate_view.go new file mode 100644 index 0000000..d21e8d4 --- /dev/null +++ b/internal/cli/migrate_view.go @@ -0,0 +1,138 @@ +package cli + +import ( + "fmt" + "strings" +) + +func (m migrateModel) View() string { + switch m.step { + case mStepPlan: + return m.viewPlan() + case mStepDecision: + return m.viewDecision() + case mStepMigrating: + return m.viewMigrating() + case mStepDone: + return m.viewDone() + } + return "" +} + +func (m migrateModel) viewPlan() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Migrate plan ")) + b.WriteString("\n\n") + b.WriteString(bsDimStyle.Render(wsRoot)) + b.WriteString("\n\n") + + rows := []struct { + state migrateState + mark string + }{ + {mstReady, bsArrowStyle.Render("→")}, + {mstDirty, bsWarnStyle.Render("●")}, + {mstStash, bsWarnStyle.Render("●")}, + {mstDetached, bsWarnStyle.Render("●")}, + {mstAlready, bsCheckStyle.Render("✓")}, + {mstMissing, bsDimStyle.Render("⊘")}, + {mstNotRepo, bsErrStyle.Render("✗")}, + } + for _, row := range rows { + items := m.plan.Bucket(row.state) + if len(items) == 0 { + continue + } + fmt.Fprintf(&b, " %s %s (%d)\n", row.mark, bsHeaderStyle.Render(row.state.label()), len(items)) + max := len(items) + if max > 8 { + max = 8 + } + for i := 0; i < max; i++ { + fmt.Fprintf(&b, " %s\n", items[i].Name) + } + if len(items) > max { + fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(fmt.Sprintf("… and %d more", len(items)-max))) + } + } + + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("[Y] proceed [n/esc] cancel")) + return b.String() +} + +func (m migrateModel) viewDecision() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Decision needed ")) + b.WriteString("\n\n") + fmt.Fprintf(&b, " Project: %s\n", bsHeaderStyle.Render(m.current.Name)) + fmt.Fprintf(&b, " State: %s\n\n", bsWarnStyle.Render(m.current.State.label())) + + switch m.current.State { + case mstDirty: + b.WriteString(" Working tree has uncommitted changes.\n\n") + b.WriteString(" [w] snapshot to wt/" + m.machine + "/migration-wip- and migrate\n") + b.WriteString(" [s] skip this project\n") + b.WriteString(" [a] abort migrate\n") + case mstStash: + b.WriteString(" Repository has stash entries (would be lost on bare clone).\n\n") + b.WriteString(" [b] convert each stash to wt/" + m.machine + "/migration-stash--N branch and migrate\n") + b.WriteString(" [s] skip this project\n") + b.WriteString(" [a] abort migrate\n") + case mstDetached: + b.WriteString(" HEAD is detached. Migration needs to attach to a branch.\n\n") + b.WriteString(" [c] checkout default_branch (orphaned commits saved to wt/" + m.machine + "/migration-detached-)\n") + b.WriteString(" [s] skip this project\n") + b.WriteString(" [a] abort migrate\n") + } + + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("press the bracketed letter to choose")) + return b.String() +} + +func (m migrateModel) viewMigrating() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Migrating ")) + b.WriteString("\n\n") + b.WriteString(bsDimStyle.Render(wsRoot)) + b.WriteString("\n\n") + + total := len(m.queue) + done := m.cursor + bar := renderProgressBar(done, total, 30) + fmt.Fprintf(&b, " %s %d / %d\n\n", bar, done, total) + + if m.cursor < total { + fmt.Fprintf(&b, " %s %s\n", m.spinner.View(), m.current.Name) + fmt.Fprintf(&b, " %s\n", bsDimStyle.Render(m.current.Project.Path)) + } + + if len(m.errors) > 0 { + fmt.Fprintf(&b, "\n%s %d failed (full errors after exit)\n", + bsErrStyle.Render("✗"), len(m.errors)) + } + + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("[ctrl+c] abort")) + return b.String() +} + +func (m migrateModel) viewDone() string { + var b strings.Builder + b.WriteString(bsTitleStyle.Render(" Migrate finished ")) + b.WriteString("\n\n") + fmt.Fprintf(&b, " %s %d migrated\n", bsCheckStyle.Render("✓"), len(m.successes)) + if m.skipped > 0 { + fmt.Fprintf(&b, " %s %d skipped\n", bsDimStyle.Render("⊘"), m.skipped) + } + if len(m.errors) > 0 { + fmt.Fprintf(&b, " %s %d failed\n", bsErrStyle.Render("✗"), len(m.errors)) + b.WriteString("\n") + b.WriteString(bsDimStyle.Render(" Full errors will be printed after exit.")) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(bsHelpStyle.Render("[any key] exit")) + return b.String() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9c84601..89981e6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -64,10 +64,10 @@ func NewRootCmd() *cobra.Command { } return nil }, - // Bare `ws` in a TTY launches the agent TUI. In pipe/CI → help. + // Bare `ws` in a TTY launches the explorer TUI. In pipe/CI → help. RunE: func(cmd *cobra.Command, args []string) error { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - return runAgentTUI() + return runExplorerTUI() } return cmd.Help() }, @@ -90,7 +90,8 @@ func NewRootCmd() *cobra.Command { newMigrateCmd(), newWorktreeCmd(), newBootstrapCmd(), - newAgentCmd(), + newExplorerCmd(), + newFavoriteCmd(), newDocsCmd(), newDoctorCmd(), ) diff --git a/internal/cli/worktree.go b/internal/cli/worktree.go index 570eb96..e1b9fab 100644 --- a/internal/cli/worktree.go +++ b/internal/cli/worktree.go @@ -1,14 +1,11 @@ package cli import ( - "errors" "fmt" "os" "os/exec" "path/filepath" - "sort" "strings" - "time" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/git" @@ -88,444 +85,3 @@ func validateBranchName(branch string) error { } return nil } - -func newWorktreeAddCmd() *cobra.Command { - var fromBase string - cmd := &cobra.Command{ - Use: "add ", - Short: "Create or attach a worktree for the named branch", - Annotations: map[string]string{ - "capability": "worktree", - "agent:when": "Start a new feature in an isolated worktree, or check out an existing local/remote branch", - }, - Long: `Create a new worktree for on the literal branch . - -The branch name is taken verbatim — no prefix injection, no slug -rewrite — beyond what git check-ref-format accepts. The same command -covers three cases: - - 1. Branch is new: created from --from (or project default_branch) - and a fresh [[branches]] entry is recorded in workspace.toml. - - 2. Branch exists on origin: fetched into the bare repo, the new - worktree checks it out, upstream tracking wired automatically. - - 3. Branch exists locally only (no remote): worktree attaches to the - existing local branch. This is also the path that re-registers a - legacy wt// branch under the new schema — - ws worktree add myapp wt/linux/legacy-foo will pick it up and - give it [[branches]] metadata. - -EXAMPLES - - # New feature branch from main: - ws worktree add myapp feat/auth-refactor - - # Auto-detect existing remote branch: - ws worktree add myapp feat/data-api - - # Re-register a legacy wt//* worktree: - ws worktree add myapp wt/linux/old-topic - - # Branch off a non-default base: - ws worktree add myapp hotfix --from release/v2`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - projectName, branch := args[0], strings.TrimSpace(args[1]) - if branch == "" { - return errors.New("branch must not be empty") - } - if err := validateBranchName(branch); err != nil { - return err - } - - machine, err := ensureMachineName() - if err != nil { - return err - } - - proj, mainPath, barePath, err := resolveProject(projectName) - if err != nil { - return err - } - - // One-time repair: pre-0.5.1 bare repos were created without - // remote.origin.fetch configured. Without it, the fetch below - // would only update FETCH_HEAD, leaving refs/remotes/origin/* - // untouched — and HasRemoteBranch would always return false, - // breaking the "branch is on origin" detection. Mirrors the - // reconciler's repair step at reconciler.go:336. - if !git.HasFetchRefspec(barePath) { - _ = git.SetFetchRefspec(barePath) - } - - // Best-effort fetch the named branch via the standard remote- - // tracking refspec so refs/remotes/origin/ reflects - // the latest origin state. We deliberately do NOT force-fetch - // into refs/heads/ here: that would silently rewind a - // local branch with unpushed commits (e.g. legacy - // wt//* re-registration with work-in-progress) to - // origin's tip. - _ = git.FetchRefspec(barePath, "origin", branch) - localExists := git.HasBranch(barePath, branch) - remoteExists := git.HasRemoteBranch(barePath, "origin", branch) - - // Re-registration short-circuit: if the branch is already - // checked out in some existing worktree (legacy wt//* - // dir, or a previous `ws worktree add` whose saveWorkspace - // step failed), don't try to create another worktree — git - // refuses without --force, and the user's intent is to repair - // metadata, not to materialize a duplicate checkout. - if existingWtPath := locateWorktreeForBranch(barePath, branch); existingWtPath != "" { - p := ws.Projects[projectName] - changed, _ := p.ClaimBranch(branch, machine) - if remoteExists && p.MarkPushed(branch, machine, time.Now()) { - changed = true - } - if changed { - ws.Projects[projectName] = p - if err := saveWorkspace(); err != nil { - return fmt.Errorf("registry update failed: %w", err) - } - } - machines := strings.Join(p.LookupBranch(branch).Machines, ", ") - fmt.Printf("re-registered existing worktree %s\n branch: %s\n registered in workspace.toml (machines=[%s])\n", - existingWtPath, branch, machines) - return nil - } - - wtPath := layout.WorktreePathForBranch(mainPath, machine, branch) - if _, err := os.Stat(wtPath); err == nil { - return fmt.Errorf("worktree path already exists: %s", wtPath) - } - - source := "" // "fetched", "local", or "" for new - switch { - case localExists: - if fromBase != "" { - fmt.Fprintf(os.Stderr, "warning: --from ignored: branch %s already exists locally\n", branch) - } - if err := git.WorktreeAdd(barePath, wtPath, branch, ""); err != nil { - return err - } - if remoteExists { - source = "fetched" - } else { - source = "local" - } - case remoteExists: - if fromBase != "" { - fmt.Fprintf(os.Stderr, "warning: --from ignored: branch %s already exists on origin\n", branch) - } - if err := git.WorktreeAdd(barePath, wtPath, branch, "origin/"+branch); err != nil { - return err - } - source = "fetched" - default: - base := fromBase - if base == "" { - base = proj.DefaultBranch - } - if base == "" { - return fmt.Errorf("project %s has no default_branch and --from was not given", projectName) - } - if err := git.WorktreeAdd(barePath, wtPath, branch, base); err != nil { - return err - } - } - if source != "" { - _ = git.SetBranchUpstream(wtPath, branch, "origin") - } - - // Update the registry: claim this machine against the branch. - // When we attached to a branch that was already on origin - // ("fetched" path), also mark it as pushed — the branch was - // observed on origin at this exact moment, so the orphan - // detector should treat it as published from now on. - p := ws.Projects[projectName] - changed, _ := p.ClaimBranch(branch, machine) - if source == "fetched" && p.MarkPushed(branch, machine, time.Now()) { - changed = true - } - if changed { - ws.Projects[projectName] = p - if err := saveWorkspace(); err != nil { - return fmt.Errorf("worktree created but workspace.toml save failed: %w", err) - } - } - - machines := strings.Join(p.LookupBranch(branch).Machines, ", ") - - fmt.Printf("created worktree %s\n", wtPath) - switch source { - case "fetched": - fmt.Printf(" branch: %s (checked out existing remote)\n", branch) - case "local": - fmt.Printf(" branch: %s (attached to existing local branch)\n", branch) - default: - base := fromBase - if base == "" { - base = proj.DefaultBranch - } - fmt.Printf(" branch: %s\n base: %s\n", branch, base) - } - fmt.Printf(" registered in workspace.toml (machines=[%s])\n", machines) - return nil - }, - } - cmd.Flags().StringVar(&fromBase, "from", "", "base ref to create the new branch from (default: project default_branch).\nIgnored with a warning when the branch already exists on origin or locally.") - return cmd -} - -func newWorktreeListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list [project]", - Short: "List worktrees across projects", - Annotations: map[string]string{ - "capability": "worktree", - "agent:when": "List all worktrees across projects with branch, dirty/clean state, and ownership info", - }, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - machine, _ := config.LoadMachineConfig() - myMachine := "" - if machine != nil { - myMachine = machine.MachineName - } - - var names []string - if len(args) == 1 { - names = []string{args[0]} - } else { - for n, p := range ws.Projects { - if p.Status == config.StatusActive { - names = append(names, n) - } - } - sort.Strings(names) - } - - fmt.Printf("%-20s %-50s %-30s %s\n", "PROJECT", "WORKTREE", "BRANCH", "STATE") - for _, name := range names { - proj, ok := ws.Projects[name] - if !ok { - continue - } - mainPath := filepath.Join(wsRoot, proj.Path) - barePath := layout.BarePath(mainPath) - if _, err := os.Stat(barePath); err != nil { - fmt.Printf("%-20s %s\n", name, "(not migrated)") - continue - } - wts, err := git.WorktreeList(barePath) - if err != nil { - fmt.Printf("%-20s ERROR %v\n", name, err) - continue - } - for _, wt := range wts { - if wt.Bare { - continue - } - rel, _ := filepath.Rel(wsRoot, wt.Path) - if rel == "" { - rel = wt.Path - } - branchLabel := wt.Branch - if wt.Detached { - branchLabel = "(detached)" - } - state := worktreeStateString(&proj, wt, myMachine, proj.DefaultBranch) - fmt.Printf("%-20s %-50s %-30s %s\n", name, rel, branchLabel, state) - } - } - return nil - }, - } -} - -func worktreeStateString(proj *config.Project, wt git.Worktree, myMachine, defaultBranch string) string { - parts := []string{} - if git.IsDirty(wt.Path) { - parts = append(parts, "DIRTY") - } else { - parts = append(parts, "clean") - } - if wt.Branch != "" { - ahead, behind, has := git.AheadBehind(wt.Path, wt.Branch) - if has { - parts = append(parts, fmt.Sprintf("↑%d ↓%d", ahead, behind)) - } else { - parts = append(parts, "no upstream") - } - } - owner := "shared" - switch { - case wt.Branch == defaultBranch: - owner = "main" - case strings.HasPrefix(wt.Branch, "wt/"): - owner = "legacy-wt" - default: - if meta := proj.LookupBranch(wt.Branch); meta != nil { - myMine := false - others := []string{} - for _, m := range meta.Machines { - if m == myMachine { - myMine = true - continue - } - others = append(others, m) - } - if myMine && len(others) == 0 { - owner = "mine" - } else if myMine { - owner = "shared with " + strings.Join(others, ", ") - } else if len(others) > 0 { - owner = "remote (" + strings.Join(others, ", ") + ")" - } - if meta.LastActiveMachine != "" && meta.LastActiveAt != "" { - if t, err := time.Parse(time.RFC3339, meta.LastActiveAt); err == nil { - owner += fmt.Sprintf(" (last: %s %s)", meta.LastActiveMachine, t.Format("2006-01-02")) - } - } - } - } - parts = append(parts, owner) - return strings.Join(parts, ", ") -} - -func newWorktreeRmCmd() *cobra.Command { - var force bool - cmd := &cobra.Command{ - Use: "rm ", - Short: "Remove a worktree (refuses if dirty or unpushed unless --force)", - Annotations: map[string]string{ - "capability": "worktree", - "agent:when": "Remove a worktree after its branch has been merged or is no longer needed", - "agent:safety": "Refuses if dirty or has unpushed commits unless --force. Does not delete the branch on origin.", - }, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - projectName, branch := args[0], strings.TrimSpace(args[1]) - if branch == "" { - return errors.New("branch must not be empty") - } - machine, err := ensureMachineName() - if err != nil { - return err - } - _, mainPath, barePath, err := resolveProject(projectName) - if err != nil { - return err - } - wtPath := locateWorktreeForBranch(barePath, branch) - if wtPath == "" { - return fmt.Errorf("no worktree on branch %s in project %s", branch, projectName) - } - // Refuse to remove the main worktree by branch — that would - // leave the project unusable. Default-branch checkouts and - // any other branch that happens to be at proj.path are - // off-limits to `ws worktree rm`; the user has to delete - // the project entirely (out of scope here) or check out a - // different branch into the main worktree first. - if wtPath == mainPath { - return fmt.Errorf("refusing to remove main worktree of %s (branch %s is checked out at %s)", projectName, branch, mainPath) - } - - if !force { - if git.IsDirty(wtPath) { - return fmt.Errorf("worktree %s is dirty; commit/stash or use --force", wtPath) - } - ahead, _, has := git.AheadBehind(wtPath, branch) - if has && ahead > 0 { - return fmt.Errorf("branch %s has %d unpushed commits; push or use --force", branch, ahead) - } - } - - if err := git.WorktreeRemove(barePath, wtPath, force); err != nil { - return err - } - - p := ws.Projects[projectName] - if changed, _ := p.ReleaseBranch(branch, machine); changed { - ws.Projects[projectName] = p - if err := saveWorkspace(); err != nil { - fmt.Fprintf(os.Stderr, "warning: worktree removed but workspace.toml save failed: %v\n", err) - } - } - fmt.Printf("removed worktree %s\n", wtPath) - return nil - }, - } - cmd.Flags().BoolVar(&force, "force", false, "remove even if dirty or has unpushed commits") - return cmd -} - -func newWorktreePushCmd() *cobra.Command { - var forceDirty bool - cmd := &cobra.Command{ - Use: "push ", - Short: "Push the branch to origin and stamp last_active_* in workspace.toml", - Annotations: map[string]string{ - "capability": "worktree", - "agent:when": "Publish a worktree's branch to origin and update the registry's last_active_* fields", - }, - Long: `Push to origin from its local worktree. Updates -last_active_machine and last_active_at in workspace.toml so other machines -see the activity. Refuses dirty worktrees unless --force-dirty is set, and -refuses branches that are not registered in [[branches]] (a sign of -out-of-band creation; the user should re-register via ws worktree add).`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - projectName, branch := args[0], strings.TrimSpace(args[1]) - if branch == "" { - return errors.New("branch must not be empty") - } - machine, err := ensureMachineName() - if err != nil { - return err - } - proj, _, barePath, err := resolveProject(projectName) - if err != nil { - return err - } - - if proj.LookupBranch(branch) == nil { - return fmt.Errorf("branch %s has no [[branches]] entry in workspace.toml\n"+ - " this is usually a sign of an out-of-band creation; either:\n"+ - " - ws worktree add %s %s (re-register; works for legacy wt/* too)\n"+ - " - cd && git push (skip metadata update)", - branch, projectName, branch) - } - - wtPath := locateWorktreeForBranch(barePath, branch) - if wtPath == "" { - return fmt.Errorf("no worktree on branch %s; create one first with ws worktree add %s %s", branch, projectName, branch) - } - if !forceDirty && git.IsDirty(wtPath) { - return fmt.Errorf("worktree %s is dirty; commit or stash, or rerun with --force-dirty", wtPath) - } - - fmt.Printf("pushing %s to origin\n", branch) - if err := git.PushBranch(wtPath, branch); err != nil { - return fmt.Errorf("git push: %w", err) - } - _ = git.SetBranchUpstream(wtPath, branch, "origin") - - p := ws.Projects[projectName] - if p.MarkPushed(branch, machine, time.Now()) { - ws.Projects[projectName] = p - if err := saveWorkspace(); err != nil { - fmt.Fprintf(os.Stderr, "warning: push succeeded but workspace.toml save failed: %v\n", err) - } - } - meta := p.LookupBranch(branch) - if meta != nil { - fmt.Printf("updated workspace.toml: last_pushed_machine=%s, last_pushed_at=%s\n", - meta.LastPushedMachine, meta.LastPushedAt) - } - return nil - }, - } - cmd.Flags().BoolVar(&forceDirty, "force-dirty", false, "push even if the worktree has uncommitted changes") - return cmd -} diff --git a/internal/cli/worktree_add.go b/internal/cli/worktree_add.go new file mode 100644 index 0000000..62f0b3e --- /dev/null +++ b/internal/cli/worktree_add.go @@ -0,0 +1,201 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/kuchmenko/workspace/internal/git" + "github.com/kuchmenko/workspace/internal/layout" + "github.com/spf13/cobra" +) + +func newWorktreeAddCmd() *cobra.Command { + var fromBase string + cmd := &cobra.Command{ + Use: "add ", + Short: "Create or attach a worktree for the named branch", + Annotations: map[string]string{ + "capability": "worktree", + "agent:when": "Start a new feature in an isolated worktree, or check out an existing local/remote branch", + }, + Long: `Create a new worktree for on the literal branch . + +The branch name is taken verbatim — no prefix injection, no slug +rewrite — beyond what git check-ref-format accepts. The same command +covers three cases: + + 1. Branch is new: created from --from (or project default_branch) + and a fresh [[branches]] entry is recorded in workspace.toml. + + 2. Branch exists on origin: fetched into the bare repo, the new + worktree checks it out, upstream tracking wired automatically. + + 3. Branch exists locally only (no remote): worktree attaches to the + existing local branch. This is also the path that re-registers a + legacy wt// branch under the new schema — + ws worktree add myapp wt/linux/legacy-foo will pick it up and + give it [[branches]] metadata. + +EXAMPLES + + # New feature branch from main: + ws worktree add myapp feat/auth-refactor + + # Auto-detect existing remote branch: + ws worktree add myapp feat/data-api + + # Re-register a legacy wt//* worktree: + ws worktree add myapp wt/linux/old-topic + + # Branch off a non-default base: + ws worktree add myapp hotfix --from release/v2`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + projectName, branch := args[0], strings.TrimSpace(args[1]) + if branch == "" { + return errors.New("branch must not be empty") + } + if err := validateBranchName(branch); err != nil { + return err + } + + machine, err := ensureMachineName() + if err != nil { + return err + } + + proj, mainPath, barePath, err := resolveProject(projectName) + if err != nil { + return err + } + + // One-time repair: pre-0.5.1 bare repos were created without + // remote.origin.fetch configured. Without it, the fetch below + // would only update FETCH_HEAD, leaving refs/remotes/origin/* + // untouched — and HasRemoteBranch would always return false, + // breaking the "branch is on origin" detection. Mirrors the + // reconciler's repair step at reconciler.go:336. + if !git.HasFetchRefspec(barePath) { + _ = git.SetFetchRefspec(barePath) + } + + // Best-effort fetch the named branch via the standard remote- + // tracking refspec so refs/remotes/origin/ reflects + // the latest origin state. We deliberately do NOT force-fetch + // into refs/heads/ here: that would silently rewind a + // local branch with unpushed commits (e.g. legacy + // wt//* re-registration with work-in-progress) to + // origin's tip. + _ = git.FetchRefspec(barePath, "origin", branch) + localExists := git.HasBranch(barePath, branch) + remoteExists := git.HasRemoteBranch(barePath, "origin", branch) + + // Re-registration short-circuit: if the branch is already + // checked out in some existing worktree (legacy wt//* + // dir, or a previous `ws worktree add` whose saveWorkspace + // step failed), don't try to create another worktree — git + // refuses without --force, and the user's intent is to repair + // metadata, not to materialize a duplicate checkout. + if existingWtPath := locateWorktreeForBranch(barePath, branch); existingWtPath != "" { + p := ws.Projects[projectName] + changed, _ := p.ClaimBranch(branch, machine) + if remoteExists && p.MarkPushed(branch, machine, time.Now()) { + changed = true + } + if changed { + ws.Projects[projectName] = p + if err := saveWorkspace(); err != nil { + return fmt.Errorf("registry update failed: %w", err) + } + } + machines := strings.Join(p.LookupBranch(branch).Machines, ", ") + fmt.Printf("re-registered existing worktree %s\n branch: %s\n registered in workspace.toml (machines=[%s])\n", + existingWtPath, branch, machines) + return nil + } + + wtPath := layout.WorktreePathForBranch(mainPath, machine, branch) + if _, err := os.Stat(wtPath); err == nil { + return fmt.Errorf("worktree path already exists: %s", wtPath) + } + + source := "" // "fetched", "local", or "" for new + switch { + case localExists: + if fromBase != "" { + fmt.Fprintf(os.Stderr, "warning: --from ignored: branch %s already exists locally\n", branch) + } + if err := git.WorktreeAdd(barePath, wtPath, branch, ""); err != nil { + return err + } + if remoteExists { + source = "fetched" + } else { + source = "local" + } + case remoteExists: + if fromBase != "" { + fmt.Fprintf(os.Stderr, "warning: --from ignored: branch %s already exists on origin\n", branch) + } + if err := git.WorktreeAdd(barePath, wtPath, branch, "origin/"+branch); err != nil { + return err + } + source = "fetched" + default: + base := fromBase + if base == "" { + base = proj.DefaultBranch + } + if base == "" { + return fmt.Errorf("project %s has no default_branch and --from was not given", projectName) + } + if err := git.WorktreeAdd(barePath, wtPath, branch, base); err != nil { + return err + } + } + if source != "" { + _ = git.SetBranchUpstream(wtPath, branch, "origin") + } + + // Update the registry: claim this machine against the branch. + // When we attached to a branch that was already on origin + // ("fetched" path), also mark it as pushed — the branch was + // observed on origin at this exact moment, so the orphan + // detector should treat it as published from now on. + p := ws.Projects[projectName] + changed, _ := p.ClaimBranch(branch, machine) + if source == "fetched" && p.MarkPushed(branch, machine, time.Now()) { + changed = true + } + if changed { + ws.Projects[projectName] = p + if err := saveWorkspace(); err != nil { + return fmt.Errorf("worktree created but workspace.toml save failed: %w", err) + } + } + + machines := strings.Join(p.LookupBranch(branch).Machines, ", ") + + fmt.Printf("created worktree %s\n", wtPath) + switch source { + case "fetched": + fmt.Printf(" branch: %s (checked out existing remote)\n", branch) + case "local": + fmt.Printf(" branch: %s (attached to existing local branch)\n", branch) + default: + base := fromBase + if base == "" { + base = proj.DefaultBranch + } + fmt.Printf(" branch: %s\n base: %s\n", branch, base) + } + fmt.Printf(" registered in workspace.toml (machines=[%s])\n", machines) + return nil + }, + } + cmd.Flags().StringVar(&fromBase, "from", "", "base ref to create the new branch from (default: project default_branch).\nIgnored with a warning when the branch already exists on origin or locally.") + return cmd +} diff --git a/internal/cli/worktree_list.go b/internal/cli/worktree_list.go new file mode 100644 index 0000000..8cdc3cb --- /dev/null +++ b/internal/cli/worktree_list.go @@ -0,0 +1,131 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/git" + "github.com/kuchmenko/workspace/internal/layout" + "github.com/spf13/cobra" +) + +func newWorktreeListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list [project]", + Short: "List worktrees across projects", + Annotations: map[string]string{ + "capability": "worktree", + "agent:when": "List all worktrees across projects with branch, dirty/clean state, and ownership info", + }, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + machine, _ := config.LoadMachineConfig() + myMachine := "" + if machine != nil { + myMachine = machine.MachineName + } + + var names []string + if len(args) == 1 { + names = []string{args[0]} + } else { + for n, p := range ws.Projects { + if p.Status == config.StatusActive { + names = append(names, n) + } + } + sort.Strings(names) + } + + fmt.Printf("%-20s %-50s %-30s %s\n", "PROJECT", "WORKTREE", "BRANCH", "STATE") + for _, name := range names { + proj, ok := ws.Projects[name] + if !ok { + continue + } + mainPath := filepath.Join(wsRoot, proj.Path) + barePath := layout.BarePath(mainPath) + if _, err := os.Stat(barePath); err != nil { + fmt.Printf("%-20s %s\n", name, "(not migrated)") + continue + } + wts, err := git.WorktreeList(barePath) + if err != nil { + fmt.Printf("%-20s ERROR %v\n", name, err) + continue + } + for _, wt := range wts { + if wt.Bare { + continue + } + rel, _ := filepath.Rel(wsRoot, wt.Path) + if rel == "" { + rel = wt.Path + } + branchLabel := wt.Branch + if wt.Detached { + branchLabel = "(detached)" + } + state := worktreeStateString(&proj, wt, myMachine, proj.DefaultBranch) + fmt.Printf("%-20s %-50s %-30s %s\n", name, rel, branchLabel, state) + } + } + return nil + }, + } +} + +func worktreeStateString(proj *config.Project, wt git.Worktree, myMachine, defaultBranch string) string { + parts := []string{} + if git.IsDirty(wt.Path) { + parts = append(parts, "DIRTY") + } else { + parts = append(parts, "clean") + } + if wt.Branch != "" { + ahead, behind, has := git.AheadBehind(wt.Path, wt.Branch) + if has { + parts = append(parts, fmt.Sprintf("↑%d ↓%d", ahead, behind)) + } else { + parts = append(parts, "no upstream") + } + } + owner := "shared" + switch { + case wt.Branch == defaultBranch: + owner = "main" + case strings.HasPrefix(wt.Branch, "wt/"): + owner = "legacy-wt" + default: + if meta := proj.LookupBranch(wt.Branch); meta != nil { + myMine := false + others := []string{} + for _, m := range meta.Machines { + if m == myMachine { + myMine = true + continue + } + others = append(others, m) + } + if myMine && len(others) == 0 { + owner = "mine" + } else if myMine { + owner = "shared with " + strings.Join(others, ", ") + } else if len(others) > 0 { + owner = "remote (" + strings.Join(others, ", ") + ")" + } + if meta.LastActiveMachine != "" && meta.LastActiveAt != "" { + if t, err := time.Parse(time.RFC3339, meta.LastActiveAt); err == nil { + owner += fmt.Sprintf(" (last: %s %s)", meta.LastActiveMachine, t.Format("2006-01-02")) + } + } + } + } + parts = append(parts, owner) + return strings.Join(parts, ", ") +} diff --git a/internal/cli/worktree_push.go b/internal/cli/worktree_push.go new file mode 100644 index 0000000..0634770 --- /dev/null +++ b/internal/cli/worktree_push.go @@ -0,0 +1,82 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/kuchmenko/workspace/internal/git" + "github.com/spf13/cobra" +) + +func newWorktreePushCmd() *cobra.Command { + var forceDirty bool + cmd := &cobra.Command{ + Use: "push ", + Short: "Push the branch to origin and stamp last_active_* in workspace.toml", + Annotations: map[string]string{ + "capability": "worktree", + "agent:when": "Publish a worktree's branch to origin and update the registry's last_active_* fields", + }, + Long: `Push to origin from its local worktree. Updates +last_active_machine and last_active_at in workspace.toml so other machines +see the activity. Refuses dirty worktrees unless --force-dirty is set, and +refuses branches that are not registered in [[branches]] (a sign of +out-of-band creation; the user should re-register via ws worktree add).`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + projectName, branch := args[0], strings.TrimSpace(args[1]) + if branch == "" { + return errors.New("branch must not be empty") + } + machine, err := ensureMachineName() + if err != nil { + return err + } + proj, _, barePath, err := resolveProject(projectName) + if err != nil { + return err + } + + if proj.LookupBranch(branch) == nil { + return fmt.Errorf("branch %s has no [[branches]] entry in workspace.toml\n"+ + " this is usually a sign of an out-of-band creation; either:\n"+ + " - ws worktree add %s %s (re-register; works for legacy wt/* too)\n"+ + " - cd && git push (skip metadata update)", + branch, projectName, branch) + } + + wtPath := locateWorktreeForBranch(barePath, branch) + if wtPath == "" { + return fmt.Errorf("no worktree on branch %s; create one first with ws worktree add %s %s", branch, projectName, branch) + } + if !forceDirty && git.IsDirty(wtPath) { + return fmt.Errorf("worktree %s is dirty; commit or stash, or rerun with --force-dirty", wtPath) + } + + fmt.Printf("pushing %s to origin\n", branch) + if err := git.PushBranch(wtPath, branch); err != nil { + return fmt.Errorf("git push: %w", err) + } + _ = git.SetBranchUpstream(wtPath, branch, "origin") + + p := ws.Projects[projectName] + if p.MarkPushed(branch, machine, time.Now()) { + ws.Projects[projectName] = p + if err := saveWorkspace(); err != nil { + fmt.Fprintf(os.Stderr, "warning: push succeeded but workspace.toml save failed: %v\n", err) + } + } + meta := p.LookupBranch(branch) + if meta != nil { + fmt.Printf("updated workspace.toml: last_pushed_machine=%s, last_pushed_at=%s\n", + meta.LastPushedMachine, meta.LastPushedAt) + } + return nil + }, + } + cmd.Flags().BoolVar(&forceDirty, "force-dirty", false, "push even if the worktree has uncommitted changes") + return cmd +} diff --git a/internal/cli/worktree_rm.go b/internal/cli/worktree_rm.go new file mode 100644 index 0000000..e104c11 --- /dev/null +++ b/internal/cli/worktree_rm.go @@ -0,0 +1,78 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/kuchmenko/workspace/internal/git" + "github.com/spf13/cobra" +) + +func newWorktreeRmCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove a worktree (refuses if dirty or unpushed unless --force)", + Annotations: map[string]string{ + "capability": "worktree", + "agent:when": "Remove a worktree after its branch has been merged or is no longer needed", + "agent:safety": "Refuses if dirty or has unpushed commits unless --force. Does not delete the branch on origin.", + }, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + projectName, branch := args[0], strings.TrimSpace(args[1]) + if branch == "" { + return errors.New("branch must not be empty") + } + machine, err := ensureMachineName() + if err != nil { + return err + } + _, mainPath, barePath, err := resolveProject(projectName) + if err != nil { + return err + } + wtPath := locateWorktreeForBranch(barePath, branch) + if wtPath == "" { + return fmt.Errorf("no worktree on branch %s in project %s", branch, projectName) + } + // Refuse to remove the main worktree by branch — that would + // leave the project unusable. Default-branch checkouts and + // any other branch that happens to be at proj.path are + // off-limits to `ws worktree rm`; the user has to delete + // the project entirely (out of scope here) or check out a + // different branch into the main worktree first. + if wtPath == mainPath { + return fmt.Errorf("refusing to remove main worktree of %s (branch %s is checked out at %s)", projectName, branch, mainPath) + } + + if !force { + if git.IsDirty(wtPath) { + return fmt.Errorf("worktree %s is dirty; commit/stash or use --force", wtPath) + } + ahead, _, has := git.AheadBehind(wtPath, branch) + if has && ahead > 0 { + return fmt.Errorf("branch %s has %d unpushed commits; push or use --force", branch, ahead) + } + } + + if err := git.WorktreeRemove(barePath, wtPath, force); err != nil { + return err + } + + p := ws.Projects[projectName] + if changed, _ := p.ReleaseBranch(branch, machine); changed { + ws.Projects[projectName] = p + if err := saveWorkspace(); err != nil { + fmt.Fprintf(os.Stderr, "warning: worktree removed but workspace.toml save failed: %v\n", err) + } + } + fmt.Printf("removed worktree %s\n", wtPath) + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "remove even if dirty or has unpushed commits") + return cmd +} diff --git a/internal/config/branch.go b/internal/config/branch.go new file mode 100644 index 0000000..eba788c --- /dev/null +++ b/internal/config/branch.go @@ -0,0 +1,232 @@ +package config + +import "time" + +// BranchMeta carries the per-branch state for a project: which machines +// hold a local worktree, when this project last saw activity on the +// branch, and where it originated. Stored as [[projects.X.branches]] +// in workspace.toml. The array-of-tables shape is critical: union-merge +// on workspace.toml concatenates these blocks cleanly when two machines +// add different branches in parallel. +type BranchMeta struct { + Name string `toml:"name"` + Machines []string `toml:"machines,omitempty"` + LastActiveMachine string `toml:"last_active_machine,omitempty"` + LastActiveAt string `toml:"last_active_at,omitempty"` + // LastPushedMachine and LastPushedAt are written only when the + // branch is observed on origin — either by `ws worktree push` + // (after a successful push) or by `ws worktree add` attaching + // to an already-existing remote branch. They are the orphan- + // detection signal: the reconciler only treats a branch as + // "should exist on origin" if at least one machine has pushed + // it. A locally-created branch with no pushes never trips + // branch-orphan even though LastActiveAt is set on add. + LastPushedMachine string `toml:"last_pushed_machine,omitempty"` + LastPushedAt string `toml:"last_pushed_at,omitempty"` + CreatedBy string `toml:"created_by,omitempty"` + CreatedAt string `toml:"created_at,omitempty"` +} + +// LookupBranch returns a pointer to the entry for `name`, or nil if the +// branch is unknown to this project. The pointer aliases the underlying +// slice element — mutations through it modify the project's state. +func (p *Project) LookupBranch(name string) *BranchMeta { + for i := range p.Branches { + if p.Branches[i].Name == name { + return &p.Branches[i] + } + } + return nil +} + +// ClaimBranch records that `machine` currently holds a local worktree +// of `name` in this project. On first claim it also sets CreatedBy and +// CreatedAt so the original creator is preserved across handoffs. On +// every claim it bumps LastActiveMachine / LastActiveAt to (machine, +// now), reflecting that this machine just became active on the branch. +// +// Returns (changed, isNew). `changed` is true when the in-memory state +// actually moved; `isNew` is true when this call created the entry. +func (p *Project) ClaimBranch(name, machine string) (changed bool, isNew bool) { + if name == "" || machine == "" { + return false, false + } + now := time.Now().UTC().Format(time.RFC3339) + if b := p.LookupBranch(name); b != nil { + updateBranchClaim(b, machine, now) + return true, false + } + p.Branches = append(p.Branches, BranchMeta{ + Name: name, + Machines: []string{machine}, + LastActiveMachine: machine, + LastActiveAt: now, + CreatedBy: machine, + CreatedAt: now, + }) + return true, true +} + +// updateBranchClaim re-claims an already-registered branch on `machine`: +// adds the machine to the per-branch fleet (idempotent, sorted) and +// bumps last_active_*. Always considered a change because every claim +// is an explicit "I'm active here, now" stamp the cross-machine view +// relies on. +func updateBranchClaim(b *BranchMeta, machine, now string) { + if !contains(b.Machines, machine) { + b.Machines = sortedDedup(append(b.Machines, machine)) + } + b.LastActiveMachine = machine + b.LastActiveAt = now +} + +// ReleaseBranch removes `machine` from the entry's Machines slice. When +// the slice becomes empty the entry is dropped entirely — empty-machines +// blocks never persist across a Save, by acceptance criterion. +// +// Returns (changed, removed). `removed` is true only when the entry was +// dropped from p.Branches. +func (p *Project) ReleaseBranch(name, machine string) (changed bool, removed bool) { + for i := range p.Branches { + if p.Branches[i].Name == name { + return p.releaseAt(i, machine) + } + } + return false, false +} + +// releaseAt is the per-entry release path: removes `machine` from the +// entry at `idx`, dropping the entry entirely when no machines remain. +// Called by ReleaseBranch after it has located the matching entry. +func (p *Project) releaseAt(idx int, machine string) (changed bool, removed bool) { + b := &p.Branches[idx] + filtered, dropped := removeMachine(b.Machines, machine) + if !dropped { + return false, false + } + if len(filtered) == 0 { + p.Branches = append(p.Branches[:idx], p.Branches[idx+1:]...) + return true, true + } + b.Machines = filtered + // Releasing a machine that was the last_active_machine clears the + // field — the next push or commit on the branch will repopulate + // it. Keeping a stale machine name there would be misleading. + if b.LastActiveMachine == machine { + b.LastActiveMachine = "" + b.LastActiveAt = "" + } + return true, false +} + +// removeMachine returns `machines` with all occurrences of `target` +// stripped, plus a flag indicating whether at least one was removed. +func removeMachine(machines []string, target string) (filtered []string, dropped bool) { + out := make([]string, 0, len(machines)) + for _, m := range machines { + if m == target { + dropped = true + continue + } + out = append(out, m) + } + return out, dropped +} + +// TouchActive bumps LastActiveMachine / LastActiveAt for `name`. No-op +// if the branch is not registered. Returns true when state changed. +func (p *Project) TouchActive(name, machine string, when time.Time) bool { + b := p.LookupBranch(name) + if b == nil { + return false + } + stamp := when.UTC().Format(time.RFC3339) + if b.LastActiveMachine == machine && b.LastActiveAt == stamp { + return false + } + b.LastActiveMachine = machine + b.LastActiveAt = stamp + return true +} + +// StampActivity records "machine just did something on branch `name` +// in this project, right now". Unlike ClaimBranch this is NOT a user- +// driven act of branch creation, so CreatedBy/CreatedAt are intentionally +// left untouched: a freshly stamped main-branch entry must not pretend +// the current machine created `main`. Used by `ws agent`'s shell/claude +// launchers to make every launch into a worktree count toward the +// project's last-activity timestamp (computed as max over branches). +// +// If the branch entry exists: bumps LastActive* and adds `machine` to +// Machines if missing. If absent: creates a minimal entry carrying only +// the activity fields. +// +// Returns true when in-memory state moved. +func (p *Project) StampActivity(name, machine string, when time.Time) bool { + if name == "" || machine == "" { + return false + } + stamp := when.UTC().Format(time.RFC3339) + if b := p.LookupBranch(name); b != nil { + changed := false + if !contains(b.Machines, machine) { + b.Machines = sortedDedup(append(b.Machines, machine)) + changed = true + } + if b.LastActiveMachine != machine || b.LastActiveAt != stamp { + b.LastActiveMachine = machine + b.LastActiveAt = stamp + changed = true + } + return changed + } + p.Branches = append(p.Branches, BranchMeta{ + Name: name, + Machines: []string{machine}, + LastActiveMachine: machine, + LastActiveAt: stamp, + }) + return true +} + +// RemoveBranch drops the entry for `name` from this project's Branches +// slice unconditionally. Returns true if an entry was removed. Used by +// `ws sync resolve` to clean up branch-orphan entries on machines that +// never had a local worktree on the orphaned branch — ReleaseBranch +// would no-op there because the machine isn't in `Machines` to begin +// with, leaving the entry (and its `last_pushed_*` trigger) in place. +func (p *Project) RemoveBranch(name string) bool { + for i := range p.Branches { + if p.Branches[i].Name == name { + p.Branches = append(p.Branches[:i], p.Branches[i+1:]...) + return true + } + } + return false +} + +// MarkPushed records that `machine` published `name` to origin at `when`. +// Also bumps LastActiveMachine / LastActiveAt because a push is an +// activity. No-op if the branch is not registered. Returns true when +// state changed. +// +// The push fields are the orphan-detection signal: they distinguish +// "this branch was on origin and should still be" (push fields set → +// origin disappearance is meaningful) from "this branch is brand-new +// and never published" (push fields empty → origin absence is normal). +func (p *Project) MarkPushed(name, machine string, when time.Time) bool { + b := p.LookupBranch(name) + if b == nil { + return false + } + stamp := when.UTC().Format(time.RFC3339) + if b.LastPushedMachine == machine && b.LastPushedAt == stamp && + b.LastActiveMachine == machine && b.LastActiveAt == stamp { + return false + } + b.LastPushedMachine = machine + b.LastPushedAt = stamp + b.LastActiveMachine = machine + b.LastActiveAt = stamp + return true +} diff --git a/internal/config/config.go b/internal/config/config.go index 9b2d408..f1efc4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,263 +5,38 @@ import ( "os" "path/filepath" "sort" - "time" "github.com/BurntSushi/toml" ) -type Status string - -const ( - StatusActive Status = "active" - StatusArchived Status = "archived" - StatusDormant Status = "dormant" -) - -type Category string - -const ( - CategoryPersonal Category = "personal" - CategoryWork Category = "work" -) - -type Project struct { - Remote string `toml:"remote"` - Path string `toml:"path"` - Status Status `toml:"status"` - Category Category `toml:"category"` - Group string `toml:"group,omitempty"` - DefaultBranch string `toml:"default_branch,omitempty"` - // AutoSync controls per-project sync behavior. nil = inherit (default true). - // Pointer so we can distinguish "unset" from "explicitly false" in TOML. - AutoSync *bool `toml:"auto_sync,omitempty"` - - // Branches holds the per-branch metadata that travels with the project - // across machines. Replaces the legacy [[autopush.owned]] table; see - // migrateLegacyAutopush for the on-load translation. - Branches []BranchMeta `toml:"branches,omitempty"` - - // LegacyAutopush is the pre-0.7.0 [[autopush]] block. Read-only at Load - // time — migrateLegacyAutopush folds its contents into Branches and - // Save unconditionally drops the field. - LegacyAutopush *legacyAutopush `toml:"autopush,omitempty"` -} - -// BranchMeta carries the per-branch state for a project: which machines -// hold a local worktree, when this project last saw activity on the -// branch, and where it originated. Stored as [[projects.X.branches]] -// in workspace.toml. The array-of-tables shape is critical: union-merge -// on workspace.toml concatenates these blocks cleanly when two machines -// add different branches in parallel. -type BranchMeta struct { - Name string `toml:"name"` - Machines []string `toml:"machines,omitempty"` - LastActiveMachine string `toml:"last_active_machine,omitempty"` - LastActiveAt string `toml:"last_active_at,omitempty"` - // LastPushedMachine and LastPushedAt are written only when the - // branch is observed on origin — either by `ws worktree push` - // (after a successful push) or by `ws worktree add` attaching - // to an already-existing remote branch. They are the orphan- - // detection signal: the reconciler only treats a branch as - // "should exist on origin" if at least one machine has pushed - // it. A locally-created branch with no pushes never trips - // branch-orphan even though LastActiveAt is set on add. - LastPushedMachine string `toml:"last_pushed_machine,omitempty"` - LastPushedAt string `toml:"last_pushed_at,omitempty"` - CreatedBy string `toml:"created_by,omitempty"` - CreatedAt string `toml:"created_at,omitempty"` -} - -// legacyAutopush is the pre-0.7.0 schema, kept only for Load-time -// migration. New code reads/writes Project.Branches. -type legacyAutopush struct { - Branches []string `toml:"branches,omitempty"` - Owned []legacyOwnedBranch `toml:"owned,omitempty"` -} - -type legacyOwnedBranch struct { - Branch string `toml:"branch"` - Machine string `toml:"machine"` - Since string `toml:"since,omitempty"` -} - -// LookupBranch returns a pointer to the entry for `name`, or nil if the -// branch is unknown to this project. The pointer aliases the underlying -// slice element — mutations through it modify the project's state. -func (p *Project) LookupBranch(name string) *BranchMeta { - for i := range p.Branches { - if p.Branches[i].Name == name { - return &p.Branches[i] - } - } - return nil -} - -// ClaimBranch records that `machine` currently holds a local worktree -// of `name` in this project. On first claim it also sets CreatedBy and -// CreatedAt so the original creator is preserved across handoffs. On -// every claim it bumps LastActiveMachine / LastActiveAt to (machine, -// now), reflecting that this machine just became active on the branch. -// -// Returns (changed, isNew). `changed` is true when the in-memory state -// actually moved; `isNew` is true when this call created the entry. -func (p *Project) ClaimBranch(name, machine string) (changed bool, isNew bool) { - if name == "" || machine == "" { - return false, false - } - now := time.Now().UTC().Format(time.RFC3339) - if b := p.LookupBranch(name); b != nil { - updateBranchClaim(b, machine, now) - return true, false - } - p.Branches = append(p.Branches, BranchMeta{ - Name: name, - Machines: []string{machine}, - LastActiveMachine: machine, - LastActiveAt: now, - CreatedBy: machine, - CreatedAt: now, - }) - return true, true -} - -// updateBranchClaim re-claims an already-registered branch on `machine`: -// adds the machine to the per-branch fleet (idempotent, sorted) and -// bumps last_active_*. Always considered a change because every claim -// is an explicit "I'm active here, now" stamp the cross-machine view -// relies on. -func updateBranchClaim(b *BranchMeta, machine, now string) { - if !contains(b.Machines, machine) { - b.Machines = sortedDedup(append(b.Machines, machine)) - } - b.LastActiveMachine = machine - b.LastActiveAt = now -} - -// ReleaseBranch removes `machine` from the entry's Machines slice. When -// the slice becomes empty the entry is dropped entirely — empty-machines -// blocks never persist across a Save, by acceptance criterion. -// -// Returns (changed, removed). `removed` is true only when the entry was -// dropped from p.Branches. -func (p *Project) ReleaseBranch(name, machine string) (changed bool, removed bool) { - for i := range p.Branches { - if p.Branches[i].Name == name { - return p.releaseAt(i, machine) - } - } - return false, false -} - -// releaseAt is the per-entry release path: removes `machine` from the -// entry at `idx`, dropping the entry entirely when no machines remain. -// Called by ReleaseBranch after it has located the matching entry. -func (p *Project) releaseAt(idx int, machine string) (changed bool, removed bool) { - b := &p.Branches[idx] - filtered, dropped := removeMachine(b.Machines, machine) - if !dropped { - return false, false - } - if len(filtered) == 0 { - p.Branches = append(p.Branches[:idx], p.Branches[idx+1:]...) - return true, true - } - b.Machines = filtered - // Releasing a machine that was the last_active_machine clears the - // field — the next push or commit on the branch will repopulate - // it. Keeping a stale machine name there would be misleading. - if b.LastActiveMachine == machine { - b.LastActiveMachine = "" - b.LastActiveAt = "" - } - return true, false -} - -// removeMachine returns `machines` with all occurrences of `target` -// stripped, plus a flag indicating whether at least one was removed. -func removeMachine(machines []string, target string) (filtered []string, dropped bool) { - out := make([]string, 0, len(machines)) - for _, m := range machines { - if m == target { - dropped = true - continue - } - out = append(out, m) - } - return out, dropped -} - -// TouchActive bumps LastActiveMachine / LastActiveAt for `name`. No-op -// if the branch is not registered. Returns true when state changed. -func (p *Project) TouchActive(name, machine string, when time.Time) bool { - b := p.LookupBranch(name) - if b == nil { +type Group struct { + Description string `toml:"description"` + // Favorite pins this group to the quick-nav chips of `ws explorer`. + // Cross-machine — synced via workspace.toml just like project + // favorites. Toggled by `ws favorite add` / `rm` with a group name + // or by `f` on a group row in the TUI. + Favorite bool `toml:"favorite,omitempty"` +} + +// SetGroupFavorite flips the named group's Favorite flag. Returns +// true when the in-memory state actually moved. No-op when the group +// is not registered or already in the requested state. +func (w *Workspace) SetGroupFavorite(name string, fav bool) bool { + if w.Groups == nil { return false } - stamp := when.UTC().Format(time.RFC3339) - if b.LastActiveMachine == machine && b.LastActiveAt == stamp { + g, ok := w.Groups[name] + if !ok { return false } - b.LastActiveMachine = machine - b.LastActiveAt = stamp - return true -} - -// RemoveBranch drops the entry for `name` from this project's Branches -// slice unconditionally. Returns true if an entry was removed. Used by -// `ws sync resolve` to clean up branch-orphan entries on machines that -// never had a local worktree on the orphaned branch — ReleaseBranch -// would no-op there because the machine isn't in `Machines` to begin -// with, leaving the entry (and its `last_pushed_*` trigger) in place. -func (p *Project) RemoveBranch(name string) bool { - for i := range p.Branches { - if p.Branches[i].Name == name { - p.Branches = append(p.Branches[:i], p.Branches[i+1:]...) - return true - } - } - return false -} - -// MarkPushed records that `machine` published `name` to origin at `when`. -// Also bumps LastActiveMachine / LastActiveAt because a push is an -// activity. No-op if the branch is not registered. Returns true when -// state changed. -// -// The push fields are the orphan-detection signal: they distinguish -// "this branch was on origin and should still be" (push fields set → -// origin disappearance is meaningful) from "this branch is brand-new -// and never published" (push fields empty → origin absence is normal). -func (p *Project) MarkPushed(name, machine string, when time.Time) bool { - b := p.LookupBranch(name) - if b == nil { + if g.Favorite == fav { return false } - stamp := when.UTC().Format(time.RFC3339) - if b.LastPushedMachine == machine && b.LastPushedAt == stamp && - b.LastActiveMachine == machine && b.LastActiveAt == stamp { - return false - } - b.LastPushedMachine = machine - b.LastPushedAt = stamp - b.LastActiveMachine = machine - b.LastActiveAt = stamp + g.Favorite = fav + w.Groups[name] = g return true } -// SyncEnabled reports whether the reconciler should push/pull this project. -// Defaults to true when the field is unset. -func (p Project) SyncEnabled() bool { - if p.AutoSync == nil { - return true - } - return *p.AutoSync -} - -type Group struct { - Description string `toml:"description"` -} - type Meta struct { Version int `toml:"version"` Root string `toml:"root"` @@ -276,71 +51,58 @@ type Daemon struct { type Workspace struct { Meta Meta `toml:"meta"` + Agent AgentConfig `toml:"agent,omitempty"` Daemon Daemon `toml:"daemon"` Groups map[string]Group `toml:"groups"` Projects map[string]Project `toml:"projects"` Aliases map[string]string `toml:"aliases,omitempty"` } -// ValidationKind enumerates the structural problems Validate can detect. -type ValidationKind string +// AgentConfig holds workspace-wide user preferences for `ws agent`. +// Synced across machines via workspace.toml. Per-machine preferences +// would live in ~/.config/ws/config.toml instead; AgentConfig is +// intentionally cross-machine. +type AgentConfig struct { + // DefaultView is the view `ws agent` opens with: "all" (favorites + // + recent header above the full nested tree) or "favorites" (only + // the favorites section, flat). Empty string means "all". + DefaultView string `toml:"default_view,omitempty"` +} +// Agent view enumeration. Stored as the TOML value of agent.default_view. const ( - ValidationDuplicateBranch ValidationKind = "duplicate-branch" + AgentViewAll = "all" + AgentViewFavorites = "favorites" ) -// ValidationIssue describes one Workspace structural defect found by -// Validate. Callers (notably the reconciler) translate these into -// conflict-store entries (KindBranchDuplicate). -type ValidationIssue struct { - Kind ValidationKind - Project string - Branch string - Detail string -} - -// Validate inspects the in-memory Workspace for structural defects that -// the TOML decoder will not catch on its own — currently: duplicate -// branch names within a project's [[branches]] list, which arise when -// two machines independently add the same branch and union-merge -// concatenates their writes. -func (w *Workspace) Validate() []ValidationIssue { - var issues []ValidationIssue - for projName, proj := range w.Projects { - issues = append(issues, duplicateBranchIssues(projName, proj.Branches)...) - } - sort.Slice(issues, func(i, j int) bool { - if issues[i].Project != issues[j].Project { - return issues[i].Project < issues[j].Project - } - return issues[i].Branch < issues[j].Branch - }) - return issues -} - -// duplicateBranchIssues reports duplicate-name [[branches]] entries -// within one project. The first occurrence is tracked silently; every -// subsequent occurrence yields a ValidationIssue. -func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue { - seen := make(map[string]int, len(branches)) - var out []ValidationIssue - for _, b := range branches { - if b.Name == "" { - continue - } - prev, isDup := seen[b.Name] - if !isDup { - seen[b.Name] = len(seen) - continue - } - out = append(out, ValidationIssue{ - Kind: ValidationDuplicateBranch, - Project: projName, - Branch: b.Name, - Detail: fmt.Sprintf("branch %q has %d entries (first at index %d)", b.Name, prev+1, prev), - }) +// AgentDefaultView returns the configured view, falling back to +// AgentViewAll when unset or unrecognized. Callers never need to handle +// the empty-string case. +func (w *Workspace) AgentDefaultView() string { + switch w.Agent.DefaultView { + case AgentViewFavorites: + return AgentViewFavorites + default: + return AgentViewAll + } +} + +// SetAgentDefaultView updates agent.default_view. Returns true when the +// in-memory state actually moved. Unknown view values normalize to "all" +// (and are stored as the empty string so the TOML stays compact). +func (w *Workspace) SetAgentDefaultView(view string) bool { + var canonical string + switch view { + case AgentViewFavorites: + canonical = AgentViewFavorites + default: + canonical = "" + } + if w.Agent.DefaultView == canonical { + return false } - return out + w.Agent.DefaultView = canonical + return true } // FindRoot walks up from cwd (or uses WS_ROOT env) to find workspace.toml. @@ -358,6 +120,20 @@ func FindRoot() (string, error) { return "", fmt.Errorf("workspace.toml not found (set WS_ROOT or run from workspace directory)") } +// FindRootFrom walks up from `start` (an arbitrary absolute path) to the +// filesystem root, returning the first directory that contains +// workspace.toml. Honors the same WS_ROOT env override as FindRoot for +// consistency. Used by `ws agent`'s launch stampers, which receive a +// worktree path that may live anywhere under a workspace. +func FindRootFrom(start string) (string, bool) { + if env := os.Getenv("WS_ROOT"); env != "" { + if _, err := os.Stat(filepath.Join(env, "workspace.toml")); err == nil { + return env, true + } + } + return rootByWalkUp(start) +} + // rootFromEnv validates a WS_ROOT override: returns the path if it // holds a workspace.toml, otherwise an error explaining which dir // failed the check (so the user doesn't chase a typo blind). @@ -408,70 +184,6 @@ func Load(root string) (*Workspace, error) { return &ws, nil } -// migrateLegacyAutopush folds a project's [[autopush.owned]] entries and -// autopush.branches []string list into Project.Branches, then nils out -// the legacy field so subsequent saves never re-emit it. -// -// Migration is idempotent: a project with no legacy data is untouched; -// a project whose [[branches]] already exists keeps its current entries -// while still picking up any new legacy rows that pre-date the upgrade. -// -// autopush.branches []string entries (no machine attribution) become -// BranchMeta with empty Machines. The Save GC drops them on the next -// write — the user loses no actual git data because the underlying ref -// is still in the bare repo and `ws worktree add` re-registers it -// properly when the user next picks it up. -func migrateLegacyAutopush(p *Project) { - if p.LegacyAutopush == nil { - return - } - defer func() { p.LegacyAutopush = nil }() - for _, o := range p.LegacyAutopush.Owned { - p.appendLegacyOwned(o) - } - for _, name := range p.LegacyAutopush.Branches { - p.appendLegacyBare(name) - } -} - -// appendLegacyOwned converts one [[autopush.owned]] entry into the -// new [[branches]] shape. Owned entries always carry machine -// attribution and are always known-pushed (the legacy daemon pushed -// them by definition), so the migration sets every metadata field. -// Idempotent: re-loads of an already-migrated workspace.toml skip -// any branch that already has a [[branches]] entry. -func (p *Project) appendLegacyOwned(o legacyOwnedBranch) { - if o.Branch == "" || p.LookupBranch(o.Branch) != nil { - return - } - machines := []string{} - if o.Machine != "" { - machines = []string{o.Machine} - } - p.Branches = append(p.Branches, BranchMeta{ - Name: o.Branch, - Machines: machines, - LastActiveMachine: o.Machine, - LastActiveAt: o.Since, - LastPushedMachine: o.Machine, - LastPushedAt: o.Since, - CreatedBy: o.Machine, - CreatedAt: o.Since, - }) -} - -// appendLegacyBare converts one autopush.branches []string entry into -// a placeholder [[branches]] block with empty Machines. Save's empty- -// machines GC drops it on the next write — the user loses no actual -// git data because the underlying ref is still in the bare repo, and -// `ws worktree add` re-registers it properly when the user picks it up. -func (p *Project) appendLegacyBare(name string) { - if name == "" || p.LookupBranch(name) != nil { - return - } - p.Branches = append(p.Branches, BranchMeta{Name: name}) -} - // LoadOrCreate loads workspace.toml if it exists, otherwise creates a default one. func LoadOrCreate(root string) (*Workspace, error) { path := filepath.Join(root, "workspace.toml") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3600838..2763790 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -467,3 +467,189 @@ func TestSyncEnabled_DefaultsTrue(t *testing.T) { t.Error("AutoSync=false should disable sync") } } + +func TestSetFavorite_Idempotent(t *testing.T) { + p := &Project{} + if !p.SetFavorite(true) { + t.Error("first SetFavorite(true) should report changed") + } + if p.SetFavorite(true) { + t.Error("second SetFavorite(true) should be no-op") + } + if !p.SetFavorite(false) { + t.Error("SetFavorite(false) on favorited project should report changed") + } + if p.SetFavorite(false) { + t.Error("second SetFavorite(false) should be no-op") + } +} + +func TestFavorite_RoundTrip_OmitWhenFalse(t *testing.T) { + const src = ` +[meta] +version = 1 +root = "/ws" + +[daemon] +poll_interval = "5m" +stale_threshold = "30d" +auto_sync = true +watch_dirs = true + +[projects.starred] +remote = "git@github.com:me/starred.git" +path = "personal/starred" +status = "active" +category = "personal" +favorite = true + +[projects.plain] +remote = "git@github.com:me/plain.git" +path = "personal/plain" +status = "active" +category = "personal" +` + dir := writeWS(t, src) + ws, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !ws.Projects["starred"].Favorite { + t.Error("starred.Favorite should be true after Load") + } + if ws.Projects["plain"].Favorite { + t.Error("plain.Favorite should be false after Load") + } + + // Save round-trip: plain stays without `favorite =`, starred keeps it. + if err := Save(dir, ws); err != nil { + t.Fatalf("Save: %v", err) + } + body := readWS(t, dir) + starredBlock, plainBlock := isolateProject(t, body, "starred"), isolateProject(t, body, "plain") + if !strings.Contains(starredBlock, "favorite = true") { + t.Errorf("starred block missing `favorite = true`:\n%s", starredBlock) + } + if strings.Contains(plainBlock, "favorite") { + t.Errorf("plain block should omit `favorite` when false:\n%s", plainBlock) + } +} + +// isolateProject returns the substring of `body` for a single +// [projects.] section, up to the next [projects....] header or +// EOF. Resilient to encoder indentation (the toml encoder indents +// nested keys by two spaces, so the section header is prefixed with +// whitespace in the output). Tiny helper to make the favorite-round- +// trip test resilient to map iteration order in encoder output. +func isolateProject(t *testing.T, body, name string) string { + t.Helper() + header := "[projects." + name + "]" + start := strings.Index(body, header) + if start < 0 { + t.Fatalf("project %q section not found in:\n%s", name, body) + } + rest := body[start+len(header):] + // Find next sibling header, regardless of leading whitespace. + bestNext := -1 + for i := 0; i < len(rest); i++ { + if rest[i] != '\n' { + continue + } + j := i + 1 + for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') { + j++ + } + if strings.HasPrefix(rest[j:], "[projects.") { + bestNext = i + break + } + } + if bestNext < 0 { + return body[start:] + } + return body[start : start+len(header)+bestNext] +} + +func TestAgentDefaultView_FallsBackToAll(t *testing.T) { + cases := []struct { + raw, want string + }{ + {"", AgentViewAll}, + {"all", AgentViewAll}, + {"favorites", AgentViewFavorites}, + {"garbage", AgentViewAll}, + } + for _, tc := range cases { + ws := &Workspace{Agent: AgentConfig{DefaultView: tc.raw}} + if got := ws.AgentDefaultView(); got != tc.want { + t.Errorf("raw=%q: want %q, got %q", tc.raw, tc.want, got) + } + } +} + +func TestSetAgentDefaultView_NormalizesAndReportsChange(t *testing.T) { + ws := &Workspace{} + if ws.SetAgentDefaultView("all") { + t.Error(`SetAgentDefaultView("all") on empty should be no-op (canonical is "")`) + } + if !ws.SetAgentDefaultView("favorites") { + t.Error(`SetAgentDefaultView("favorites") should report changed`) + } + if ws.Agent.DefaultView != "favorites" { + t.Errorf("want stored value 'favorites', got %q", ws.Agent.DefaultView) + } + if !ws.SetAgentDefaultView("garbage") { + t.Error(`SetAgentDefaultView("garbage") flips back to "" (changed=true)`) + } + if ws.Agent.DefaultView != "" { + t.Errorf("unknown values should normalize to empty; got %q", ws.Agent.DefaultView) + } +} + +func TestAgentConfig_RoundTrip(t *testing.T) { + const src = ` +[meta] +version = 1 +root = "/ws" + +[agent] +default_view = "favorites" + +[daemon] +poll_interval = "5m" +stale_threshold = "30d" +auto_sync = true +watch_dirs = true +` + dir := writeWS(t, src) + ws, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := ws.AgentDefaultView(); got != AgentViewFavorites { + t.Errorf("want favorites view post-Load, got %q", got) + } + if err := Save(dir, ws); err != nil { + t.Fatalf("Save: %v", err) + } + body := readWS(t, dir) + if !strings.Contains(body, `default_view = "favorites"`) { + t.Errorf("Save lost agent.default_view:\n%s", body) + } +} + +func TestAgentConfig_OmitWhenEmpty(t *testing.T) { + ws := &Workspace{ + Meta: Meta{Version: 1, Root: "/ws"}, + Daemon: Daemon{PollInterval: "5m", StaleThreshold: "30d"}, + Projects: map[string]Project{}, + } + dir := t.TempDir() + if err := Save(dir, ws); err != nil { + t.Fatalf("Save: %v", err) + } + body := readWS(t, dir) + if strings.Contains(body, "[agent]") || strings.Contains(body, "default_view") { + t.Errorf("empty AgentConfig should omit the [agent] block entirely:\n%s", body) + } +} diff --git a/internal/config/legacy.go b/internal/config/legacy.go new file mode 100644 index 0000000..d62a814 --- /dev/null +++ b/internal/config/legacy.go @@ -0,0 +1,78 @@ +package config + +// legacyAutopush is the pre-0.7.0 schema, kept only for Load-time +// migration. New code reads/writes Project.Branches. +type legacyAutopush struct { + Branches []string `toml:"branches,omitempty"` + Owned []legacyOwnedBranch `toml:"owned,omitempty"` +} + +type legacyOwnedBranch struct { + Branch string `toml:"branch"` + Machine string `toml:"machine"` + Since string `toml:"since,omitempty"` +} + +// migrateLegacyAutopush folds a project's [[autopush.owned]] entries and +// autopush.branches []string list into Project.Branches, then nils out +// the legacy field so subsequent saves never re-emit it. +// +// Migration is idempotent: a project with no legacy data is untouched; +// a project whose [[branches]] already exists keeps its current entries +// while still picking up any new legacy rows that pre-date the upgrade. +// +// autopush.branches []string entries (no machine attribution) become +// BranchMeta with empty Machines. The Save GC drops them on the next +// write — the user loses no actual git data because the underlying ref +// is still in the bare repo and `ws worktree add` re-registers it +// properly when the user next picks it up. +func migrateLegacyAutopush(p *Project) { + if p.LegacyAutopush == nil { + return + } + defer func() { p.LegacyAutopush = nil }() + for _, o := range p.LegacyAutopush.Owned { + p.appendLegacyOwned(o) + } + for _, name := range p.LegacyAutopush.Branches { + p.appendLegacyBare(name) + } +} + +// appendLegacyOwned converts one [[autopush.owned]] entry into the +// new [[branches]] shape. Owned entries always carry machine +// attribution and are always known-pushed (the legacy daemon pushed +// them by definition), so the migration sets every metadata field. +// Idempotent: re-loads of an already-migrated workspace.toml skip +// any branch that already has a [[branches]] entry. +func (p *Project) appendLegacyOwned(o legacyOwnedBranch) { + if o.Branch == "" || p.LookupBranch(o.Branch) != nil { + return + } + machines := []string{} + if o.Machine != "" { + machines = []string{o.Machine} + } + p.Branches = append(p.Branches, BranchMeta{ + Name: o.Branch, + Machines: machines, + LastActiveMachine: o.Machine, + LastActiveAt: o.Since, + LastPushedMachine: o.Machine, + LastPushedAt: o.Since, + CreatedBy: o.Machine, + CreatedAt: o.Since, + }) +} + +// appendLegacyBare converts one autopush.branches []string entry into +// a placeholder [[branches]] block with empty Machines. Save's empty- +// machines GC drops it on the next write — the user loses no actual +// git data because the underlying ref is still in the bare repo, and +// `ws worktree add` re-registers it properly when the user picks it up. +func (p *Project) appendLegacyBare(name string) { + if name == "" || p.LookupBranch(name) != nil { + return + } + p.Branches = append(p.Branches, BranchMeta{Name: name}) +} diff --git a/internal/config/project.go b/internal/config/project.go new file mode 100644 index 0000000..3362ab9 --- /dev/null +++ b/internal/config/project.go @@ -0,0 +1,66 @@ +package config + +type Status string + +const ( + StatusActive Status = "active" + StatusArchived Status = "archived" + StatusDormant Status = "dormant" +) + +type Category string + +const ( + CategoryPersonal Category = "personal" + CategoryWork Category = "work" +) + +type Project struct { + Remote string `toml:"remote"` + Path string `toml:"path"` + Status Status `toml:"status"` + Category Category `toml:"category"` + Group string `toml:"group,omitempty"` + DefaultBranch string `toml:"default_branch,omitempty"` + // AutoSync controls per-project sync behavior. nil = inherit (default true). + // Pointer so we can distinguish "unset" from "explicitly false" in TOML. + AutoSync *bool `toml:"auto_sync,omitempty"` + + // Favorite pins this project to the Favorites section of `ws agent`. + // Cross-machine — synced via workspace.toml. Toggled by `ws favorite + // add/rm` or the `f` hotkey in the TUI. Race-tolerant by design: + // concurrent toggles from two machines resolve last-write-wins on the + // next reconciler tick; the user re-toggles if the wrong side won. + Favorite bool `toml:"favorite,omitempty"` + + // Branches holds the per-branch metadata that travels with the project + // across machines. Replaces the legacy [[autopush.owned]] table; see + // migrateLegacyAutopush for the on-load translation. + Branches []BranchMeta `toml:"branches,omitempty"` + + // LegacyAutopush is the pre-0.7.0 [[autopush]] block. Read-only at Load + // time — migrateLegacyAutopush folds its contents into Branches and + // Save unconditionally drops the field. + LegacyAutopush *legacyAutopush `toml:"autopush,omitempty"` +} + +// SyncEnabled reports whether the reconciler should push/pull this project. +// Defaults to true when the field is unset. +func (p Project) SyncEnabled() bool { + if p.AutoSync == nil { + return true + } + return *p.AutoSync +} + +// SetFavorite flips this project's Favorite flag. Returns true when the +// in-memory state actually moved. Idempotent: setting true on an +// already-favorited project (or false on a non-favorited one) is a no-op +// and returns false. +func (p *Project) SetFavorite(fav bool) bool { + if p.Favorite == fav { + return false + } + p.Favorite = fav + return true +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..7f37872 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,67 @@ +package config + +import ( + "fmt" + "sort" +) + +// ValidationKind enumerates the structural problems Validate can detect. +type ValidationKind string + +const ( + ValidationDuplicateBranch ValidationKind = "duplicate-branch" +) + +// ValidationIssue describes one Workspace structural defect found by +// Validate. Callers (notably the reconciler) translate these into +// conflict-store entries (KindBranchDuplicate). +type ValidationIssue struct { + Kind ValidationKind + Project string + Branch string + Detail string +} + +// Validate inspects the in-memory Workspace for structural defects that +// the TOML decoder will not catch on its own — currently: duplicate +// branch names within a project's [[branches]] list, which arise when +// two machines independently add the same branch and union-merge +// concatenates their writes. +func (w *Workspace) Validate() []ValidationIssue { + var issues []ValidationIssue + for projName, proj := range w.Projects { + issues = append(issues, duplicateBranchIssues(projName, proj.Branches)...) + } + sort.Slice(issues, func(i, j int) bool { + if issues[i].Project != issues[j].Project { + return issues[i].Project < issues[j].Project + } + return issues[i].Branch < issues[j].Branch + }) + return issues +} + +// duplicateBranchIssues reports duplicate-name [[branches]] entries +// within one project. The first occurrence is tracked silently; every +// subsequent occurrence yields a ValidationIssue. +func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue { + seen := make(map[string]int, len(branches)) + var out []ValidationIssue + for _, b := range branches { + if b.Name == "" { + continue + } + prev, isDup := seen[b.Name] + if !isDup { + seen[b.Name] = len(seen) + continue + } + out = append(out, ValidationIssue{ + Kind: ValidationDuplicateBranch, + Project: projName, + Branch: b.Name, + Detail: fmt.Sprintf("branch %q has %d entries (first at index %d)", b.Name, prev+1, prev), + }) + } + return out +} diff --git a/internal/create/cmd.go b/internal/create/cmd.go new file mode 100644 index 0000000..a41ec6e --- /dev/null +++ b/internal/create/cmd.go @@ -0,0 +1,100 @@ +package create + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/add" + "github.com/kuchmenko/workspace/internal/config" +) + +type ownersLoadedMsg struct{ owners []Owner } +type ownersErrMsg struct{ err error } +type createDoneMsg struct{ result *Result } +type createErrMsg struct{ err error } + +// fetchOwnersCmd queries gh for the current user + orgs in a goroutine. +// Returns ownersLoadedMsg on success, ownersErrMsg on failure. +func (m CreateModel) fetchOwnersCmd() tea.Cmd { + runner := m.opts.GHRunner + if runner == nil { + runner = realGHRunner{} + } + return func() tea.Msg { + owners, err := ListOwners(runner) + if err != nil { + return ownersErrMsg{err: err} + } + return ownersLoadedMsg{owners: owners} + } +} + +// createCmd kicks off the gh repo create + register + clone pipeline +// off the bubbletea event loop. Returns createDoneMsg on success, +// createErrMsg on any step's failure. +func (m CreateModel) createCmd() tea.Cmd { + runner := m.opts.GHRunner + if runner == nil { + runner = realGHRunner{} + } + + owner := m.currentOwner() + name := strings.TrimSpace(m.nameInput.Value()) + desc := strings.TrimSpace(m.descInput.Value()) + visibility := m.visibilities[m.visIdx] + category := m.categories[m.catIdx] + group := strings.TrimSpace(m.groupInput.Value()) + + wsRoot := m.opts.WsRoot + ws := m.opts.Workspace + saveFn := m.opts.Save + if saveFn == nil { + saveFn = func(w *config.Workspace) error { return config.Save(wsRoot, w) } + } + projectName := m.opts.ProjectName + if projectName == "" { + projectName = name + } + + return func() tea.Msg { + if _, err := CreateRepo(runner, CreateRepoOptions{ + Owner: owner, + Name: name, + Visibility: visibility, + Description: desc, + AddReadme: true, + }); err != nil { + return createErrMsg{err: fmt.Errorf("create repo: %w", err)} + } + + urlFor := m.opts.URLFor + if urlFor == nil { + urlFor = SSHURLFromOwnerRepo + } + sshURL := urlFor(owner, name) + regOpts := add.Options{ + Category: category, + Group: group, + Name: projectName, + WsRoot: wsRoot, + Workspace: ws, + Save: saveFn, + } + regRes, err := add.Register(regOpts, sshURL) + if err != nil { + return createErrMsg{ + err: fmt.Errorf("repo created on GitHub at %s but register failed: %w", sshURL, err), + } + } + + return createDoneMsg{ + result: &Result{ + Project: regRes.Project, + Name: regRes.Name, + URL: sshURL, + Cloned: regRes.Cloned, + }, + } + } +} diff --git a/internal/create/render.go b/internal/create/render.go new file mode 100644 index 0000000..6b30927 --- /dev/null +++ b/internal/create/render.go @@ -0,0 +1,199 @@ +package create + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m CreateModel) View() string { + switch m.st { + case stateLoadingOwners: + return fmt.Sprintf("\n %s %s loading GitHub owners…\n", m.spinner.View(), createTitle.Render(" ws create ")) + case stateErrored: + return m.viewErrored() + case stateDone: + return m.viewDone() + case stateCreating: + return m.viewCreating() + } + return m.viewForm() +} + +func (m CreateModel) viewForm() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(createTitle.Render(" ws create ")) + b.WriteString(" ") + b.WriteString(createDim.Render("Bootstrap a new GitHub repo, register, and clone.")) + b.WriteString("\n\n") + + b.WriteString(m.renderOwnerList()) + b.WriteString("\n") + b.WriteString(m.renderField("Name", m.nameInput.View(), focusName)) + b.WriteString("\n") + b.WriteString(m.renderToggle("Visibility", []string{"private", "public"}, m.visIdx, focusVisibility)) + b.WriteString("\n") + b.WriteString(m.renderField("Description", m.descInput.View(), focusDescription)) + b.WriteString("\n") + b.WriteString(m.renderToggle("Category", []string{"personal", "work"}, m.catIdx, focusCategory)) + b.WriteString("\n") + b.WriteString(m.renderField("Group", m.groupInput.View(), focusGroup)) + b.WriteString("\n") + b.WriteString(m.renderCreateButton()) + b.WriteString("\n\n") + b.WriteString(createDim.Render("tab/shift-tab move between fields • ←/→ toggles • esc cancels")) + b.WriteString("\n") + return b.String() +} + +func (m CreateModel) renderOwnerList() string { + var b strings.Builder + header := "Owner" + if m.focus == focusOwner { + header = createCursor.Render("▸ ") + createAccent.Render(header) + } else { + header = " " + createLabel.Render(header) + } + b.WriteString(header) + b.WriteString("\n") + if len(m.owners) == 0 { + b.WriteString(" " + createDim.Render("(no owners loaded)")) + return b.String() + } + // Window: keep the cursor visible. Show up to 6 rows. + const maxRows = 6 + start := m.ownerScroll + if m.ownerCursor < start { + start = m.ownerCursor + } + if m.ownerCursor >= start+maxRows { + start = m.ownerCursor - maxRows + 1 + } + if start < 0 { + start = 0 + } + end := start + maxRows + if end > len(m.owners) { + end = len(m.owners) + } + for i := start; i < end; i++ { + o := m.owners[i] + marker := " " + name := o.Login + if i == m.ownerCursor { + marker = createCursor.Render("● ") + name = createAccent.Render(name) + } else { + name = createItemName.Render(name) + } + tag := "" + if o.Kind == OwnerKindUser { + tag = " " + createDim.Render("(you)") + } + b.WriteString(" " + marker + name + tag + "\n") + } + if end < len(m.owners) { + b.WriteString(" " + createDim.Render(fmt.Sprintf("…%d more", len(m.owners)-end)) + "\n") + } + return b.String() +} + +func (m CreateModel) renderField(label, view string, fieldFocus int) string { + cursor := " " + lbl := createLabel.Render(label) + if m.focus == fieldFocus { + cursor = createCursor.Render("▸ ") + lbl = createAccent.Render(label) + } + return fmt.Sprintf("%s%s\n %s", cursor, lbl, view) +} + +func (m CreateModel) renderToggle(label string, options []string, idx, fieldFocus int) string { + cursor := " " + lbl := createLabel.Render(label) + if m.focus == fieldFocus { + cursor = createCursor.Render("▸ ") + lbl = createAccent.Render(label) + } + parts := make([]string, len(options)) + for i, o := range options { + if i == idx { + parts[i] = createChip.Render("[" + o + "]") + } else { + parts[i] = createDim.Render(" " + o + " ") + } + } + return fmt.Sprintf("%s%s\n %s", cursor, lbl, strings.Join(parts, " ")) +} + +func (m CreateModel) renderCreateButton() string { + cursor := " " + label := createBtn.Render(" Create ") + if m.focus == focusCreate { + cursor = createCursor.Render("▸ ") + label = createBtnFocus.Render(" Create ") + } + return cursor + label + " " + createDim.Render("(enter to confirm)") +} + +func (m CreateModel) viewErrored() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(createTitle.Render(" ws create ")) + b.WriteString("\n\n ") + b.WriteString(createErr.Render("error: ")) + b.WriteString(m.err.Error()) + b.WriteString("\n\n ") + b.WriteString(createDim.Render("enter to retry • esc to cancel")) + b.WriteString("\n") + return b.String() +} + +func (m CreateModel) viewCreating() string { + owner := m.currentOwner() + name := strings.TrimSpace(m.nameInput.Value()) + return fmt.Sprintf( + "\n %s %s creating %s/%s…\n", + m.spinner.View(), + createTitle.Render(" ws create "), + createAccent.Render(owner), + createAccent.Render(name), + ) +} + +func (m CreateModel) viewDone() string { + var b strings.Builder + b.WriteString("\n ") + b.WriteString(createCheck.Render("✓ ")) + b.WriteString(createTitle.Render(" ws create ")) + b.WriteString("\n\n") + if m.result != nil { + fmt.Fprintf(&b, " project: %s\n", createAccent.Render(m.result.Name)) + fmt.Fprintf(&b, " remote: %s\n", createDim.Render(m.result.URL)) + fmt.Fprintf(&b, " path: %s\n", createDim.Render(m.result.Project.Path)) + } + b.WriteString("\n ") + b.WriteString(createDim.Render("press any key to exit")) + b.WriteString("\n") + return b.String() +} + +var ( + createTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("6")). + Padding(0, 1) + createDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + createLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Bold(true) + createCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + createAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + createErr = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + createCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) + createChip = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Bold(true) + createItemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + createBtn = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Background(lipgloss.Color("8")) + createBtnFocus = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) +) diff --git a/internal/create/runner.go b/internal/create/runner.go new file mode 100644 index 0000000..5575cc4 --- /dev/null +++ b/internal/create/runner.go @@ -0,0 +1,59 @@ +package create + +import ( + "context" + "errors" + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// ErrCancelled is returned by Run when the user dismisses the TUI +// without confirming. The cobra layer maps this to a soft exit (no +// error printed, exit 0) since cancellation is a user action, not a +// failure. +var ErrCancelled = errors.New("create canceled by user") + +// runTUI launches the model as a tea.Program and returns the captured +// Result when the user confirms. Cancellation (Esc, Ctrl+C) returns +// (nil, ErrCancelled). +func runTUI(ctx context.Context, opts Options) (*Result, error) { + model := NewCreateModel(CreateModelOptions{ + WsRoot: opts.WsRoot, + Workspace: opts.Workspace, + Save: resolveSaveFn(opts), + GHRunner: opts.GHRunner, + Owner: opts.Owner, + Name: opts.Name, + Visibility: opts.Visibility, + Description: opts.Description, + Category: opts.Category, + Group: opts.Group, + ProjectName: opts.ProjectName, + URLFor: opts.URLFor, + }) + + prog := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithContext(ctx), + ) + finalModel, err := prog.Run() + if err != nil { + return nil, fmt.Errorf("create TUI: %w", err) + } + final, ok := finalModel.(CreateModel) + if !ok { + return nil, fmt.Errorf("create TUI: unexpected final model type %T", finalModel) + } + if final.canceled { + return nil, ErrCancelled + } + if final.err != nil { + return nil, final.err + } + if final.result == nil { + return nil, errors.New("create TUI exited with no result") + } + return final.result, nil +} diff --git a/internal/create/tui.go b/internal/create/tui.go index 909cb01..4484030 100644 --- a/internal/create/tui.go +++ b/internal/create/tui.go @@ -1,16 +1,12 @@ package create import ( - "context" "errors" - "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/kuchmenko/workspace/internal/add" "github.com/kuchmenko/workspace/internal/config" ) @@ -169,104 +165,6 @@ func (m CreateModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, m.fetchOwnersCmd()) } -// fetchOwnersCmd queries gh for the current user + orgs in a goroutine. -// Returns ownersLoadedMsg on success, ownersErrMsg on failure. -func (m CreateModel) fetchOwnersCmd() tea.Cmd { - runner := m.opts.GHRunner - if runner == nil { - runner = realGHRunner{} - } - return func() tea.Msg { - owners, err := ListOwners(runner) - if err != nil { - return ownersErrMsg{err: err} - } - return ownersLoadedMsg{owners: owners} - } -} - -// createCmd kicks off the gh repo create + register + clone pipeline -// off the bubbletea event loop. Returns createDoneMsg on success, -// createErrMsg on any step's failure. -func (m CreateModel) createCmd() tea.Cmd { - runner := m.opts.GHRunner - if runner == nil { - runner = realGHRunner{} - } - - owner := m.currentOwner() - name := strings.TrimSpace(m.nameInput.Value()) - desc := strings.TrimSpace(m.descInput.Value()) - visibility := m.visibilities[m.visIdx] - category := m.categories[m.catIdx] - group := strings.TrimSpace(m.groupInput.Value()) - - wsRoot := m.opts.WsRoot - ws := m.opts.Workspace - saveFn := m.opts.Save - if saveFn == nil { - saveFn = func(w *config.Workspace) error { return config.Save(wsRoot, w) } - } - projectName := m.opts.ProjectName - if projectName == "" { - projectName = name - } - - return func() tea.Msg { - if _, err := CreateRepo(runner, CreateRepoOptions{ - Owner: owner, - Name: name, - Visibility: visibility, - Description: desc, - AddReadme: true, - }); err != nil { - return createErrMsg{err: fmt.Errorf("create repo: %w", err)} - } - - urlFor := m.opts.URLFor - if urlFor == nil { - urlFor = SSHURLFromOwnerRepo - } - sshURL := urlFor(owner, name) - regOpts := add.Options{ - Category: category, - Group: group, - Name: projectName, - WsRoot: wsRoot, - Workspace: ws, - Save: saveFn, - } - regRes, err := add.Register(regOpts, sshURL) - if err != nil { - return createErrMsg{ - err: fmt.Errorf("repo created on GitHub at %s but register failed: %w", sshURL, err), - } - } - - return createDoneMsg{ - result: &Result{ - Project: regRes.Project, - Name: regRes.Name, - URL: sshURL, - Cloned: regRes.Cloned, - }, - } - } -} - -// ============================================================================= -// Messages -// ============================================================================= - -type ownersLoadedMsg struct{ owners []Owner } -type ownersErrMsg struct{ err error } -type createDoneMsg struct{ result *Result } -type createErrMsg struct{ err error } - -// ============================================================================= -// Update -// ============================================================================= - func (m CreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -499,256 +397,3 @@ func (m CreateModel) currentOwner() string { } return m.owners[m.ownerCursor].Login } - -// ============================================================================= -// View -// ============================================================================= - -func (m CreateModel) View() string { - switch m.st { - case stateLoadingOwners: - return fmt.Sprintf("\n %s %s loading GitHub owners…\n", m.spinner.View(), createTitle.Render(" ws create ")) - case stateErrored: - return m.viewErrored() - case stateDone: - return m.viewDone() - case stateCreating: - return m.viewCreating() - } - return m.viewForm() -} - -func (m CreateModel) viewForm() string { - var b strings.Builder - b.WriteString("\n") - b.WriteString(createTitle.Render(" ws create ")) - b.WriteString(" ") - b.WriteString(createDim.Render("Bootstrap a new GitHub repo, register, and clone.")) - b.WriteString("\n\n") - - b.WriteString(m.renderOwnerList()) - b.WriteString("\n") - b.WriteString(m.renderField("Name", m.nameInput.View(), focusName)) - b.WriteString("\n") - b.WriteString(m.renderToggle("Visibility", []string{"private", "public"}, m.visIdx, focusVisibility)) - b.WriteString("\n") - b.WriteString(m.renderField("Description", m.descInput.View(), focusDescription)) - b.WriteString("\n") - b.WriteString(m.renderToggle("Category", []string{"personal", "work"}, m.catIdx, focusCategory)) - b.WriteString("\n") - b.WriteString(m.renderField("Group", m.groupInput.View(), focusGroup)) - b.WriteString("\n") - b.WriteString(m.renderCreateButton()) - b.WriteString("\n\n") - b.WriteString(createDim.Render("tab/shift-tab move between fields • ←/→ toggles • esc cancels")) - b.WriteString("\n") - return b.String() -} - -func (m CreateModel) renderOwnerList() string { - var b strings.Builder - header := "Owner" - if m.focus == focusOwner { - header = createCursor.Render("▸ ") + createAccent.Render(header) - } else { - header = " " + createLabel.Render(header) - } - b.WriteString(header) - b.WriteString("\n") - if len(m.owners) == 0 { - b.WriteString(" " + createDim.Render("(no owners loaded)")) - return b.String() - } - // Window: keep the cursor visible. Show up to 6 rows. - const maxRows = 6 - start := m.ownerScroll - if m.ownerCursor < start { - start = m.ownerCursor - } - if m.ownerCursor >= start+maxRows { - start = m.ownerCursor - maxRows + 1 - } - if start < 0 { - start = 0 - } - end := start + maxRows - if end > len(m.owners) { - end = len(m.owners) - } - for i := start; i < end; i++ { - o := m.owners[i] - marker := " " - name := o.Login - if i == m.ownerCursor { - marker = createCursor.Render("● ") - name = createAccent.Render(name) - } else { - name = createItemName.Render(name) - } - tag := "" - if o.Kind == OwnerKindUser { - tag = " " + createDim.Render("(you)") - } - b.WriteString(" " + marker + name + tag + "\n") - } - if end < len(m.owners) { - b.WriteString(" " + createDim.Render(fmt.Sprintf("…%d more", len(m.owners)-end)) + "\n") - } - return b.String() -} - -func (m CreateModel) renderField(label, view string, fieldFocus int) string { - cursor := " " - lbl := createLabel.Render(label) - if m.focus == fieldFocus { - cursor = createCursor.Render("▸ ") - lbl = createAccent.Render(label) - } - return fmt.Sprintf("%s%s\n %s", cursor, lbl, view) -} - -func (m CreateModel) renderToggle(label string, options []string, idx, fieldFocus int) string { - cursor := " " - lbl := createLabel.Render(label) - if m.focus == fieldFocus { - cursor = createCursor.Render("▸ ") - lbl = createAccent.Render(label) - } - parts := make([]string, len(options)) - for i, o := range options { - if i == idx { - parts[i] = createChip.Render("[" + o + "]") - } else { - parts[i] = createDim.Render(" " + o + " ") - } - } - return fmt.Sprintf("%s%s\n %s", cursor, lbl, strings.Join(parts, " ")) -} - -func (m CreateModel) renderCreateButton() string { - cursor := " " - label := createBtn.Render(" Create ") - if m.focus == focusCreate { - cursor = createCursor.Render("▸ ") - label = createBtnFocus.Render(" Create ") - } - return cursor + label + " " + createDim.Render("(enter to confirm)") -} - -func (m CreateModel) viewErrored() string { - var b strings.Builder - b.WriteString("\n") - b.WriteString(createTitle.Render(" ws create ")) - b.WriteString("\n\n ") - b.WriteString(createErr.Render("error: ")) - b.WriteString(m.err.Error()) - b.WriteString("\n\n ") - b.WriteString(createDim.Render("enter to retry • esc to cancel")) - b.WriteString("\n") - return b.String() -} - -func (m CreateModel) viewCreating() string { - owner := m.currentOwner() - name := strings.TrimSpace(m.nameInput.Value()) - return fmt.Sprintf( - "\n %s %s creating %s/%s…\n", - m.spinner.View(), - createTitle.Render(" ws create "), - createAccent.Render(owner), - createAccent.Render(name), - ) -} - -func (m CreateModel) viewDone() string { - var b strings.Builder - b.WriteString("\n ") - b.WriteString(createCheck.Render("✓ ")) - b.WriteString(createTitle.Render(" ws create ")) - b.WriteString("\n\n") - if m.result != nil { - fmt.Fprintf(&b, " project: %s\n", createAccent.Render(m.result.Name)) - fmt.Fprintf(&b, " remote: %s\n", createDim.Render(m.result.URL)) - fmt.Fprintf(&b, " path: %s\n", createDim.Render(m.result.Project.Path)) - } - b.WriteString("\n ") - b.WriteString(createDim.Render("press any key to exit")) - b.WriteString("\n") - return b.String() -} - -// ============================================================================= -// Styles -// ============================================================================= - -var ( - createTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). - Padding(0, 1) - createDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - createLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Bold(true) - createCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) - createAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) - createErr = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - createCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) - createChip = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Bold(true) - createItemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - createBtn = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Background(lipgloss.Color("8")) - createBtnFocus = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("6")).Bold(true) -) - -// ============================================================================= -// Standalone runner -// ============================================================================= - -// runTUI launches the model as a tea.Program and returns the captured -// Result when the user confirms. Cancellation (Esc, Ctrl+C) returns -// (nil, ErrCancelled). -func runTUI(ctx context.Context, opts Options) (*Result, error) { - model := NewCreateModel(CreateModelOptions{ - WsRoot: opts.WsRoot, - Workspace: opts.Workspace, - Save: resolveSaveFn(opts), - GHRunner: opts.GHRunner, - Owner: opts.Owner, - Name: opts.Name, - Visibility: opts.Visibility, - Description: opts.Description, - Category: opts.Category, - Group: opts.Group, - ProjectName: opts.ProjectName, - URLFor: opts.URLFor, - }) - - prog := tea.NewProgram( - model, - tea.WithAltScreen(), - tea.WithContext(ctx), - ) - finalModel, err := prog.Run() - if err != nil { - return nil, fmt.Errorf("create TUI: %w", err) - } - final, ok := finalModel.(CreateModel) - if !ok { - return nil, fmt.Errorf("create TUI: unexpected final model type %T", finalModel) - } - if final.canceled { - return nil, ErrCancelled - } - if final.err != nil { - return nil, final.err - } - if final.result == nil { - return nil, errors.New("create TUI exited with no result") - } - return final.result, nil -} - -// ErrCancelled is returned by Run when the user dismisses the TUI -// without confirming. The cobra layer maps this to a soft exit (no -// error printed, exit 0) since cancellation is a user action, not a -// failure. -var ErrCancelled = errors.New("create canceled by user") diff --git a/internal/daemon/conflicts.go b/internal/daemon/conflicts.go new file mode 100644 index 0000000..0f2dbdb --- /dev/null +++ b/internal/daemon/conflicts.go @@ -0,0 +1,72 @@ +package daemon + +import ( + "encoding/json" + "time" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/conflict" +) + +// recordValidationIssues runs ws.Validate() and turns each ValidationIssue +// into a conflict-store entry. Currently the only issue kind is duplicate +// branch names within a project (KindBranchDuplicate), which arises when +// two machines ws-worktree-add the same branch concurrently and union-merge +// concatenates their [[branches]] writes into the same project. +func (r *Reconciler) recordValidationIssues(ws *config.Workspace) { + for _, issue := range ws.Validate() { + switch issue.Kind { + case config.ValidationDuplicateBranch: + r.recordProjectConflict(issue.Project, issue.Branch, conflict.KindBranchDuplicate, issue.Detail) + } + } +} + +func (r *Reconciler) recordProjectConflict(project, branch string, kind conflict.Kind, msg string) { + if r.store == nil { + return + } + details, _ := json.Marshal(map[string]string{"message": msg}) + c := conflict.Conflict{ + Workspace: r.root, + Project: project, + Branch: branch, + Kind: kind, + Details: details, + } + created, err := r.store.Record(c) + if err != nil { + r.logger.Printf("reconciler: record %s: %v", kind, err) + return + } + if created { + r.logger.Printf("reconciler: NEW conflict %s for %s/%s: %s", kind, project, branch, msg) + conflict.NotifyNew(c) + } +} + +func (r *Reconciler) clearProjectConflict(project, branch string, kind conflict.Kind) error { + if r.store == nil { + return nil + } + return r.store.Clear(r.root, project, branch, kind) +} + +func (r *Reconciler) recordBackoff(name string, cause error) { + bs, ok := r.backoff[name] + if !ok { + bs = &backoffState{currentDelay: r.interval} + r.backoff[name] = bs + } else { + bs.currentDelay *= 2 + if bs.currentDelay > r.maxInterval { + bs.currentDelay = r.maxInterval + } + } + bs.nextAllowedAt = time.Now().Add(bs.currentDelay) + r.logger.Printf("reconciler: %s failed (%v); next attempt in %s", name, cause, bs.currentDelay) +} + +func (r *Reconciler) resetBackoff(name string) { + delete(r.backoff, name) +} diff --git a/internal/daemon/git.go b/internal/daemon/git.go new file mode 100644 index 0000000..0529664 --- /dev/null +++ b/internal/daemon/git.go @@ -0,0 +1,95 @@ +package daemon + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/git" +) + +// findGitRoot walks up from dir looking for the nearest git repo. Returns +// "" if no repo is found before reaching the filesystem root. +func findGitRoot(dir string) string { + for { + if git.IsRepo(dir) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +func isClean(repoPath, file string) bool { + cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain", file) + out, err := cmd.Output() + if err != nil { + return true + } + return strings.TrimSpace(string(out)) == "" +} + +func runIn(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s in %s: %s", name, strings.Join(args, " "), dir, strings.TrimSpace(string(out))) + } + return nil +} + +// ensureUnionMerge appends ` merge=union` to .gitattributes in the +// repo root if it isn't already configured. Idempotent. +func ensureUnionMerge(repoRoot, tomlAbs string) error { + rel, err := filepath.Rel(repoRoot, tomlAbs) + if err != nil { + return err + } + attrPath := filepath.Join(repoRoot, ".gitattributes") + wantLine := rel + " merge=union" + existing, err := os.ReadFile(attrPath) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, line := range strings.Split(string(existing), "\n") { + if strings.TrimSpace(line) == wantLine { + return nil + } + } + f, err := os.OpenFile(attrPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") { + _, _ = f.WriteString("\n") + } + _, err = f.WriteString(wantLine + "\n") + return err +} + +func loadMachineName() string { + mc, err := config.LoadMachineConfig() + if err != nil || mc == nil { + return "" + } + return mc.MachineName +} + +func machineHostname() string { + if name := loadMachineName(); name != "" { + return name + } + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} diff --git a/internal/daemon/projects.go b/internal/daemon/projects.go new file mode 100644 index 0000000..cbb328e --- /dev/null +++ b/internal/daemon/projects.go @@ -0,0 +1,263 @@ +package daemon + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kuchmenko/workspace/internal/clone" + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/conflict" + "github.com/kuchmenko/workspace/internal/git" + "github.com/kuchmenko/workspace/internal/layout" +) + +func (r *Reconciler) reconcileProjects(ws *config.Workspace) { + machine := loadMachineName() + now := time.Now() + dirty := false + for name, proj := range ws.Projects { + if proj.Status != config.StatusActive { + continue + } + if !proj.SyncEnabled() { + r.logger.Printf("reconciler: %s auto_sync=false, fetch only", name) + } + if bs, ok := r.backoff[name]; ok && now.Before(bs.nextAllowedAt) { + continue + } + touched := false + if err := r.syncProject(name, &proj, machine, &touched); err != nil { + r.recordBackoff(name, err) + } else { + r.resetBackoff(name) + } + if touched { + ws.Projects[name] = proj + dirty = true + } + } + if dirty { + // Persist metadata refreshes (last_active_*, KindBranchOrphan + // clearings) so Phase 1 of the next tick commits and pushes + // them. Save's empty-machines GC also fires here, completing + // the legacy-autopush migration started at Load time. + if err := config.Save(r.root, ws); err != nil { + r.logger.Printf("reconciler: save workspace.toml after metadata refresh: %v", err) + } + } +} + +func (r *Reconciler) syncProject(name string, proj *config.Project, machine string, touched *bool) error { + mainPath := filepath.Join(r.root, proj.Path) + barePath := layout.BarePath(mainPath) + + // Layout check: classify on-disk state and route accordingly. + bareMissing := false + mainMissing := false + if _, err := os.Stat(barePath); os.IsNotExist(err) { + bareMissing = true + } + if _, err := os.Stat(mainPath); os.IsNotExist(err) { + mainMissing = true + } + + if bareMissing && mainMissing { + // Project is registered in workspace.toml but nothing exists on + // disk. Auto-clone if enabled. Sequential semantics happen for + // free: this clone is the only filesystem op for this project on + // this tick, the next project's loop iteration runs after, and + // the next tick reuses the now-present bare branch. + if !r.autoBootstrap || !proj.SyncEnabled() { + return nil + } + return r.autoCloneMissing(name, *proj) + } + + if bareMissing { + // mainPath exists, no bare → plain checkout drift, needs migrate. + r.recordProjectConflict(name, "", conflict.KindNeedsMigration, fmt.Sprintf("plain checkout at %s", mainPath)) + return nil + } + + // One-time repair for bare repos created before the SetFetchRefspec + // fix: if no remote.origin.fetch is configured, the upcoming Fetch + // would update only FETCH_HEAD and leave refs/remotes/origin/* empty, + // breaking AheadBehind and ff-pull for the main worktree. Best-effort: + // a failure here is logged via the fetch error path below, since we + // still attempt the fetch unconditionally. + if !git.HasFetchRefspec(barePath) { + if err := git.SetFetchRefspec(barePath); err != nil { + r.logger.Printf("reconciler: %s: repair fetch refspec: %v", name, err) + } + } + + if err := git.Fetch(barePath); err != nil { + return err // counts toward backoff + } + + // auto_sync=false → fetch only, no push or pull. + if !proj.SyncEnabled() { + return nil + } + + wts, err := git.WorktreeList(barePath) + if err != nil { + return err + } + + for _, wt := range wts { + if wt.Bare || wt.Detached || wt.Branch == "" { + continue + } + + // "Main worktree" is strictly the one at proj.Path. We do NOT treat + // any worktree on default_branch as main, because git allows --force + // attaching another worktree to that branch and we don't want to + // accidentally ff-pull a non-main checkout. + isMain := wt.Path == mainPath + + // Skip anything where the user is mid-edit. + if git.HasIndexLock(wt.Path) { + continue + } + + // Main worktree on the project's default branch → ff-pull when safe. + if isMain { + if git.IsDirty(wt.Path) { + continue + } + ahead, behind, has := git.AheadBehind(wt.Path, wt.Branch) + if !has { + continue + } + if behind > 0 && ahead == 0 { + if err := git.Pull(wt.Path); err != nil { + r.recordProjectConflict(name, wt.Branch, conflict.KindMainDivergence, err.Error()) + continue + } + _ = r.clearProjectConflict(name, wt.Branch, conflict.KindMainDivergence) + } else if ahead > 0 && behind > 0 { + r.recordProjectConflict(name, wt.Branch, conflict.KindMainDivergence, + fmt.Sprintf("ahead %d, behind %d — main worktree should not be diverged", ahead, behind)) + } + continue + } + + // Sibling worktrees: no push from the daemon. Refresh metadata for + // branches the user is actively committing to so `ws worktree list` + // and the workspace.toml registry reflect the latest activity. + // Branches not yet in [[branches]] (legacy wt//* checkouts + // that pre-date this PR) are silently skipped — they'll get + // re-registered when the user runs `ws worktree add` against them. + if machine != "" && proj.LookupBranch(wt.Branch) != nil { + ahead, _, has := git.AheadBehind(wt.Path, wt.Branch) + if has && ahead > 0 { + if proj.TouchActive(wt.Branch, machine, time.Now()) { + *touched = true + } + } + } + } + + // Branch-orphan detection: any registered branch whose last_pushed_at + // is set was observed on origin at least once, so its origin ref + // should still exist post-fetch. If it doesn't — the branch was + // deleted on origin (typical: PR merged with auto-delete-branch). + // Record the orphan and let the user decide via `ws sync resolve`. + // Re-appearance on the next tick auto-clears the conflict. + // + // Branches with empty last_pushed_at are local-only (created via + // `ws worktree add` and never pushed) — origin's missing ref is + // expected and must NOT trip orphan detection. + for _, b := range proj.Branches { + if b.LastPushedAt == "" { + _ = r.clearProjectConflict(name, b.Name, conflict.KindBranchOrphan) + continue + } + if git.HasRemoteBranch(barePath, "origin", b.Name) { + _ = r.clearProjectConflict(name, b.Name, conflict.KindBranchOrphan) + continue + } + details := fmt.Sprintf("origin ref refs/remotes/origin/%s missing post-fetch (last pushed by %s at %s)", + b.Name, b.LastPushedMachine, b.LastPushedAt) + r.recordProjectConflict(name, b.Name, conflict.KindBranchOrphan, details) + } + + return nil +} + +// autoCloneMissing handles the "registered in workspace.toml but nothing on +// disk" case. Called from syncProject when both .bare and are +// absent and AutoBootstrap is enabled. Sequential by construction: one clone +// happens per project per tick, after which the project takes the existing- +// bare branch on subsequent ticks. +// +// Error mapping: +// - ErrNeedsBootstrap → conflict 'needs-bootstrap' (default branch ambiguous) +// - ErrPathBlocked → conflict 'path-blocked' (shouldn't really happen here, but defensive) +// - any other error → returned to caller, which feeds it into per-project +// exponential backoff (network/auth flakes are the common case) +// +// On success, proj.DefaultBranch may have been filled in by CloneIntoLayout; +// we persist workspace.toml in place so the next tick (and the rest of the +// fleet via the workspace.toml sync) sees the new value. +func (r *Reconciler) autoCloneMissing(name string, proj config.Project) error { + r.logger.Printf("reconciler: auto-clone %s from %s", name, proj.Remote) + + res, err := clone.CloneIntoLayout(r.root, name, &proj, clone.Options{ + Logf: r.logger.Printf, + // Non-interactive: PromptDefaultBranch nil → ErrNeedsBootstrap if + // the branch can't be auto-detected. + }) + if err != nil { + switch { + case errors.Is(err, clone.ErrNeedsBootstrap): + r.recordProjectConflict(name, "", conflict.KindNeedsBootstrap, + "default branch could not be auto-detected — run `ws bootstrap "+name+"`") + return nil + case errors.Is(err, clone.ErrPathBlocked): + r.recordProjectConflict(name, "", conflict.KindPathBlocked, + "non-repo files at project path — clean up manually and re-run") + return nil + case errors.Is(err, clone.ErrNeedsMigration), errors.Is(err, clone.ErrAlreadyCloned): + // Both indicate disk state changed under us between the stat + // and the clone. Treat as a no-op; the next tick will route + // the project through the normal sync path. + return nil + default: + r.recordProjectConflict(name, "", conflict.KindCloneFailed, err.Error()) + return err + } + } + + r.logger.Printf("reconciler: cloned %s → %s (default_branch=%s)", name, res.BarePath, res.DefaultBranch) + // Clear any previously recorded clone failure for this project. + _ = r.clearProjectConflict(name, "", conflict.KindCloneFailed) + _ = r.clearProjectConflict(name, "", conflict.KindNeedsBootstrap) + + // Persist default_branch back into workspace.toml. We re-load from disk + // to avoid trampling unrelated edits the user (or another reconciler + // for a different workspace) may have made between Phase 1 and now. + if proj.DefaultBranch != "" { + fresh, err := config.Load(r.root) + if err != nil { + r.logger.Printf("reconciler: reload workspace.toml after clone: %v", err) + return nil + } + stored, ok := fresh.Projects[name] + if !ok { + return nil // project was removed from registry mid-tick; nothing to update + } + if stored.DefaultBranch == "" { + stored.DefaultBranch = proj.DefaultBranch + fresh.Projects[name] = stored + if err := config.Save(r.root, fresh); err != nil { + r.logger.Printf("reconciler: save workspace.toml after clone: %v", err) + } + } + } + return nil +} diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 4493375..0af0b8a 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -13,23 +13,12 @@ package daemon import ( - "encoding/json" - "fmt" "log" - "os" - "os/exec" - "path/filepath" - "strings" "sync" "time" - "errors" - - "github.com/kuchmenko/workspace/internal/clone" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/conflict" - "github.com/kuchmenko/workspace/internal/git" - "github.com/kuchmenko/workspace/internal/layout" "github.com/kuchmenko/workspace/internal/sidecar" ) @@ -137,542 +126,3 @@ func (r *Reconciler) Tick() { r.recordValidationIssues(ws) r.reconcileProjects(ws) } - -// recordValidationIssues runs ws.Validate() and turns each ValidationIssue -// into a conflict-store entry. Currently the only issue kind is duplicate -// branch names within a project (KindBranchDuplicate), which arises when -// two machines ws-worktree-add the same branch concurrently and union-merge -// concatenates their [[branches]] writes into the same project. -func (r *Reconciler) recordValidationIssues(ws *config.Workspace) { - for _, issue := range ws.Validate() { - switch issue.Kind { - case config.ValidationDuplicateBranch: - r.recordProjectConflict(issue.Project, issue.Branch, conflict.KindBranchDuplicate, issue.Detail) - } - } -} - -// ============================================================================= -// Phase 1: workspace.toml sync -// ============================================================================= - -// syncTOML implements the decision matrix from the design proposal §6.2. -// Returns (tomlChangedOnDisk, error). -func (r *Reconciler) syncTOML() (bool, error) { - tomlPath := filepath.Join(r.root, "workspace.toml") - realPath, err := filepath.EvalSymlinks(tomlPath) - if err != nil { - return false, fmt.Errorf("resolve symlink: %w", err) - } - repoRoot := findGitRoot(filepath.Dir(realPath)) - if repoRoot == "" { - return false, nil // not in a git repo, nothing to sync - } - if !git.HasRemote(repoRoot) { - return false, nil - } - - // Ensure the .gitattributes union-merge driver is in place. This makes - // most concurrent edits to workspace.toml merge cleanly without manual - // intervention. Best-effort: failure to write is logged but not fatal. - if err := ensureUnionMerge(repoRoot, realPath); err != nil { - r.logger.Printf("reconciler: ensureUnionMerge: %v", err) - } - - relFile, err := filepath.Rel(repoRoot, realPath) - if err != nil { - return false, err - } - - // Capture original HEAD so we can detect whether pull changed the file. - originalHead := git.RevParse(repoRoot, "HEAD") - - if err := git.Fetch(repoRoot); err != nil { - // Network failures here are common and not actionable; log and skip. - r.logger.Printf("reconciler: fetch failed in %s: %v", repoRoot, err) - return false, nil - } - - localDirty := !isClean(repoRoot, relFile) - branch, _ := git.CurrentBranch(repoRoot) - if branch == "" { - return false, fmt.Errorf("workspace repo is in detached HEAD") - } - ahead, behind, hasUpstream := git.AheadBehind(repoRoot, branch) - if !hasUpstream { - return false, nil - } - - // Fast path: nothing to do. - if !localDirty && ahead == 0 && behind == 0 { - _ = r.clearTOMLConflicts() - return false, nil - } - - // Commit dirty changes first so the rest of the matrix only deals with - // committed state. - if localDirty { - if err := git.Add(repoRoot, relFile); err != nil { - return false, fmt.Errorf("git add: %w", err) - } - host := machineHostname() - msg := fmt.Sprintf("ws: auto-sync workspace.toml from %s", host) - if err := git.Commit(repoRoot, msg); err != nil { - return false, fmt.Errorf("git commit: %w", err) - } - ahead++ - } - - // Re-evaluate behind in case fetch happened pre-commit. - _, behind, _ = git.AheadBehind(repoRoot, branch) - - // If remote moved while we were committing, rebase before push. - if behind > 0 { - if err := runIn(repoRoot, "git", "pull", "--rebase"); err != nil { - r.recordTOMLConflict(repoRoot, conflict.KindTOMLMerge, err) - return false, err - } - _ = r.clearTOMLConflicts() - } - - // Push if anything to push. - if ahead > 0 || behind > 0 { - if err := git.Push(repoRoot); err != nil { - // One retry: fetch + rebase + push, mirror of the legacy syncer. - if perr := runIn(repoRoot, "git", "pull", "--rebase"); perr != nil { - r.recordTOMLConflict(repoRoot, conflict.KindTOMLMerge, perr) - return false, perr - } - if perr := git.Push(repoRoot); perr != nil { - r.recordTOMLConflict(repoRoot, conflict.KindTOMLPushFailed, perr) - return false, perr - } - } - } - - newHead := git.RevParse(repoRoot, "HEAD") - return newHead != originalHead, nil -} - -func (r *Reconciler) recordTOMLConflict(workspace string, kind conflict.Kind, cause error) { - if r.store == nil { - return - } - details, _ := json.Marshal(map[string]string{"error": cause.Error()}) - c := conflict.Conflict{ - Workspace: workspace, - Kind: kind, - Details: details, - } - created, err := r.store.Record(c) - if err != nil { - r.logger.Printf("reconciler: record conflict: %v", err) - return - } - if created { - r.logger.Printf("reconciler: NEW conflict %s in %s: %v", kind, workspace, cause) - conflict.NotifyNew(c) - } -} - -func (r *Reconciler) clearTOMLConflicts() error { - if r.store == nil { - return nil - } - for _, k := range []conflict.Kind{conflict.KindTOMLMerge, conflict.KindTOMLPushFailed} { - _ = r.store.Clear(r.root, "", "", k) - } - return nil -} - -// ============================================================================= -// Phase 2: per-project reconcile -// ============================================================================= - -func (r *Reconciler) reconcileProjects(ws *config.Workspace) { - machine := loadMachineName() - now := time.Now() - dirty := false - for name, proj := range ws.Projects { - if proj.Status != config.StatusActive { - continue - } - if !proj.SyncEnabled() { - r.logger.Printf("reconciler: %s auto_sync=false, fetch only", name) - } - if bs, ok := r.backoff[name]; ok && now.Before(bs.nextAllowedAt) { - continue - } - touched := false - if err := r.syncProject(name, &proj, machine, &touched); err != nil { - r.recordBackoff(name, err) - } else { - r.resetBackoff(name) - } - if touched { - ws.Projects[name] = proj - dirty = true - } - } - if dirty { - // Persist metadata refreshes (last_active_*, KindBranchOrphan - // clearings) so Phase 1 of the next tick commits and pushes - // them. Save's empty-machines GC also fires here, completing - // the legacy-autopush migration started at Load time. - if err := config.Save(r.root, ws); err != nil { - r.logger.Printf("reconciler: save workspace.toml after metadata refresh: %v", err) - } - } -} - -func (r *Reconciler) syncProject(name string, proj *config.Project, machine string, touched *bool) error { - mainPath := filepath.Join(r.root, proj.Path) - barePath := layout.BarePath(mainPath) - - // Layout check: classify on-disk state and route accordingly. - bareMissing := false - mainMissing := false - if _, err := os.Stat(barePath); os.IsNotExist(err) { - bareMissing = true - } - if _, err := os.Stat(mainPath); os.IsNotExist(err) { - mainMissing = true - } - - if bareMissing && mainMissing { - // Project is registered in workspace.toml but nothing exists on - // disk. Auto-clone if enabled. Sequential semantics happen for - // free: this clone is the only filesystem op for this project on - // this tick, the next project's loop iteration runs after, and - // the next tick reuses the now-present bare branch. - if !r.autoBootstrap || !proj.SyncEnabled() { - return nil - } - return r.autoCloneMissing(name, *proj) - } - - if bareMissing { - // mainPath exists, no bare → plain checkout drift, needs migrate. - r.recordProjectConflict(name, "", conflict.KindNeedsMigration, fmt.Sprintf("plain checkout at %s", mainPath)) - return nil - } - - // One-time repair for bare repos created before the SetFetchRefspec - // fix: if no remote.origin.fetch is configured, the upcoming Fetch - // would update only FETCH_HEAD and leave refs/remotes/origin/* empty, - // breaking AheadBehind and ff-pull for the main worktree. Best-effort: - // a failure here is logged via the fetch error path below, since we - // still attempt the fetch unconditionally. - if !git.HasFetchRefspec(barePath) { - if err := git.SetFetchRefspec(barePath); err != nil { - r.logger.Printf("reconciler: %s: repair fetch refspec: %v", name, err) - } - } - - if err := git.Fetch(barePath); err != nil { - return err // counts toward backoff - } - - // auto_sync=false → fetch only, no push or pull. - if !proj.SyncEnabled() { - return nil - } - - wts, err := git.WorktreeList(barePath) - if err != nil { - return err - } - - for _, wt := range wts { - if wt.Bare || wt.Detached || wt.Branch == "" { - continue - } - - // "Main worktree" is strictly the one at proj.Path. We do NOT treat - // any worktree on default_branch as main, because git allows --force - // attaching another worktree to that branch and we don't want to - // accidentally ff-pull a non-main checkout. - isMain := wt.Path == mainPath - - // Skip anything where the user is mid-edit. - if git.HasIndexLock(wt.Path) { - continue - } - - // Main worktree on the project's default branch → ff-pull when safe. - if isMain { - if git.IsDirty(wt.Path) { - continue - } - ahead, behind, has := git.AheadBehind(wt.Path, wt.Branch) - if !has { - continue - } - if behind > 0 && ahead == 0 { - if err := git.Pull(wt.Path); err != nil { - r.recordProjectConflict(name, wt.Branch, conflict.KindMainDivergence, err.Error()) - continue - } - _ = r.clearProjectConflict(name, wt.Branch, conflict.KindMainDivergence) - } else if ahead > 0 && behind > 0 { - r.recordProjectConflict(name, wt.Branch, conflict.KindMainDivergence, - fmt.Sprintf("ahead %d, behind %d — main worktree should not be diverged", ahead, behind)) - } - continue - } - - // Sibling worktrees: no push from the daemon. Refresh metadata for - // branches the user is actively committing to so `ws worktree list` - // and the workspace.toml registry reflect the latest activity. - // Branches not yet in [[branches]] (legacy wt//* checkouts - // that pre-date this PR) are silently skipped — they'll get - // re-registered when the user runs `ws worktree add` against them. - if machine != "" && proj.LookupBranch(wt.Branch) != nil { - ahead, _, has := git.AheadBehind(wt.Path, wt.Branch) - if has && ahead > 0 { - if proj.TouchActive(wt.Branch, machine, time.Now()) { - *touched = true - } - } - } - } - - // Branch-orphan detection: any registered branch whose last_pushed_at - // is set was observed on origin at least once, so its origin ref - // should still exist post-fetch. If it doesn't — the branch was - // deleted on origin (typical: PR merged with auto-delete-branch). - // Record the orphan and let the user decide via `ws sync resolve`. - // Re-appearance on the next tick auto-clears the conflict. - // - // Branches with empty last_pushed_at are local-only (created via - // `ws worktree add` and never pushed) — origin's missing ref is - // expected and must NOT trip orphan detection. - for _, b := range proj.Branches { - if b.LastPushedAt == "" { - _ = r.clearProjectConflict(name, b.Name, conflict.KindBranchOrphan) - continue - } - if git.HasRemoteBranch(barePath, "origin", b.Name) { - _ = r.clearProjectConflict(name, b.Name, conflict.KindBranchOrphan) - continue - } - details := fmt.Sprintf("origin ref refs/remotes/origin/%s missing post-fetch (last pushed by %s at %s)", - b.Name, b.LastPushedMachine, b.LastPushedAt) - r.recordProjectConflict(name, b.Name, conflict.KindBranchOrphan, details) - } - - return nil -} - -// autoCloneMissing handles the "registered in workspace.toml but nothing on -// disk" case. Called from syncProject when both .bare and are -// absent and AutoBootstrap is enabled. Sequential by construction: one clone -// happens per project per tick, after which the project takes the existing- -// bare branch on subsequent ticks. -// -// Error mapping: -// - ErrNeedsBootstrap → conflict 'needs-bootstrap' (default branch ambiguous) -// - ErrPathBlocked → conflict 'path-blocked' (shouldn't really happen here, but defensive) -// - any other error → returned to caller, which feeds it into per-project -// exponential backoff (network/auth flakes are the common case) -// -// On success, proj.DefaultBranch may have been filled in by CloneIntoLayout; -// we persist workspace.toml in place so the next tick (and the rest of the -// fleet via the workspace.toml sync) sees the new value. -func (r *Reconciler) autoCloneMissing(name string, proj config.Project) error { - r.logger.Printf("reconciler: auto-clone %s from %s", name, proj.Remote) - - res, err := clone.CloneIntoLayout(r.root, name, &proj, clone.Options{ - Logf: r.logger.Printf, - // Non-interactive: PromptDefaultBranch nil → ErrNeedsBootstrap if - // the branch can't be auto-detected. - }) - if err != nil { - switch { - case errors.Is(err, clone.ErrNeedsBootstrap): - r.recordProjectConflict(name, "", conflict.KindNeedsBootstrap, - "default branch could not be auto-detected — run `ws bootstrap "+name+"`") - return nil - case errors.Is(err, clone.ErrPathBlocked): - r.recordProjectConflict(name, "", conflict.KindPathBlocked, - "non-repo files at project path — clean up manually and re-run") - return nil - case errors.Is(err, clone.ErrNeedsMigration), errors.Is(err, clone.ErrAlreadyCloned): - // Both indicate disk state changed under us between the stat - // and the clone. Treat as a no-op; the next tick will route - // the project through the normal sync path. - return nil - default: - r.recordProjectConflict(name, "", conflict.KindCloneFailed, err.Error()) - return err - } - } - - r.logger.Printf("reconciler: cloned %s → %s (default_branch=%s)", name, res.BarePath, res.DefaultBranch) - // Clear any previously recorded clone failure for this project. - _ = r.clearProjectConflict(name, "", conflict.KindCloneFailed) - _ = r.clearProjectConflict(name, "", conflict.KindNeedsBootstrap) - - // Persist default_branch back into workspace.toml. We re-load from disk - // to avoid trampling unrelated edits the user (or another reconciler - // for a different workspace) may have made between Phase 1 and now. - if proj.DefaultBranch != "" { - fresh, err := config.Load(r.root) - if err != nil { - r.logger.Printf("reconciler: reload workspace.toml after clone: %v", err) - return nil - } - stored, ok := fresh.Projects[name] - if !ok { - return nil // project was removed from registry mid-tick; nothing to update - } - if stored.DefaultBranch == "" { - stored.DefaultBranch = proj.DefaultBranch - fresh.Projects[name] = stored - if err := config.Save(r.root, fresh); err != nil { - r.logger.Printf("reconciler: save workspace.toml after clone: %v", err) - } - } - } - return nil -} - -func (r *Reconciler) recordProjectConflict(project, branch string, kind conflict.Kind, msg string) { - if r.store == nil { - return - } - details, _ := json.Marshal(map[string]string{"message": msg}) - c := conflict.Conflict{ - Workspace: r.root, - Project: project, - Branch: branch, - Kind: kind, - Details: details, - } - created, err := r.store.Record(c) - if err != nil { - r.logger.Printf("reconciler: record %s: %v", kind, err) - return - } - if created { - r.logger.Printf("reconciler: NEW conflict %s for %s/%s: %s", kind, project, branch, msg) - conflict.NotifyNew(c) - } -} - -func (r *Reconciler) clearProjectConflict(project, branch string, kind conflict.Kind) error { - if r.store == nil { - return nil - } - return r.store.Clear(r.root, project, branch, kind) -} - -// ============================================================================= -// Backoff -// ============================================================================= - -func (r *Reconciler) recordBackoff(name string, cause error) { - bs, ok := r.backoff[name] - if !ok { - bs = &backoffState{currentDelay: r.interval} - r.backoff[name] = bs - } else { - bs.currentDelay *= 2 - if bs.currentDelay > r.maxInterval { - bs.currentDelay = r.maxInterval - } - } - bs.nextAllowedAt = time.Now().Add(bs.currentDelay) - r.logger.Printf("reconciler: %s failed (%v); next attempt in %s", name, cause, bs.currentDelay) -} - -func (r *Reconciler) resetBackoff(name string) { - delete(r.backoff, name) -} - -// ============================================================================= -// Helpers -// ============================================================================= - -// findGitRoot walks up from dir looking for the nearest git repo. Returns -// "" if no repo is found before reaching the filesystem root. -func findGitRoot(dir string) string { - for { - if git.IsRepo(dir) { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - return "" - } - dir = parent - } -} - -func isClean(repoPath, file string) bool { - cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain", file) - out, err := cmd.Output() - if err != nil { - return true - } - return strings.TrimSpace(string(out)) == "" -} - -func runIn(dir, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s %s in %s: %s", name, strings.Join(args, " "), dir, strings.TrimSpace(string(out))) - } - return nil -} - -// ensureUnionMerge appends ` merge=union` to .gitattributes in the -// repo root if it isn't already configured. Idempotent. -func ensureUnionMerge(repoRoot, tomlAbs string) error { - rel, err := filepath.Rel(repoRoot, tomlAbs) - if err != nil { - return err - } - attrPath := filepath.Join(repoRoot, ".gitattributes") - wantLine := rel + " merge=union" - existing, err := os.ReadFile(attrPath) - if err != nil && !os.IsNotExist(err) { - return err - } - for _, line := range strings.Split(string(existing), "\n") { - if strings.TrimSpace(line) == wantLine { - return nil - } - } - f, err := os.OpenFile(attrPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") { - _, _ = f.WriteString("\n") - } - _, err = f.WriteString(wantLine + "\n") - return err -} - -func loadMachineName() string { - mc, err := config.LoadMachineConfig() - if err != nil || mc == nil { - return "" - } - return mc.MachineName -} - -func machineHostname() string { - if name := loadMachineName(); name != "" { - return name - } - h, err := os.Hostname() - if err != nil { - return "unknown" - } - return h -} diff --git a/internal/daemon/toml.go b/internal/daemon/toml.go new file mode 100644 index 0000000..ccbb58a --- /dev/null +++ b/internal/daemon/toml.go @@ -0,0 +1,139 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/kuchmenko/workspace/internal/conflict" + "github.com/kuchmenko/workspace/internal/git" +) + +// syncTOML implements the decision matrix from the design proposal §6.2. +// Returns (tomlChangedOnDisk, error). +func (r *Reconciler) syncTOML() (bool, error) { + tomlPath := filepath.Join(r.root, "workspace.toml") + realPath, err := filepath.EvalSymlinks(tomlPath) + if err != nil { + return false, fmt.Errorf("resolve symlink: %w", err) + } + repoRoot := findGitRoot(filepath.Dir(realPath)) + if repoRoot == "" { + return false, nil // not in a git repo, nothing to sync + } + if !git.HasRemote(repoRoot) { + return false, nil + } + + // Ensure the .gitattributes union-merge driver is in place. This makes + // most concurrent edits to workspace.toml merge cleanly without manual + // intervention. Best-effort: failure to write is logged but not fatal. + if err := ensureUnionMerge(repoRoot, realPath); err != nil { + r.logger.Printf("reconciler: ensureUnionMerge: %v", err) + } + + relFile, err := filepath.Rel(repoRoot, realPath) + if err != nil { + return false, err + } + + // Capture original HEAD so we can detect whether pull changed the file. + originalHead := git.RevParse(repoRoot, "HEAD") + + if err := git.Fetch(repoRoot); err != nil { + // Network failures here are common and not actionable; log and skip. + r.logger.Printf("reconciler: fetch failed in %s: %v", repoRoot, err) + return false, nil + } + + localDirty := !isClean(repoRoot, relFile) + branch, _ := git.CurrentBranch(repoRoot) + if branch == "" { + return false, fmt.Errorf("workspace repo is in detached HEAD") + } + ahead, behind, hasUpstream := git.AheadBehind(repoRoot, branch) + if !hasUpstream { + return false, nil + } + + // Fast path: nothing to do. + if !localDirty && ahead == 0 && behind == 0 { + _ = r.clearTOMLConflicts() + return false, nil + } + + // Commit dirty changes first so the rest of the matrix only deals with + // committed state. + if localDirty { + if err := git.Add(repoRoot, relFile); err != nil { + return false, fmt.Errorf("git add: %w", err) + } + host := machineHostname() + msg := fmt.Sprintf("ws: auto-sync workspace.toml from %s", host) + if err := git.Commit(repoRoot, msg); err != nil { + return false, fmt.Errorf("git commit: %w", err) + } + ahead++ + } + + // Re-evaluate behind in case fetch happened pre-commit. + _, behind, _ = git.AheadBehind(repoRoot, branch) + + // If remote moved while we were committing, rebase before push. + if behind > 0 { + if err := runIn(repoRoot, "git", "pull", "--rebase"); err != nil { + r.recordTOMLConflict(repoRoot, conflict.KindTOMLMerge, err) + return false, err + } + _ = r.clearTOMLConflicts() + } + + // Push if anything to push. + if ahead > 0 || behind > 0 { + if err := git.Push(repoRoot); err != nil { + // One retry: fetch + rebase + push, mirror of the legacy syncer. + if perr := runIn(repoRoot, "git", "pull", "--rebase"); perr != nil { + r.recordTOMLConflict(repoRoot, conflict.KindTOMLMerge, perr) + return false, perr + } + if perr := git.Push(repoRoot); perr != nil { + r.recordTOMLConflict(repoRoot, conflict.KindTOMLPushFailed, perr) + return false, perr + } + } + } + + newHead := git.RevParse(repoRoot, "HEAD") + return newHead != originalHead, nil +} + +func (r *Reconciler) recordTOMLConflict(workspace string, kind conflict.Kind, cause error) { + if r.store == nil { + return + } + details, _ := json.Marshal(map[string]string{"error": cause.Error()}) + c := conflict.Conflict{ + Workspace: workspace, + Kind: kind, + Details: details, + } + created, err := r.store.Record(c) + if err != nil { + r.logger.Printf("reconciler: record conflict: %v", err) + return + } + if created { + r.logger.Printf("reconciler: NEW conflict %s in %s: %v", kind, workspace, cause) + conflict.NotifyNew(c) + } +} + +func (r *Reconciler) clearTOMLConflicts() error { + if r.store == nil { + return nil + } + for _, k := range []conflict.Kind{conflict.KindTOMLMerge, conflict.KindTOMLPushFailed} { + _ = r.store.Clear(r.root, "", "", k) + } + return nil +} diff --git a/internal/migrate/check.go b/internal/migrate/check.go new file mode 100644 index 0000000..6c326c0 --- /dev/null +++ b/internal/migrate/check.go @@ -0,0 +1,56 @@ +package migrate + +import ( + "os" + "path/filepath" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/git" + "github.com/kuchmenko/workspace/internal/layout" +) + +// CheckResult reports the migration-related state of one project without +// making any changes. +type CheckResult struct { + Project string + State string // "migrated" | "needs-migration" | "missing" | "not-a-repo" + MainPath string + BarePath string + HasStash bool + IsDirty bool + Detached bool + Branch string + HooksFound int +} + +// Check inspects a project on disk and reports its layout state without +// touching anything. Useful for `ws migrate --check`. +func Check(wsRoot string, name string, proj config.Project) CheckResult { + mainPath := filepath.Join(wsRoot, proj.Path) + barePath := layout.BarePath(mainPath) + res := CheckResult{Project: name, MainPath: mainPath, BarePath: barePath} + + if _, err := os.Stat(barePath); err == nil { + res.State = "migrated" + return res + } + if _, err := os.Stat(mainPath); os.IsNotExist(err) { + res.State = "missing" + return res + } + if !git.IsRepo(mainPath) { + res.State = "not-a-repo" + return res + } + res.State = "needs-migration" + res.HasStash = git.HasStash(mainPath) + res.IsDirty = git.IsDirty(mainPath) + if br, _ := git.CurrentBranch(mainPath); br == "" { + res.Detached = true + } else { + res.Branch = br + } + hooks, _ := listActiveHooks(filepath.Join(mainPath, ".git", "hooks")) + res.HooksFound = len(hooks) + return res +} diff --git a/internal/migrate/hooks.go b/internal/migrate/hooks.go new file mode 100644 index 0000000..e001458 --- /dev/null +++ b/internal/migrate/hooks.go @@ -0,0 +1,80 @@ +package migrate + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// listActiveHooks returns hook filenames in dir that are NOT *.sample and +// have at least one executable bit set. Returns nil, nil if dir is missing. +func listActiveHooks(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".sample") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.Mode()&0o111 == 0 { + continue + } + out = append(out, name) + } + return out, nil +} + +// copyHooks copies the named hook files from srcDir to dstDir, preserving +// the executable bit. Returns the names that were successfully copied. +func copyHooks(srcDir, dstDir string, names []string) ([]string, error) { + if len(names) == 0 { + return nil, nil + } + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return nil, err + } + var copied []string + for _, name := range names { + if err := copyFilePreservingMode(filepath.Join(srcDir, name), filepath.Join(dstDir, name)); err != nil { + return copied, fmt.Errorf("copy hook %s: %w", name, err) + } + copied = append(copied, name) + } + return copied, nil +} + +func copyFilePreservingMode(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 12630a8..7d0f23b 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -10,7 +10,6 @@ package migrate import ( "errors" "fmt" - "io" "os" "path/filepath" "strings" @@ -75,52 +74,6 @@ type Result struct { // a hard error. var ErrAlreadyMigrated = errors.New("project already migrated") -// CheckResult reports the migration-related state of one project without -// making any changes. -type CheckResult struct { - Project string - State string // "migrated" | "needs-migration" | "missing" | "not-a-repo" - MainPath string - BarePath string - HasStash bool - IsDirty bool - Detached bool - Branch string - HooksFound int -} - -// Check inspects a project on disk and reports its layout state without -// touching anything. Useful for `ws migrate --check`. -func Check(wsRoot string, name string, proj config.Project) CheckResult { - mainPath := filepath.Join(wsRoot, proj.Path) - barePath := layout.BarePath(mainPath) - res := CheckResult{Project: name, MainPath: mainPath, BarePath: barePath} - - if _, err := os.Stat(barePath); err == nil { - res.State = "migrated" - return res - } - if _, err := os.Stat(mainPath); os.IsNotExist(err) { - res.State = "missing" - return res - } - if !git.IsRepo(mainPath) { - res.State = "not-a-repo" - return res - } - res.State = "needs-migration" - res.HasStash = git.HasStash(mainPath) - res.IsDirty = git.IsDirty(mainPath) - if br, _ := git.CurrentBranch(mainPath); br == "" { - res.Detached = true - } else { - res.Branch = br - } - hooks, _ := listActiveHooks(filepath.Join(mainPath, ".git", "hooks")) - res.HooksFound = len(hooks) - return res -} - // MigrateProject runs the full migration for one named project. The caller // owns the workspace.toml save: this function may mutate `proj` to fill in // DefaultBranch, but it does not write the file. @@ -483,134 +436,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio }, nil } -// commitReachableFromAnyBranch reports whether commit `sha` is an ancestor -// of any local branch in repoPath. Used by detached-HEAD recovery to decide -// whether the current commit needs to be preserved on a side branch before -// we walk away from it. -func commitReachableFromAnyBranch(repoPath, sha string) (bool, error) { - if sha == "" { - return false, nil - } - branches, err := git.Branches(repoPath) - if err != nil { - return false, err - } - for _, b := range branches { - if err := runGit(repoPath, "merge-base", "--is-ancestor", sha, b); err == nil { - return true, nil - } - } - return false, nil -} - -// resolveDefaultBranch returns the project's default branch, prompting the -// user (via opts.PromptDefaultBranch) only when it cannot be inferred. -func resolveDefaultBranch(name string, proj *config.Project, mainPath string, opts Options) (string, error) { - if proj.DefaultBranch != "" { - return proj.DefaultBranch, nil - } - if br := git.SymbolicRef(mainPath, "refs/remotes/origin/HEAD"); br != "" { - // strip "origin/" - if i := strings.Index(br, "/"); i >= 0 { - br = br[i+1:] - } - return br, nil - } - // Try common candidates that actually exist locally. - var candidates []string - for _, c := range []string{"main", "master", "trunk"} { - if git.HasBranch(mainPath, c) { - candidates = append(candidates, c) - } - } - if opts.PromptDefaultBranch == nil { - if len(candidates) == 1 { - return candidates[0], nil - } - return "", fmt.Errorf("cannot determine default branch for %s and no prompter configured", name) - } - picked, err := opts.PromptDefaultBranch(name, candidates) - if err != nil { - return "", err - } - picked = strings.TrimSpace(picked) - if picked == "" { - return "", fmt.Errorf("no default branch selected for %s", name) - } - return picked, nil -} - -// listActiveHooks returns hook filenames in dir that are NOT *.sample and -// have at least one executable bit set. Returns nil, nil if dir is missing. -func listActiveHooks(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - var out []string - for _, e := range entries { - if e.IsDir() { - continue - } - name := e.Name() - if strings.HasSuffix(name, ".sample") { - continue - } - info, err := e.Info() - if err != nil { - continue - } - if info.Mode()&0o111 == 0 { - continue - } - out = append(out, name) - } - return out, nil -} - -// copyHooks copies the named hook files from srcDir to dstDir, preserving -// the executable bit. Returns the names that were successfully copied. -func copyHooks(srcDir, dstDir string, names []string) ([]string, error) { - if len(names) == 0 { - return nil, nil - } - if err := os.MkdirAll(dstDir, 0o755); err != nil { - return nil, err - } - var copied []string - for _, name := range names { - if err := copyFilePreservingMode(filepath.Join(srcDir, name), filepath.Join(dstDir, name)); err != nil { - return copied, fmt.Errorf("copy hook %s: %w", name, err) - } - copied = append(copied, name) - } - return copied, nil -} - -func copyFilePreservingMode(src, dst string) error { - info, err := os.Stat(src) - if err != nil { - return err - } - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - out.Close() - return err - } - return out.Close() -} - // rollbackBare removes a partially-created bare repo. Best-effort. func rollbackBare(barePath string) { _ = os.RemoveAll(barePath) diff --git a/internal/migrate/resolve.go b/internal/migrate/resolve.go new file mode 100644 index 0000000..1a33da1 --- /dev/null +++ b/internal/migrate/resolve.go @@ -0,0 +1,66 @@ +package migrate + +import ( + "fmt" + "strings" + + "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/git" +) + +// commitReachableFromAnyBranch reports whether commit `sha` is an ancestor +// of any local branch in repoPath. Used by detached-HEAD recovery to decide +// whether the current commit needs to be preserved on a side branch before +// we walk away from it. +func commitReachableFromAnyBranch(repoPath, sha string) (bool, error) { + if sha == "" { + return false, nil + } + branches, err := git.Branches(repoPath) + if err != nil { + return false, err + } + for _, b := range branches { + if err := runGit(repoPath, "merge-base", "--is-ancestor", sha, b); err == nil { + return true, nil + } + } + return false, nil +} + +// resolveDefaultBranch returns the project's default branch, prompting the +// user (via opts.PromptDefaultBranch) only when it cannot be inferred. +func resolveDefaultBranch(name string, proj *config.Project, mainPath string, opts Options) (string, error) { + if proj.DefaultBranch != "" { + return proj.DefaultBranch, nil + } + if br := git.SymbolicRef(mainPath, "refs/remotes/origin/HEAD"); br != "" { + // strip "origin/" + if i := strings.Index(br, "/"); i >= 0 { + br = br[i+1:] + } + return br, nil + } + // Try common candidates that actually exist locally. + var candidates []string + for _, c := range []string{"main", "master", "trunk"} { + if git.HasBranch(mainPath, c) { + candidates = append(candidates, c) + } + } + if opts.PromptDefaultBranch == nil { + if len(candidates) == 1 { + return candidates[0], nil + } + return "", fmt.Errorf("cannot determine default branch for %s and no prompter configured", name) + } + picked, err := opts.PromptDefaultBranch(name, candidates) + if err != nil { + return "", err + } + picked = strings.TrimSpace(picked) + if picked == "" { + return "", fmt.Errorf("no default branch selected for %s", name) + } + return picked, nil +}