From 0d093dd02187389373a4065476282910465cd804 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 09:57:44 +0300 Subject: [PATCH 01/21] feat(agent): add favorites and recent-projects quick-nav header Add a quick-nav header to ws agent surfacing up to five favorites and five recently-touched projects, sorted by last activity. Activity is recorded by stamping the launched project's branch on every enter / p / l / ctrl+s, creating a minimal [[branches]] entry on first launch without setting CreatedBy/CreatedAt (the launcher is not a creation event). Favorites live on Project.Favorite in workspace.toml and sync across machines like any other project metadata. The agent view (all | favorites) persists to [agent].default_view so the next ws agent invocation opens in the same mode on any machine. New CLI surface: ws favorite add|rm|list . New TUI hotkeys: f toggles favorite on a project row; space v flips the view. --- docs/agent-tui.md | 38 ++- internal/agent/header.go | 121 +++++++++ internal/agent/header_test.go | 130 ++++++++++ internal/agent/persist.go | 32 +++ internal/agent/source.go | 40 ++- internal/agent/stamp.go | 120 +++++++++ internal/agent/stamp_test.go | 179 ++++++++++++++ internal/agent/tui.go | 436 ++++++++++++++++++++++++++++++++- internal/agent/types.go | 36 ++- internal/cli/agent.go | 33 ++- internal/cli/favorite.go | 134 ++++++++++ internal/cli/root.go | 1 + internal/config/config.go | 121 +++++++++ internal/config/config_test.go | 186 ++++++++++++++ 14 files changed, 1578 insertions(+), 29 deletions(-) create mode 100644 internal/agent/header.go create mode 100644 internal/agent/header_test.go create mode 100644 internal/agent/persist.go create mode 100644 internal/agent/stamp.go create mode 100644 internal/agent/stamp_test.go create mode 100644 internal/cli/favorite.go diff --git a/docs/agent-tui.md b/docs/agent-tui.md index 05b7519..1c4fcfe 100644 --- a/docs/agent-tui.md +++ b/docs/agent-tui.md @@ -18,9 +18,17 @@ piping / scripts get help instead of a TUI prompt. The agent 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 single nested list, optionally topped by a +quick-nav header. ```text + Favorites + * myapp 2m linux + * api 1h linux + Recent + docs-site 3h linux + experiments 1d linux + -- all workspaces -- ~/dev — workspace ├── personal │ ├── dotfiles @@ -38,6 +46,23 @@ Group / project rows expand and collapse. Worktrees show the same ownership tags as `ws worktree list` (`main`, `mine`, `shared with `, `legacy-wt`). +The header shows up to five favorited projects, then up to five +recently-touched non-favorite projects, sorted by activity desc. +Activity = the most recent `last_active_at` across the project's +`[[branches]]`. Every `enter`, `p`, `l`, and `ctrl+s` launch stamps +the current branch (creating a minimal `[[branches]]` entry for the +default branch on first launch). Projects with zero activity never +appear in Recent; favorites with zero activity sort to the end of +the Favorites section but still show. + +Two views are available, persisted to `[agent].default_view` in +`workspace.toml`: + +- `all` (default) — header above the full tree. +- `favorites` — only the Favorites section, flat. Tree is hidden. + +Toggle via `space v`. + ## Keys Navigation: @@ -62,6 +87,17 @@ 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. + +View toggle (via the which-key chord): + +- `space` then `v` — flip between `all` and `favorites` view. The new + value is written to `[agent].default_view` in `workspace.toml` so + the next `ws agent` invocation opens in the same mode, and other + machines inherit the preference. Search: diff --git a/internal/agent/header.go b/internal/agent/header.go new file mode 100644 index 0000000..090124f --- /dev/null +++ b/internal/agent/header.go @@ -0,0 +1,121 @@ +package agent + +import ( + "sort" + "time" +) + +// HeaderCap is the maximum number of project rows shown in either the +// Favorites or Recent header section. Five per section, total ten — +// chosen as the largest count that fits comfortably above the tree on +// a 24-row terminal without pushing all real projects below the fold. +const HeaderCap = 5 + +// headerSections returns the two project lists rendered in the +// Favorites/Recent shortcut header above the workspace tree: +// +// - favs: projects with Favorite=true, sorted by LastActiveAt +// desc, name asc for ties. Zero-activity favorites sort last +// but are still included (the user explicitly pinned them). +// Capped at HeaderCap. +// - recent: non-favorite projects with LastActiveAt > zero, +// sorted the same way. Capped at HeaderCap. Projects that +// have never been stamped never appear here. +// +// Returns two distinct slices — no project ever appears in both; +// favorites take precedence over recent. +func headerSections(projects []Project) (favs, recent []Project) { + for _, p := range projects { + if p.Favorite { + favs = append(favs, p) + } else if !p.LastActiveAt.IsZero() { + recent = append(recent, p) + } + } + sortByActivity(favs) + sortByActivity(recent) + favs = capProjects(favs, HeaderCap) + recent = capProjects(recent, HeaderCap) + return favs, recent +} + +func sortByActivity(ps []Project) { + sort.Slice(ps, func(i, j int) bool { + ai, aj := ps[i].LastActiveAt, ps[j].LastActiveAt + if !ai.Equal(aj) { + return ai.After(aj) + } + return ps[i].Name < ps[j].Name + }) +} + +func capProjects(ps []Project, n int) []Project { + if len(ps) <= n { + return ps + } + return ps[:n] +} + +// allProjects flattens every workspace's Projects into a single slice. +// Header sorting is global across workspaces — a Favorites pin from a +// work workspace and one from a personal workspace appear in the same +// list, ordered purely by activity. The category column on the row +// disambiguates if the user is multi-workspace. +func allProjects(workspaces []WorkspaceData) []Project { + var out []Project + for _, ws := range workspaces { + out = append(out, ws.Projects...) + } + return out +} + +// 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" + } +} + +// 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..95dda6f --- /dev/null +++ b/internal/agent/header_test.go @@ -0,0 +1,130 @@ +package agent + +import ( + "reflect" + "testing" + "time" +) + +func TestHeaderSections_FavoritesBeforeRecent_FavoritesExcludedFromRecent(t *testing.T) { + now := time.Now().UTC() + 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)}, + } + + favs, recent := headerSections(projects) + + gotFav := names(favs) + wantFav := []string{"fav-new", "fav-old"} + if !reflect.DeepEqual(gotFav, wantFav) { + t.Errorf("favorites order: got %v, want %v (descending by activity)", gotFav, wantFav) + } + + gotRecent := names(recent) + wantRecent := []string{"recent-new", "recent-old"} + if !reflect.DeepEqual(gotRecent, wantRecent) { + t.Errorf("recent order: got %v, want %v", gotRecent, wantRecent) + } + + // Stale (zero LastActiveAt) must never show up in Recent — and never + // in Favorites either, because it isn't a favorite. + for _, p := range append(favs, recent...) { + if p.Name == "stale" { + t.Errorf("zero-activity non-favorite %q leaked into header", p.Name) + } + } +} + +func TestHeaderSections_CappedAtFive(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), + }) + } + + favs, recent := headerSections(projects) + if len(favs) != HeaderCap { + t.Errorf("favorites should be capped at %d, got %d", HeaderCap, len(favs)) + } + if len(recent) != HeaderCap { + t.Errorf("recent should be capped at %d, got %d", HeaderCap, len(recent)) + } +} + +func TestHeaderSections_TiesByName(t *testing.T) { + t0 := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC) + projects := []Project{ + {Name: "z-app", Favorite: false, LastActiveAt: t0}, + {Name: "a-app", Favorite: false, LastActiveAt: t0}, + {Name: "m-app", Favorite: false, LastActiveAt: t0}, + } + _, recent := headerSections(projects) + got := names(recent) + 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 TestHeaderSections_FavoritesIncludeZeroActivity(t *testing.T) { + now := time.Now().UTC() + projects := []Project{ + {Name: "fresh-fav", Favorite: true, LastActiveAt: time.Time{}}, + {Name: "old-fav", Favorite: true, LastActiveAt: now.Add(-1 * time.Hour)}, + } + favs, _ := headerSections(projects) + got := names(favs) + // Order: activity desc; zero comes last because nothing is greater + // than zero in time.After semantics. + want := []string{"old-fav", "fresh-fav"} + if !reflect.DeepEqual(got, want) { + t.Errorf("favorites with mixed activity: 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 names(ps []Project) []string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.Name + } + return out +} 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/source.go b/internal/agent/source.go index d350fc2..34ffc73 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" @@ -71,13 +72,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 +108,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..2eb8e56 --- /dev/null +++ b/internal/agent/stamp_test.go @@ -0,0 +1,179 @@ +package agent + +import ( + "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 deeper path inside the worktree. + deep := filepath.Join(mainPath, ".") // alphabetically minimal subpath + 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/tui.go b/internal/agent/tui.go index e745666..a2f5dd4 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -41,6 +41,22 @@ type listItem struct { indent int path string // filesystem path for shell navigation parentProj *Project // for worktree/session: which project they belong to + + // sectionTitle is the text rendered for KindSection rows. Empty + // sectionTitle on a KindSection row renders as a blank line — + // used as the spacer between Favorites/Recent and the full tree. + sectionTitle string + // inHeader marks a project row that lives inside the Favorites or + // Recent header section rather than the full workspace tree. Such + // rows skip worktree/session expansion (they are quick-nav only). + inHeader bool +} + +// isSelectable reports whether the cursor is allowed to land on this +// row. KindSection rows are visual-only and skipped by j/k movement, +// flash-search match collection, and the initial-cursor placement. +func (it listItem) isSelectable() bool { + return it.kind != KindSection } // LaunchRequest is set when the user selects an action that should @@ -57,10 +73,16 @@ type LaunchRequest struct { type Model struct { workspaces []WorkspaceData mode viewMode - items []listItem // flattened visible items - cursor int - expanded map[string]bool // group/project name → expanded - scroll int // scroll offset for long lists + // agentView is "all" (Favorites+Recent header above full tree) or + // "favorites" (only the Favorites section, flat — no tree). Loaded + // from workspace.toml's [agent].default_view at startup. The user + // flips it via the which-key `space v` chord; the new value is + // persisted back to workspace.toml so other machines pick it up. + agentView string + items []listItem // flattened visible items + cursor int + expanded map[string]bool // group/project name → expanded + scroll int // scroll offset for long lists // Caches — loaded lazily, invalidated after mutations. sessCache *SessionCache @@ -110,13 +132,23 @@ type Model struct { // NewModel constructs the TUI model from loaded workspace data. // sessCache should be the cache returned by LoadWorkspaces (already // populated with session counts from the initial scan). -func NewModel(workspaces []WorkspaceData, sessCache *SessionCache) *Model { +// +// initialView selects the opening view ("all" or "favorites"). The +// caller typically reads it from workspace.toml's agent.default_view +// via Workspace.AgentDefaultView(); pass the empty string to default +// to "all". +func NewModel(workspaces []WorkspaceData, sessCache *SessionCache, initialView string) *Model { if sessCache == nil { sessCache = NewSessionCache() } + view := config.AgentViewAll + if initialView == config.AgentViewFavorites { + view = config.AgentViewFavorites + } m := &Model{ workspaces: workspaces, mode: viewList, + agentView: view, expanded: make(map[string]bool), sessCache: sessCache, wtCache: NewWorktreeCache(), @@ -128,9 +160,41 @@ func NewModel(workspaces []WorkspaceData, sessCache *SessionCache) *Model { } } m.rebuildItems() + m.cursor = m.firstSelectableIndex() return m } +// firstSelectableIndex returns the index of the first row the cursor +// can legally land on. Used to skip past the Favorites/Recent section +// headers at startup. Returns 0 when nothing is selectable (degenerate +// empty workspace) so the cursor still has a defined value. +func (m *Model) firstSelectableIndex() int { + for i, it := range m.items { + if it.isSelectable() { + return i + } + } + return 0 +} + +// nextSelectable steps from `from` in direction `dir` (+1 down, -1 up) +// over any KindSection rows until it lands on a selectable row. +// Returns `from` when no selectable row exists in that direction — +// callers use the no-change signal to skip the ensureVisible call. +func (m *Model) nextSelectable(from, dir int) int { + if len(m.items) == 0 { + return from + } + i := from + dir + for i >= 0 && i < len(m.items) { + if m.items[i].isSelectable() { + return i + } + i += dir + } + return from +} + func (m *Model) Init() tea.Cmd { return nil } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -212,13 +276,13 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "j", "down": - if m.cursor < len(m.items)-1 { - m.cursor++ + if next := m.nextSelectable(m.cursor, +1); next != m.cursor { + m.cursor = next m.ensureVisible() } case "k", "up": - if m.cursor > 0 { - m.cursor-- + if next := m.nextSelectable(m.cursor, -1); next != m.cursor { + m.cursor = next m.ensureVisible() } @@ -291,6 +355,15 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } + case "f": + // Toggle favorite on the cursor project (works in both header + // and tree variants). 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) + } + case "h", "left": if item != nil { switch { @@ -416,6 +489,7 @@ func (m *Model) whichKeyActions() []whichKeyAction { {"p", "+prompt"}, {"l", "shell"}, {"tab", "expand"}, + {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -423,10 +497,12 @@ func (m *Model) whichKeyActions() []whichKeyAction { return []whichKeyAction{ {"\u23ce", "open claude"}, {"p", "+prompt"}, + {"f", m.favoriteToggleLabel(item)}, {"w", "worktree \u203a"}, {"e", "edit"}, {"l", "shell"}, {"tab", "expand"}, + {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -439,6 +515,7 @@ func (m *Model) whichKeyActions() []whichKeyAction { if item.worktree != nil && !item.worktree.IsMain { actions = append(actions, whichKeyAction{"d", "delete"}) } + actions = append(actions, whichKeyAction{"v", m.viewToggleLabel()}) actions = append(actions, whichKeyAction{"", ""}) actions = append(actions, whichKeyAction{"esc", "close"}) return actions @@ -446,6 +523,7 @@ func (m *Model) whichKeyActions() []whichKeyAction { return []whichKeyAction{ {"\u23ce", "resume"}, {"p", "resume +prompt"}, + {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -453,6 +531,26 @@ func (m *Model) whichKeyActions() []whichKeyAction { return nil } +// viewToggleLabel describes the `v` chord destination: "favorites view" +// when currently in all, "all view" when currently in favorites. The +// label is the *target*, not the current state, matching how which-key +// hints describe what each key does next. +func (m *Model) viewToggleLabel() string { + if m.agentView == config.AgentViewFavorites { + return "all view" + } + return "favorites view" +} + +// 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" +} + func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() item := m.currentItem() @@ -505,6 +603,22 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "e": m.mode = viewList return m.updateList(msg) + case "f": + // Favorite toggle is a per-project action; only meaningful when + // the cursor is on a project row. Closes the panel either way + // so the user sees the result immediately. + m.mode = viewList + if item != nil && item.kind == KindProject && item.project != nil { + m.toggleFavoriteFor(item.project) + } + return m, nil + case "v": + // View toggle is a global action — flips all<->favorites and + // persists to workspace.toml so the choice survives restart + // and syncs to other machines via the reconciler. + m.mode = viewList + m.toggleAgentView() + return m, nil case "tab": m.mode = viewList return m.updateList(msg) @@ -512,6 +626,82 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// toggleAgentView flips between "all" and "favorites", rebuilds the +// item list, and persists the new view to workspace.toml so future +// `ws agent` invocations open in the same mode and other machines +// inherit the preference on the next reconciler tick. +func (m *Model) toggleAgentView() { + if m.agentView == config.AgentViewFavorites { + m.agentView = config.AgentViewAll + } else { + m.agentView = config.AgentViewFavorites + } + m.rebuildItems() + m.cursor = m.firstSelectableIndex() + m.ensureVisible() + m.statusMsg = "view: " + m.agentView + + root := m.primaryWorkspaceRoot() + if root == "" { + return + } + target := m.agentView + err := MutateAndSave(root, func(ws *config.Workspace) bool { + return ws.SetAgentDefaultView(target) + }) + if err != nil { + m.statusMsg = "view saved locally only: " + err.Error() + } +} + +// 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() +} + +// primaryWorkspaceRoot returns the root used for workspace-wide +// settings (currently just `agent.default_view`). The TUI displays +// at most one workspace at a time in practice; when multiple are +// registered we pick the first deterministically — the user has only +// one global preference per session and `loadAgentDefaultView` reads +// from the same workspace, so the round-trip is consistent. +func (m *Model) primaryWorkspaceRoot() string { + if len(m.workspaces) == 0 { + return "" + } + return m.workspaces[0].Root +} + func (m *Model) whichKeyTitle() string { item := m.currentItem() if item == nil { @@ -771,8 +961,13 @@ func (m *Model) recomputeFlash() { m.flashMatches = nil m.flashLabels = nil - // Collect matches. + // 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 { + if !item.isSelectable() { + continue + } name := m.itemSearchName(item) if query == "" || strings.Contains(strings.ToLower(name), query) { m.flashMatches = append(m.flashMatches, i) @@ -952,9 +1147,56 @@ func (m *Model) breadcrumb() string { } // rebuildItems flattens the workspace tree into a visible list, -// respecting group expansion state. +// respecting group expansion state and the active agent view. +// +// Layout depends on m.agentView: +// +// - "favorites": only the Favorites header section is shown. +// Empty favorites produce a hint row pointing the user at the +// `f` hotkey. The full workspace tree is intentionally hidden. +// +// - "all" (default): if there are any favorites or any recent +// non-favorite activity, emit a Favorites section then a +// Recent section then a `-- all workspaces --` divider above +// the regular tree. With no activity at all, the header is +// skipped entirely and the user sees just the tree. func (m *Model) rebuildItems() { m.items = nil + + favs, recent := headerSections(allProjects(m.workspaces)) + + if m.agentView == config.AgentViewFavorites { + m.appendSectionTitle("Favorites") + if len(favs) == 0 { + m.appendSectionHint("(no favorites yet — press f on a project)") + } else { + for i := range favs { + m.appendHeaderProject(&favs[i]) + } + } + m.clampCursor() + return + } + + headerShown := false + if len(favs) > 0 { + m.appendSectionTitle("Favorites") + for i := range favs { + m.appendHeaderProject(&favs[i]) + } + headerShown = true + } + if len(recent) > 0 { + m.appendSectionTitle("Recent") + for i := range recent { + m.appendHeaderProject(&recent[i]) + } + headerShown = true + } + if headerShown { + m.appendSectionDivider("-- all workspaces --") + } + for _, ws := range m.workspaces { // Ungrouped projects first. for i := range ws.Projects { @@ -976,12 +1218,70 @@ func (m *Model) rebuildItems() { } } } + m.clampCursor() +} + +// appendSectionTitle pushes a non-selectable header row carrying the +// label rendered above each shortcut section ("Favorites", "Recent"). +func (m *Model) appendSectionTitle(title string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: title}) +} + +// appendSectionHint pushes a non-selectable hint row used inside an +// empty Favorites view to point the user at the `f` hotkey. Visually +// distinct from a title via the leading "(" — the renderer uses the +// same style block for both. +func (m *Model) appendSectionHint(text string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) +} + +// appendSectionDivider pushes a non-selectable divider line drawn +// between the shortcut header and the full tree. +func (m *Model) appendSectionDivider(text string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) +} + +// appendHeaderProject emits a project row inside the Favorites/Recent +// shortcut section. The row is fully selectable and launches just +// like a tree-row project on Enter, but inHeader=true suppresses the +// worktree/session expansion children — these are quick-nav rows, +// not a place for nested navigation. +func (m *Model) appendHeaderProject(p *Project) { + m.items = append(m.items, listItem{ + kind: KindProject, + project: p, + indent: 0, + path: p.Path, + inHeader: true, + }) +} + +// clampCursor keeps m.cursor inside the items range and pulls it off +// any KindSection rows it might have landed on after a rebuild. When +// the cursor is on a non-selectable row, prefer moving downward first +// (the natural reading direction) and only fall back to upward if +// nothing selectable lies below. +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 } + if m.items[m.cursor].isSelectable() { + return + } + if next := m.nextSelectable(m.cursor, +1); next != m.cursor && m.items[next].isSelectable() { + m.cursor = next + return + } + if next := m.nextSelectable(m.cursor, -1); next != m.cursor && m.items[next].isSelectable() { + m.cursor = next + } } func (m *Model) addProjectItem(p *Project, indent int) { @@ -1091,6 +1391,8 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { line = m.renderWorktree(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) case KindPortal: line = m.renderSession(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) + case KindSection: + line = m.renderSection(item, listW, dimAll) } rows = append(rows, line) @@ -1099,12 +1401,21 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { } // itemGroupKey returns a key that identifies the visual group boundary -// for inserting blank lines between groups. +// for inserting blank lines between groups. KindSection rows return +// their own key per title so each section visually owns its block +// (Favorites != Recent != divider); the rows beneath them inherit the +// tree's normal keys because the shortcut projects are inHeader=true +// without any Group of their own. func (m *Model) itemGroupKey(item listItem) string { switch item.kind { + case KindSection: + return "section:" + item.sectionTitle case KindGroup: return "g:" + item.group case KindProject: + if item.inHeader { + return "header" + } if item.project.Group != "" { return "g:" + item.project.Group } @@ -1118,6 +1429,22 @@ func (m *Model) itemGroupKey(item listItem) string { return "" } +// renderSection draws the non-selectable header rows: section titles +// ("Favorites" / "Recent"), the divider line above the full tree, and +// the empty-state hint shown inside an empty Favorites view. All four +// share one style block; the title text already disambiguates them. +func (m *Model) renderSection(item listItem, w int, dimAll bool) string { + text := item.sectionTitle + if text == "" { + return strings.Repeat(" ", w) + } + label := " " + text + if dimAll { + return dimStyle.Width(w).Render(label) + } + return sectionStyle.Width(w).Render(label) +} + func (m *Model) renderGroup(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { arrow := "\u25b8" if m.expanded[item.group] { @@ -1142,6 +1469,15 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl p := item.project indent := strings.Repeat(" ", item.indent) + // Project rows in the Favorites/Recent shortcut section have a + // distinct shape: a leading `*` marker for favorites, no + // expansion arrow (they never expand to worktrees here), and a + // trailing age column ("2m linux"). The tree variant below is + // unchanged. + if item.inHeader { + return m.renderHeaderProject(p, selected, inFlash, isMatch, flashLabel, w, dimAll) + } + expandMark := "" if p.WorktreeCount > 1 || p.SessionCount > 0 { if m.expanded["proj:"+p.ID] { @@ -1190,6 +1526,64 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl 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 +// and shares Enter/p/l semantics with tree rows — only the visual shape +// differs. +func (m *Model) renderHeaderProject(p *Project, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { + name := p.Name + if inFlash && isMatch { + name = flashInlineLabel(name, m.flashQuery, flashLabel) + } + + star := " " + if p.Favorite { + star = "* " + } + left := fmt.Sprintf(" %s%s %s", star, iconProject, name) + + var rightParts []string + if age := humanizeAge(p.LastActiveAt); age != "" { + rightParts = append(rightParts, age) + } + if p.LastActiveMachine != "" { + rightParts = append(rightParts, p.LastActiveMachine) + } + right := strings.Join(rightParts, " ") + + line := m.padRight(left, right, w) + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(line) + } + if selected { + return m.renderSelected(line, itemStyle, w) + } + if right == "" { + // Star + project body share the project color; favorited + // projects get a brighter star via favoriteStarStyle for visual + // scanability without using a different background. + if p.Favorite { + body := fmt.Sprintf(" %s %s", iconProject, name) + return favoriteStarStyle.Render(" * ") + itemStyle.Render(body[len(" * "):]) + strings.Repeat(" ", w-lipgloss.Width(left)) + } + return itemStyle.Width(w).Render(line) + } + padding := w - lipgloss.Width(left) - lipgloss.Width(right) - 1 + if padding < 1 { + padding = 1 + } + leftRendered := itemStyle.Render(left) + if p.Favorite { + // Overlay just the star with the brighter style. left already + // contains the star at positions 2-3 (" * "+icon+...). + leftRendered = itemStyle.Render(" ") + + favoriteStarStyle.Render("* ") + + itemStyle.Render(left[len(" * "):]) + } + return leftRendered + strings.Repeat(" ", padding) + activityAgeStyle.Render(right) + " " +} + 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 @@ -1521,6 +1915,24 @@ var ( dimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) + // sectionStyle paints the "Favorites" / "Recent" / divider labels + // that head the quick-nav shortcuts above the workspace tree. + // Color is deliberately the same family as headerStyle so the eye + // reads it as chrome, not as a clickable row. + sectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")). // amber dim + Bold(true) + + // 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")) + // Flash search. flashSearchStyle = lipgloss.NewStyle(). Bold(true). diff --git a/internal/agent/types.go b/internal/agent/types.go index 65dce4c..4babb9a 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 @@ -15,18 +18,33 @@ const ( KindProject KindWorktree KindPortal + // KindSection is a non-selectable visual element: the "Favorites" + // / "Recent" headers above the tree, the "-- all workspaces --" + // divider beneath them, and the empty-state hint inside an empty + // Favorites view. Cursor movement skips KindSection rows. + KindSection ) // 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 diff --git a/internal/cli/agent.go b/internal/cli/agent.go index a0ae0c5..3798c17 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -6,9 +6,26 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/agent" + "github.com/kuchmenko/workspace/internal/config" "github.com/spf13/cobra" ) +// loadAgentDefaultView returns the workspace.toml-stored agent.default_view +// for the first workspace in the list. The TUI is single-view (no per- +// workspace switching), so we pick the active workspace's preference and +// use it for the whole session. Returns "all" on any read error so the +// launcher never fails on a corrupt or missing workspace.toml. +func loadAgentDefaultView(workspaces []agent.WorkspaceData) string { + if len(workspaces) == 0 { + return config.AgentViewAll + } + ws, err := config.Load(workspaces[0].Root) + if err != nil { + return config.AgentViewAll + } + return ws.AgentDefaultView() +} + func newAgentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent", @@ -46,6 +63,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) }, } @@ -63,6 +81,7 @@ func newAgentShellCmd() *cobra.Command { }, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + stampLaunchActivity(args[0]) return agent.LaunchShell(args[0]) }, } @@ -84,6 +103,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) }, } @@ -101,7 +121,7 @@ func runAgentTUI() error { return fmt.Errorf("no workspaces found") } - m := agent.NewModel(workspaces, sessCache) + m := agent.NewModel(workspaces, sessCache, loadAgentDefaultView(workspaces)) p := tea.NewProgram(m, tea.WithAltScreen()) finalModel, err := p.Run() if err != nil { @@ -111,6 +131,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 +139,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..bddd031 --- /dev/null +++ b/internal/cli/favorite.go @@ -0,0 +1,134 @@ +package cli + +import ( + "fmt" + "os" + "sort" + "text/tabwriter" + + "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 as favorite", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Pin a project to the Favorites section of `ws agent`", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return setProjectFavorite(args[0], true) + }, + } +} + +func newFavoriteRmCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm ", + Short: "Unmark a favorite project", + Args: cobra.ExactArgs(1), + Annotations: map[string]string{ + "capability": "organization", + "agent:when": "Unpin a project from the Favorites section of `ws agent`", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return setProjectFavorite(args[0], false) + }, + } +} + +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 { + names := make([]string, 0) + for n, p := range ws.Projects { + if p.Favorite { + names = append(names, n) + } + } + if len(names) == 0 { + fmt.Println("No favorites. Use `ws favorite add ` to pin one.") + return nil + } + sort.Strings(names) + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tCATEGORY\tGROUP") + for _, n := range names { + p := ws.Projects[n] + group := p.Group + if group == "" { + group = "-" + } + fmt.Fprintf(tw, "%s\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/root.go b/internal/cli/root.go index 9c84601..ec78e09 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,6 +91,7 @@ func NewRootCmd() *cobra.Command { newWorktreeCmd(), newBootstrapCmd(), newAgentCmd(), + newFavoriteCmd(), newDocsCmd(), newDoctorCmd(), ) diff --git a/internal/config/config.go b/internal/config/config.go index 9b2d408..b945e0a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,13 @@ type Project struct { // 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. @@ -207,6 +214,46 @@ func (p *Project) TouchActive(name, machine string, when time.Time) bool { 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 @@ -276,12 +323,72 @@ 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"` } +// 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 ( + AgentViewAll = "all" + AgentViewFavorites = "favorites" +) + +// 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 + } + w.Agent.DefaultView = canonical + return true +} + +// 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 +} + // ValidationKind enumerates the structural problems Validate can detect. type ValidationKind string @@ -358,6 +465,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). 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) + } +} From d340c3211636567cb3619860878fbed40cea982a Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 09:57:50 +0300 Subject: [PATCH 02/21] chore(docs): consolidate CLAUDE.md into AGENTS.md Fold the project-level CLAUDE.md content into AGENTS.md so contributors and agents have a single source of truth. Also refresh two stale sections: the sidecar pre-check now lists all four kinds (bootstrap, migrate, add, create), and the reconciler description drops the non-existent Phase 0/3 numbering. --- AGENTS.md | 558 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- CLAUDE.md | 490 ----------------------------------------------- 2 files changed, 539 insertions(+), 509 deletions(-) delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 493e0ee..b92f77e 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). +``` +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 +``` -## Performance Protocol +- `/` 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) +``` + +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 @@ -182,4 +703,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. From e7a353fa29b2eab3808bb12c77d83065c3e3514f Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:06:17 +0300 Subject: [PATCH 03/21] docs(agents): adopt mechanical and architectural rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four rules borrowed from the Sunny agent playbook, adapted to Go: - No big files: 500-line soft split, 800-line hard split. - No decorative section separators — extract instead. - Comments are a last resort. - Architectural changes ask first, with explicit counter-examples so split-on-cohesive-seam stays a just-do-it. The Rust-specific 'pub or nothing' rule is dropped — Go visibility is already binary by identifier case. --- AGENTS.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b92f77e..e20ff51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -685,6 +685,76 @@ 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. 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`, From 9e4d86a840b9f18be7a4e4c16b83f7e5c403987c Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:21:11 +0300 Subject: [PATCH 04/21] refactor(config): split config.go by domain Break the 691-line config.go into Project (project.go), BranchMeta and its methods (branch.go), Validate/ValidationIssue (validate.go), and legacy [[autopush]] migration (legacy.go). config.go retains Workspace, Meta, Group, Daemon, AgentConfig, path resolution, Load/Save, and the package-private contains/sortedDedup helpers. Pure move; no behaviour change. All existing tests pass. --- internal/config/branch.go | 232 +++++++++++++++++++ internal/config/config.go | 433 ------------------------------------ internal/config/legacy.go | 78 +++++++ internal/config/project.go | 66 ++++++ internal/config/validate.go | 67 ++++++ 5 files changed, 443 insertions(+), 433 deletions(-) create mode 100644 internal/config/branch.go create mode 100644 internal/config/legacy.go create mode 100644 internal/config/project.go create mode 100644 internal/config/validate.go 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 b945e0a..71becf0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,306 +5,10 @@ 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"` - - // 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"` -} - -// 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 { - 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 -} - -// 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"` } @@ -377,79 +81,6 @@ func (w *Workspace) SetAgentDefaultView(view string) bool { return true } -// 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 -} - -// 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 -} - // FindRoot walks up from cwd (or uses WS_ROOT env) to find workspace.toml. func FindRoot() (string, error) { if env := os.Getenv("WS_ROOT"); env != "" { @@ -529,70 +160,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/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 +} From fde021ec23702a418977c105197c59d02399cfde Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:23:11 +0300 Subject: [PATCH 05/21] refactor(cli): split worktree.go by subcommand Break the 531-line worktree.go into per-subcommand files. worktree.go keeps the root cobra wiring (newWorktreeCmd) and three shared helpers (resolveProject, locateWorktreeForBranch, validateBranchName). worktree_add.go / worktree_list.go / worktree_rm.go / worktree_push.go own one cobra command each. worktreeStateString moves with list since it is its only caller. Pure move; no behaviour change. --- internal/cli/worktree.go | 444 ---------------------------------- internal/cli/worktree_add.go | 201 +++++++++++++++ internal/cli/worktree_list.go | 131 ++++++++++ internal/cli/worktree_push.go | 82 +++++++ internal/cli/worktree_rm.go | 78 ++++++ 5 files changed, 492 insertions(+), 444 deletions(-) create mode 100644 internal/cli/worktree_add.go create mode 100644 internal/cli/worktree_list.go create mode 100644 internal/cli/worktree_push.go create mode 100644 internal/cli/worktree_rm.go 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 +} From 2c3f95f225d6bf8369334ac5a4a87626751ed7e7 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:25:47 +0300 Subject: [PATCH 06/21] refactor(migrate): split migrate.go by responsibility Break the 617-line migrate.go into check.go (Check + CheckResult), hooks.go (listActiveHooks, copyHooks, copyFilePreservingMode), and resolve.go (commitReachableFromAnyBranch, resolveDefaultBranch). migrate.go retains the MigrateProject core, Options, Result, ErrAlreadyMigrated, and the 2-line rollbackBare (its only caller is MigrateProject). Pure move; no behaviour change. --- internal/migrate/check.go | 56 ++++++++++++ internal/migrate/hooks.go | 80 +++++++++++++++++ internal/migrate/migrate.go | 175 ------------------------------------ internal/migrate/resolve.go | 66 ++++++++++++++ 4 files changed, 202 insertions(+), 175 deletions(-) create mode 100644 internal/migrate/check.go create mode 100644 internal/migrate/hooks.go create mode 100644 internal/migrate/resolve.go 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 +} From b4617475905ac4ab56eb1d8145989e547c340266 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:27:50 +0300 Subject: [PATCH 07/21] refactor(cli): split migrate_tui.go into model/view Pull the bubbletea model (state, Update, decision flow) into migrate_model.go and the View renderers into migrate_view.go. migrate_tui.go retains the runMigrateTUI entry point, buildMigratePlan, commitMigrate, and the migratePlanItem/migrateState plan types. Pure move; no behaviour change. --- internal/cli/migrate_model.go | 275 +++++++++++++++++++++++ internal/cli/migrate_tui.go | 409 ---------------------------------- internal/cli/migrate_view.go | 138 ++++++++++++ 3 files changed, 413 insertions(+), 409 deletions(-) create mode 100644 internal/cli/migrate_model.go create mode 100644 internal/cli/migrate_view.go 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() +} From 2c1e3c333112dd5f74e00798ae432cf89dd0b022 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:29:57 +0300 Subject: [PATCH 08/21] refactor(cli): split bootstrap.go into model/view Pull the bubbletea model (state, Update, branch-prompt routing, startClone goroutine) into bootstrap_model.go and the View renderers plus the cli-package lipgloss styles into bootstrap_view.go. bootstrap.go retains newBootstrapCmd, runBootstrap, commitBootstrap, and printPlanText. Pure move; no behaviour change. --- internal/cli/bootstrap.go | 454 -------------------------------- internal/cli/bootstrap_model.go | 282 ++++++++++++++++++++ internal/cli/bootstrap_view.go | 180 +++++++++++++ 3 files changed, 462 insertions(+), 454 deletions(-) create mode 100644 internal/cli/bootstrap_model.go create mode 100644 internal/cli/bootstrap_view.go 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) +) From b24d38e473d58220363a31646701c4745aca401f Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:32:23 +0300 Subject: [PATCH 09/21] refactor(daemon): split reconciler.go by phase Pull the workspace.toml sync (Phase 1) into toml.go, the per-project reconcile loop and auto-clone path (Phase 2) into projects.go, conflict- store and backoff bookkeeping into conflicts.go, and the git/fs helpers into git.go. reconciler.go retains the Reconciler type, NewReconciler, SetAutoBootstrap, Run, and the Tick driver that wires the phases together. Pure move; no behaviour change. --- internal/daemon/conflicts.go | 72 +++++ internal/daemon/git.go | 95 ++++++ internal/daemon/projects.go | 263 ++++++++++++++++ internal/daemon/reconciler.go | 550 ---------------------------------- internal/daemon/toml.go | 139 +++++++++ 5 files changed, 569 insertions(+), 550 deletions(-) create mode 100644 internal/daemon/conflicts.go create mode 100644 internal/daemon/git.go create mode 100644 internal/daemon/projects.go create mode 100644 internal/daemon/toml.go 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 +} From 0c359022739a032dadfdd616f1297dbd0dd1f679 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:34:47 +0300 Subject: [PATCH 10/21] refactor(create): split tui.go into cmd/render/runner Pull the Bubbletea commands (fetchOwnersCmd, createCmd) and msg types into cmd.go, the View renderers and lipgloss styles into render.go, and the standalone runTUI entry point into runner.go. tui.go retains the Model, NewCreateModel, Init, Update, and the key/focus state machine. Pure move; no behaviour change. --- internal/create/cmd.go | 100 +++++++++++ internal/create/render.go | 199 +++++++++++++++++++++ internal/create/runner.go | 59 +++++++ internal/create/tui.go | 355 -------------------------------------- 4 files changed, 358 insertions(+), 355 deletions(-) create mode 100644 internal/create/cmd.go create mode 100644 internal/create/render.go create mode 100644 internal/create/runner.go 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") From 3ea37b379731aa8ee4ea6c1ea18141e353b1d768 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:39:55 +0300 Subject: [PATCH 11/21] refactor(add): split tui.go by state and concern Pull each lifecycle stage of the add TUI into its own file (gather, browse, manual, edit + confirm + bulk, clone + branchprompt + done), move messages into msg.go, generic text/source-chip helpers into format.go, and lipgloss styles into styles.go. tui.go retains the Model definition, NewAddModel, Init, the streaming startSource cmd, Update and View dispatchers, and the state-machine helpers. Pure move; no behaviour change. --- internal/add/browse.go | 463 ++++++++++++++ internal/add/clone.go | 177 ++++++ internal/add/edit.go | 250 ++++++++ internal/add/format.go | 169 ++++++ internal/add/gather.go | 88 +++ internal/add/manual.go | 53 ++ internal/add/msg.go | 46 ++ internal/add/styles.go | 70 +++ internal/add/tui.go | 1299 ---------------------------------------- 9 files changed, 1316 insertions(+), 1299 deletions(-) create mode 100644 internal/add/browse.go create mode 100644 internal/add/clone.go create mode 100644 internal/add/edit.go create mode 100644 internal/add/format.go create mode 100644 internal/add/gather.go create mode 100644 internal/add/manual.go create mode 100644 internal/add/msg.go create mode 100644 internal/add/styles.go 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")) -) From 8227823d44c3cd2b218c3547cf7d08938ca88ee3 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:46:09 +0300 Subject: [PATCH 12/21] refactor(agent): split tui.go by state and concern Pull each piece of the agent TUI into its own file: - list.go owns updateList (the cursor's main dispatch loop). - whichkey.go owns the action panel: actions table, updateWhichKey, toggleAgentView, toggleFavoriteFor, whichKeyTitle, viewWhichKey. - forms.go owns the worktree-create and prompt-input forms. - flash.go owns the flash-search state machine and label assignment. - items.go owns rebuildItems and the list-construction helpers (clampCursor, addProjectItem, the section append*). - render.go owns View dispatch, viewList, and every per-kind renderer (group/project/header-project/worktree/session/section) plus the rendering helpers (truncateStr, renderSelected, padRight). - styles.go owns the lipgloss palette. tui.go retains the Model, listItem, LaunchRequest, NewModel, Init, Update, View dispatcher, and the cursor/scroll/breadcrumb helpers that every other file calls into. Pure move; no behaviour change. --- internal/agent/flash.go | 182 ++++ internal/agent/forms.go | 181 ++++ internal/agent/items.go | 179 ++++ internal/agent/list.go | 232 +++++ internal/agent/render.go | 417 +++++++++ internal/agent/styles.go | 122 +++ internal/agent/tui.go | 1679 +----------------------------------- internal/agent/whichkey.go | 324 +++++++ 8 files changed, 1676 insertions(+), 1640 deletions(-) create mode 100644 internal/agent/flash.go create mode 100644 internal/agent/forms.go create mode 100644 internal/agent/items.go create mode 100644 internal/agent/list.go create mode 100644 internal/agent/render.go create mode 100644 internal/agent/styles.go create mode 100644 internal/agent/whichkey.go diff --git a/internal/agent/flash.go b/internal/agent/flash.go new file mode 100644 index 0000000..8436d38 --- /dev/null +++ b/internal/agent/flash.go @@ -0,0 +1,182 @@ +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 { + if !item.isSelectable() { + continue + } + 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/items.go b/internal/agent/items.go new file mode 100644 index 0000000..24f0205 --- /dev/null +++ b/internal/agent/items.go @@ -0,0 +1,179 @@ +package agent + +import "github.com/kuchmenko/workspace/internal/config" + +// rebuildItems flattens the workspace tree into a visible list, +// respecting group expansion state and the active agent view. +// +// Layout depends on m.agentView: +// +// - "favorites": only the Favorites header section is shown. +// Empty favorites produce a hint row pointing the user at the +// `f` hotkey. The full workspace tree is intentionally hidden. +// +// - "all" (default): if there are any favorites or any recent +// non-favorite activity, emit a Favorites section then a +// Recent section then a `-- all workspaces --` divider above +// the regular tree. With no activity at all, the header is +// skipped entirely and the user sees just the tree. +func (m *Model) rebuildItems() { + m.items = nil + + favs, recent := headerSections(allProjects(m.workspaces)) + + if m.agentView == config.AgentViewFavorites { + m.appendSectionTitle("Favorites") + if len(favs) == 0 { + m.appendSectionHint("(no favorites yet — press f on a project)") + } else { + for i := range favs { + m.appendHeaderProject(&favs[i]) + } + } + m.clampCursor() + return + } + + headerShown := false + if len(favs) > 0 { + m.appendSectionTitle("Favorites") + for i := range favs { + m.appendHeaderProject(&favs[i]) + } + headerShown = true + } + if len(recent) > 0 { + m.appendSectionTitle("Recent") + for i := range recent { + m.appendHeaderProject(&recent[i]) + } + headerShown = true + } + if headerShown { + m.appendSectionDivider("-- all 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() +} + +// appendSectionTitle pushes a non-selectable header row carrying the +// label rendered above each shortcut section ("Favorites", "Recent"). +func (m *Model) appendSectionTitle(title string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: title}) +} + +// appendSectionHint pushes a non-selectable hint row used inside an +// empty Favorites view to point the user at the `f` hotkey. Visually +// distinct from a title via the leading "(" — the renderer uses the +// same style block for both. +func (m *Model) appendSectionHint(text string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) +} + +// appendSectionDivider pushes a non-selectable divider line drawn +// between the shortcut header and the full tree. +func (m *Model) appendSectionDivider(text string) { + m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) +} + +// appendHeaderProject emits a project row inside the Favorites/Recent +// shortcut section. The row is fully selectable and launches just +// like a tree-row project on Enter, but inHeader=true suppresses the +// worktree/session expansion children — these are quick-nav rows, +// not a place for nested navigation. +func (m *Model) appendHeaderProject(p *Project) { + m.items = append(m.items, listItem{ + kind: KindProject, + project: p, + indent: 0, + path: p.Path, + inHeader: true, + }) +} + +// clampCursor keeps m.cursor inside the items range and pulls it off +// any KindSection rows it might have landed on after a rebuild. When +// the cursor is on a non-selectable row, prefer moving downward first +// (the natural reading direction) and only fall back to upward if +// nothing selectable lies below. +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 + } + if m.items[m.cursor].isSelectable() { + return + } + if next := m.nextSelectable(m.cursor, +1); next != m.cursor && m.items[next].isSelectable() { + m.cursor = next + return + } + if next := m.nextSelectable(m.cursor, -1); next != m.cursor && m.items[next].isSelectable() { + m.cursor = next + } +} + +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/list.go b/internal/agent/list.go new file mode 100644 index 0000000..f8555f6 --- /dev/null +++ b/internal/agent/list.go @@ -0,0 +1,232 @@ +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() + + switch msg.String() { + case "q": + return m, tea.Quit + case "j", "down": + if next := m.nextSelectable(m.cursor, +1); next != m.cursor { + m.cursor = next + m.ensureVisible() + } + case "k", "up": + if next := m.nextSelectable(m.cursor, -1); next != m.cursor { + m.cursor = next + 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 project (works in both header + // and tree variants). 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) + } + + 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/render.go b/internal/agent/render.go new file mode 100644 index 0000000..eacb601 --- /dev/null +++ b/internal/agent/render.go @@ -0,0 +1,417 @@ +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) + case KindSection: + line = m.renderSection(item, listW, dimAll) + } + + rows = append(rows, line) + } + return rows +} + +// itemGroupKey returns a key that identifies the visual group boundary +// for inserting blank lines between groups. KindSection rows return +// their own key per title so each section visually owns its block +// (Favorites != Recent != divider); the rows beneath them inherit the +// tree's normal keys because the shortcut projects are inHeader=true +// without any Group of their own. +func (m *Model) itemGroupKey(item listItem) string { + switch item.kind { + case KindSection: + return "section:" + item.sectionTitle + case KindGroup: + return "g:" + item.group + case KindProject: + if item.inHeader { + return "header" + } + 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 "" +} + +// renderSection draws the non-selectable header rows: section titles +// ("Favorites" / "Recent"), the divider line above the full tree, and +// the empty-state hint shown inside an empty Favorites view. All four +// share one style block; the title text already disambiguates them. +func (m *Model) renderSection(item listItem, w int, dimAll bool) string { + text := item.sectionTitle + if text == "" { + return strings.Repeat(" ", w) + } + label := " " + text + if dimAll { + return dimStyle.Width(w).Render(label) + } + return sectionStyle.Width(w).Render(label) +} + +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) + + // Project rows in the Favorites/Recent shortcut section have a + // distinct shape: a leading `*` marker for favorites, no + // expansion arrow (they never expand to worktrees here), and a + // trailing age column ("2m linux"). The tree variant below is + // unchanged. + if item.inHeader { + return m.renderHeaderProject(p, selected, inFlash, isMatch, flashLabel, w, dimAll) + } + + expandMark := "" + if p.WorktreeCount > 1 || p.SessionCount > 0 { + if m.expanded["proj:"+p.ID] { + expandMark = "▾ " + } else { + expandMark = "▸ " + } + } + + 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, " · ") + + // 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) +} + +// 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 +// and shares Enter/p/l semantics with tree rows — only the visual shape +// differs. +func (m *Model) renderHeaderProject(p *Project, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { + name := p.Name + if inFlash && isMatch { + name = flashInlineLabel(name, m.flashQuery, flashLabel) + } + + star := " " + if p.Favorite { + star = "* " + } + left := fmt.Sprintf(" %s%s %s", star, iconProject, name) + + var rightParts []string + if age := humanizeAge(p.LastActiveAt); age != "" { + rightParts = append(rightParts, age) + } + if p.LastActiveMachine != "" { + rightParts = append(rightParts, p.LastActiveMachine) + } + right := strings.Join(rightParts, " ") + + line := m.padRight(left, right, w) + if dimAll || (inFlash && !isMatch) { + return dimStyle.Width(w).Render(line) + } + if selected { + return m.renderSelected(line, itemStyle, w) + } + if right == "" { + // Star + project body share the project color; favorited + // projects get a brighter star via favoriteStarStyle for visual + // scanability without using a different background. + if p.Favorite { + body := fmt.Sprintf(" %s %s", iconProject, name) + return favoriteStarStyle.Render(" * ") + itemStyle.Render(body[len(" * "):]) + strings.Repeat(" ", w-lipgloss.Width(left)) + } + return itemStyle.Width(w).Render(line) + } + padding := w - lipgloss.Width(left) - lipgloss.Width(right) - 1 + if padding < 1 { + padding = 1 + } + leftRendered := itemStyle.Render(left) + if p.Favorite { + // Overlay just the star with the brighter style. left already + // contains the star at positions 2-3 (" * "+icon+...). + leftRendered = itemStyle.Render(" ") + + favoriteStarStyle.Render("* ") + + itemStyle.Render(left[len(" * "):]) + } + return leftRendered + strings.Repeat(" ", padding) + activityAgeStyle.Render(right) + " " +} + +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 + + // 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/styles.go b/internal/agent/styles.go new file mode 100644 index 0000000..ca5ae1e --- /dev/null +++ b/internal/agent/styles.go @@ -0,0 +1,122 @@ +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")) + + // sectionStyle paints the "Favorites" / "Recent" / divider labels + // that head the quick-nav shortcuts above the workspace tree. + // Color is deliberately the same family as headerStyle so the eye + // reads it as chrome, not as a clickable row. + sectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")). // amber dim + Bold(true) + + // 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")) + + // 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 a2f5dd4..f731f14 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. @@ -25,10 +19,10 @@ const ( // 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. @@ -237,456 +231,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 next := m.nextSelectable(m.cursor, +1); next != m.cursor { - m.cursor = next - m.ensureVisible() - } - case "k", "up": - if next := m.nextSelectable(m.cursor, -1); next != m.cursor { - m.cursor = next - 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 project (works in both header - // and tree variants). 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) - } - - 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 +func (m *Model) View() string { + if m.width == 0 { + return "loading…" } - 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.mode == viewPromptInput { + return m.viewPromptInput() } - - if m.whichKeyLevel == 1 { - // Worktree sub-menu. - return []whichKeyAction{ - {"n", "new worktree"}, - {"", ""}, - {"esc", "back"}, - } + if m.mode == viewNewWorktree { + return m.viewNewWorktree() } - - switch item.kind { - case KindGroup: - return []whichKeyAction{ - {"\u23ce", "open claude"}, - {"p", "+prompt"}, - {"l", "shell"}, - {"tab", "expand"}, - {"v", m.viewToggleLabel()}, - {"", ""}, - {"esc", "close"}, - } - case KindProject: - return []whichKeyAction{ - {"\u23ce", "open claude"}, - {"p", "+prompt"}, - {"f", m.favoriteToggleLabel(item)}, - {"w", "worktree \u203a"}, - {"e", "edit"}, - {"l", "shell"}, - {"tab", "expand"}, - {"v", m.viewToggleLabel()}, - {"", ""}, - {"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{"v", m.viewToggleLabel()}) - actions = append(actions, whichKeyAction{"", ""}) - actions = append(actions, whichKeyAction{"esc", "close"}) - return actions - case KindPortal: - return []whichKeyAction{ - {"\u23ce", "resume"}, - {"p", "resume +prompt"}, - {"v", m.viewToggleLabel()}, - {"", ""}, - {"esc", "close"}, - } + if m.mode == viewEditProject { + return m.viewEditProject() } - return nil -} - -// viewToggleLabel describes the `v` chord destination: "favorites view" -// when currently in all, "all view" when currently in favorites. The -// label is the *target*, not the current state, matching how which-key -// hints describe what each key does next. -func (m *Model) viewToggleLabel() string { - if m.agentView == config.AgentViewFavorites { - return "all view" + if m.mode == viewWhichKey { + return m.viewWhichKey() } - return "favorites view" + return m.viewList() } -// 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" +func (m *Model) currentItem() *listItem { + if m.cursor >= 0 && m.cursor < len(m.items) { + return &m.items[m.cursor] } - return "favorite" + 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 +// workspaceRootFor returns the workspace root directory for a project. +// Matches by Path (globally unique) rather than ID (per-workspace key). +func (m *Model) workspaceRootFor(proj *Project) string { + for _, ws := range m.workspaces { + for _, p := range ws.Projects { + if p.Path == proj.Path { + return ws.Root } } - 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 a per-project action; only meaningful when - // the cursor is on a project row. Closes the panel either way - // so the user sees the result immediately. - m.mode = viewList - if item != nil && item.kind == KindProject && item.project != nil { - m.toggleFavoriteFor(item.project) - } - return m, nil - case "v": - // View toggle is a global action — flips all<->favorites and - // persists to workspace.toml so the choice survives restart - // and syncs to other machines via the reconciler. - m.mode = viewList - m.toggleAgentView() - return m, nil - case "tab": - m.mode = viewList - return m.updateList(msg) - } - return m, nil -} - -// toggleAgentView flips between "all" and "favorites", rebuilds the -// item list, and persists the new view to workspace.toml so future -// `ws agent` invocations open in the same mode and other machines -// inherit the preference on the next reconciler tick. -func (m *Model) toggleAgentView() { - if m.agentView == config.AgentViewFavorites { - m.agentView = config.AgentViewAll - } else { - m.agentView = config.AgentViewFavorites } - m.rebuildItems() - m.cursor = m.firstSelectableIndex() - m.ensureVisible() - m.statusMsg = "view: " + m.agentView - - root := m.primaryWorkspaceRoot() - if root == "" { - return - } - target := m.agentView - err := MutateAndSave(root, func(ws *config.Workspace) bool { - return ws.SetAgentDefaultView(target) - }) - if err != nil { - m.statusMsg = "view saved locally only: " + err.Error() - } -} - -// 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() + return "" } // primaryWorkspaceRoot returns the root used for workspace-wide @@ -702,53 +283,6 @@ func (m *Model) primaryWorkspaceRoot() string { return m.workspaces[0].Root } -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] + "\u2026" - } - return t - } - } - return "actions" -} - -func (m *Model) currentItem() *listItem { - if m.cursor >= 0 && m.cursor < len(m.items) { - return &m.items[m.cursor] - } - return nil -} - -// workspaceRootFor returns the workspace root directory for a project. -// Matches by Path (globally unique) rather than ID (per-workspace key). -func (m *Model) workspaceRootFor(proj *Project) string { - for _, ws := range m.workspaces { - for _, p := range ws.Projects { - if p.Path == proj.Path { - return ws.Root - } - } - } - return "" -} - func (m *Model) toggleExpand(key string) { m.expanded[key] = !m.expanded[key] m.rebuildItems() @@ -775,300 +309,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. 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 { - if !item.isSelectable() { - continue - } - 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() @@ -1096,26 +336,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 } @@ -1128,16 +368,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 } @@ -1145,844 +385,3 @@ func (m *Model) breadcrumb() string { } return "ws" } - -// rebuildItems flattens the workspace tree into a visible list, -// respecting group expansion state and the active agent view. -// -// Layout depends on m.agentView: -// -// - "favorites": only the Favorites header section is shown. -// Empty favorites produce a hint row pointing the user at the -// `f` hotkey. The full workspace tree is intentionally hidden. -// -// - "all" (default): if there are any favorites or any recent -// non-favorite activity, emit a Favorites section then a -// Recent section then a `-- all workspaces --` divider above -// the regular tree. With no activity at all, the header is -// skipped entirely and the user sees just the tree. -func (m *Model) rebuildItems() { - m.items = nil - - favs, recent := headerSections(allProjects(m.workspaces)) - - if m.agentView == config.AgentViewFavorites { - m.appendSectionTitle("Favorites") - if len(favs) == 0 { - m.appendSectionHint("(no favorites yet — press f on a project)") - } else { - for i := range favs { - m.appendHeaderProject(&favs[i]) - } - } - m.clampCursor() - return - } - - headerShown := false - if len(favs) > 0 { - m.appendSectionTitle("Favorites") - for i := range favs { - m.appendHeaderProject(&favs[i]) - } - headerShown = true - } - if len(recent) > 0 { - m.appendSectionTitle("Recent") - for i := range recent { - m.appendHeaderProject(&recent[i]) - } - headerShown = true - } - if headerShown { - m.appendSectionDivider("-- all 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() -} - -// appendSectionTitle pushes a non-selectable header row carrying the -// label rendered above each shortcut section ("Favorites", "Recent"). -func (m *Model) appendSectionTitle(title string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: title}) -} - -// appendSectionHint pushes a non-selectable hint row used inside an -// empty Favorites view to point the user at the `f` hotkey. Visually -// distinct from a title via the leading "(" — the renderer uses the -// same style block for both. -func (m *Model) appendSectionHint(text string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) -} - -// appendSectionDivider pushes a non-selectable divider line drawn -// between the shortcut header and the full tree. -func (m *Model) appendSectionDivider(text string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) -} - -// appendHeaderProject emits a project row inside the Favorites/Recent -// shortcut section. The row is fully selectable and launches just -// like a tree-row project on Enter, but inHeader=true suppresses the -// worktree/session expansion children — these are quick-nav rows, -// not a place for nested navigation. -func (m *Model) appendHeaderProject(p *Project) { - m.items = append(m.items, listItem{ - kind: KindProject, - project: p, - indent: 0, - path: p.Path, - inHeader: true, - }) -} - -// clampCursor keeps m.cursor inside the items range and pulls it off -// any KindSection rows it might have landed on after a rebuild. When -// the cursor is on a non-selectable row, prefer moving downward first -// (the natural reading direction) and only fall back to upward if -// nothing selectable lies below. -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 - } - if m.items[m.cursor].isSelectable() { - return - } - if next := m.nextSelectable(m.cursor, +1); next != m.cursor && m.items[next].isSelectable() { - m.cursor = next - return - } - if next := m.nextSelectable(m.cursor, -1); next != m.cursor && m.items[next].isSelectable() { - m.cursor = next - } -} - -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) - case KindSection: - line = m.renderSection(item, listW, dimAll) - } - - rows = append(rows, line) - } - return rows -} - -// itemGroupKey returns a key that identifies the visual group boundary -// for inserting blank lines between groups. KindSection rows return -// their own key per title so each section visually owns its block -// (Favorites != Recent != divider); the rows beneath them inherit the -// tree's normal keys because the shortcut projects are inHeader=true -// without any Group of their own. -func (m *Model) itemGroupKey(item listItem) string { - switch item.kind { - case KindSection: - return "section:" + item.sectionTitle - case KindGroup: - return "g:" + item.group - case KindProject: - if item.inHeader { - return "header" - } - 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 "" -} - -// renderSection draws the non-selectable header rows: section titles -// ("Favorites" / "Recent"), the divider line above the full tree, and -// the empty-state hint shown inside an empty Favorites view. All four -// share one style block; the title text already disambiguates them. -func (m *Model) renderSection(item listItem, w int, dimAll bool) string { - text := item.sectionTitle - if text == "" { - return strings.Repeat(" ", w) - } - label := " " + text - if dimAll { - return dimStyle.Width(w).Render(label) - } - return sectionStyle.Width(w).Render(label) -} - -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) - - // Project rows in the Favorites/Recent shortcut section have a - // distinct shape: a leading `*` marker for favorites, no - // expansion arrow (they never expand to worktrees here), and a - // trailing age column ("2m linux"). The tree variant below is - // unchanged. - if item.inHeader { - return m.renderHeaderProject(p, selected, inFlash, isMatch, flashLabel, w, dimAll) - } - - 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) -} - -// 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 -// and shares Enter/p/l semantics with tree rows — only the visual shape -// differs. -func (m *Model) renderHeaderProject(p *Project, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { - name := p.Name - if inFlash && isMatch { - name = flashInlineLabel(name, m.flashQuery, flashLabel) - } - - star := " " - if p.Favorite { - star = "* " - } - left := fmt.Sprintf(" %s%s %s", star, iconProject, name) - - var rightParts []string - if age := humanizeAge(p.LastActiveAt); age != "" { - rightParts = append(rightParts, age) - } - if p.LastActiveMachine != "" { - rightParts = append(rightParts, p.LastActiveMachine) - } - right := strings.Join(rightParts, " ") - - line := m.padRight(left, right, w) - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(line) - } - if selected { - return m.renderSelected(line, itemStyle, w) - } - if right == "" { - // Star + project body share the project color; favorited - // projects get a brighter star via favoriteStarStyle for visual - // scanability without using a different background. - if p.Favorite { - body := fmt.Sprintf(" %s %s", iconProject, name) - return favoriteStarStyle.Render(" * ") + itemStyle.Render(body[len(" * "):]) + strings.Repeat(" ", w-lipgloss.Width(left)) - } - return itemStyle.Width(w).Render(line) - } - padding := w - lipgloss.Width(left) - lipgloss.Width(right) - 1 - if padding < 1 { - padding = 1 - } - leftRendered := itemStyle.Render(left) - if p.Favorite { - // Overlay just the star with the brighter style. left already - // contains the star at positions 2-3 (" * "+icon+...). - leftRendered = itemStyle.Render(" ") + - favoriteStarStyle.Render("* ") + - itemStyle.Render(left[len(" * "):]) - } - return leftRendered + strings.Repeat(" ", padding) + activityAgeStyle.Render(right) + " " -} - -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")) - - // sectionStyle paints the "Favorites" / "Recent" / divider labels - // that head the quick-nav shortcuts above the workspace tree. - // Color is deliberately the same family as headerStyle so the eye - // reads it as chrome, not as a clickable row. - sectionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")). // amber dim - Bold(true) - - // 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")) - - // 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/whichkey.go b/internal/agent/whichkey.go new file mode 100644 index 0000000..b19f45c --- /dev/null +++ b/internal/agent/whichkey.go @@ -0,0 +1,324 @@ +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"}, + {"l", "shell"}, + {"tab", "expand"}, + {"v", m.viewToggleLabel()}, + {"", ""}, + {"esc", "close"}, + } + case KindProject: + return []whichKeyAction{ + {"⏎", "open claude"}, + {"p", "+prompt"}, + {"f", m.favoriteToggleLabel(item)}, + {"w", "worktree ›"}, + {"e", "edit"}, + {"l", "shell"}, + {"tab", "expand"}, + {"v", m.viewToggleLabel()}, + {"", ""}, + {"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{"v", m.viewToggleLabel()}) + actions = append(actions, whichKeyAction{"", ""}) + actions = append(actions, whichKeyAction{"esc", "close"}) + return actions + case KindPortal: + return []whichKeyAction{ + {"⏎", "resume"}, + {"p", "resume +prompt"}, + {"v", m.viewToggleLabel()}, + {"", ""}, + {"esc", "close"}, + } + } + return nil +} + +// viewToggleLabel describes the `v` chord destination: "favorites view" +// when currently in all, "all view" when currently in favorites. The +// label is the *target*, not the current state, matching how which-key +// hints describe what each key does next. +func (m *Model) viewToggleLabel() string { + if m.agentView == config.AgentViewFavorites { + return "all view" + } + return "favorites view" +} + +// 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" +} + +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 a per-project action; only meaningful when + // the cursor is on a project row. Closes the panel either way + // so the user sees the result immediately. + m.mode = viewList + if item != nil && item.kind == KindProject && item.project != nil { + m.toggleFavoriteFor(item.project) + } + return m, nil + case "v": + // View toggle is a global action — flips all<->favorites and + // persists to workspace.toml so the choice survives restart + // and syncs to other machines via the reconciler. + m.mode = viewList + m.toggleAgentView() + return m, nil + case "tab": + m.mode = viewList + return m.updateList(msg) + } + return m, nil +} + +// toggleAgentView flips between "all" and "favorites", rebuilds the +// item list, and persists the new view to workspace.toml so future +// `ws agent` invocations open in the same mode and other machines +// inherit the preference on the next reconciler tick. +func (m *Model) toggleAgentView() { + if m.agentView == config.AgentViewFavorites { + m.agentView = config.AgentViewAll + } else { + m.agentView = config.AgentViewFavorites + } + m.rebuildItems() + m.cursor = m.firstSelectableIndex() + m.ensureVisible() + m.statusMsg = "view: " + m.agentView + + root := m.primaryWorkspaceRoot() + if root == "" { + return + } + target := m.agentView + err := MutateAndSave(root, func(ws *config.Workspace) bool { + return ws.SetAgentDefaultView(target) + }) + if err != nil { + m.statusMsg = "view saved locally only: " + err.Error() + } +} + +// 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, + ) +} From 0f1accb3ebf730e3de38d31db105c03e31459c9f Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 10:54:33 +0300 Subject: [PATCH 13/21] docs(agents): add cyclomatic complexity rule (10 soft / 15 hard) Production .go functions over gocyclo 15 are split now; functions over 10 are split on the next touch. Mirrors the file-length rule's hard/soft split. gocyclo invocation included so reviewers can run the gate locally. --- AGENTS.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e20ff51..3d65b59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -709,7 +709,25 @@ file or package. Extract instead. Not this rule: godoc headings on exported symbols, `// Package foo` comments, and license headers at file top. -### 3. Comments are a last resort +### 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 From 077cccec9e234557fd04696caeacd0084e729450 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 11:11:54 +0300 Subject: [PATCH 14/21] feat(agent): pin compact quick-nav chips above the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the in-list Favorites/Recent section rows and render up to nine numbered chips on one or two pinned lines above the scrollable tree. The chip header stays visible while the tree scrolls under it, fixing the short-Hyprland-tile case where favorites disappeared off the top. Also drops the all/favorites view toggle and the [agent].default_view preference plumbing — the pinned header makes the toggle redundant. KindSection, inHeader, sectionTitle, view-toggle code, isSelectable and nextSelectable are removed; every row in m.items is now a real selectable item. Tree rows now indent four spaces per level instead of two for clearer parent/child relationship at a glance. --- internal/agent/flash.go | 3 - internal/agent/header.go | 157 +++++++++++++++++++++++++++++----- internal/agent/header_test.go | 47 +++------- internal/agent/items.go | 110 +++--------------------- internal/agent/list.go | 8 +- internal/agent/render.go | 110 ++++-------------------- internal/agent/styles.go | 11 +++ internal/agent/tui.go | 95 ++++++-------------- internal/agent/whichkey.go | 50 ----------- internal/cli/agent.go | 19 +--- 10 files changed, 213 insertions(+), 397 deletions(-) diff --git a/internal/agent/flash.go b/internal/agent/flash.go index 8436d38..284d342 100644 --- a/internal/agent/flash.go +++ b/internal/agent/flash.go @@ -77,9 +77,6 @@ func (m *Model) recomputeFlash() { // 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 { - if !item.isSelectable() { - continue - } name := m.itemSearchName(item) if query == "" || strings.Contains(strings.ToLower(name), query) { m.flashMatches = append(m.flashMatches, i) diff --git a/internal/agent/header.go b/internal/agent/header.go index 090124f..794f57f 100644 --- a/internal/agent/header.go +++ b/internal/agent/header.go @@ -1,30 +1,28 @@ package agent import ( + "fmt" "sort" + "strings" "time" + + "github.com/charmbracelet/lipgloss" ) -// HeaderCap is the maximum number of project rows shown in either the -// Favorites or Recent header section. Five per section, total ten — -// chosen as the largest count that fits comfortably above the tree on -// a 24-row terminal without pushing all real projects below the fold. -const HeaderCap = 5 +// 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 -// headerSections returns the two project lists rendered in the -// Favorites/Recent shortcut header above the workspace tree: -// -// - favs: projects with Favorite=true, sorted by LastActiveAt -// desc, name asc for ties. Zero-activity favorites sort last -// but are still included (the user explicitly pinned them). -// Capped at HeaderCap. -// - recent: non-favorite projects with LastActiveAt > zero, -// sorted the same way. Capped at HeaderCap. Projects that -// have never been stamped never appear here. -// -// Returns two distinct slices — no project ever appears in both; -// favorites take precedence over recent. -func headerSections(projects []Project) (favs, recent []Project) { +// headerProjects returns the single ordered list of projects rendered +// in the pinned quick-nav chip header. Favorites come first (always +// visible regardless of activity), then non-favorite recently-touched +// projects. The result is capped at HeaderCap so the chips fit in the +// 1-9 number-key hotkey range. Returns nil when nothing qualifies — +// the caller skips header rendering entirely in that case. +func headerProjects(projects []Project) []Project { + var favs, recent []Project for _, p := range projects { if p.Favorite { favs = append(favs, p) @@ -34,9 +32,8 @@ func headerSections(projects []Project) (favs, recent []Project) { } sortByActivity(favs) sortByActivity(recent) - favs = capProjects(favs, HeaderCap) - recent = capProjects(recent, HeaderCap) - return favs, recent + merged := append(favs, recent...) + return capProjects(merged, HeaderCap) } func sortByActivity(ps []Project) { @@ -101,6 +98,122 @@ func humanizeAgeAt(t, now time.Time) string { } } +// renderHeaderChips formats `projects` as numbered chips packed into +// at most `maxLines` lines of width `w`. Each chip is rendered as +// `1.name 2m` with a leading `*` for favorites. Chips wrap to a new +// line when the next chip would overflow `w`; chips that would not +// fit in `maxLines` are dropped (HeaderCap=9 already keeps the count +// small enough that this is rare). +// +// Returns nil when projects is empty — callers omit the header rows +// entirely so an idle workspace doesn't burn vertical space on chrome. +func renderHeaderChips(projects []Project, w, maxLines int) []string { + if len(projects) == 0 || w <= 0 || maxLines <= 0 { + return nil + } + chips := make([]string, len(projects)) + for i, p := range projects { + chips[i] = formatChip(i+1, p) + } + return packChips(chips, w, maxLines) +} + +// formatChip builds the "1.name 2m" string for one header project, +// prefixed with `*` when the project is favorited. The age is omitted +// when LastActiveAt is zero (favorited but never stamped). +func formatChip(num int, p Project) string { + star := "" + if p.Favorite { + star = "*" + } + age := humanizeAge(p.LastActiveAt) + if age == "" { + return fmt.Sprintf("%s%d.%s", star, num, p.Name) + } + return fmt.Sprintf("%s%d.%s %s", star, num, p.Name, 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 { diff --git a/internal/agent/header_test.go b/internal/agent/header_test.go index 95dda6f..437a796 100644 --- a/internal/agent/header_test.go +++ b/internal/agent/header_test.go @@ -6,7 +6,7 @@ import ( "time" ) -func TestHeaderSections_FavoritesBeforeRecent_FavoritesExcludedFromRecent(t *testing.T) { +func TestHeaderProjects_FavoritesFirstThenRecent(t *testing.T) { now := time.Now().UTC() projects := []Project{ {Name: "fav-old", Favorite: true, LastActiveAt: now.Add(-48 * time.Hour)}, @@ -16,30 +16,14 @@ func TestHeaderSections_FavoritesBeforeRecent_FavoritesExcludedFromRecent(t *tes {Name: "recent-old", Favorite: false, LastActiveAt: now.Add(-3 * time.Hour)}, } - favs, recent := headerSections(projects) - - gotFav := names(favs) - wantFav := []string{"fav-new", "fav-old"} - if !reflect.DeepEqual(gotFav, wantFav) { - t.Errorf("favorites order: got %v, want %v (descending by activity)", gotFav, wantFav) - } - - gotRecent := names(recent) - wantRecent := []string{"recent-new", "recent-old"} - if !reflect.DeepEqual(gotRecent, wantRecent) { - t.Errorf("recent order: got %v, want %v", gotRecent, wantRecent) - } - - // Stale (zero LastActiveAt) must never show up in Recent — and never - // in Favorites either, because it isn't a favorite. - for _, p := range append(favs, recent...) { - if p.Name == "stale" { - t.Errorf("zero-activity non-favorite %q leaked into header", p.Name) - } + got := names(headerProjects(projects)) + 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 TestHeaderSections_CappedAtFive(t *testing.T) { +func TestHeaderProjects_CappedAtNine(t *testing.T) { now := time.Now().UTC() var projects []Project for i := 0; i < 8; i++ { @@ -55,38 +39,33 @@ func TestHeaderSections_CappedAtFive(t *testing.T) { }) } - favs, recent := headerSections(projects) - if len(favs) != HeaderCap { - t.Errorf("favorites should be capped at %d, got %d", HeaderCap, len(favs)) - } - if len(recent) != HeaderCap { - t.Errorf("recent should be capped at %d, got %d", HeaderCap, len(recent)) + got := headerProjects(projects) + if len(got) != HeaderCap { + t.Errorf("expected cap of %d, got %d", HeaderCap, len(got)) } } -func TestHeaderSections_TiesByName(t *testing.T) { +func TestHeaderProjects_TiesByName(t *testing.T) { t0 := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC) projects := []Project{ {Name: "z-app", Favorite: false, LastActiveAt: t0}, {Name: "a-app", Favorite: false, LastActiveAt: t0}, {Name: "m-app", Favorite: false, LastActiveAt: t0}, } - _, recent := headerSections(projects) - got := names(recent) + got := names(headerProjects(projects)) 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 TestHeaderSections_FavoritesIncludeZeroActivity(t *testing.T) { +func TestHeaderProjects_FavoritesIncludeZeroActivity(t *testing.T) { now := time.Now().UTC() projects := []Project{ {Name: "fresh-fav", Favorite: true, LastActiveAt: time.Time{}}, {Name: "old-fav", Favorite: true, LastActiveAt: now.Add(-1 * time.Hour)}, } - favs, _ := headerSections(projects) - got := names(favs) + got := names(headerProjects(projects)) // Order: activity desc; zero comes last because nothing is greater // than zero in time.After semantics. want := []string{"old-fav", "fresh-fav"} diff --git a/internal/agent/items.go b/internal/agent/items.go index 24f0205..e2e2f29 100644 --- a/internal/agent/items.go +++ b/internal/agent/items.go @@ -1,57 +1,13 @@ package agent -import "github.com/kuchmenko/workspace/internal/config" - -// rebuildItems flattens the workspace tree into a visible list, -// respecting group expansion state and the active agent view. -// -// Layout depends on m.agentView: -// -// - "favorites": only the Favorites header section is shown. -// Empty favorites produce a hint row pointing the user at the -// `f` hotkey. The full workspace tree is intentionally hidden. -// -// - "all" (default): if there are any favorites or any recent -// non-favorite activity, emit a Favorites section then a -// Recent section then a `-- all workspaces --` divider above -// the regular tree. With no activity at all, the header is -// skipped entirely and the user sees just the tree. +// 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 - - favs, recent := headerSections(allProjects(m.workspaces)) - - if m.agentView == config.AgentViewFavorites { - m.appendSectionTitle("Favorites") - if len(favs) == 0 { - m.appendSectionHint("(no favorites yet — press f on a project)") - } else { - for i := range favs { - m.appendHeaderProject(&favs[i]) - } - } - m.clampCursor() - return - } - - headerShown := false - if len(favs) > 0 { - m.appendSectionTitle("Favorites") - for i := range favs { - m.appendHeaderProject(&favs[i]) - } - headerShown = true - } - if len(recent) > 0 { - m.appendSectionTitle("Recent") - for i := range recent { - m.appendHeaderProject(&recent[i]) - } - headerShown = true - } - if headerShown { - m.appendSectionDivider("-- all workspaces --") - } + m.headerProjects = headerProjects(allProjects(m.workspaces)) for _, ws := range m.workspaces { // Ungrouped projects first. @@ -77,46 +33,10 @@ func (m *Model) rebuildItems() { m.clampCursor() } -// appendSectionTitle pushes a non-selectable header row carrying the -// label rendered above each shortcut section ("Favorites", "Recent"). -func (m *Model) appendSectionTitle(title string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: title}) -} - -// appendSectionHint pushes a non-selectable hint row used inside an -// empty Favorites view to point the user at the `f` hotkey. Visually -// distinct from a title via the leading "(" — the renderer uses the -// same style block for both. -func (m *Model) appendSectionHint(text string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) -} - -// appendSectionDivider pushes a non-selectable divider line drawn -// between the shortcut header and the full tree. -func (m *Model) appendSectionDivider(text string) { - m.items = append(m.items, listItem{kind: KindSection, sectionTitle: text}) -} - -// appendHeaderProject emits a project row inside the Favorites/Recent -// shortcut section. The row is fully selectable and launches just -// like a tree-row project on Enter, but inHeader=true suppresses the -// worktree/session expansion children — these are quick-nav rows, -// not a place for nested navigation. -func (m *Model) appendHeaderProject(p *Project) { - m.items = append(m.items, listItem{ - kind: KindProject, - project: p, - indent: 0, - path: p.Path, - inHeader: true, - }) -} - -// clampCursor keeps m.cursor inside the items range and pulls it off -// any KindSection rows it might have landed on after a rebuild. When -// the cursor is on a non-selectable row, prefer moving downward first -// (the natural reading direction) and only fall back to upward if -// nothing selectable lies below. +// 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 @@ -128,16 +48,6 @@ func (m *Model) clampCursor() { if m.cursor < 0 { m.cursor = 0 } - if m.items[m.cursor].isSelectable() { - return - } - if next := m.nextSelectable(m.cursor, +1); next != m.cursor && m.items[next].isSelectable() { - m.cursor = next - return - } - if next := m.nextSelectable(m.cursor, -1); next != m.cursor && m.items[next].isSelectable() { - m.cursor = next - } } func (m *Model) addProjectItem(p *Project, indent int) { diff --git a/internal/agent/list.go b/internal/agent/list.go index f8555f6..44e3636 100644 --- a/internal/agent/list.go +++ b/internal/agent/list.go @@ -47,13 +47,13 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "j", "down": - if next := m.nextSelectable(m.cursor, +1); next != m.cursor { - m.cursor = next + if m.cursor+1 < len(m.items) { + m.cursor++ m.ensureVisible() } case "k", "up": - if next := m.nextSelectable(m.cursor, -1); next != m.cursor { - m.cursor = next + if m.cursor > 0 { + m.cursor-- m.ensureVisible() } diff --git a/internal/agent/render.go b/internal/agent/render.go index eacb601..b1cee9f 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -55,8 +55,6 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { line = m.renderWorktree(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) case KindPortal: line = m.renderSession(item, selected, listW, dimAll, inFlash, isMatch, flashLabel) - case KindSection: - line = m.renderSection(item, listW, dimAll) } rows = append(rows, line) @@ -65,21 +63,12 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { } // itemGroupKey returns a key that identifies the visual group boundary -// for inserting blank lines between groups. KindSection rows return -// their own key per title so each section visually owns its block -// (Favorites != Recent != divider); the rows beneath them inherit the -// tree's normal keys because the shortcut projects are inHeader=true -// without any Group of their own. +// for inserting blank lines between groups. func (m *Model) itemGroupKey(item listItem) string { switch item.kind { - case KindSection: - return "section:" + item.sectionTitle case KindGroup: return "g:" + item.group case KindProject: - if item.inHeader { - return "header" - } if item.project.Group != "" { return "g:" + item.project.Group } @@ -93,22 +82,6 @@ func (m *Model) itemGroupKey(item listItem) string { return "" } -// renderSection draws the non-selectable header rows: section titles -// ("Favorites" / "Recent"), the divider line above the full tree, and -// the empty-state hint shown inside an empty Favorites view. All four -// share one style block; the title text already disambiguates them. -func (m *Model) renderSection(item listItem, w int, dimAll bool) string { - text := item.sectionTitle - if text == "" { - return strings.Repeat(" ", w) - } - label := " " + text - if dimAll { - return dimStyle.Width(w).Render(label) - } - return sectionStyle.Width(w).Render(label) -} - func (m *Model) renderGroup(item listItem, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { arrow := "▸" if m.expanded[item.group] { @@ -131,16 +104,7 @@ func (m *Model) renderGroup(item listItem, selected, inFlash, isMatch bool, flas 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) - - // Project rows in the Favorites/Recent shortcut section have a - // distinct shape: a leading `*` marker for favorites, no - // expansion arrow (they never expand to worktrees here), and a - // trailing age column ("2m linux"). The tree variant below is - // unchanged. - if item.inHeader { - return m.renderHeaderProject(p, selected, inFlash, isMatch, flashLabel, w, dimAll) - } + indent := strings.Repeat(" ", item.indent) expandMark := "" if p.WorktreeCount > 1 || p.SessionCount > 0 { @@ -193,63 +157,8 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl // 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 -// and shares Enter/p/l semantics with tree rows — only the visual shape -// differs. -func (m *Model) renderHeaderProject(p *Project, selected, inFlash, isMatch bool, flashLabel rune, w int, dimAll bool) string { - name := p.Name - if inFlash && isMatch { - name = flashInlineLabel(name, m.flashQuery, flashLabel) - } - - star := " " - if p.Favorite { - star = "* " - } - left := fmt.Sprintf(" %s%s %s", star, iconProject, name) - - var rightParts []string - if age := humanizeAge(p.LastActiveAt); age != "" { - rightParts = append(rightParts, age) - } - if p.LastActiveMachine != "" { - rightParts = append(rightParts, p.LastActiveMachine) - } - right := strings.Join(rightParts, " ") - - line := m.padRight(left, right, w) - if dimAll || (inFlash && !isMatch) { - return dimStyle.Width(w).Render(line) - } - if selected { - return m.renderSelected(line, itemStyle, w) - } - if right == "" { - // Star + project body share the project color; favorited - // projects get a brighter star via favoriteStarStyle for visual - // scanability without using a different background. - if p.Favorite { - body := fmt.Sprintf(" %s %s", iconProject, name) - return favoriteStarStyle.Render(" * ") + itemStyle.Render(body[len(" * "):]) + strings.Repeat(" ", w-lipgloss.Width(left)) - } - return itemStyle.Width(w).Render(line) - } - padding := w - lipgloss.Width(left) - lipgloss.Width(right) - 1 - if padding < 1 { - padding = 1 - } - leftRendered := itemStyle.Render(left) - if p.Favorite { - // Overlay just the star with the brighter style. left already - // contains the star at positions 2-3 (" * "+icon+...). - leftRendered = itemStyle.Render(" ") + - favoriteStarStyle.Render("* ") + - itemStyle.Render(left[len(" * "):]) - } - return leftRendered + strings.Repeat(" ", padding) + activityAgeStyle.Render(right) + " " -} - func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { - indent := strings.Repeat(" ", item.indent) + indent := strings.Repeat(" ", item.indent) name := item.group // worktreeDisplayName stored in group field if name == "" { name = "worktree" @@ -305,7 +214,7 @@ func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, } func (m *Model) renderSession(item listItem, selected bool, w int, dimAll bool, inFlash bool, isMatch bool, flashLabel rune) string { - indent := strings.Repeat(" ", item.indent) + indent := strings.Repeat(" ", item.indent) title := "(session)" if item.session != nil { title = fmt.Sprintf("%s %s", TimeAgo(item.session.Updated), item.session.Title) @@ -374,6 +283,17 @@ func (m *Model) viewList() string { 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.headerProjects, listW-2, 2) + for _, l := range styleHeaderLines(chipLines) { + rows = append(rows, l) + } + if len(chipLines) > 0 { + rows = append(rows, strings.Repeat(" ", listW)) + } + // Header — breadcrumb + position. inFlash := m.mode == viewFlash if inFlash { diff --git a/internal/agent/styles.go b/internal/agent/styles.go index ca5ae1e..a97d262 100644 --- a/internal/agent/styles.go +++ b/internal/agent/styles.go @@ -67,6 +67,17 @@ var ( 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). diff --git a/internal/agent/tui.go b/internal/agent/tui.go index f731f14..89d19a8 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -25,7 +25,9 @@ const ( 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) @@ -35,22 +37,6 @@ type listItem struct { indent int path string // filesystem path for shell navigation parentProj *Project // for worktree/session: which project they belong to - - // sectionTitle is the text rendered for KindSection rows. Empty - // sectionTitle on a KindSection row renders as a blank line — - // used as the spacer between Favorites/Recent and the full tree. - sectionTitle string - // inHeader marks a project row that lives inside the Favorites or - // Recent header section rather than the full workspace tree. Such - // rows skip worktree/session expansion (they are quick-nav only). - inHeader bool -} - -// isSelectable reports whether the cursor is allowed to land on this -// row. KindSection rows are visual-only and skipped by j/k movement, -// flash-search match collection, and the initial-cursor placement. -func (it listItem) isSelectable() bool { - return it.kind != KindSection } // LaunchRequest is set when the user selects an action that should @@ -67,16 +53,16 @@ type LaunchRequest struct { type Model struct { workspaces []WorkspaceData mode viewMode - // agentView is "all" (Favorites+Recent header above full tree) or - // "favorites" (only the Favorites section, flat — no tree). Loaded - // from workspace.toml's [agent].default_view at startup. The user - // flips it via the which-key `space v` chord; the new value is - // persisted back to workspace.toml so other machines pick it up. - agentView string - items []listItem // flattened visible items - cursor int - expanded map[string]bool // group/project name → expanded - scroll int // scroll offset for long lists + 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 + + // headerProjects is the ordered list of projects rendered as + // numbered chips in the pinned quick-nav header above the tree. + // Recomputed in rebuildItems from favorites + recently-touched + // projects across all workspaces. + headerProjects []Project // Caches — loaded lazily, invalidated after mutations. sessCache *SessionCache @@ -126,23 +112,13 @@ type Model struct { // NewModel constructs the TUI model from loaded workspace data. // sessCache should be the cache returned by LoadWorkspaces (already // populated with session counts from the initial scan). -// -// initialView selects the opening view ("all" or "favorites"). The -// caller typically reads it from workspace.toml's agent.default_view -// via Workspace.AgentDefaultView(); pass the empty string to default -// to "all". -func NewModel(workspaces []WorkspaceData, sessCache *SessionCache, initialView string) *Model { +func NewModel(workspaces []WorkspaceData, sessCache *SessionCache) *Model { if sessCache == nil { sessCache = NewSessionCache() } - view := config.AgentViewAll - if initialView == config.AgentViewFavorites { - view = config.AgentViewFavorites - } m := &Model{ workspaces: workspaces, mode: viewList, - agentView: view, expanded: make(map[string]bool), sessCache: sessCache, wtCache: NewWorktreeCache(), @@ -154,41 +130,9 @@ func NewModel(workspaces []WorkspaceData, sessCache *SessionCache, initialView s } } m.rebuildItems() - m.cursor = m.firstSelectableIndex() return m } -// firstSelectableIndex returns the index of the first row the cursor -// can legally land on. Used to skip past the Favorites/Recent section -// headers at startup. Returns 0 when nothing is selectable (degenerate -// empty workspace) so the cursor still has a defined value. -func (m *Model) firstSelectableIndex() int { - for i, it := range m.items { - if it.isSelectable() { - return i - } - } - return 0 -} - -// nextSelectable steps from `from` in direction `dir` (+1 down, -1 up) -// over any KindSection rows until it lands on a selectable row. -// Returns `from` when no selectable row exists in that direction — -// callers use the no-change signal to skip the ensureVisible call. -func (m *Model) nextSelectable(from, dir int) int { - if len(m.items) == 0 { - return from - } - i := from + dir - for i >= 0 && i < len(m.items) { - if m.items[i].isSelectable() { - return i - } - i += dir - } - return from -} - func (m *Model) Init() tea.Cmd { return nil } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -325,7 +269,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.headerProjects) > 0 { + chrome += 3 + } + h := m.height - chrome if h < 3 { h = 3 } diff --git a/internal/agent/whichkey.go b/internal/agent/whichkey.go index b19f45c..b9eb734 100644 --- a/internal/agent/whichkey.go +++ b/internal/agent/whichkey.go @@ -36,7 +36,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { {"p", "+prompt"}, {"l", "shell"}, {"tab", "expand"}, - {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -49,7 +48,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { {"e", "edit"}, {"l", "shell"}, {"tab", "expand"}, - {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -62,7 +60,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { if item.worktree != nil && !item.worktree.IsMain { actions = append(actions, whichKeyAction{"d", "delete"}) } - actions = append(actions, whichKeyAction{"v", m.viewToggleLabel()}) actions = append(actions, whichKeyAction{"", ""}) actions = append(actions, whichKeyAction{"esc", "close"}) return actions @@ -70,7 +67,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { return []whichKeyAction{ {"⏎", "resume"}, {"p", "resume +prompt"}, - {"v", m.viewToggleLabel()}, {"", ""}, {"esc", "close"}, } @@ -78,17 +74,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { return nil } -// viewToggleLabel describes the `v` chord destination: "favorites view" -// when currently in all, "all view" when currently in favorites. The -// label is the *target*, not the current state, matching how which-key -// hints describe what each key does next. -func (m *Model) viewToggleLabel() string { - if m.agentView == config.AgentViewFavorites { - return "all view" - } - return "favorites view" -} - // 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 { @@ -159,13 +144,6 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.toggleFavoriteFor(item.project) } return m, nil - case "v": - // View toggle is a global action — flips all<->favorites and - // persists to workspace.toml so the choice survives restart - // and syncs to other machines via the reconciler. - m.mode = viewList - m.toggleAgentView() - return m, nil case "tab": m.mode = viewList return m.updateList(msg) @@ -173,34 +151,6 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// toggleAgentView flips between "all" and "favorites", rebuilds the -// item list, and persists the new view to workspace.toml so future -// `ws agent` invocations open in the same mode and other machines -// inherit the preference on the next reconciler tick. -func (m *Model) toggleAgentView() { - if m.agentView == config.AgentViewFavorites { - m.agentView = config.AgentViewAll - } else { - m.agentView = config.AgentViewFavorites - } - m.rebuildItems() - m.cursor = m.firstSelectableIndex() - m.ensureVisible() - m.statusMsg = "view: " + m.agentView - - root := m.primaryWorkspaceRoot() - if root == "" { - return - } - target := m.agentView - err := MutateAndSave(root, func(ws *config.Workspace) bool { - return ws.SetAgentDefaultView(target) - }) - if err != nil { - m.statusMsg = "view saved locally only: " + err.Error() - } -} - // 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 diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 3798c17..dcc30cd 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -6,26 +6,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/agent" - "github.com/kuchmenko/workspace/internal/config" "github.com/spf13/cobra" ) -// loadAgentDefaultView returns the workspace.toml-stored agent.default_view -// for the first workspace in the list. The TUI is single-view (no per- -// workspace switching), so we pick the active workspace's preference and -// use it for the whole session. Returns "all" on any read error so the -// launcher never fails on a corrupt or missing workspace.toml. -func loadAgentDefaultView(workspaces []agent.WorkspaceData) string { - if len(workspaces) == 0 { - return config.AgentViewAll - } - ws, err := config.Load(workspaces[0].Root) - if err != nil { - return config.AgentViewAll - } - return ws.AgentDefaultView() -} - func newAgentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent", @@ -121,7 +104,7 @@ func runAgentTUI() error { return fmt.Errorf("no workspaces found") } - m := agent.NewModel(workspaces, sessCache, loadAgentDefaultView(workspaces)) + m := agent.NewModel(workspaces, sessCache) p := tea.NewProgram(m, tea.WithAltScreen()) finalModel, err := p.Run() if err != nil { From a20b8b3d0ac5ac1887da786ee4a3ab3c2b726667 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 11:13:23 +0300 Subject: [PATCH 15/21] feat(agent): language-based project icons Detect each project's primary language via marker files (go.mod, Cargo.toml, pyproject.toml, tsconfig.json, package.json, Gemfile, pom.xml, build.gradle, Dockerfile) and render its Nerd Font glyph in the tree. Falls back to a top-level extension scan when no marker fires, then to a Markdown glyph for README-only repos, then to the generic package icon. Results are memoized per project path. Marker order disambiguates polyglot repos correctly: a Go project that also ships a Dockerfile reads as Go because go.mod is checked before Dockerfile. --- internal/agent/lang.go | 134 +++++++++++++++++++++++++++++++++++++++ internal/agent/render.go | 9 ++- 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 internal/agent/lang.go 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/render.go b/internal/agent/render.go index b1cee9f..333e120 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -120,8 +120,11 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl 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 left part: indent + expand + 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 %s", indent, expandMark, icon, name) // Build right part: badges (right-aligned) var badgeParts []string @@ -144,7 +147,7 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl } // Render with styled badges. if badges != "" { - leftPart := fmt.Sprintf(" %s%s%s %s", indent, expandMark, iconProject, name) + leftPart := fmt.Sprintf(" %s%s%s %s", indent, expandMark, icon, name) padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 if padding < 1 { padding = 1 From f5226e0f93b71c7cb039f05b3a42c5d3c8eb2f98 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 11:13:52 +0300 Subject: [PATCH 16/21] feat(agent): number hotkeys 1-9 launch chip projects Press 1-9 in the tree to launch the corresponding pinned chip project. Digits never collide with the tree navigation keys (j/k/h/l/ g/G/q) and are checked before the navigation switch so a chip launch beats any future single-digit binding. --- internal/agent/list.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/agent/list.go b/internal/agent/list.go index 44e3636..702001f 100644 --- a/internal/agent/list.go +++ b/internal/agent/list.go @@ -43,6 +43,18 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.statusMsg = "" // clear status on any key item := m.currentItem() + // Number hotkeys 1-9 launch the corresponding chip in the pinned + // quick-nav header. Handled here 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.headerProjects) { + p := m.headerProjects[idx] + m.Launch = &LaunchRequest{Cwd: p.Path} + return m, tea.Quit + } + } + switch msg.String() { case "q": return m, tea.Quit From 483ff103da7af0340da4bbd511ff4fdd1b51212e Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 11:15:43 +0300 Subject: [PATCH 17/21] =?UTF-8?q?feat(explorer):=20rename=20ws=20agent=20?= =?UTF-8?q?=E2=86=92=20ws=20explorer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI is an explorer over projects/worktrees/sessions, not an agent. Rename the cobra command, the entry point, and the docs file to match. `ws agent` keeps working as a backwards-compat alias. Source files: internal/cli/agent.go → internal/cli/explorer.go, docs/agent-tui.md → docs/explorer.md. --- docs/{agent-tui.md => explorer.md} | 104 +++++++++++-------------- internal/cli/{agent.go => explorer.go} | 36 +++++---- internal/cli/root.go | 6 +- 3 files changed, 68 insertions(+), 78 deletions(-) rename docs/{agent-tui.md => explorer.md} (52%) rename internal/cli/{agent.go => explorer.go} (77%) diff --git a/docs/agent-tui.md b/docs/explorer.md similarity index 52% rename from docs/agent-tui.md rename to docs/explorer.md index 1c4fcfe..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,52 +16,44 @@ 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, optionally topped by a -quick-nav header. +sessions, and renders a pinned quick-nav header above a scrollable +tree. ```text - Favorites - * myapp 2m linux - * api 1h linux - Recent - docs-site 3h linux - experiments 1d linux - -- all workspaces -- +*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`, -`shared with `, `legacy-wt`). +### Pinned chip header -The header shows up to five favorited projects, then up to five -recently-touched non-favorite projects, sorted by activity desc. -Activity = the most recent `last_active_at` across the project's -`[[branches]]`. Every `enter`, `p`, `l`, and `ctrl+s` launch stamps -the current branch (creating a minimal `[[branches]]` entry for the -default branch on first launch). Projects with zero activity never -appear in Recent; favorites with zero activity sort to the end of -the Favorites section but still show. +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. -Two views are available, persisted to `[agent].default_view` in -`workspace.toml`: +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. -- `all` (default) — header above the full tree. -- `favorites` — only the Favorites section, flat. Tree is hidden. +### Tree -Toggle via `space v`. +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 @@ -72,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: @@ -92,13 +85,6 @@ Per-row actions: persisted to `workspace.toml` and synced across machines via the reconciler. -View toggle (via the which-key chord): - -- `space` then `v` — flip between `all` and `favorites` view. The new - value is written to `[agent].default_view` in `workspace.toml` so - the next `ws agent` invocation opens in the same mode, and other - machines inherit the preference. - Search: - `s` — flash search inside the current view (jump labels per match). @@ -110,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. @@ -120,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 @@ -141,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/cli/agent.go b/internal/cli/explorer.go similarity index 77% rename from internal/cli/agent.go rename to internal/cli/explorer.go index dcc30cd..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 ", @@ -54,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)", @@ -70,7 +74,7 @@ func newAgentShellCmd() *cobra.Command { } } -func newAgentResumeCmd() *cobra.Command { +func newExplorerResumeCmd() *cobra.Command { var prompt string cmd := &cobra.Command{ Use: "resume ", @@ -94,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 { diff --git a/internal/cli/root.go b/internal/cli/root.go index ec78e09..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,7 @@ func NewRootCmd() *cobra.Command { newMigrateCmd(), newWorktreeCmd(), newBootstrapCmd(), - newAgentCmd(), + newExplorerCmd(), newFavoriteCmd(), newDocsCmd(), newDoctorCmd(), From ca8bce16ff68ab47698fa797f1264de8e47f865c Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 12:02:19 +0300 Subject: [PATCH 18/21] style(explorer): drop expand arrow, lightning prefix on worktree count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project row had a leading ▸/▾ arrow before the language icon indicating whether worktree/session children were expanded; the information was redundant with tab and read as visual noise. Drop it. On the right side, prefix the worktree count badge with ⚡ so the "branches in flight" signal reads at a glance. --- internal/agent/render.go | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/internal/agent/render.go b/internal/agent/render.go index 333e120..930deb0 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -106,30 +106,23 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl p := item.project indent := strings.Repeat(" ", item.indent) - expandMark := "" - if p.WorktreeCount > 1 || p.SessionCount > 0 { - if m.expanded["proj:"+p.ID] { - expandMark = "▾ " - } else { - expandMark = "▸ " - } - } - name := p.Name if inFlash && isMatch { name = flashInlineLabel(name, m.flashQuery, flashLabel) } - // Build left part: indent + expand + 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. + // 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 %s", indent, expandMark, icon, name) + left := fmt.Sprintf(" %s%s %s", indent, icon, name) - // Build right part: badges (right-aligned) + // 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("%dwt", p.WorktreeCount)) + badgeParts = append(badgeParts, fmt.Sprintf("⚡%d", p.WorktreeCount)) } if p.SessionCount > 0 { badgeParts = append(badgeParts, fmt.Sprintf("%ds", p.SessionCount)) @@ -147,7 +140,7 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl } // Render with styled badges. if badges != "" { - leftPart := fmt.Sprintf(" %s%s%s %s", indent, expandMark, icon, name) + leftPart := fmt.Sprintf(" %s%s %s", indent, icon, name) padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 if padding < 1 { padding = 1 From 8e204e6ffd5baeea989213edc178619cb17b55ed Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 12:11:36 +0300 Subject: [PATCH 19/21] feat(explorer): group favorites + chip action modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group favorites: a [groups.].favorite=true flag pins a group to the quick-nav chips alongside project favorites. The TUI 'f' hotkey on a group row toggles it, ws favorite add @ and ws favorite list also accept groups. Group chips render as @name with the same * marker that projects use. Chip action modal: pressing 1-9 on a header chip now opens a small modal asking what to do — c/enter for claude, p for claude+prompt, s/l for shell, w for new worktree (project chips only), esc to cancel. The chip itself stays a hotkey shortcut but the launch intent is explicit instead of always-claude-always-cwd. Internally, headerProjects []Project becomes headerChips []Chip with a {Kind, Name, Path, Favorite, Project*, WorkspaceRoot} struct so group and project chips share one rendering path. KindSection is dropped (was already dead code after the pinned-header commit). --- internal/agent/chip_action.go | 89 ++++++++++++++++++++++ internal/agent/header.go | 137 +++++++++++++++++++--------------- internal/agent/header_test.go | 94 +++++++++++++---------- internal/agent/items.go | 2 +- internal/agent/list.go | 27 ++++--- internal/agent/render.go | 2 +- internal/agent/source.go | 15 +++- internal/agent/tui.go | 24 ++++-- internal/agent/types.go | 33 +++++--- internal/agent/whichkey.go | 82 +++++++++++++++++++- internal/cli/favorite.go | 81 ++++++++++++++++---- internal/config/config.go | 24 ++++++ 12 files changed, 460 insertions(+), 150 deletions(-) create mode 100644 internal/agent/chip_action.go 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/header.go b/internal/agent/header.go index 794f57f..dd4c92a 100644 --- a/internal/agent/header.go +++ b/internal/agent/header.go @@ -15,57 +15,65 @@ import ( // worth the cognitive cost. const HeaderCap = 9 -// headerProjects returns the single ordered list of projects rendered -// in the pinned quick-nav chip header. Favorites come first (always -// visible regardless of activity), then non-favorite recently-touched -// projects. The result is capped at HeaderCap so the chips fit in the -// 1-9 number-key hotkey range. Returns nil when nothing qualifies — -// the caller skips header rendering entirely in that case. -func headerProjects(projects []Project) []Project { - var favs, recent []Project - for _, p := range projects { - if p.Favorite { - favs = append(favs, p) - } else if !p.LastActiveAt.IsZero() { - recent = append(recent, p) +// 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, + }) } } - sortByActivity(favs) - sortByActivity(recent) + sortChipsByActivity(favs) + sortChipsByActivity(recent) merged := append(favs, recent...) - return capProjects(merged, HeaderCap) + if len(merged) > HeaderCap { + merged = merged[:HeaderCap] + } + return merged } -func sortByActivity(ps []Project) { - sort.Slice(ps, func(i, j int) bool { - ai, aj := ps[i].LastActiveAt, ps[j].LastActiveAt +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 ps[i].Name < ps[j].Name + return cs[i].Name < cs[j].Name }) } -func capProjects(ps []Project, n int) []Project { - if len(ps) <= n { - return ps - } - return ps[:n] -} - -// allProjects flattens every workspace's Projects into a single slice. -// Header sorting is global across workspaces — a Favorites pin from a -// work workspace and one from a personal workspace appear in the same -// list, ordered purely by activity. The category column on the row -// disambiguates if the user is multi-workspace. -func allProjects(workspaces []WorkspaceData) []Project { - var out []Project - for _, ws := range workspaces { - out = append(out, ws.Projects...) - } - return out -} - // 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). @@ -98,39 +106,44 @@ func humanizeAgeAt(t, now time.Time) string { } } -// renderHeaderChips formats `projects` as numbered chips packed into -// at most `maxLines` lines of width `w`. Each chip is rendered as -// `1.name 2m` with a leading `*` for favorites. Chips wrap to a new -// line when the next chip would overflow `w`; chips that would not -// fit in `maxLines` are dropped (HeaderCap=9 already keeps the count -// small enough that this is rare). +// 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 when projects is empty — callers omit the header rows -// entirely so an idle workspace doesn't burn vertical space on chrome. -func renderHeaderChips(projects []Project, w, maxLines int) []string { - if len(projects) == 0 || w <= 0 || maxLines <= 0 { +// 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 } - chips := make([]string, len(projects)) - for i, p := range projects { - chips[i] = formatChip(i+1, p) + tokens := make([]string, len(chips)) + for i, c := range chips { + tokens[i] = formatChip(i+1, c) } - return packChips(chips, w, maxLines) + return packChips(tokens, w, maxLines) } -// formatChip builds the "1.name 2m" string for one header project, -// prefixed with `*` when the project is favorited. The age is omitted -// when LastActiveAt is zero (favorited but never stamped). -func formatChip(num int, p Project) string { +// 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 p.Favorite { + if c.Favorite { star = "*" } - age := humanizeAge(p.LastActiveAt) + body := c.Name + if c.Kind == KindGroup { + body = "@" + c.Name + } + age := humanizeAge(c.LastActiveAt) if age == "" { - return fmt.Sprintf("%s%d.%s", star, num, p.Name) + return fmt.Sprintf("%s%d.%s", star, num, body) } - return fmt.Sprintf("%s%d.%s %s", star, num, p.Name, age) + return fmt.Sprintf("%s%d.%s %s", star, num, body, age) } // packChips greedily fills lines with chips separated by two spaces, diff --git a/internal/agent/header_test.go b/internal/agent/header_test.go index 437a796..0316c59 100644 --- a/internal/agent/header_test.go +++ b/internal/agent/header_test.go @@ -6,24 +6,54 @@ import ( "time" ) -func TestHeaderProjects_FavoritesFirstThenRecent(t *testing.T) { +func TestBuildHeaderChips_FavoritesFirstThenRecent(t *testing.T) { now := time.Now().UTC() - 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)}, - } + 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 := names(headerProjects(projects)) + 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 TestHeaderProjects_CappedAtNine(t *testing.T) { +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++ { @@ -38,42 +68,30 @@ func TestHeaderProjects_CappedAtNine(t *testing.T) { LastActiveAt: now.Add(time.Duration(-i-100) * time.Hour), }) } - - got := headerProjects(projects) + 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 TestHeaderProjects_TiesByName(t *testing.T) { +func TestBuildHeaderChips_TiesByName(t *testing.T) { t0 := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC) - projects := []Project{ - {Name: "z-app", Favorite: false, LastActiveAt: t0}, - {Name: "a-app", Favorite: false, LastActiveAt: t0}, - {Name: "m-app", Favorite: false, LastActiveAt: t0}, - } - got := names(headerProjects(projects)) + 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 TestHeaderProjects_FavoritesIncludeZeroActivity(t *testing.T) { - now := time.Now().UTC() - projects := []Project{ - {Name: "fresh-fav", Favorite: true, LastActiveAt: time.Time{}}, - {Name: "old-fav", Favorite: true, LastActiveAt: now.Add(-1 * time.Hour)}, - } - got := names(headerProjects(projects)) - // Order: activity desc; zero comes last because nothing is greater - // than zero in time.After semantics. - want := []string{"old-fav", "fresh-fav"} - if !reflect.DeepEqual(got, want) { - t.Errorf("favorites with mixed activity: 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 { @@ -100,10 +118,10 @@ func TestHumanizeAgeAt(t *testing.T) { } } -func names(ps []Project) []string { - out := make([]string, len(ps)) - for i, p := range ps { - out[i] = p.Name +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 index e2e2f29..f405ece 100644 --- a/internal/agent/items.go +++ b/internal/agent/items.go @@ -7,7 +7,7 @@ package agent // scrollable region. func (m *Model) rebuildItems() { m.items = nil - m.headerProjects = headerProjects(allProjects(m.workspaces)) + m.headerChips = buildHeaderChips(m.workspaces) for _, ws := range m.workspaces { // Ungrouped projects first. diff --git a/internal/agent/list.go b/internal/agent/list.go index 702001f..87d7787 100644 --- a/internal/agent/list.go +++ b/internal/agent/list.go @@ -43,15 +43,16 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.statusMsg = "" // clear status on any key item := m.currentItem() - // Number hotkeys 1-9 launch the corresponding chip in the pinned - // quick-nav header. Handled here before the navigation switch so - // the digits never collide with future bindings. + // 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.headerProjects) { - p := m.headerProjects[idx] - m.Launch = &LaunchRequest{Cwd: p.Path} - return m, tea.Quit + if idx < len(m.headerChips) { + c := m.headerChips[idx] + m.chipTarget = &c + m.mode = viewChipAction + return m, nil } } @@ -139,13 +140,17 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "f": - // Toggle favorite on the cursor project (works in both header - // and tree variants). Persists the new flag to workspace.toml - // and refreshes the in-memory model so the new state is visible - // without a TUI restart. + // 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 { diff --git a/internal/agent/render.go b/internal/agent/render.go index 930deb0..16ef905 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -282,7 +282,7 @@ func (m *Model) viewList() 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.headerProjects, listW-2, 2) + chipLines := renderHeaderChips(m.headerChips, listW-2, 2) for _, l := range styleHeaderLines(chipLines) { rows = append(rows, l) } diff --git a/internal/agent/source.go b/internal/agent/source.go index 34ffc73..ff1b4d6 100644 --- a/internal/agent/source.go +++ b/internal/agent/source.go @@ -46,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 { @@ -62,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) diff --git a/internal/agent/tui.go b/internal/agent/tui.go index 89d19a8..811608e 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -15,6 +15,7 @@ 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. @@ -58,11 +59,16 @@ type Model struct { expanded map[string]bool // group/project name → expanded scroll int // scroll offset for long lists - // headerProjects is the ordered list of projects rendered as - // numbered chips in the pinned quick-nav header above the tree. - // Recomputed in rebuildItems from favorites + recently-touched - // projects across all workspaces. - headerProjects []Project + // 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 @@ -170,6 +176,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.mode == viewEditProject { return m.updateEditProject(msg) } + if m.mode == viewChipAction { + return m.updateChipAction(msg) + } return m.updateList(msg) } return m, nil @@ -188,6 +197,9 @@ func (m *Model) View() string { if m.mode == viewEditProject { return m.viewEditProject() } + if m.mode == viewChipAction { + return m.viewChipAction() + } if m.mode == viewWhichKey { return m.viewWhichKey() } @@ -275,7 +287,7 @@ func (m *Model) listHeight() int { // workspace) — listHeight then matches the pre-rework value so a // fresh install has the same vertical density. chrome := 5 - if len(m.headerProjects) > 0 { + if len(m.headerChips) > 0 { chrome += 3 } h := m.height - chrome diff --git a/internal/agent/types.go b/internal/agent/types.go index 4babb9a..04cc502 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -18,11 +18,6 @@ const ( KindProject KindWorktree KindPortal - // KindSection is a non-selectable visual element: the "Favorites" - // / "Recent" headers above the tree, the "-- all workspaces --" - // divider beneath them, and the empty-state hint inside an empty - // Favorites view. Cursor movement skips KindSection rows. - KindSection ) // Project is one navigable project in the workspace tree. @@ -57,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 index b9eb734..78c8502 100644 --- a/internal/agent/whichkey.go +++ b/internal/agent/whichkey.go @@ -34,6 +34,7 @@ func (m *Model) whichKeyActions() []whichKeyAction { return []whichKeyAction{ {"⏎", "open claude"}, {"p", "+prompt"}, + {"f", m.favoriteToggleLabelGroup(item.group)}, {"l", "shell"}, {"tab", "expand"}, {"", ""}, @@ -83,6 +84,18 @@ func (m *Model) favoriteToggleLabel(it *listItem) string { 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() @@ -136,13 +149,16 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = viewList return m.updateList(msg) case "f": - // Favorite toggle is a per-project action; only meaningful when - // the cursor is on a project row. Closes the panel either way - // so the user sees the result immediately. + // 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 @@ -151,6 +167,66 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/cli/favorite.go b/internal/cli/favorite.go index bddd031..f3a9069 100644 --- a/internal/cli/favorite.go +++ b/internal/cli/favorite.go @@ -6,6 +6,7 @@ import ( "sort" "text/tabwriter" + "github.com/kuchmenko/workspace/internal/config" "github.com/spf13/cobra" ) @@ -40,34 +41,73 @@ any project row.`, func newFavoriteAddCmd() *cobra.Command { return &cobra.Command{ - Use: "add ", - Short: "Mark a project as favorite", + 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 to the Favorites section of `ws agent`", + "agent:when": "Pin a project or group to the quick-nav chips of `ws explorer`", }, RunE: func(cmd *cobra.Command, args []string) error { - return setProjectFavorite(args[0], true) + return setFavorite(args[0], true) }, } } func newFavoriteRmCmd() *cobra.Command { return &cobra.Command{ - Use: "rm ", - Short: "Unmark a favorite project", + Use: "rm ", + Short: "Unmark a favorite project or group", Args: cobra.ExactArgs(1), Annotations: map[string]string{ "capability": "organization", - "agent:when": "Unpin a project from the Favorites section of `ws agent`", + "agent:when": "Unpin a project or group from the quick-nav chips of `ws explorer`", }, RunE: func(cmd *cobra.Command, args []string) error { - return setProjectFavorite(args[0], false) + 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", @@ -77,26 +117,35 @@ func newFavoriteListCmd() *cobra.Command { "agent:when": "Print favorited projects with their category and group", }, RunE: func(cmd *cobra.Command, args []string) error { - names := make([]string, 0) + var projNames, groupNames []string for n, p := range ws.Projects { if p.Favorite { - names = append(names, n) + projNames = append(projNames, n) } } - if len(names) == 0 { - fmt.Println("No favorites. Use `ws favorite add ` to pin one.") + 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(names) + sort.Strings(projNames) + sort.Strings(groupNames) tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "NAME\tCATEGORY\tGROUP") - for _, n := range names { + 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\t%s\t%s\n", n, p.Category, group) + fmt.Fprintf(tw, "%s\tproject\t%s\t%s\n", n, p.Category, group) } return tw.Flush() }, diff --git a/internal/config/config.go b/internal/config/config.go index 71becf0..f1efc4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,30 @@ import ( 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 + } + g, ok := w.Groups[name] + if !ok { + return false + } + if g.Favorite == fav { + return false + } + g.Favorite = fav + w.Groups[name] = g + return true } type Meta struct { From e0fef1ca3d9cf2147dba750392bbbff58f6de14b Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 13:17:12 +0300 Subject: [PATCH 20/21] fix(lint): align golangci complexity with AGENTS.md (15 hard) CI was failing with gocognit min-complexity=7, which contradicted the AGENTS.md rule (10 soft / 15 hard). Bump both gocognit and gocyclo to 15 to match the policy doc. Also clean three stale lint hits from the explorer rework: - replace a 2-line append loop with a single append-spread (S1011) - drop the unused sectionStyle (Favorites/Recent section headers were removed when the header became pinned chips) - drop the unused primaryWorkspaceRoot (last caller toggleAgentView was removed in the view-toggle deletion) PR feedback (CodeRabbit): - AGENTS.md fenced layout block now declares 'text' for MD040. - stamp_test subpath test walks into a real subdirectory instead of filepath.Join(mainPath, '.') which normalized to mainPath. --- .golangci.yml | 18 +++++++++--------- AGENTS.md | 2 +- internal/agent/render.go | 4 +--- internal/agent/stamp_test.go | 10 ++++++++-- internal/agent/styles.go | 8 -------- internal/agent/tui.go | 13 ------------- 6 files changed, 19 insertions(+), 36 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 2d28c4c..6716a82 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. diff --git a/AGENTS.md b/AGENTS.md index 3d65b59..dd23918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Many design decisions were deliberate trade-offs and are non-obvious. After `ws migrate`, every project lives as a sibling triplet under its category directory: -``` +```text personal/ ├── myapp/ ← main worktree (project.default_branch) │ └── .git ← file pointing into ../myapp.bare diff --git a/internal/agent/render.go b/internal/agent/render.go index 16ef905..3a3694e 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -283,9 +283,7 @@ func (m *Model) viewList() string { // 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) - for _, l := range styleHeaderLines(chipLines) { - rows = append(rows, l) - } + rows = append(rows, styleHeaderLines(chipLines)...) if len(chipLines) > 0 { rows = append(rows, strings.Repeat(" ", listW)) } diff --git a/internal/agent/stamp_test.go b/internal/agent/stamp_test.go index 2eb8e56..52b47aa 100644 --- a/internal/agent/stamp_test.go +++ b/internal/agent/stamp_test.go @@ -1,6 +1,7 @@ package agent import ( + "os" "path/filepath" "testing" @@ -143,8 +144,13 @@ func TestStampLaunchFromPath_FindRootFrom_HandlesSubpath(t *testing.T) { }) seedMachine(t, "linux") - // Launch from a deeper path inside the worktree. - deep := filepath.Join(mainPath, ".") // alphabetically minimal subpath + // 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) } diff --git a/internal/agent/styles.go b/internal/agent/styles.go index a97d262..4b0bd3a 100644 --- a/internal/agent/styles.go +++ b/internal/agent/styles.go @@ -49,14 +49,6 @@ var ( dimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) - // sectionStyle paints the "Favorites" / "Recent" / divider labels - // that head the quick-nav shortcuts above the workspace tree. - // Color is deliberately the same family as headerStyle so the eye - // reads it as chrome, not as a clickable row. - sectionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")). // amber dim - Bold(true) - // favoriteStarStyle paints the leading `*` indicator placed // before favorited projects in the header section. favoriteStarStyle = lipgloss.NewStyle(). diff --git a/internal/agent/tui.go b/internal/agent/tui.go index 811608e..fb76ce3 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -226,19 +226,6 @@ func (m *Model) workspaceRootFor(proj *Project) string { return "" } -// primaryWorkspaceRoot returns the root used for workspace-wide -// settings (currently just `agent.default_view`). The TUI displays -// at most one workspace at a time in practice; when multiple are -// registered we pick the first deterministically — the user has only -// one global preference per session and `loadAgentDefaultView` reads -// from the same workspace, so the round-trip is consistent. -func (m *Model) primaryWorkspaceRoot() string { - if len(m.workspaces) == 0 { - return "" - } - return m.workspaces[0].Root -} - func (m *Model) toggleExpand(key string) { m.expanded[key] = !m.expanded[key] m.rebuildItems() From 03a8b976e4f3dfdbd05c111f997451a311009704 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Sat, 16 May 2026 13:20:09 +0300 Subject: [PATCH 21/21] fix(lint): widen golangci path globs to cover split files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier refactor commits split four big files: - internal/cli/worktree.go → worktree_{add,list,rm,push}.go - internal/cli/bootstrap.go → bootstrap_{model,view}.go (+ original) - internal/cli/migrate_tui.go → migrate_{model,view}.go (+ original) - internal/daemon/reconciler.go → projects.go, toml.go, conflicts.go, git.go The .golangci.yml exclusions still pointed at the pre-split filenames, so the cognitive-complexity allowances no longer applied to the moved code. Widen the globs: - 'internal/cli/.*(tui|model|view|clone).*\.go' covers every Bubbletea reducer split. - 'internal/cli/worktree.*\.go' covers all four worktree commands. - 'internal/daemon/.*\.go' covers the reconciler family. --- .golangci.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 6716a82..7decf97 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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