From 27d0268bb69b2bd6f2b07115280f3f0125e89f5f Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 10:25:14 +0300 Subject: [PATCH 1/9] refactor: remove 10 unreachable functions across git, alias, agent, clipboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identified by golang.org/x/tools/cmd/deadcode and verified to have zero callers in production, tests, or bench code. The deadcode tool flagged 37 functions; 27 of those are test helpers (testutil, benchfixture), same-package test-only exports (CacheTTL, IsAuthErr, SortedCapabilityKeys), or intentional stubs (GhAppProviderStub) — all live and kept. Deletions: - internal/agent/worktrees.go: DeleteWorktree (wrapper around live DeleteWorktreeWithRegistry) - internal/alias/resolve.go: Resolve, RemoveForTarget - internal/git/bare.go: RenameBranch - internal/git/git.go: Clone, ParseOwnerRepo - internal/git/worktree.go: WorktreeAddExisting (already marked DEPRECATED), WorktreeMove - internal/clipboard/clipboard.go: Read, Detect (package-level convenience wrappers; production consumers use DefaultReader.Read directly) clipboard_test.go rewritten to exercise DefaultReader.Read and detect() directly, matching the actual production usage pattern. Net: -121 LOC. Build, race tests, and golangci-lint all clean. --- internal/agent/worktrees.go | 13 +++-------- internal/alias/resolve.go | 22 ------------------ internal/clipboard/clipboard.go | 14 ------------ internal/clipboard/clipboard_test.go | 22 +++++------------- internal/git/bare.go | 9 -------- internal/git/git.go | 25 -------------------- internal/git/worktree.go | 34 ---------------------------- 7 files changed, 9 insertions(+), 130 deletions(-) diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go index e1e90a9..2b435e8 100644 --- a/internal/agent/worktrees.go +++ b/internal/agent/worktrees.go @@ -149,16 +149,9 @@ func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, return &WorktreeResult{Path: wtPath, Branch: branch}, nil } -// DeleteWorktree removes a worktree. Refuses if it's the main worktree. -// The workspace.toml [[branches]] entry is updated when wsRoot/projID -// are non-empty: this machine is released from the branch's machines -// slice; an empty machines slice causes the entry to be GC'd on Save. -func DeleteWorktree(mainPath, wtPath string, force bool) error { - return DeleteWorktreeWithRegistry(mainPath, wtPath, force, "", "", "") -} - -// DeleteWorktreeWithRegistry is the registry-aware variant. Pass empty -// strings for wsRoot/projID/branch to skip the workspace.toml update. +// DeleteWorktreeWithRegistry removes a worktree and releases this machine +// from the workspace.toml [[branches]] entry. Pass empty wsRoot/projID/branch +// to skip the registry update. func DeleteWorktreeWithRegistry(mainPath, wtPath string, force bool, wsRoot, projID, branch string) error { if wtPath == mainPath { return fmt.Errorf("cannot delete main worktree") diff --git a/internal/alias/resolve.go b/internal/alias/resolve.go index 9050236..2349d32 100644 --- a/internal/alias/resolve.go +++ b/internal/alias/resolve.go @@ -41,15 +41,6 @@ type Resolved struct { Path string // absolute filesystem path } -// Resolve looks up a single alias and returns its absolute path. -func Resolve(ws *config.Workspace, root, name string) (Resolved, error) { - target, ok := ws.Aliases[name] - if !ok { - return Resolved{}, fmt.Errorf("alias %q not defined", name) - } - return resolveTarget(ws, root, name, target) -} - // ResolveAll returns every alias resolved, sorted by alias name. // Aliases that fail to resolve are returned with Kind=TargetUnknown // and an empty Path so callers can flag them. @@ -94,16 +85,3 @@ func resolveTarget(ws *config.Workspace, root, name, target string) (Resolved, e return Resolved{}, fmt.Errorf("alias %q points to unknown target %q", name, target) } -// RemoveForTarget deletes every alias whose target equals `target`. -// Returns the names removed. -func RemoveForTarget(ws *config.Workspace, target string) []string { - var removed []string - for name, t := range ws.Aliases { - if t == target { - removed = append(removed, name) - delete(ws.Aliases, name) - } - } - sort.Strings(removed) - return removed -} diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index c3cbb33..9324b1a 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -45,14 +45,6 @@ type Reader interface { Read(ctx context.Context) (string, error) } -// Read is the package-level convenience wrapper around DefaultReader.Read. -// It detects the platform tool at call time, not at package init, so -// missing tools can be installed mid-session and picked up on the next -// invocation. -func Read(ctx context.Context) (string, error) { - return DefaultReader.Read(ctx) -} - // DefaultReader is the production Reader. Use it as the zero-config // choice; tests substitute their own implementation. var DefaultReader Reader = systemReader{} @@ -134,9 +126,3 @@ func runTool(ctx context.Context, cmd string, args ...string) (string, error) { return strings.TrimRight(string(out), "\n"), nil } -// Detect exposes the current platform's (tool, args) pair and availability -// for diagnostics. Returns the command path (not just the base name) so -// callers can show "clipboard: /usr/bin/wl-paste" in status output. -func Detect() (tool string, args []string, err error) { - return detect() -} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go index e79f5bd..66c4ddf 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_test.go @@ -10,48 +10,38 @@ import ( func TestDetect_UnsupportedPlatform(t *testing.T) { if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - t.Skip("Detect returns ErrUnavailable only on unsupported platforms; this GOOS is supported") + t.Skip("detect returns ErrUnavailable only on unsupported platforms; this GOOS is supported") } - _, _, err := Detect() + _, _, err := detect() if !errors.Is(err, ErrUnavailable) { t.Errorf("want ErrUnavailable, got %v", err) } } func TestRead_ContextCancelled(t *testing.T) { - // Pre-canceled context: regardless of whether a clipboard tool - // exists, detect() may succeed and runTool is invoked — it must - // surface ctx.Err(). If no tool → ErrUnavailable. Either outcome - // is acceptable; unsupported platforms test ErrUnavailable above. ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := Read(ctx) + _, err := DefaultReader.Read(ctx) if err == nil { t.Fatal("expected error from canceled context or missing tool") } - // Two valid outcomes: tool missing → ErrUnavailable, tool present → ctx err. if !errors.Is(err, ErrUnavailable) && !errors.Is(err, context.Canceled) { t.Errorf("unexpected error: %v", err) } } func TestRead_DeadlineExceeded(t *testing.T) { - // Very short deadline. Same two-outcome contract as above: tool - // missing → ErrUnavailable, tool present but slow → context err. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() - time.Sleep(10 * time.Millisecond) // ensure deadline is exceeded + time.Sleep(10 * time.Millisecond) - _, err := Read(ctx) + _, err := DefaultReader.Read(ctx) if err == nil { t.Fatal("expected error") } } -// fakeReader lets us exercise the Reader interface without touching the -// real clipboard — useful for consumers of this package (the `ws add` -// gather path) to wire a test double. type fakeReader struct { val string err error @@ -64,7 +54,7 @@ func TestReaderInterface_CanSwapDefault(t *testing.T) { t.Cleanup(func() { DefaultReader = orig }) DefaultReader = fakeReader{val: "git@github.com:foo/bar.git"} - got, err := Read(context.Background()) + got, err := DefaultReader.Read(context.Background()) if err != nil { t.Fatal(err) } diff --git a/internal/git/bare.go b/internal/git/bare.go index b462ac5..e9d42c5 100644 --- a/internal/git/bare.go +++ b/internal/git/bare.go @@ -124,12 +124,3 @@ func HasRemoteBranch(repoPath, remote, branch string) bool { return cmd.Run() == nil } -// RenameBranch renames a local branch. Works on both bare and non-bare repos. -func RenameBranch(repoPath, oldName, newName string) error { - cmd := exec.Command("git", "-C", repoPath, "branch", "-m", oldName, newName) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git branch -m %s %s: %s", oldName, newName, strings.TrimSpace(string(out))) - } - return nil -} diff --git a/internal/git/git.go b/internal/git/git.go index d567472..d7a7bc8 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -9,17 +9,6 @@ import ( "time" ) -func Clone(remote, dest string) error { - cmd := exec.Command("git", "clone", remote, dest) - cmd.Stdout = nil - cmd.Stderr = nil - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git clone %s: %s", remote, strings.TrimSpace(string(out))) - } - return nil -} - func Pull(repoPath string) error { cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") out, err := cmd.CombinedOutput() @@ -306,17 +295,3 @@ func ParseRepoName(remote string) string { return remote } -// ParseOwnerRepo extracts "owner/repo" from a git remote URL. -func ParseOwnerRepo(remote string) string { - remote = strings.TrimSuffix(remote, ".git") - // SSH: git@github.com:owner/repo - if idx := strings.Index(remote, ":"); idx >= 0 && !strings.Contains(remote, "://") { - return remote[idx+1:] - } - // HTTPS: https://github.com/owner/repo - parts := strings.Split(remote, "/") - if len(parts) >= 2 { - return parts[len(parts)-2] + "/" + parts[len(parts)-1] - } - return remote -} diff --git a/internal/git/worktree.go b/internal/git/worktree.go index 078877f..fd805fa 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -33,24 +33,6 @@ func WorktreeAdd(repoPath, wtPath, branch, createFromBase string) error { return nil } -// WorktreeAddExisting attaches an existing directory as a worktree for the -// named branch. Used by migration: after we move the original checkout's -// .git aside, we run this to make the existing files become a real worktree. -// Requires --force because the target path already contains files. -// -// DEPRECATED: kept for backwards compatibility. Modern git refuses to attach -// a worktree to a non-empty existing directory even with --force; use -// WorktreeAddNoCheckout + manual pointer swap instead. See migrate.go for -// the working strategy. -func WorktreeAddExisting(repoPath, wtPath, branch string) error { - cmd := exec.Command("git", "-C", repoPath, "worktree", "add", "--force", wtPath, branch) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git worktree add --force %s in %s: %s", wtPath, repoPath, strings.TrimSpace(string(out))) - } - return nil -} - // WorktreeAddNoCheckout creates a worktree at wtPath checked out on branch, // but skips writing the working-tree files. The result is a directory // containing only a .git pointer file (and the matching admin dir under @@ -100,22 +82,6 @@ func WorktreeRemove(repoPath, wtPath string, force bool) error { return nil } -// WorktreeMove renames a worktree directory. Wraps `git worktree move`, -// which updates the bare repo's worktrees//gitdir entry and the -// worktree's .git pointer file atomically. Refuses if the worktree is -// dirty or locked. -// -// repoPath can be either the bare repo or any worktree — `git worktree -// move` accepts both, since they share the same admin dir. -func WorktreeMove(repoPath, oldPath, newPath string) error { - cmd := exec.Command("git", "-C", repoPath, "worktree", "move", oldPath, newPath) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git worktree move %s → %s: %s", oldPath, newPath, strings.TrimSpace(string(out))) - } - return nil -} - // WorktreeList parses `git worktree list --porcelain` output. Works on either // a bare repo or a regular checkout — git resolves to the same shared list. func WorktreeList(repoPath string) ([]Worktree, error) { From c813306506a931cdb006e2dee1560e9f45da60a2 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 10:42:57 +0300 Subject: [PATCH 2/9] refactor: strip narrative comments from production Go files Tooling: AST-aware Go program at /tmp/stripcomments/main.go (go/parser + go/format) walks internal/ and cmd/, drops all CommentGroups except those matching preserve prefixes: //go:build, // +build, //go:generate, // Package, // DECISION:, // TODO:, // FIXME:, // HACK:, // NOTE:. Test files (_test.go) untouched. Outcome: 134 of 140 production files modified. Build, race tests, golangci-lint all green. Trade-off acknowledged: this strips ~10-30 legitimately load-bearing WHY-comments per file (e.g. SetBranchUpstream's 13-line explanation of why config keys are written directly instead of via --set-upstream-to, which works around a narrow timing window after CloneBare/SetFetchRefspec). Per the operator's stated stance (no comments in main code), these are removed. They remain recoverable via git history and can be reinstated as // DECISION: blocks in a follow-up commit if a specific surprise lands in code review or future maintenance. Net: -3,322 LOC across 134 files. Total Go LOC: 31,360 -> 27,847. --- internal/add/add.go | 86 +-------------- internal/add/browse.go | 86 ++------------- internal/add/clipboard.go | 66 +---------- internal/add/clone.go | 24 +--- internal/add/dedup.go | 21 ---- internal/add/disk.go | 50 --------- internal/add/edit.go | 21 +--- internal/add/format.go | 31 +----- internal/add/gather.go | 26 +---- internal/add/github_source.go | 25 +---- internal/add/manual.go | 2 +- internal/add/msg.go | 11 -- internal/add/options.go | 50 --------- internal/add/register.go | 48 +------- internal/add/sidecar.go | 31 +----- internal/add/styles.go | 17 --- internal/add/suggestions.go | 105 +----------------- internal/add/tui.go | 92 ++-------------- internal/agent/chip_action.go | 8 +- internal/agent/edit_project.go | 27 +---- internal/agent/flash.go | 36 +----- internal/agent/forms.go | 8 +- internal/agent/header.go | 41 ------- internal/agent/items.go | 13 +-- internal/agent/lang.go | 57 +++------- internal/agent/launcher.go | 8 -- internal/agent/list.go | 34 ++---- internal/agent/persist.go | 12 -- internal/agent/render.go | 34 +----- internal/agent/sessions.go | 35 +----- internal/agent/source.go | 16 --- internal/agent/stamp.go | 36 ------ internal/agent/styles.go | 51 +++------ internal/agent/tui.go | 113 ++++++------------- internal/agent/types.go | 33 +----- internal/agent/whichkey.go | 29 +---- internal/agent/worktrees.go | 70 +----------- internal/alias/conflict.go | 3 - internal/alias/generate.go | 18 --- internal/alias/install.go | 14 --- internal/alias/resolve.go | 13 +-- internal/alias/shell_zsh.go | 4 - internal/aliasmgr/model.go | 26 ++--- internal/aliasmgr/step_confirm.go | 2 - internal/aliasmgr/step_manage.go | 33 +----- internal/auth/auth.go | 6 - internal/auth/device_flow.go | 21 +--- internal/auth/pat.go | 2 - internal/benchfixture/fixture.go | 47 +------- internal/bootstrap/bootstrap.go | 55 ++-------- internal/bootstrap/sidecar.go | 26 ----- internal/branchprompt/branchprompt.go | 39 ------- internal/branchprompt/messages.go | 8 -- internal/cli/add.go | 31 +----- internal/cli/alias.go | 1 - internal/cli/auth.go | 1 - internal/cli/bootstrap.go | 20 +--- internal/cli/bootstrap_model.go | 47 ++------ internal/cli/bootstrap_view.go | 5 +- internal/cli/create.go | 15 +-- internal/cli/daemon.go | 4 - internal/cli/doctor.go | 20 ---- internal/cli/explorer.go | 8 +- internal/cli/favorite.go | 18 --- internal/cli/migrate.go | 14 +-- internal/cli/migrate_model.go | 29 ++--- internal/cli/migrate_tui.go | 26 +---- internal/cli/path.go | 17 +-- internal/cli/root.go | 14 +-- internal/cli/scan.go | 23 +--- internal/cli/setup.go | 3 - internal/cli/status.go | 4 - internal/cli/sync.go | 29 +---- internal/cli/sync_resolve.go | 74 +------------ internal/cli/worktree.go | 12 -- internal/cli/worktree_add.go | 26 +---- internal/cli/worktree_rm.go | 7 +- internal/clipboard/clipboard.go | 30 +---- internal/clone/clone.go | 73 +------------ internal/config/branch.go | 76 +------------ internal/config/config.go | 48 +------- internal/config/legacy.go | 26 ----- internal/config/machine.go | 19 +--- internal/config/project.go | 20 +--- internal/config/validate.go | 12 -- internal/conflict/conflict.go | 46 ++------ internal/conflict/notify.go | 6 - internal/create/cmd.go | 5 - internal/create/create.go | 43 -------- internal/create/gh.go | 70 ------------ internal/create/options.go | 38 +------ internal/create/render.go | 2 +- internal/create/runner.go | 7 -- internal/create/sidecar.go | 14 --- internal/create/tui.go | 46 ++------ internal/daemon/config.go | 20 +--- internal/daemon/conflicts.go | 5 - internal/daemon/daemon.go | 34 +----- internal/daemon/git.go | 4 - internal/daemon/ipc_client.go | 6 +- internal/daemon/projects.go | 70 +----------- internal/daemon/reconciler.go | 44 +------- internal/daemon/socket.go | 2 - internal/daemon/toml.go | 42 +------ internal/daemon/watcher.go | 13 +-- internal/docs/agent.go | 28 +---- internal/docs/schema.go | 3 - internal/doctor/doctor.go | 77 +++---------- internal/doctor/format.go | 50 --------- internal/doctor/project.go | 92 +--------------- internal/doctor/system.go | 60 +--------- internal/git/bare.go | 36 ------ internal/git/git.go | 48 -------- internal/git/worktree.go | 38 +------ internal/github/app_provider.go | 16 --- internal/github/cache.go | 52 --------- internal/github/client.go | 1 - internal/github/gh_client.go | 1 - internal/github/github.go | 6 +- internal/github/http_client.go | 26 +---- internal/github/provider.go | 109 +----------------- internal/github/resolve.go | 5 - internal/layout/layout.go | 48 +------- internal/migrate/check.go | 6 +- internal/migrate/git_helpers.go | 5 - internal/migrate/hooks.go | 4 - internal/migrate/migrate.go | 152 +++----------------------- internal/migrate/resolve.go | 9 +- internal/migrate/sidecar.go | 23 ---- internal/setup/setup.go | 10 +- internal/setup/step_group.go | 22 ++-- internal/setup/step_select.go | 15 +-- internal/sidecar/sidecar.go | 56 +--------- internal/testutil/gitfixture.go | 31 +----- 134 files changed, 338 insertions(+), 3730 deletions(-) diff --git a/internal/add/add.go b/internal/add/add.go index b876574..2283485 100644 --- a/internal/add/add.go +++ b/internal/add/add.go @@ -27,31 +27,10 @@ import ( "github.com/kuchmenko/workspace/internal/github" ) -// ErrEmbedNotSupported is returned when a caller asks for ModeEmbedded. -// Reserved for a future `ws agent` add screen that will host AddModel -// inside its own bubbletea program; the interface is frozen now so -// the embedding contract is locked. var ErrEmbedNotSupported = errors.New("embedded mode not yet supported") -// ErrNoURLs is returned by headless runs with Options.URLs empty. -// The TUI is the usual answer to "I didn't bring URLs"; headless by -// definition cannot consult the user. var ErrNoURLs = errors.New("no URLs provided; pass one or more git remote URLs") -// Run is the single entry point for every `ws add` style operation. -// It owns the sidecar lifecycle and dispatches on Mode: -// -// ModeAuto with URLs → headless -// ModeAuto without URLs → TUI -// ModeHeadless with URLs → headless -// ModeHeadless without URLs → ErrNoURLs -// ModeTUI → TUI -// ModeEmbedded → ErrEmbedNotSupported (reserved) -// -// Per-URL errors accumulate in Result.Errors and do NOT abort the -// loop; Run returns a non-nil error only when the whole operation -// fails to start (nil required fields, sidecar conflict, ctx cancel -// before the first register). func Run(ctx context.Context, opts Options) (*Result, error) { if err := ctx.Err(); err != nil { return nil, err @@ -73,7 +52,7 @@ func Run(ctx context.Context, opts Options) (*Result, error) { if len(opts.URLs) == 0 { useTUI = true } - // ModeAuto with URLs → headless (useTUI stays false). + case ModeHeadless: if len(opts.URLs) == 0 { return nil, ErrNoURLs @@ -82,10 +61,6 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("add.Run: unknown mode %d", opts.Mode) } - // Sidecar acquire (blocks concurrent `ws add`, pauses the daemon). - // We don't read the returned sidecar struct — the file's mere - // presence with our pid is the contract; `defer releaseSidecar` - // removes it on every exit. if _, err := acquireSidecar(opts.WsRoot, opts.Mode, opts.URLs); err != nil { return nil, err } @@ -97,23 +72,9 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return runHeadless(ctx, opts) } -// runTUI launches AddModel as a standalone tea.Program and returns -// the Result accumulated by the model when it reaches its done state. -// -// The sources used by the gather pass come from buildSources unless -// the caller pre-populated opts.GhProvider (which only overrides the -// GitHub source — disk and clipboard sources are always built from -// opts.WsRoot/opts.Workspace). This keeps standalone `ws add` working -// out of the box while letting tests inject any subset. func runTUI(ctx context.Context, opts Options) (*Result, error) { sources := buildSources(opts) - // 10s per-source budget covers gh CLI paginate at ~300 repos - // (observed: 7.5s for 294 repos via gh --paginate). Disk and - // clipboard sources always finish well under this; the cap only - // matters for github. Increase further if real users at 1k+ - // repos hit it — the TUI keeps spinning until ctx.Done either - // way. model := NewAddModel(AddModelOptions{ WsRoot: opts.WsRoot, Workspace: opts.Workspace, @@ -123,8 +84,6 @@ func runTUI(ctx context.Context, opts Options) (*Result, error) { Standalone: true, }) - // Use AltScreen + signal handler so Ctrl+C surfaces as a clean - // AddDoneMsg and the terminal restores correctly on quit. prog := tea.NewProgram( model, tea.WithAltScreen(), @@ -147,17 +106,6 @@ func runTUI(ctx context.Context, opts Options) (*Result, error) { }, nil } -// buildSources assembles the default suggestion sources for a TUI run. -// Honors opts.GhProvider override but constructs disk and clipboard -// sources from the current environment (workspace, default reader). -// -// The GitHub source receives a workspace-derived `KnownRemotes` map so -// it can mark suggestions whose URL matches an already-registered -// project. The TUI uses that mark to render the "already cloned" -// highlight on the affected rows. -// -// Tests that need to swap sources construct their own AddModel directly -// (see tui_test.go); buildSources is the production wiring. func buildSources(opts Options) []Source { gh := opts.GhProvider if gh == nil { @@ -174,18 +122,6 @@ func buildSources(opts Options) []Source { } } -// knownRemotesFromWorkspace builds a "owner/repo" → project-path map -// from the workspace's registered projects. Used to flag GitHub -// suggestions whose remote already exists locally — the TUI then -// renders those rows with a "● cloned at " suffix and a dimmed -// style so the user can see at a glance which suggestions would -// produce duplicates. -// -// Lower-cased keys so case differences in URLs (Foo/Bar vs foo/bar) -// still collide. Lossy on the rare case of two registered projects -// with the same upstream URL — last write wins, which is fine because -// the "already cloned" highlight is a hint, not a data integrity -// guarantee. func knownRemotesFromWorkspace(ws *config.Workspace) map[string]string { if ws == nil || len(ws.Projects) == 0 { return nil @@ -204,25 +140,19 @@ func knownRemotesFromWorkspace(ws *config.Workspace) map[string]string { return out } -// ownerRepoFromRemote extracts a lowercased "owner/repo" from a git -// remote URL. Handles SSH shorthand (`git@host:owner/repo.git`) and -// scheme://host/owner/repo[.git] forms. Returns empty string when the -// shape doesn't match — the caller treats that as "no match". func ownerRepoFromRemote(remote string) string { s := strings.TrimSpace(remote) s = strings.TrimSuffix(s, ".git") s = strings.TrimSuffix(s, "/") - // SSH shorthand: git@host:owner/repo if at := strings.Index(s, "@"); at >= 0 && !strings.Contains(s, "://") { rest := s[at+1:] if colon := strings.Index(rest, ":"); colon >= 0 { - s = rest[colon+1:] // owner/repo + s = rest[colon+1:] return strings.ToLower(s) } } - // scheme://host/owner/repo if i := strings.Index(s, "://"); i >= 0 { s = s[i+3:] if slash := strings.Index(s, "/"); slash >= 0 { @@ -230,7 +160,6 @@ func ownerRepoFromRemote(remote string) string { } } - // Strip any trailing path segments beyond owner/repo. parts := strings.Split(s, "/") if len(parts) >= 2 { return strings.ToLower(parts[0] + "/" + parts[1]) @@ -238,9 +167,6 @@ func ownerRepoFromRemote(remote string) string { return "" } -// resolveSaveFn returns opts.Save when set, else falls back to the -// production config.Save call. AddModel needs a non-nil saver because -// Register asserts on the field at the per-job level. func resolveSaveFn(opts Options) func(*config.Workspace) error { if opts.Save != nil { return opts.Save @@ -250,11 +176,6 @@ func resolveSaveFn(opts Options) func(*config.Workspace) error { } } -// runHeadless walks Options.URLs, calling Register for each. It does -// NOT short-circuit on the first failure — failed URLs accumulate in -// Result.Errors so the user sees the whole story after one invocation. -// Between URLs, runHeadless re-checks ctx so a Ctrl+C mid-batch is -// honored promptly. func runHeadless(ctx context.Context, opts Options) (*Result, error) { res := &Result{} for _, url := range opts.URLs { @@ -262,8 +183,7 @@ func runHeadless(ctx context.Context, opts Options) (*Result, error) { res.Errors = append(res.Errors, err) return res, nil } - // Reset per-URL options: Name is URL-specific, mustn't leak - // across loop iterations even if the caller set it once. + perURL := opts regRes, err := Register(perURL, url) if err != nil { diff --git a/internal/add/browse.go b/internal/add/browse.go index 32500cf..3958509 100644 --- a/internal/add/browse.go +++ b/internal/add/browse.go @@ -56,12 +56,12 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -69,9 +69,7 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } @@ -89,8 +87,7 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 } @@ -117,7 +114,7 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 @@ -143,9 +140,6 @@ func (m AddModel) viewBrowse() string { 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)) @@ -161,10 +155,6 @@ func (m AddModel) viewBrowse() string { 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 @@ -198,11 +188,6 @@ func (m AddModel) viewBrowse() string { } 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) @@ -217,9 +202,6 @@ func (m AddModel) viewBrowse() string { 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)) @@ -240,36 +222,24 @@ func (m AddModel) viewBrowse() string { 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)) @@ -288,9 +258,6 @@ func renderSelectionPreview(s *Suggestion) string { 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 ( @@ -300,23 +267,15 @@ const ( type browseRow struct { kind browseRowKind - text string // pre-formatted header text; empty for items - suggestion *Suggestion // non-nil for items + text string + suggestion *Suggestion } -// 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]) @@ -340,13 +299,6 @@ func buildBrowseRows(view []Suggestion) []browseRow { 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) @@ -367,9 +319,6 @@ func groupKey(s Suggestion) (key, label string, order int) { } } -// 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 @@ -390,10 +339,6 @@ func windowAround(cursor, total, size int) (start, end int) { 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 := "" @@ -401,15 +346,12 @@ func renderItemLine(cursor string, s *Suggestion) string { 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)) @@ -431,10 +373,6 @@ func (m AddModel) filteredView() []Suggestion { } 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) @@ -445,9 +383,7 @@ func (m AddModel) filteredView() []Suggestion { 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 diff --git a/internal/add/clipboard.go b/internal/add/clipboard.go index 798142b..9e219c4 100644 --- a/internal/add/clipboard.go +++ b/internal/add/clipboard.go @@ -12,35 +12,9 @@ import ( "github.com/kuchmenko/workspace/internal/git" ) -// ClipboardSource reads the system clipboard via internal/clipboard -// and surfaces its contents as a Suggestion when (and only when) the -// content looks like a git remote URL. -// -// The "looks like a git URL" test is intentionally tight per the issue -// review (issue #20 v2 Minor: clipboard regex boundary). A bare -// "https://example.com" must NOT surface as a chip — that's a generic -// web URL, not a repo. Acceptance is the conjunction of: -// -// - scheme is one of https, http, ssh, git (plus the git@host:owner -// shorthand), AND -// - one of: -// - path ends in `.git` -// - host is in the known-forge whitelist (github / gitlab / -// bitbucket / codeberg + $WS_GIT_HOSTS, colon-separated) -// - path matches `^//$` shape (single segment owner + -// repo, no deeper path) -// -// Anything else returns no suggestion (empty slice, nil error). The -// source never fails fatally — clipboard tool missing is silent -// (ErrUnavailable swallowed), regex miss is silent. type ClipboardSource struct { - // Reader overrides the default clipboard reader. nil → use - // internal/clipboard.DefaultReader. Tests inject fakes. Reader clipboard.Reader - // AllowedHostsExtra extends the built-in known-forge whitelist with - // per-call additions. Useful when callers want to honor an env - // override without touching environment globals. AllowedHostsExtra []string } @@ -54,9 +28,6 @@ func (s *ClipboardSource) FetchSuggestions(ctx context.Context) ([]Suggestion, e raw, err := r.Read(ctx) if err != nil { - // Tool missing is a no-op for this source. Other errors (ctx - // cancel/timeout) propagate so Gather can record the cause in - // PerSource diagnostics. if errors.Is(err, clipboard.ErrUnavailable) { return nil, nil } @@ -78,9 +49,6 @@ func (s *ClipboardSource) FetchSuggestions(ctx context.Context) ([]Suggestion, e }}, nil } -// allowedHosts merges the built-in forge whitelist with the env override -// $WS_GIT_HOSTS (colon-separated) and any AllowedHostsExtra. Lower-cased -// for host comparison. func (s *ClipboardSource) allowedHosts() map[string]bool { hosts := map[string]bool{ "github.com": true, @@ -105,54 +73,38 @@ func (s *ClipboardSource) allowedHosts() map[string]bool { return hosts } -// shorthandRegex matches the SCP-style `git@host:owner/repo[.git]` form -// that ssh shorthand uses. Captured groups: 1=host, 2=owner, 3=repo. var shorthandRegex = regexp.MustCompile( `^[a-zA-Z0-9._-]+@([a-zA-Z0-9.-]+):([a-zA-Z0-9._/-]+?)(?:\.git)?/?$`, ) -// ownerRepoPath matches `//[/]` with single-segment owner -// and repo. The TrimSuffix on `.git` is done before the regex so we -// don't have to handle it inside. var ownerRepoPath = regexp.MustCompile( `^/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/?$`, ) -// looksLikeGitURL is the tightened content filter. See the doc comment -// on ClipboardSource for the policy. func looksLikeGitURL(s string, allowedHosts map[string]bool) bool { s = strings.TrimSpace(s) - // Reject inputs with whitespace or newlines anywhere — the clipboard - // is allowed to contain multi-line text, but a multi-line "URL" is - // not a single URL. if strings.ContainsAny(s, " \t\n\r") { return false } - // SCP-style `git@host:owner/repo`. if m := shorthandRegex.FindStringSubmatch(s); m != nil { host := strings.ToLower(m[1]) - // Always trust shorthand on a known host; otherwise require the - // path to also look like owner/repo (which the regex enforces - // by structure already). + if allowedHosts[host] { return true } - // Even on unknown hosts, accept shorthand — it is unambiguously - // a git form and not a generic web URL. (The web does not have - // `git@host:path` URIs.) + return true } - // scheme://... forms. u, err := url.Parse(s) if err != nil { return false } switch strings.ToLower(u.Scheme) { case "https", "http", "ssh", "git": - // allowed schemes + default: return false } @@ -161,30 +113,24 @@ func looksLikeGitURL(s string, allowedHosts map[string]bool) bool { } host := strings.ToLower(u.Host) - // Strip port for whitelist matching: `git.example.com:8080` → `git.example.com`. + if i := strings.LastIndex(host, ":"); i >= 0 { host = host[:i] } pathTrimmed := strings.TrimSuffix(u.Path, "/") - // 1. .git suffix is the unambiguous marker. if strings.HasSuffix(pathTrimmed, ".git") { return true } - // 2. Known forge whitelist. + if allowedHosts[host] { - // Belt + braces: even on a known forge, require something - // resembling owner/repo. `https://github.com/` alone is the - // forge front page, not a repo. if ownerRepoPath.MatchString(pathTrimmed+"/") || ownerRepoPath.MatchString(pathTrimmed) { return true } return false } - // 3. owner/repo shape on an unknown host: accept (covers - // self-hosted Gitea/Forgejo via $WS_GIT_HOSTS, but also any - // generic git host the user happens to use). + if ownerRepoPath.MatchString(pathTrimmed) || ownerRepoPath.MatchString(pathTrimmed+"/") { return true } diff --git a/internal/add/clone.go b/internal/add/clone.go index 4127ab8..2691e68 100644 --- a/internal/add/clone.go +++ b/internal/add/clone.go @@ -17,18 +17,6 @@ func (m AddModel) startCloneJob(idx int) tea.Cmd { } 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, @@ -38,7 +26,7 @@ func (m AddModel) startCloneJob(idx int) tea.Cmd { Workspace: m.ws, Save: m.saveFn, Mode: ModeHeadless, - NoClone: job.FromDisk != "", // disk-found → register only + NoClone: job.FromDisk != "", } regRes, err := Register(opts, job.URL) @@ -83,11 +71,7 @@ func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { } 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) @@ -121,10 +105,6 @@ func (m AddModel) viewCloning() string { 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: diff --git a/internal/add/dedup.go b/internal/add/dedup.go index d1d3efa..e914750 100644 --- a/internal/add/dedup.go +++ b/internal/add/dedup.go @@ -5,30 +5,12 @@ import ( "strings" ) -// normalizeRemoteURL collapses the many spellings of the same git remote -// into a single comparable key. Used by the suggestion-dedup path: -// when the disk source reports `git@github.com:foo/bar.git` and the -// GitHub source reports `https://github.com/foo/bar`, those are the -// same repo and should produce one Suggestion with Sources=[disk, gh]. -// -// Normalized form: "//" -// with the trailing .git stripped and any leading scheme/user dropped. -// Lower-casing is safe here because GitHub, GitLab, and Bitbucket all -// treat owner/repo as case-insensitive; self-hosted forges that care -// about case should still normalize to one canonical form anyway. -// -// The function is designed to be forgiving: inputs it cannot parse -// (empty string, not a URL, weird scheme) pass through unchanged so -// the suggestion code can still de-duplicate on exact string equality -// in the worst case. func normalizeRemoteURL(raw string) string { s := strings.TrimSpace(raw) if s == "" { return "" } - // SSH shorthand: git@host:path → translate to ssh://git@host/path - // so url.Parse understands it. if idx := strings.Index(s, "@"); idx > 0 && !strings.Contains(s, "://") { rest := s[idx+1:] if colon := strings.Index(rest, ":"); colon >= 0 { @@ -40,9 +22,6 @@ func normalizeRemoteURL(raw string) string { u, err := url.Parse(s) if err != nil || u.Host == "" || u.Path == "" { - // Couldn't parse — return the original trimmed string. Dedup - // still works for exact-duplicate strings, just not across - // ssh/https variants. return s } diff --git a/internal/add/disk.go b/internal/add/disk.go index 1da4a0b..5dcbc82 100644 --- a/internal/add/disk.go +++ b/internal/add/disk.go @@ -11,46 +11,16 @@ import ( "github.com/kuchmenko/workspace/internal/git" ) -// DiskSource walks the workspace's category directories looking for -// git repositories that are not yet registered in workspace.toml. It -// is the successor of the standalone `ws scan` command — same walk, -// same skip rules, same one-level recursion — but plugged into the -// `ws add` TUI as a suggestion source instead of a print-and-exit -// CLI. -// -// Behavior is intentionally identical to scan's directory walk: -// -// - Roots: personal/, work/, playground/, researches/, tools/. -// Override via DiskSource{Roots: ...} for tests; nil → defaults. -// - Skip directory entries that are hidden (start with `.`) or carry -// the worktree-infrastructure suffixes `.bare` / `-wt-*` so we don't -// report a project's own bare clone or extra worktrees as orphans. -// - Recurse one level deeper (work// shape) when a top-level -// entry isn't itself a git repo. No deeper — the workspace layout -// never nests projects beyond two segments. -// -// The source pulls a remote URL via `git remote get-url origin` for each -// found repo; an empty result is OK and surfaces as a Suggestion with -// no RemoteURL (the TUI's edit screen lets the user fill it in). type DiskSource struct { - // WsRoot is required. WsRoot string - // Known is the set of `Project.Path` values already in workspace.toml. - // We match against this set to filter out registered projects. Known map[string]bool - // Roots overrides the default scan directories. nil → DefaultDiskRoots. - // Useful in tests and on workspaces with non-standard layouts. Roots []string } -// DefaultDiskRoots are the category directories scanned by the disk -// source when DiskSource.Roots is nil. Mirrors what `ws scan` walks. var DefaultDiskRoots = []string{"personal", "work", "playground", "researches", "tools"} -// NewDiskSource is the convenience constructor. Builds the Known set -// from a Workspace so callers don't have to. func NewDiskSource(wsRoot string, ws *config.Workspace) *DiskSource { known := make(map[string]bool) if ws != nil { @@ -83,20 +53,12 @@ func (s *DiskSource) FetchSuggestions(ctx context.Context) ([]Suggestion, error) } if err := s.walk(ctx, absDir, &out); err != nil { - // Walk-level errors are non-fatal: ENOENT/EACCES on a single - // subdir shouldn't abort the whole scan. Mirror scan's - // stderr-warn behavior by logging would require a logger; - // instead we silently skip — the source contract is "best - // effort". continue } } return out, nil } -// walk handles one root and one optional level of recursion. A repo at -// the top level is reported directly; a non-repo dir is descended once -// to catch the work// shape used by some workspaces. func (s *DiskSource) walk(ctx context.Context, absDir string, out *[]Suggestion) error { entries, err := os.ReadDir(absDir) if err != nil { @@ -117,7 +79,6 @@ func (s *DiskSource) walk(ctx context.Context, absDir string, out *[]Suggestion) continue } - // One-level recursion for org-grouped layouts. subEntries, err := os.ReadDir(entryPath) if err != nil { continue @@ -138,9 +99,6 @@ func (s *DiskSource) walk(ctx context.Context, absDir string, out *[]Suggestion) return nil } -// maybeAdd appends a Suggestion for absPath unless it's already in the -// known-paths set. The suggestion's Name is the directory leaf, which -// the TUI/Register honor unless the user overrides via the edit screen. func (s *DiskSource) maybeAdd(absPath string, out *[]Suggestion) { relPath, err := filepath.Rel(s.WsRoot, absPath) if err != nil { @@ -160,14 +118,6 @@ func (s *DiskSource) maybeAdd(absPath string, out *[]Suggestion) { }) } -// skipName matches directory entries we should not descend into: -// - hidden (`.git`, `.cache`, etc.) -// - bare repos belonging to a registered project (`.bare`) -// - extra worktrees of a registered project (`-wt-*`) -// -// The `.bare` and `-wt-` filters intentionally use string ops, not -// fs.Stat checks, so disconnected leftover dirs (whose parent project -// got deleted) are still skipped — they are not the user's intent. func (s *DiskSource) skipName(name string) bool { if strings.HasPrefix(name, ".") { return true diff --git a/internal/add/edit.go b/internal/add/edit.go index 82c24a8..3b431fb 100644 --- a/internal/add/edit.go +++ b/internal/add/edit.go @@ -16,11 +16,11 @@ func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { } switch key.String() { case "tab", "down": - m.editFocus = (m.editFocus + 1) % 4 // 0=Name 1=URL 2=Category 3=Group + m.editFocus = (m.editFocus + 1) % 4 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 @@ -32,9 +32,9 @@ func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -56,8 +56,7 @@ func (m *AddModel) applyEditRunes(runes []rune) { 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 @@ -168,11 +167,6 @@ func (m AddModel) viewConfirm() string { 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 { @@ -197,11 +191,6 @@ func (m AddModel) updateBulkConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/add/format.go b/internal/add/format.go index 3127b66..f198c94 100644 --- a/internal/add/format.go +++ b/internal/add/format.go @@ -11,10 +11,6 @@ import ( "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 @@ -25,8 +21,6 @@ func truncate(s string, n int) string { 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 { @@ -52,8 +46,6 @@ func emit(msg tea.Msg) tea.Cmd { } 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, "/") @@ -91,16 +83,6 @@ func shortURL(s Suggestion) string { 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 { @@ -123,15 +105,6 @@ func renderSourceChipsLive(outcomes []SourceOutcome) string { 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 "" @@ -154,9 +127,7 @@ func sourceErrHint(err error) string { 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:]) diff --git a/internal/add/gather.go b/internal/add/gather.go index 2e195a3..ef292b1 100644 --- a/internal/add/gather.go +++ b/internal/add/gather.go @@ -8,17 +8,6 @@ import ( 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{ @@ -28,24 +17,15 @@ func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tea.Model, tea.Cmd) { 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: @@ -72,12 +52,10 @@ func (m AddModel) viewGathering() string { 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)) diff --git a/internal/add/github_source.go b/internal/add/github_source.go index 2117f44..2c3e862 100644 --- a/internal/add/github_source.go +++ b/internal/add/github_source.go @@ -9,30 +9,14 @@ import ( "github.com/kuchmenko/workspace/internal/github" ) -// GitHubSource wraps github.Provider into the Source contract used by -// the gather pipeline. It converts each github.Repo into a Suggestion -// with SourceGitHub and the activity/PushedAt fields filled in. -// -// The number of suggestions returned is capped by Limit (default 50) -// to keep the TUI readable. Callers wanting "all repos" pass Limit=0. type GitHubSource struct { - // Provider is the github.Provider to query. Required. Provider github.Provider - // Limit caps the number of suggestions per call. 0 → DefaultLimit. Limit int - // KnownRemotes maps lowercased "owner/repo" to the workspace.toml - // project path. Suggestions whose FullName hits this map get - // RegisteredPath set so the TUI can highlight already-cloned - // repositories. Empty/nil → no highlights, all repos surface as - // fresh suggestions. KnownRemotes map[string]string } -// DefaultLimit is the suggestion cap when GitHubSource.Limit is 0. -// 50 is enough for the sole user's account today; revisit if 10x-scale -// users complain. const DefaultLimit = 50 func (*GitHubSource) Name() string { return "github" } @@ -48,9 +32,6 @@ func (s *GitHubSource) FetchSuggestions(ctx context.Context) ([]Suggestion, erro repos, err := s.Provider.SuggestRepos(ctx, limit) if err != nil { - // Treat ErrNotAuthed as "source unavailable" — silent, like - // a missing clipboard tool. Gather records the error in - // PerSource so the TUI can show a "run gh auth login" chip. if errors.Is(err, github.ErrNotAuthed) { return nil, fmt.Errorf("github source: %w", err) } @@ -66,11 +47,9 @@ func (s *GitHubSource) FetchSuggestions(ctx context.Context) ([]Suggestion, erro GhActivity: r.Activity, PushedAt: r.PushedAt, Description: r.Description, - InferredGrp: r.Owner, // GitHub owner = inferred group (org or self) + InferredGrp: r.Owner, } - // Cross-reference workspace.toml: if a project with this - // remote is already registered, surface its path so the TUI - // can highlight the suggestion as "already cloned at X". + if s.KnownRemotes != nil { if p, ok := s.KnownRemotes[strings.ToLower(r.FullName)]; ok && p != "" { sug.RegisteredPath = p diff --git a/internal/add/manual.go b/internal/add/manual.go index dc94bc2..16e282b 100644 --- a/internal/add/manual.go +++ b/internal/add/manual.go @@ -16,7 +16,7 @@ func (m AddModel) updateManual(msg tea.Msg) (tea.Model, tea.Cmd) { m.manualErr = "URL is required" return m, nil } - // Build editFields from the bare URL. + name := parseRepoNameFromURL(val) m.editFields = editFields{ Name: name, diff --git a/internal/add/msg.go b/internal/add/msg.go index 5b4ffda..2d9ce56 100644 --- a/internal/add/msg.go +++ b/internal/add/msg.go @@ -6,16 +6,12 @@ import ( "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 @@ -23,21 +19,14 @@ type cloneDoneMsg struct { 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 diff --git a/internal/add/options.go b/internal/add/options.go index ab8d7ca..065dfc6 100644 --- a/internal/add/options.go +++ b/internal/add/options.go @@ -5,98 +5,48 @@ import ( "github.com/kuchmenko/workspace/internal/github" ) -// Mode controls whether Run presents a TUI or runs headless. type Mode int const ( - // ModeAuto picks based on runtime: TUI when stdin is a TTY and - // no URLs are given; headless otherwise. Default for `ws add`. ModeAuto Mode = iota - // ModeHeadless forces the non-interactive path. Set by `--no-tui` - // or selected automatically when stdin is not a TTY. ModeHeadless - // ModeTUI forces the TUI even on a non-TTY (rarely useful; - // included for symmetry with --no-tui and to let the future - // embed path opt in explicitly). ModeTUI - // ModeEmbedded is used when `ws add` is hosted inside another - // bubbletea program (the agent TUI). The caller is responsible - // for the parent tea.Program lifecycle; Run does not create its - // own. Currently returns ErrEmbedNotSupported — the embedded - // path lands with the agent integration. ModeEmbedded ) -// Options is the union of every knob `ws add` exposes. CLI and agent -// callers populate the fields they care about; all others take sane -// defaults. Runtime-dependency fields (GhProvider, ClipboardImpl, -// DiskRoots, Save) are nil-able — the zero value triggers production -// defaults, tests inject doubles. type Options struct { - // Inputs. - - // URLs lists positional git-remote URLs. Empty → Run gathers - // suggestions (TUI) or errors (headless). URLs []string - // Category is the `Projects[*].Category` field to write. Empty → - // config.CategoryPersonal. Category config.Category - // Group overrides the auto-inferred group. Empty → inferGroup. Group string - // Name overrides the derived repo name. Empty → git.ParseRepoName. Name string - // NoClone writes the TOML entry without cloning. Useful for - // pre-registering a project whose remote will become available - // later. Disk-source entries do not honor this flag (they are - // already cloned). NoClone bool - // Mode selects TUI vs headless. See Mode. Mode Mode - // Runtime + injection. - - // WsRoot is the workspace root. Required; Run errors on empty. WsRoot string - // Workspace is the in-memory toml state. Required; Run errors on nil. Workspace *config.Workspace - // Save persists the workspace. Defaults to config.Save(WsRoot, ws). - // Injected for tests. Save func(*config.Workspace) error - // GhProvider is the GitHub suggestion backend. nil → - // github.ResolveProvider(). Override in tests or to inject a - // stubbed provider. GhProvider github.Provider } -// Result summarizes what Run did. Always non-nil; check Errors for -// partial-failure cases. type Result struct { - // Added are the projects successfully registered and, if relevant, - // cloned. One entry per URL for headless multi-add. Added []config.Project - // Skipped records URLs that were intentionally skipped (e.g. - // already registered, or the user chose "skip" in the TUI). Skipped []SkipReason - // Errors collects per-URL failures. Run returns a non-nil error - // only when the whole operation failed (e.g. sidecar-acquire - // conflict); individual per-URL failures land here instead. Errors []error } -// SkipReason explains why Run did not register a URL. type SkipReason struct { URL string Reason string diff --git a/internal/add/register.go b/internal/add/register.go index 01b66c9..f25f97b 100644 --- a/internal/add/register.go +++ b/internal/add/register.go @@ -10,40 +10,14 @@ import ( "github.com/kuchmenko/workspace/internal/git" ) -// ErrAlreadyRegistered is returned when a URL maps to a project name -// that already exists in Workspace.Projects. Distinguished from clone's -// ErrAlreadyCloned so callers can surface a different message ("try -// --name" rather than "already cloned"). var ErrAlreadyRegistered = errors.New("project already registered") -// RegisterResult carries the outcome of a single Register call. type RegisterResult struct { Project config.Project Name string - Cloned bool // true if we actually invoked CloneIntoLayout; false with --no-clone + Cloned bool } -// Register materializes one URL into a workspace.toml entry (and, by -// default, a bare+worktree clone). It is the single place that writes -// workspace.toml — both Run's headless loop and the TUI edit→confirm -// path funnel through here. -// -// The caller is responsible for supplying Options with WsRoot, -// Workspace, and Save set. Register does not acquire the sidecar — Run -// owns that lifecycle. -// -// Lifecycle: -// 1. Derive name (opts.Name override → git.ParseRepoName fallback). -// 2. Validate category, build relative path (group/name or category/name). -// 3. Reject if the name is already in Workspace.Projects. -// 4. Optionally CloneIntoLayout (unless opts.NoClone). -// 5. Mutate ws.Projects[name] = proj, persist via opts.Save. -// -// On CloneIntoLayout failure, Register does NOT save the workspace — -// the caller sees the error and no half-state lands in workspace.toml. -// If Save itself fails after a successful clone, the cloned bare+worktree -// stays on disk; the user can re-run `ws add` and the second invocation -// will detect clone.ErrAlreadyCloned and register only. func Register(opts Options, url string) (*RegisterResult, error) { if opts.WsRoot == "" { return nil, errors.New("register: empty WsRoot") @@ -89,11 +63,6 @@ func Register(opts Options, url string) (*RegisterResult, error) { cloned := false if !opts.NoClone { - // CloneIntoLayout mutates proj.DefaultBranch on success. - // Pass no PromptDefaultBranch — Register is non-interactive - // by contract; ambiguous defaults surface as - // clone.ErrNeedsBootstrap and the caller is told to run - // `ws bootstrap ` afterwards. _, err := clone.CloneIntoLayout(opts.WsRoot, name, &proj, clone.Options{}) if err != nil { return nil, fmt.Errorf("clone %s: %w", name, err) @@ -119,25 +88,10 @@ func Register(opts Options, url string) (*RegisterResult, error) { return &RegisterResult{Project: proj, Name: name, Cloned: cloned}, nil } -// inferGroup picks a group label for a Suggestion when the caller -// hasn't supplied an explicit Group. Called from headless registration -// (where group is rarely set on the CLI). For TUI registrations the -// edit screen pre-fills from the suggestion's InferredGrp (typically -// the GitHub owner) and the user can override before confirm — -// inferGroup is the fallback for when no signal is available. -// -// Current policy is simple: group == string(category). The legacy -// `ws setup` step_group.go grouped GitHub repos by owner; this -// minimal version preserves the contract (every project has a -// non-empty group) without dragging in the org-resolution -// scaffolding. The TUI's edit screen handles richer cases. func inferGroup(_ string, cat config.Category) string { return string(cat) } -// buildPath assembles the workspace-relative directory for a project. -// Preserves the legacy `ws add` behavior: group=="" falls through to -// `/`; explicit group trumps category. func buildPath(group string, cat config.Category, name string) string { if group != "" { return filepath.Join(group, name) diff --git a/internal/add/sidecar.go b/internal/add/sidecar.go index acd500f..04e4f1d 100644 --- a/internal/add/sidecar.go +++ b/internal/add/sidecar.go @@ -8,34 +8,13 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// sidecarPayload is the `ws add` command-specific body stored inside -// the shared sidecar envelope. It records enough to tell a user who -// invokes `ws add` while another run is in flight what the other run -// is doing — mode + URLs it's processing. -// -// The shared sidecar struct round-trips arbitrary payload types via -// json.RawMessage; we slot this through sidecar.Set under a fixed key. type sidecarPayload struct { Mode Mode `json:"mode"` URLs []string `json:"urls,omitempty"` } -// sidecarPayloadKey is the well-known entry name under which we store -// the payload. The shared Done map is keyed by "project name"; for the -// `add` kind we use a single pseudo-entry because Run operates as a -// session, not per-project. const sidecarPayloadKey = "__session__" -// acquireSidecar creates and persists the sidecar for this Run. If a -// live sidecar already exists, returns an error describing the other -// run. If a stale sidecar exists, deletes it before acquiring. -// -// Stale sidecars are cleared silently rather than prompting for -// resume (the way bootstrap does) because `ws add` has no -// per-project mid-progress state to resume — a failed run's partial -// clone is either recoverable via a re-run (clone.ErrAlreadyCloned -// on the bare path) or leaves nothing behind (if the sidecar was -// written before any clone started). func acquireSidecar(wsRoot string, mode Mode, urls []string) (*sidecar.Sidecar, error) { existing, err := sidecar.Load(wsRoot, sidecar.KindAdd) if err != nil { @@ -43,7 +22,6 @@ func acquireSidecar(wsRoot string, mode Mode, urls []string) (*sidecar.Sidecar, } if existing != nil { if sidecar.IsAlive(existing) { - // Describe the other run so the user can decide. var pay sidecarPayload _, _ = existing.Get(sidecarPayloadKey, &pay) return nil, fmt.Errorf( @@ -53,8 +31,7 @@ func acquireSidecar(wsRoot string, mode Mode, urls []string) (*sidecar.Sidecar, describePayload(pay), ) } - // Stale: dead pid. Clear it silently — the failed run has no - // recoverable state that a user could resume. + if err := sidecar.Delete(wsRoot, sidecar.KindAdd); err != nil { return nil, fmt.Errorf("clear stale add sidecar: %w", err) } @@ -70,16 +47,10 @@ func acquireSidecar(wsRoot string, mode Mode, urls []string) (*sidecar.Sidecar, return sc, nil } -// releaseSidecar removes the sidecar file. Best-effort — errors here -// mean "leftover sidecar on disk" which the next `ws add` invocation -// will recognize as stale and clear. Callers `defer` this on every -// exit path of Run. func releaseSidecar(wsRoot string) { _ = sidecar.Delete(wsRoot, sidecar.KindAdd) } -// describePayload summarizes the payload for "already running" error -// messages. Kept short to fit on one terminal line. func describePayload(p sidecarPayload) string { modeName := "auto" switch p.Mode { diff --git a/internal/add/styles.go b/internal/add/styles.go index 5e1bab7..b3f3394 100644 --- a/internal/add/styles.go +++ b/internal/add/styles.go @@ -29,42 +29,25 @@ var ( 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/suggestions.go b/internal/add/suggestions.go index f1a583f..9f84c6e 100644 --- a/internal/add/suggestions.go +++ b/internal/add/suggestions.go @@ -7,42 +7,21 @@ import ( "time" ) -// Source is a producer of Suggestions for the `ws add` TUI. Three -// concrete implementations: DiskSource, ClipboardSource, GitHubSource. -// -// FetchSuggestions must: -// - Honor ctx cancellation promptly. -// - Never panic on transient errors — return (nil, err) and let -// the caller decide whether to surface or swallow. -// - Return empty slice + nil error when the source has nothing to -// say (e.g. clipboard doesn't contain a URL). Nil slice is -// equivalent. type Source interface { - // FetchSuggestions returns this source's offerings. AddModel - // invokes each source in parallel as a separate tea.Cmd; results - // arrive incrementally and fold into the rendered tree. FetchSuggestions(ctx context.Context) ([]Suggestion, error) - // Name is a short tag for diagnostics ("disk", "clipboard", - // "github", "gh-cli", etc.). Used in GatherResult to attribute - // per-source timing and errors. Name() string } -// SourceKind identifies where a Suggestion came from. One Suggestion -// may carry multiple kinds after dedup — e.g. a repo that is both on -// disk AND in the clipboard shows Sources=[Disk, Clipboard]. type SourceKind int const ( SourceDisk SourceKind = iota SourceClipboard SourceGitHub - SourceManual // typed into the TUI by hand + SourceManual ) -// String returns the short label rendered as a chip in the TUI. -// Keeps the mapping centralized so UI code never hardcodes strings. func (k SourceKind) String() string { switch k { case SourceDisk: @@ -58,87 +37,41 @@ func (k SourceKind) String() string { } } -// Suggestion is one candidate row shown in the `ws add` browse list, -// and also the unit of the dedup layer. Multiple providers surfacing -// the same logical repo merge into one Suggestion with accumulated -// Sources. type Suggestion struct { - // Name is the repo short name (e.g. "workspace"). Derived from - // the URL by the source; may be overridden at register time. Name string - // RemoteURL is the original URL as the source reported it. Use - // normalizeRemoteURL(RemoteURL) for dedup comparisons; keep the - // raw string here so register can pass it straight to clone. RemoteURL string - // Sources lists every provider that offered this suggestion. The - // TUI renders these as chips ([disk] [clip] [gh]). Sources []SourceKind - // DiskPath is non-empty when the suggestion comes from the disk - // source. Presence flips the register action from "clone" to - // "migrate / reconcile" because the repo is already local. DiskPath string - // RegisteredPath is non-empty when the GitHub-suggested URL maps - // to a project already present in workspace.toml. The TUI renders - // these with a "● cloned at " highlight so the user can tell - // at a glance which suggestions would be duplicates. Selecting one - // is still allowed — the edit screen will surface a name conflict - // and the user can rename to create a copy at a fresh path. RegisteredPath string - // GhActivity is the event count from GitHub Events API — useful - // for sort order when a repo is in the GitHub source. Zero for - // non-GitHub suggestions. GhActivity int - // PushedAt is the upstream-last-push timestamp. Zero when the - // source doesn't provide it (clipboard). PushedAt time.Time - // Description is the human-readable repo blurb, when the source - // has one. The TUI shows it on the currently-selected row and - // includes it in the substring search. Empty for clipboard / - // disk / manual entries (only GitHub provides descriptions - // today). Description string - // InferredGrp is the group name our grouper assigned. Used by the - // TUI to pre-fill the group field on the edit screen. InferredGrp string } -// SourceOutcome is the per-source status row tracked by AddModel as -// each Source's FetchSuggestions call completes. Used by the TUI to -// render the "disk:5 github:294" status chip line. type SourceOutcome struct { Name string - Count int // number of suggestions this source produced - Duration time.Duration // wall-clock time the fetch took - Err error // nil on success; timeout / failure otherwise + Count int + Duration time.Duration + Err error } -// DefaultSourceTimeout is the out-of-the-box per-source deadline. -// AddModel's runTUI raises this to 10s to cover gh-CLI paginate at -// scale; the constant is the lower-bound default for callers that -// don't override. const DefaultSourceTimeout = 3 * time.Second -// mergeSuggestions deduplicates the union of all source outputs by -// normalized URL. When two providers contribute the same repo, the -// merged Suggestion accumulates Sources from both, and the first -// non-empty field wins for scalars (DiskPath, GhActivity, PushedAt, -// Name, RemoteURL, InferredGrp). func mergeSuggestions(buckets [][]Suggestion) []Suggestion { byKey := make(map[string]*Suggestion) for _, bucket := range buckets { for _, s := range bucket { key := normalizeRemoteURL(s.RemoteURL) if key == "" { - // Fall back to name-based grouping when URL can't be - // normalized. Better than dropping the entry. key = "name:" + s.Name } cur, ok := byKey[key] @@ -147,7 +80,7 @@ func mergeSuggestions(buckets [][]Suggestion) []Suggestion { byKey[key] = © continue } - // Merge: union Sources, first non-zero wins for scalars. + cur.Sources = unionSources(cur.Sources, s.Sources) if cur.DiskPath == "" { cur.DiskPath = s.DiskPath @@ -183,9 +116,6 @@ func mergeSuggestions(buckets [][]Suggestion) []Suggestion { return out } -// unionSources appends kinds from b to a that are not already present, -// preserving relative order in a. Expected list sizes are tiny (≤3), -// so a linear search is cheaper than a map. func unionSources(a, b []SourceKind) []SourceKind { out := append([]SourceKind{}, a...) Outer: @@ -200,29 +130,6 @@ Outer: return out } -// sortByRelevance orders merged suggestions so the in-memory slice -// matches the order the TUI tree will render them. Critical: m.cursor -// indexes this slice, and the tree's cursor marker is computed by -// counting items in render order. If the two orderings drift, the -// visual cursor and the actual selection point at different rows -// (the bug observed in production: pressing Enter selected an item -// several rows below the visible ▸). -// -// Sort axes, in descending precedence: -// -// 1. Group order — Clipboard / Manual at top, then Local -// (unregistered), then GitHub orgs alphabetical. Mirrors what -// buildBrowseRows does for headers; pre-sorting here lets that -// function be a simple linear walk. -// 2. Within group: -// a. Disk presence (a github repo also-on-disk floats above -// github-only ones in the same org) -// b. Activity desc -// c. PushedAt desc -// d. Name asc -// -// Stable so that otherwise-equal entries keep the order from the -// first source they appeared in. func sortByRelevance(s []Suggestion) { sort.SliceStable(s, func(i, j int) bool { _, li, oi := groupKey(s[i]) @@ -233,7 +140,7 @@ func sortByRelevance(s []Suggestion) { if li != lj { return strings.ToLower(li) < strings.ToLower(lj) } - // Same group. Disk presence wins. + diskI := hasSource(s[i].Sources, SourceDisk) diskJ := hasSource(s[j].Sources, SourceDisk) if diskI != diskJ { diff --git a/internal/add/tui.go b/internal/add/tui.go index bc56ca3..8efb179 100644 --- a/internal/add/tui.go +++ b/internal/add/tui.go @@ -12,90 +12,47 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// AddModel is the bubbletea model for the `ws add` interactive flow. -// -// Lifecycle: -// -// gathering → browse | browseEmpty -// browse / browseEmpty → manual (i) | edit (⏎) | quit (esc) -// manual → edit (valid URL) | browse (esc) -// edit → confirm (⏎) | browse (esc) -// confirm → cloning (y) | browse (esc) -// cloning → branchPrompt (clone.ErrNeedsBootstrap) | done -// branchPrompt → cloning -// done → quit -// -// Embedding: AddModel never calls tea.Quit. When it reaches done, it -// emits AddDoneMsg and waits for a key. Standalone callers (`ws add`) -// wrap AddModel and convert AddDoneMsg into tea.Quit; embedded -// callers (the future agent integration) keep running their own -// Update loop. type AddModel struct { state addState stateChangedAt time.Time - // Inputs from the caller. wsRoot string ws *config.Workspace saveFn func(*config.Workspace) error sources []Source gatherTO time.Duration - // Standalone flag: when true, AddModel calls tea.Quit on done. - // When embedded inside ws agent, the parent owns the quit decision. standalone bool - // Optional pre-supplied URLs from the CLI that bypass the gather + - // browse phases. Headless callers don't construct AddModel at all, - // but a TUI run with positional URLs (rare — this design treats - // "URLs given" as a headless signal) could use this. preURLs []string - // Window sizing. width, height int - // State for each step. Most fields belong to one state; see the - // comment headers below for which. - - // gathering. spinner spinner.Model - // sourceOutcomes accumulates per-source results as each one - // completes. Used by viewBrowse to render the "disk:N github:M" - // chip line and by Update to decide when all sources are done. sourceOutcomes []SourceOutcome sourcesDone int - // browse. - cursor int // index into filteredView() + cursor int allSuggestions []Suggestion filterMode bool filterInput textinput.Model - // selectedURLs holds RemoteURLs of suggestions the user marked - // for bulk add via space-toggle in browse. Stable across filter - // changes — toggling a row off-screen still works as long as the - // URL stays in allSuggestions. selectedURLs map[string]bool - // manual. manualInput textinput.Model manualErr string - // edit (also reused by confirm). editFields editFields - editFocus int // 0=Name 1=Category 2=Group + editFocus int editErr string - // cloning. - queue []editFields // resolved selections waiting to clone - currentIdx int // index into queue - branchAnswer chan branchAnswer // unblocks worker goroutines + queue []editFields + currentIdx int + branchAnswer chan branchAnswer - // branchPrompt. branchPrompt branchprompt.Model - // done. added []config.Project skipped []SkipReason errors []error @@ -121,8 +78,8 @@ type editFields struct { URL string Category config.Category Group string - Path string // computed from Category/Group/Name - FromDisk string // non-empty → migrate path, not clone + Path string + FromDisk string } type branchAnswer struct { @@ -130,11 +87,6 @@ type branchAnswer struct { err error } -// NewAddModel constructs an AddModel ready to be run via tea.NewProgram. -// -// The caller supplies the workspace, the save function, and the gather -// sources. NewAddModel does NOT call Gather itself — that happens in -// Init() so the bubbletea runtime can render the gathering view first. func NewAddModel(opts AddModelOptions) AddModel { sp := spinner.New() sp.Spinner = spinner.Dot @@ -165,8 +117,6 @@ func NewAddModel(opts AddModelOptions) AddModel { } } -// AddModelOptions is the constructor input. Carved out as a struct so -// the constructor signature doesn't grow with each new knob. type AddModelOptions struct { WsRoot string Workspace *config.Workspace @@ -174,26 +124,12 @@ type AddModelOptions struct { Sources []Source GatherTimeout time.Duration - // Standalone is true when AddModel runs as the root program (i.e. - // `ws add` without an embedding parent). Done state then issues - // tea.Quit. Embedded callers pass false; they handle AddDoneMsg - // themselves to decide quit vs return-to-list. Standalone bool - // PreURLs are URLs supplied by the caller — currently unused by the - // TUI proper (CLI passes headless when URLs are given), kept as a - // hook for callers that want to launch the TUI with a starter list. PreURLs []string } 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 - // returns. Disk + clipboard typically finish within a few hundred - // ms; GitHub can take seconds on cold cache. The TUI transitions - // from "gathering" to "browse" as soon as the FIRST source has - // any data — repos from later sources fold in dynamically without - // the user staring at a spinner. cmds := []tea.Cmd{m.spinner.Tick} for _, src := range m.sources { cmds = append(cmds, m.startSource(src)) @@ -201,11 +137,6 @@ func (m AddModel) Init() tea.Cmd { return tea.Batch(cmds...) } -// startSource produces a tea.Cmd that runs one source's -// FetchSuggestions in a goroutine and emits a sourceDoneMsg with the -// outcome. The per-source ctx deadline is applied here so a single -// slow provider never holds up the others — Gather's own timeout -// logic stays available but is unused by the streaming path. func (m AddModel) startSource(src Source) tea.Cmd { timeout := m.gatherTO if timeout <= 0 { @@ -232,13 +163,11 @@ func (m AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil case tea.KeyMsg: - // Phantom-input debounce mirrors the bootstrap pattern. + if !m.stateChangedAt.IsZero() && time.Since(m.stateChangedAt) < 100*time.Millisecond { return m, nil } if msg.String() == "ctrl+c" { - // Cancel everything. In standalone, quit; embedded - // callers see an empty AddDoneMsg. done := m.toDone() if m.standalone { return done, tea.Sequence(emit(AddDoneMsg{}), tea.Quit) @@ -246,10 +175,7 @@ func (m AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return done, emit(AddDoneMsg{}) } case sourceDoneMsg: - // Source completion can land in any state — fold the new - // results into allSuggestions even if the user has already - // moved on to manual / edit / confirm. We just don't change - // the visible state from those screens. + return m.handleSourceDone(msg) } diff --git a/internal/agent/chip_action.go b/internal/agent/chip_action.go index 518237e..1666c26 100644 --- a/internal/agent/chip_action.go +++ b/internal/agent/chip_action.go @@ -8,9 +8,6 @@ import ( "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 @@ -35,7 +32,7 @@ func (m *Model) updateChipAction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 = "" @@ -49,9 +46,6 @@ func (m *Model) updateChipAction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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() diff --git a/internal/agent/edit_project.go b/internal/agent/edit_project.go index 5a94362..c8c7fc8 100644 --- a/internal/agent/edit_project.go +++ b/internal/agent/edit_project.go @@ -10,12 +10,6 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// EditProjectMetadata persists a project's group and category to -// workspace.toml. Loads the on-disk config fresh, mutates the named -// project, writes back. Returns an error if the project is missing. -// -// Called by the agent TUI from updateEditProject. Pulled out into a -// pure function so it can be unit-tested without bubbletea. func EditProjectMetadata(wsRoot, projID, group string, category config.Category) error { if wsRoot == "" { return fmt.Errorf("workspace root required") @@ -45,9 +39,6 @@ func EditProjectMetadata(wsRoot, projID, group string, category config.Category) return nil } -// existingGroups returns the union of currently-known group names -// across all loaded workspaces, sorted, for use as suggestions in the -// edit form. func existingGroups(workspaces []WorkspaceData) []string { seen := map[string]bool{} for _, ws := range workspaces { @@ -65,8 +56,6 @@ func existingGroups(workspaces []WorkspaceData) []string { return out } -// updateEditProject handles input while the edit-project popup is open. -// Field layout: 0=Group (text), 1=Category (space toggles), 2=Save. func (m *Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { @@ -112,9 +101,6 @@ func (m *Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// executeEditProject persists the edited fields, then refreshes the -// in-memory workspace state so the list reflects the change without a -// reload. func (m *Model) executeEditProject() (tea.Model, tea.Cmd) { proj := m.popupProj if proj == nil { @@ -135,10 +121,6 @@ func (m *Model) executeEditProject() (tea.Model, tea.Cmd) { return m, nil } - // Patch in-memory state. The loaded WorkspaceData carries pointer- - // less Project copies in a slice — find by ID, mutate, recompute - // the workspace's Groups list, then expand any newly-introduced - // group so the moved project is visible. for wi := range m.workspaces { if m.workspaces[wi].Root != wsRoot { continue @@ -164,7 +146,7 @@ func (m *Model) executeEditProject() (tea.Model, tea.Cmd) { m.statusMsg = fmt.Sprintf("updated %s: group=%s category=%s", proj.Name, displayGroup(newGroup), newCat) m.rebuildItems() - // Re-find the project so cursor lands on the moved row. + m.jumpToProject(proj.ID) m.ensureVisible() return m, nil @@ -192,8 +174,6 @@ func recomputeGroups(projects []Project) []string { return out } -// viewEditProject renders the edit-project popup. Mirrors the layout -// of viewNewWorktree / viewPromote for visual consistency. func (m *Model) viewEditProject() string { p := m.popupProj popupW := 56 @@ -210,7 +190,6 @@ func (m *Model) viewEditProject() string { lines = append(lines, popupTitleStyle.Width(innerW).Render(title)) lines = append(lines, "") - // Field 0: group. groupLabel := " Group:" groupVal := m.editGroup if m.editField == 0 { @@ -230,7 +209,6 @@ func (m *Model) viewEditProject() string { } lines = append(lines, "") - // Field 1: category. catLabel := " Category:" catVal := string(m.editCategory) + " (space to toggle: personal | work)" if m.editField == 1 { @@ -243,7 +221,6 @@ func (m *Model) viewEditProject() string { } lines = append(lines, "") - // Field 2: save button. saveLabel := " → Save" if m.editField == 2 { lines = append(lines, popupSelectedStyle.Width(innerW).Render(saveLabel)) @@ -265,8 +242,6 @@ func (m *Model) viewEditProject() string { lipgloss.WithWhitespaceBackground(lipgloss.Color("234"))) } -// groupHint returns a comma-joined preview of existing groups for the -// edit popup. Capped at 5 for visual brevity. func groupHint(workspaces []WorkspaceData) string { groups := existingGroups(workspaces) if len(groups) == 0 { diff --git a/internal/agent/flash.go b/internal/agent/flash.go index 284d342..7713148 100644 --- a/internal/agent/flash.go +++ b/internal/agent/flash.go @@ -6,7 +6,6 @@ import ( 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) { @@ -24,7 +23,7 @@ func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.exitFlash(false) } case "enter": - // Jump to first match. + if len(m.flashMatches) > 0 { m.cursor = m.flashMatches[0] m.ensureVisible() @@ -33,9 +32,7 @@ func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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) { @@ -46,7 +43,7 @@ func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } - // Not a label — append to query to narrow results. + m.flashQuery += key m.recomputeFlash() } @@ -54,9 +51,6 @@ func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 { @@ -73,9 +67,6 @@ func (m *Model) recomputeFlash() { m.flashMatches = nil m.flashLabels = nil - // Collect matches. Section rows are non-selectable and must never - // appear in the flash match list — pressing a label that targets - // a section row would be a no-op and confuse the user. for i, item := range m.items { name := m.itemSearchName(item) if query == "" || strings.Contains(strings.ToLower(name), query) { @@ -83,28 +74,20 @@ func (m *Model) recomputeFlash() { } } - // 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 + m.flashLabels = append(m.flashLabels, 0) } } } -// 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 + return nil } var available []rune for _, r := range jumpLabels { @@ -124,7 +107,6 @@ func (m *Model) availableJumpLabels() []rune { return available } -// itemSearchName returns the searchable text for a list item. func (m *Model) itemSearchName(item listItem) string { switch item.kind { case KindGroup: @@ -132,7 +114,7 @@ func (m *Model) itemSearchName(item listItem) string { case KindProject: return item.project.Name case KindWorktree: - return item.group // display name + return item.group case KindPortal: if item.session != nil { return item.session.Title @@ -141,10 +123,6 @@ func (m *Model) itemSearchName(item listItem) string { 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 @@ -164,13 +142,11 @@ func flashInlineLabel(name, query string, label rune) string { } 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:])) } diff --git a/internal/agent/forms.go b/internal/agent/forms.go index 6d5c9f6..40a9401 100644 --- a/internal/agent/forms.go +++ b/internal/agent/forms.go @@ -23,7 +23,7 @@ func (m *Model) updateNewWorktree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.wtField = (m.wtField + 1) % 2 return m, nil case "enter": - if m.wtField == 1 { // confirm + if m.wtField == 1 { return m.executeNewWorktree() } m.wtField = (m.wtField + 1) % 2 @@ -56,7 +56,6 @@ func (m *Model) executeNewWorktree() (tea.Model, tea.Cmd) { } 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 @@ -66,7 +65,6 @@ func (m *Model) executeNewWorktree() (tea.Model, tea.Cmd) { return m, nil } - // Go to prompt input before launching. m.pendingLaunch = &LaunchRequest{Cwd: result.Path} m.promptInput = "" m.mode = viewPromptInput @@ -85,7 +83,6 @@ func (m *Model) viewNewWorktree() 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 { @@ -107,7 +104,6 @@ func (m *Model) viewNewWorktree() string { } lines = append(lines, "") - // Field 1: confirm button confirmLabel := " → Create worktree" if m.wtField == 1 { lines = append(lines, popupSelectedStyle.Width(innerW).Render(confirmLabel)) @@ -132,7 +128,7 @@ func (m *Model) updatePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/agent/header.go b/internal/agent/header.go index dd4c92a..0076314 100644 --- a/internal/agent/header.go +++ b/internal/agent/header.go @@ -9,18 +9,8 @@ import ( "github.com/charmbracelet/lipgloss" ) -// HeaderCap is the maximum number of chips that share the pinned -// quick-nav header. Nine because chips are numbered 1-9 for direct -// keyboard launch — adding more would need shift+digit and isn't -// worth the cognitive cost. const HeaderCap = 9 -// buildHeaderChips returns the ordered list of chips rendered in the -// pinned quick-nav. Favorites come first (groups and projects merged, -// sorted by activity desc with name asc tiebreak; groups carry zero -// activity so they sort last among favs), then non-favorite -// recently-touched projects. Capped at HeaderCap so chips fit in the -// 1-9 hotkey range. func buildHeaderChips(workspaces []WorkspaceData) []Chip { var favs, recent []Chip for i := range workspaces { @@ -74,9 +64,6 @@ func sortChipsByActivity(cs []Chip) { }) } -// 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 "" @@ -106,15 +93,6 @@ func humanizeAgeAt(t, now time.Time) string { } } -// renderHeaderChips formats `chips` as numbered hotkey chips packed -// into at most `maxLines` lines of width `w`. Each chip is rendered -// as `1.name 2m` (project) or `1.@group` (group, with `@` prefix to -// disambiguate at a glance). A leading `*` marks favorites. Chips -// that wouldn't fit in `maxLines` are dropped — HeaderCap=9 keeps -// the count small enough that this is rare. -// -// Returns nil on an empty input so callers omit the header rows -// entirely; an idle workspace doesn't burn vertical space on chrome. func renderHeaderChips(chips []Chip, w, maxLines int) []string { if len(chips) == 0 || w <= 0 || maxLines <= 0 { return nil @@ -126,10 +104,6 @@ func renderHeaderChips(chips []Chip, w, maxLines int) []string { return packChips(tokens, w, maxLines) } -// formatChip builds the chip token: `*N.name age` for projects and -// `*N.@group` for groups. The age column is omitted when LastActiveAt -// is zero (favorited but never stamped) so chips stay compact on a -// fresh install. func formatChip(num int, c Chip) string { star := "" if c.Favorite { @@ -146,10 +120,6 @@ func formatChip(num int, c Chip) string { return fmt.Sprintf("%s%d.%s %s", star, num, body, age) } -// packChips greedily fills lines with chips separated by two spaces, -// breaking to a new line whenever appending the next chip would push -// the running width past w. Stops once maxLines is reached, dropping -// the remaining chips silently. func packChips(chips []string, w, maxLines int) []string { var lines []string cur := "" @@ -176,12 +146,6 @@ func packChips(chips []string, w, maxLines int) []string { 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 { @@ -198,9 +162,6 @@ func styleChipLine(line string) string { 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 { @@ -227,8 +188,6 @@ func styleChip(c string) string { return b.String() } -// formatInt avoids pulling in fmt for the hot path; the values are -// always small non-negative ints (max ~24-30 in practice). func formatInt(n int) string { if n == 0 { return "0" diff --git a/internal/agent/items.go b/internal/agent/items.go index f405ece..d2f9a06 100644 --- a/internal/agent/items.go +++ b/internal/agent/items.go @@ -1,23 +1,17 @@ package agent -// rebuildItems flattens the workspace tree into the scrollable item -// list. The pinned quick-nav header is rendered separately (see -// renderHeaderChips) and never enters m.items — keeping the header -// fixed above the scroll viewport requires it to be outside the -// scrollable region. func (m *Model) rebuildItems() { m.items = nil m.headerChips = buildHeaderChips(m.workspaces) for _, ws := range m.workspaces { - // Ungrouped projects first. for i := range ws.Projects { p := &ws.Projects[i] if p.Group == "" { m.addProjectItem(p, 0) } } - // Then groups. + for _, g := range ws.Groups { m.items = append(m.items, listItem{kind: KindGroup, group: g, indent: 0, path: GroupPath(ws.Root, g)}) if m.expanded[g] { @@ -33,10 +27,6 @@ func (m *Model) rebuildItems() { m.clampCursor() } -// clampCursor keeps m.cursor inside the items range. Every row in -// m.items is selectable now that section headers live outside the -// scroll list, but we still bracket-clamp the index for safety after -// rebuilds that may have shrunk the list. func (m *Model) clampCursor() { if len(m.items) == 0 { m.cursor = 0 @@ -53,7 +43,6 @@ func (m *Model) clampCursor() { 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 } diff --git a/internal/agent/lang.go b/internal/agent/lang.go index a03b0d6..b715616 100644 --- a/internal/agent/lang.go +++ b/internal/agent/lang.go @@ -7,35 +7,23 @@ import ( "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 + iconGo = "" + iconRust = "" + iconPython = "" + iconNode = "" + iconTypeScript = "" + iconJavaScript = "" + iconRuby = "" + iconJava = "" + iconCSharp = "" + iconDocker = "" + iconShell = "" + iconMarkdown = "" ) -// 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 +var projectIconCache sync.Map -// 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 @@ -48,11 +36,6 @@ func DetectIcon(path string) string { 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 @@ -67,12 +50,9 @@ var markerFiles = []struct { {"pom.xml", iconJava}, {"build.gradle", iconJava}, {"build.gradle.kts", iconJava}, - {"package.json", iconNode}, // after tsconfig so TS wins over JS+TS + {"package.json", iconNode}, } -// 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 @@ -94,22 +74,16 @@ var suffixIcons = []struct { } 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 @@ -126,7 +100,6 @@ func detectIconUncached(path string) string { } } - // Pass 4: a lonely README.md is at least *something* recognizable. if _, err := os.Stat(filepath.Join(path, "README.md")); err == nil { return iconMarkdown } diff --git a/internal/agent/launcher.go b/internal/agent/launcher.go index 5a644b3..009dc68 100644 --- a/internal/agent/launcher.go +++ b/internal/agent/launcher.go @@ -7,11 +7,6 @@ import ( "syscall" ) -// LaunchClaude replaces the current process with `claude` in the given -// working directory. The TUI exits cleanly before this is called — -// bubbletea restores the terminal, then we exec. -// -// If resumeID is non-empty, passes --resume to claude. func LaunchClaude(cwd, resumeID, prompt string) error { bin, err := exec.LookPath("claude") if err != nil { @@ -33,9 +28,6 @@ func LaunchClaude(cwd, resumeID, prompt string) error { return syscall.Exec(bin, args, os.Environ()) } -// LaunchShell replaces the current process with the user's $SHELL in -// the given working directory. Used when the user just wants to cd -// into a project/worktree without launching claude. func LaunchShell(cwd string) error { shell := os.Getenv("SHELL") if shell == "" { diff --git a/internal/agent/list.go b/internal/agent/list.go index 87d7787..2671745 100644 --- a/internal/agent/list.go +++ b/internal/agent/list.go @@ -9,17 +9,12 @@ import ( ) 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 @@ -40,12 +35,9 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - m.statusMsg = "" // clear status on any key + m.statusMsg = "" item := m.currentItem() - // Number hotkeys 1-9 open the chip-action modal for the - // corresponding chip. Handled before the navigation switch so the - // digits never collide with future bindings. if s := msg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { idx := int(s[0] - '1') if idx < len(m.headerChips) { @@ -92,14 +84,14 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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 = "" @@ -108,7 +100,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "w": - // New worktree — only on projects. + if item != nil && item.kind == KindProject { m.wtNoLaunch = true m.wtBranch = "" @@ -119,7 +111,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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 @@ -140,11 +132,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "f": - // Toggle favorite on the cursor row. Works on projects and on - // groups; both kinds appear as chips in the pinned header. - // Persists the new flag to workspace.toml and refreshes the - // in-memory model so the new state is visible without a TUI - // restart. + if item != nil && item.kind == KindProject && item.project != nil { m.toggleFavoriteFor(item.project) } @@ -175,7 +163,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "tab": - // Expand/collapse — groups and projects. + if item != nil { switch item.kind { case KindGroup: @@ -200,7 +188,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 @@ -214,13 +202,13 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/agent/persist.go b/internal/agent/persist.go index 2219a0c..262a02e 100644 --- a/internal/agent/persist.go +++ b/internal/agent/persist.go @@ -4,18 +4,6 @@ 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 { diff --git a/internal/agent/render.go b/internal/agent/render.go index 3a3694e..2a4d0bc 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -17,20 +17,17 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { 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 { @@ -62,8 +59,6 @@ func (m *Model) renderListRows(listW int, dimAll bool) []string { return rows } -// itemGroupKey returns a key that identifies the visual group boundary -// for inserting blank lines between groups. func (m *Model) itemGroupKey(item listItem) string { switch item.kind { case KindGroup: @@ -111,15 +106,9 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl name = flashInlineLabel(name, m.flashQuery, flashLabel) } - // Build left part: indent + icon + name. Icon is language-detected - // via marker files so a Go project shows the Go glyph, a Rust - // project shows the Rust glyph, etc. icon := DetectIcon(p.Path) left := fmt.Sprintf(" %s%s %s", indent, icon, name) - // Build right part: badges (right-aligned). Worktree count gets a - // lightning-bolt prefix so it reads as "branches in flight" at a - // glance; session count keeps the unprefixed `Ns` form. var badgeParts []string if p.WorktreeCount > 1 { badgeParts = append(badgeParts, fmt.Sprintf("⚡%d", p.WorktreeCount)) @@ -129,7 +118,6 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl } badges := strings.Join(badgeParts, " · ") - // Pad between left and right to fill width. line := m.padRight(left, badges, w) if dimAll || (inFlash && !isMatch) { @@ -138,7 +126,7 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl if selected { return m.renderSelected(line, itemStyle, w) } - // Render with styled badges. + if badges != "" { leftPart := fmt.Sprintf(" %s%s %s", indent, icon, name) padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 @@ -150,12 +138,9 @@ 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 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 + name := item.group if name == "" { name = "worktree" } @@ -163,7 +148,6 @@ func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, name = flashInlineLabel(name, m.flashQuery, flashLabel) } - // Status indicators: * for dirty, ↑N for ahead. var status string if item.worktree != nil { if item.worktree.Dirty { @@ -176,7 +160,7 @@ func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, } 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) @@ -220,7 +204,6 @@ func (m *Model) renderSession(item listItem, selected bool, w int, dimAll bool, 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 { @@ -237,7 +220,6 @@ func (m *Model) renderSession(item listItem, selected bool, w int, dimAll bool, 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 { @@ -249,15 +231,13 @@ func truncateStr(s string, maxLen int) string { 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) @@ -279,16 +259,12 @@ 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.headerChips, listW-2, 2) rows = append(rows, styleHeaderLines(chipLines)...) if len(chipLines) > 0 { rows = append(rows, strings.Repeat(" ", listW)) } - // Header — breadcrumb + position. inFlash := m.mode == viewFlash if inFlash { prefix := iconSearch @@ -304,10 +280,8 @@ func (m *Model) viewList() string { 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 { diff --git a/internal/agent/sessions.go b/internal/agent/sessions.go index d5b3511..549bbf3 100644 --- a/internal/agent/sessions.go +++ b/internal/agent/sessions.go @@ -11,23 +11,19 @@ import ( "time" ) -// Session is a Claude Code session discovered from ~/.claude/projects. type Session struct { ID string - Title string // first user message, truncated - Cwd string // original working directory + Title string + Cwd string Updated time.Time } -// LoadSessions scans ~/.claude/projects for sessions whose cwd matches -// any of the given paths. Returns sessions sorted by most-recent first. func LoadSessions(paths []string) []Session { claudeRoot := claudeProjectsDir() if claudeRoot == "" { return nil } - // Build lookup: encoded-cwd → original path. pathLookup := make(map[string]string, len(paths)) for _, p := range paths { encoded := encodeCwd(p) @@ -83,8 +79,6 @@ func LoadSessions(paths []string) []Session { return sessions } -// extractTitle reads the first "type":"user" message from a JSONL file -// and returns the content (truncated to 60 chars). func extractTitle(path string) string { f, err := os.Open(path) if err != nil { @@ -109,7 +103,7 @@ func extractTitle(path string) string { if err := json.Unmarshal(line, &entry); err != nil || entry.Type != "user" { continue } - // Content can be string or array of objects. + var text string if err := json.Unmarshal(entry.Message.Content, &text); err != nil { var parts []struct { @@ -127,9 +121,6 @@ func extractTitle(path string) string { return "" } -// encodeCwd converts a filesystem path to the format Claude Code uses -// for directory names in ~/.claude/projects: slashes replaced with -// dashes. func encodeCwd(path string) string { return strings.ReplaceAll(path, "/", "-") } @@ -146,8 +137,6 @@ func claudeProjectsDir() string { return dir } -// FindSession searches all sessions in ~/.claude/projects for one -// matching the given ID. Returns nil if not found. func FindSession(id string) *Session { claudeRoot := claudeProjectsDir() if claudeRoot == "" { @@ -169,10 +158,9 @@ func FindSession(id string) *Session { if err != nil { continue } - // Decode cwd from directory name (dashes back to slashes). + cwd := strings.ReplaceAll(entry.Name(), "-", "/") - // Verify the path exists — prevents false positives from - // ambiguous dash-to-slash decoding. + if _, err := os.Stat(cwd); err != nil { continue } @@ -186,21 +174,14 @@ func FindSession(id string) *Session { return nil } -// SessionCache is a lazy, map-based cache for Claude Code sessions. -// Sessions are loaded from disk on first access for a given path and -// then served from memory. Invalidation is explicit — call Invalidate -// after operations that may create new sessions. type SessionCache struct { - data map[string][]Session // mainPath → sessions + data map[string][]Session } -// NewSessionCache creates an empty session cache. func NewSessionCache() *SessionCache { return &SessionCache{data: make(map[string][]Session)} } -// Get returns sessions for the given mainPath, loading from disk on -// first access and caching the result. func (c *SessionCache) Get(mainPath string) []Session { if sessions, ok := c.data[mainPath]; ok { return sessions @@ -210,18 +191,14 @@ func (c *SessionCache) Get(mainPath string) []Session { return sessions } -// Count returns the number of sessions for the given mainPath. func (c *SessionCache) Count(mainPath string) int { return len(c.Get(mainPath)) } -// Invalidate removes cached sessions for a path, forcing a reload -// on the next Get call. func (c *SessionCache) Invalidate(mainPath string) { delete(c.data, mainPath) } -// TimeAgo returns a human-readable relative time string. func TimeAgo(t time.Time) string { d := time.Since(t) switch { diff --git a/internal/agent/source.go b/internal/agent/source.go index ff1b4d6..95fd1f4 100644 --- a/internal/agent/source.go +++ b/internal/agent/source.go @@ -13,11 +13,6 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// LoadWorkspaces returns all registered workspaces with their projects. -// Falls back to the workspace.toml under cwd if no daemon workspaces -// are registered. The returned SessionCache is pre-populated with -// session counts from the initial scan and should be passed to NewModel -// so the TUI can serve subsequent accesses from memory. func LoadWorkspaces(fallbackRoot string) ([]WorkspaceData, *SessionCache, []string) { var diagnostics []string roots := workspaceRoots(fallbackRoot) @@ -51,9 +46,6 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s FavoriteGroups: map[string]bool{}, } - // 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 { @@ -77,7 +69,6 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s } sort.Strings(ws.Groups) - // Collect projects. for _, name := range names { p := w.Projects[name] mainPath := filepath.Join(root, p.Path) @@ -94,7 +85,6 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s LastActiveMachine: lastMachine, } - // Count worktrees. barePath := layout.BarePath(mainPath) if _, err := os.Stat(barePath); err == nil { if wts, err := git.WorktreeList(barePath); err == nil { @@ -108,7 +98,6 @@ func loadOneWorkspace(root string, sessCache *SessionCache) (*WorkspaceData, []s } } - // Count sessions (populates the cache for later TUI use). proj.SessionCount = sessCache.Count(mainPath) ws.Projects = append(ws.Projects, proj) @@ -117,10 +106,6 @@ 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 @@ -159,7 +144,6 @@ func workspaceRoots(fallback string) []string { } if len(out) == 0 && fallback != "" { - // Try exact cwd first, then walk up to find workspace root. if _, err := os.Stat(filepath.Join(fallback, "workspace.toml")); err == nil { out = append(out, fallback) } else if root, err := config.FindRoot(); err == nil && !seen[root] { diff --git a/internal/agent/stamp.go b/internal/agent/stamp.go index b47f808..6de26b1 100644 --- a/internal/agent/stamp.go +++ b/internal/agent/stamp.go @@ -10,28 +10,6 @@ import ( "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 { @@ -68,10 +46,6 @@ func StampLaunchFromPath(cwd string) error { 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 { @@ -80,12 +54,6 @@ func loadMachineName() string { 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 { @@ -106,10 +74,6 @@ func findProjectByPath(ws *config.Workspace, wsRoot, abs string) (string, *confi 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 { diff --git a/internal/agent/styles.go b/internal/agent/styles.go index 4b0bd3a..831f485 100644 --- a/internal/agent/styles.go +++ b/internal/agent/styles.go @@ -2,90 +2,76 @@ 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 + Foreground(lipgloss.Color("173")). 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 ▌ + Foreground(lipgloss.Color("215")) selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")). // bright text - Background(lipgloss.Color("236")). // subtle dark bg + Foreground(lipgloss.Color("254")). + Background(lipgloss.Color("236")). Bold(true) - // Type colors. groupStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("182")). // soft mauve + Foreground(lipgloss.Color("182")). Bold(true) itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")) // white — primary items + Foreground(lipgloss.Color("254")) wtStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("108")) // muted sage — git/branch + Foreground(lipgloss.Color("108")) sessionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("110")) // cool steel — history + Foreground(lipgloss.Color("110")) badgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) // subtle + Foreground(lipgloss.Color("240")) wtStatusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")) // warm amber dim — dirty/ahead indicators + Foreground(lipgloss.Color("173")) statusMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). // amber + Foreground(lipgloss.Color("215")). Bold(true) dimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) - // favoriteStarStyle paints the leading `*` indicator placed - // before favorited projects in the header section. favoriteStarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")) // amber, slightly brighter than section + Foreground(lipgloss.Color("215")) - // activityAgeStyle is the right-aligned " 2m linux" column on - // header-section rows. activityAgeStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) - // chipNumberStyle paints the leading "1." part of a header chip. - // Dimmer than the project name so the eye reads the name first; - // the digit is still picked up at a glance for hotkey use. chipNumberStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("245")) - // chipNameStyle paints the project name inside a header chip. chipNameStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("254")). Bold(true) - // Flash search. flashSearchStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("215")). // amber + Foreground(lipgloss.Color("215")). Background(lipgloss.Color("235")) flashLabelStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("235")). // dark on amber + Foreground(lipgloss.Color("235")). Background(lipgloss.Color("215")) flashMatchStyle = lipgloss.NewStyle(). Underline(true). - Foreground(lipgloss.Color("215")) // amber underlined match + Foreground(lipgloss.Color("215")) - // Popup forms. popupBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("173")). @@ -93,7 +79,7 @@ var ( popupTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("215")) // amber + Foreground(lipgloss.Color("215")) popupSelectedStyle = lipgloss.NewStyle(). Bold(true). @@ -106,7 +92,6 @@ var ( popupDimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("240")) - // Which-key panel. whichKeyBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("173")). @@ -117,9 +102,9 @@ var ( Bold(true) whichKeyKeyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). // amber key + Foreground(lipgloss.Color("215")). Bold(true) whichKeyDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) // secondary text + Foreground(lipgloss.Color("245")) ) diff --git a/internal/agent/tui.go b/internal/agent/tui.go index fb76ce3..bf1eda4 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -5,119 +5,90 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// View mode. type viewMode int const ( - viewList viewMode = iota // nested list — all navigation lives here - viewNewWorktree // worktree creation form - viewFlash // flash search with jump labels - 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) + viewList viewMode = iota + viewNewWorktree + viewFlash + viewPromptInput + viewWhichKey + viewEditProject + viewChipAction ) -// Nerd Font icons. const ( - iconProject = "" // nf-oct-package - iconWorktree = "" // nf-dev-git_branch - iconSession = "" // nf-md-message_text_outline - iconSearch = "" // nf-fa-search + iconProject = "" + iconWorktree = "" + iconSession = "" + iconSearch = "" ) -// 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) - project *Project // for KindProject rows - worktree *Worktree // for KindWorktree rows - session *Session // for KindPortal rows (sessions) + group string + project *Project + worktree *Worktree + session *Session indent int - path string // filesystem path for shell navigation - parentProj *Project // for worktree/session: which project they belong to + path string + parentProj *Project } -// LaunchRequest is set when the user selects an action that should -// launch claude after the TUI exits. The CLI layer reads this from -// the model and calls LaunchClaude. type LaunchRequest struct { Cwd string ResumeID string - ShellOnly bool // true = exec $SHELL instead of claude - Prompt string // optional initial prompt for claude (-p flag) + ShellOnly bool + Prompt string } -// Model is the bubbletea model for the agent TUI wizard. type Model struct { workspaces []WorkspaceData mode viewMode - items []listItem // flattened scrollable tree items (no header) + items []listItem cursor int - expanded map[string]bool // group/project name → expanded - scroll int // scroll offset for long lists + expanded map[string]bool + scroll int - // headerChips is the ordered list of project-or-group chips - // rendered in the pinned quick-nav above the tree. Recomputed in - // rebuildItems from favorited groups + favorite/recent projects. headerChips []Chip - // chipAction modal state: when the user presses 1-9 to pick a - // chip, we open a small action modal asking what to do (claude / - // prompt / shell / etc.). chipTarget holds the picked chip until - // the modal resolves. chipTarget *Chip - // Caches — loaded lazily, invalidated after mutations. sessCache *SessionCache wtCache *WorktreeCache - // Status message — shown in footer until next keypress. statusMsg string - // Delete confirmation state. - pendingDelete bool // true = waiting for y/n confirmation + pendingDelete bool deleteItem *listItem - // Active project for the worktree-creation form. popupProj *Project - // Worktree creation form state. - wtBranch string // user-typed branch name (no prefix injection) - wtNoLaunch bool // true when "create only", false when "create + launch" - wtField int // 0=branch, 1=confirm + wtBranch string + wtNoLaunch bool + wtField int - // Edit-project form state. editGroup string editCategory config.Category - editField int // 0=group, 1=category, 2=save + editField int editErr string - // Prompt input state (optional prompt before launch). - pendingLaunch *LaunchRequest // set before entering prompt input + pendingLaunch *LaunchRequest promptInput string - // Flash search state. flashQuery string - flashMatches []int // indices into m.items that match - flashLabels []rune // one label per match (a, b, c, ...) - flashGlobal bool // S = global search (all items, even collapsed) - savedExpanded map[string]bool // expansion state before global flash + flashMatches []int + flashLabels []rune + flashGlobal bool + savedExpanded map[string]bool - // Which-key state. - whichKeyLevel int // 0 = root actions, 1 = worktree sub-menu + whichKeyLevel int - // Set when the user picks a launch action. Launch *LaunchRequest width, height int } -// 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 { if sessCache == nil { sessCache = NewSessionCache() @@ -129,7 +100,7 @@ func NewModel(workspaces []WorkspaceData, sessCache *SessionCache) *Model { sessCache: sessCache, wtCache: NewWorktreeCache(), } - // Auto-expand all groups initially. + for _, ws := range workspaces { for _, g := range ws.Groups { m.expanded[g] = true @@ -149,11 +120,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - // ctrl+c and ctrl+q always quit from anywhere. + if msg.String() == "ctrl+c" || msg.String() == "ctrl+q" { return m, tea.Quit } - // ctrl+s = open shell in selected item's directory from anywhere. + if msg.String() == "ctrl+s" { item := m.currentItem() if item != nil && item.path != "" { @@ -213,8 +184,6 @@ func (m *Model) currentItem() *listItem { 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 { @@ -253,7 +222,6 @@ func (m *Model) jumpToProject(projID string) { } func (m *Model) ensureVisible() { - // Keep cursor pinned to the vertical center of the viewport. maxVisible := m.listHeight() m.scroll = m.cursor - maxVisible/2 if m.scroll < 0 { @@ -268,11 +236,6 @@ func (m *Model) ensureVisible() { } func (m *Model) listHeight() int { - // 5 = breadcrumb (1) + 2 footer lines + borders (2). Add room for - // the pinned chip header when present: up to 2 chip lines plus a - // 1-line separator below them. headerProjects may be empty (idle - // workspace) — listHeight then matches the pre-rework value so a - // fresh install has the same vertical density. chrome := 5 if len(m.headerChips) > 0 { chrome += 3 @@ -284,9 +247,6 @@ func (m *Model) listHeight() int { return h } -// footerHints returns two lines of context-sensitive keyboard hints. -// 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:↕ tab:expand s:find S:all ?:more" item := m.currentItem() @@ -312,7 +272,6 @@ func (m *Model) footerHints() (actions, nav string) { return actions, nav } -// breadcrumb derives contextual header from the current cursor position. func (m *Model) breadcrumb() string { item := m.currentItem() if item == nil { diff --git a/internal/agent/types.go b/internal/agent/types.go index 04cc502..e8bc9de 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -9,7 +9,6 @@ import ( "time" ) -// NodeKind classifies an item in the workspace tree. type NodeKind int const ( @@ -20,14 +19,6 @@ const ( KindPortal ) -// 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 @@ -42,38 +33,26 @@ type Project struct { LastActiveMachine string } -// GroupPath returns the filesystem directory for a group under a -// workspace root. E.g. root="/home/user/development", group="work" -// → "/home/user/development/work". func GroupPath(wsRoot, group string) string { return filepath.Join(wsRoot, group) } -// 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 - FavoriteGroups map[string]bool // group name → pinned to header chips + FavoriteGroups map[string]bool } -// 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 + Kind NodeKind + Name string + Path string 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 78c8502..8cfeb5f 100644 --- a/internal/agent/whichkey.go +++ b/internal/agent/whichkey.go @@ -21,7 +21,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { } if m.whichKeyLevel == 1 { - // Worktree sub-menu. return []whichKeyAction{ {"n", "new worktree"}, {"", ""}, @@ -75,8 +74,6 @@ func (m *Model) whichKeyActions() []whichKeyAction { return nil } -// favoriteToggleLabel describes the `f` action target: "favorite" if -// the project is currently unfavorited, "unfavorite" if it already is. func (m *Model) favoriteToggleLabel(it *listItem) string { if it != nil && it.project != nil && it.project.Favorite { return "unfavorite" @@ -84,9 +81,6 @@ 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] { @@ -100,7 +94,6 @@ 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": @@ -119,7 +112,6 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // Root level — dispatch action. switch key { case "esc": m.mode = viewList @@ -149,9 +141,7 @@ func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = viewList return m.updateList(msg) case "f": - // Favorite toggle is per-row: project rows toggle the project, - // group rows toggle the group. Either way close the panel so - // the user sees the result immediately. + m.mode = viewList if item != nil && item.kind == KindProject && item.project != nil { m.toggleFavoriteFor(item.project) @@ -167,10 +157,6 @@ 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 == "" { @@ -213,9 +199,6 @@ func (m *Model) toggleFavoriteGroup(group string) { 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 { @@ -227,11 +210,6 @@ func (m *Model) workspaceRootForGroup(name string) string { return "" } -// toggleFavoriteFor flips the favorite flag on `proj` and persists the -// change. The in-memory pointer is mutated so the row repaint picks -// up the new state without needing a TUI restart. The header section -// is rebuilt: a freshly favorited project may need to leave Recent -// and appear in Favorites, and vice versa. func (m *Model) toggleFavoriteFor(proj *Project) { root := m.workspaceRootFor(proj) if root == "" { @@ -276,7 +254,7 @@ func (m *Model) whichKeyTitle() string { case KindProject: return item.project.Name case KindWorktree: - return item.group // display name + return item.group case KindPortal: if item.session != nil { t := item.session.Title @@ -298,7 +276,6 @@ func (m *Model) viewWhichKey() string { } } - // Render the list (dimmed). var rows []string bc := m.breadcrumb() pos := fmt.Sprintf("%d/%d", m.cursor+1, len(m.items)) @@ -309,7 +286,6 @@ func (m *Model) viewWhichKey() string { listPanel := lipgloss.JoinVertical(lipgloss.Left, rows...) - // Render the action panel. actions := m.whichKeyActions() title := m.whichKeyTitle() @@ -331,7 +307,6 @@ func (m *Model) viewWhichKey() string { 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 diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go index 2b435e8..48c41eb 100644 --- a/internal/agent/worktrees.go +++ b/internal/agent/worktrees.go @@ -12,35 +12,19 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// Worktree is a single worktree of a project, loaded on demand. type Worktree struct { Path string Branch string IsMain bool - Dirty bool // has uncommitted changes - Ahead int // commits ahead of upstream (0 if no upstream) + Dirty bool + Ahead int } -// WorktreeResult is returned after successful worktree creation. type WorktreeResult struct { Path string Branch string } -// CreateWorktree creates a new worktree for `branch` in project p. The -// branch name is taken from the user verbatim — no prefix injection, -// no slug rewriting beyond what `git check-ref-format` accepts. If -// `branch` already exists locally in the bare repo, the new worktree -// attaches to it (the path that re-registers legacy wt//* -// branches under the new schema). Otherwise it's created from the -// project's default branch. -// -// On success the workspace.toml registry is updated: this machine is -// claimed against the branch with ClaimBranch, last_active_* are set, -// and on first claim CreatedBy/CreatedAt are recorded. -// -// wsRoot and projID are required for the workspace.toml update; pass -// empty strings to skip persistence (best-effort fallback used by tests). func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, error) { if strings.TrimSpace(branch) == "" { return nil, fmt.Errorf("branch name required") @@ -56,27 +40,14 @@ func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, machine = mc.MachineName } - // Pre-0.5.1 bares were created without remote.origin.fetch; the - // fetch below would only update FETCH_HEAD without it. Mirror the - // reconciler's repair step. if !git.HasFetchRefspec(barePath) { _ = git.SetFetchRefspec(barePath) } - // Best-effort fetch via the standard remote-tracking refspec so - // refs/remotes/origin/ reflects the latest origin state - // before we decide local vs remote vs new. Mirrors the CLI flow - // in cli/worktree.go; without it, a branch another machine just - // pushed would silently fall through to the new-from-base case. _ = 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 CreateWorktree whose registry save failed), don't try - // to materialize another worktree — git worktree add refuses - // without --force, and the user's intent is to repair metadata. if existingPath := findWorktreeForBranch(barePath, branch); existingPath != "" { if wsRoot != "" && projID != "" { if ws, err := config.Load(wsRoot); err == nil { @@ -103,16 +74,13 @@ func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, attachedToRemote := false switch { case localExists: - // Attach to existing local branch (covers branches already pulled - // from origin into refs/heads/). + if err := git.WorktreeAdd(barePath, wtPath, branch, ""); err != nil { return nil, fmt.Errorf("git worktree add: %w", err) } attachedToRemote = remoteExists case remoteExists: - // Origin has it, we don't yet — create local from origin/ - // so the user lands on the published commits, not a fresh branch - // off main. + if err := git.WorktreeAdd(barePath, wtPath, branch, "origin/"+branch); err != nil { return nil, fmt.Errorf("git worktree add: %w", err) } @@ -127,10 +95,6 @@ func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, } } - // Persist branch metadata. Best-effort: a failure here leaves the - // worktree on disk; the user can re-run `ws worktree add` to fix the - // registry, or the next reconciler tick will note the branch is - // missing from [[branches]] and silently no-op (legacy-friendly path). if wsRoot != "" && projID != "" { if ws, err := config.Load(wsRoot); err == nil { if proj, ok := ws.Projects[projID]; ok { @@ -149,9 +113,6 @@ func CreateWorktree(p *Project, branch, wsRoot, projID string) (*WorktreeResult, return &WorktreeResult{Path: wtPath, Branch: branch}, nil } -// DeleteWorktreeWithRegistry removes a worktree and releases this machine -// from the workspace.toml [[branches]] entry. Pass empty wsRoot/projID/branch -// to skip the registry update. func DeleteWorktreeWithRegistry(mainPath, wtPath string, force bool, wsRoot, projID, branch string) error { if wtPath == mainPath { return fmt.Errorf("cannot delete main worktree") @@ -170,7 +131,7 @@ func DeleteWorktreeWithRegistry(mainPath, wtPath string, force bool, wsRoot, pro } ws, err := config.Load(wsRoot) if err != nil { - return nil // best-effort + return nil } proj, ok := ws.Projects[projID] if !ok { @@ -183,9 +144,6 @@ func DeleteWorktreeWithRegistry(mainPath, wtPath string, force bool, wsRoot, pro return nil } -// findWorktreeForBranch returns the absolute path of the existing -// worktree on `branch`, or "" if no worktree is checked out on it. -// Mirrors locateWorktreeForBranch in the cli package. func findWorktreeForBranch(barePath, branch string) string { wts, err := git.WorktreeList(barePath) if err != nil { @@ -202,9 +160,6 @@ func findWorktreeForBranch(barePath, branch string) string { return "" } -// worktreeDisplayName returns a human-readable short name for a worktree. -// For main it's "main". For wt// (legacy) it extracts the -// topic. For everything else it shows the branch name (the new default). func worktreeDisplayName(wt Worktree) string { if wt.IsMain { return "main" @@ -221,21 +176,14 @@ func worktreeDisplayName(wt Worktree) string { return filepath.Base(wt.Path) } -// WorktreeCache is a lazy, map-based cache for worktree listings. -// Worktrees are loaded from git on first access for a given project -// and served from memory on subsequent accesses. Invalidate after -// create/delete operations. type WorktreeCache struct { - data map[string][]Worktree // mainPath → worktrees + data map[string][]Worktree } -// NewWorktreeCache creates an empty worktree cache. func NewWorktreeCache() *WorktreeCache { return &WorktreeCache{data: make(map[string][]Worktree)} } -// Get returns worktrees for the given mainPath, loading from git on -// first access and caching the result. func (c *WorktreeCache) Get(mainPath string) []Worktree { if wts, ok := c.data[mainPath]; ok { return wts @@ -245,19 +193,13 @@ func (c *WorktreeCache) Get(mainPath string) []Worktree { return wts } -// Invalidate removes cached worktrees for a path, forcing a reload -// on the next Get call. func (c *WorktreeCache) Invalidate(mainPath string) { delete(c.data, mainPath) } -// LoadWorktrees returns the worktrees for a project. Requires the -// project to be migrated (bare repo exists). Populates Dirty and -// Ahead fields by querying git status for each worktree. func LoadWorktrees(mainPath string) []Worktree { barePath := layout.BarePath(mainPath) if _, err := os.Stat(barePath); err != nil { - // Not migrated — return just the main path. return []Worktree{{Path: mainPath, Branch: "", IsMain: true, Dirty: git.IsDirty(mainPath)}} } diff --git a/internal/alias/conflict.go b/internal/alias/conflict.go index dbe9e97..cde56c4 100644 --- a/internal/alias/conflict.go +++ b/internal/alias/conflict.go @@ -2,9 +2,6 @@ package alias import "os/exec" -// ShellConflict reports whether `name` would shadow an existing executable on PATH. -// We only check executables — detecting shell-defined aliases/functions would -// require sourcing the user's rc file, which is too fragile to do here. func ShellConflict(name string) (string, bool) { if name == "" { return "", false diff --git a/internal/alias/generate.go b/internal/alias/generate.go index ddbfbd0..6b35648 100644 --- a/internal/alias/generate.go +++ b/internal/alias/generate.go @@ -4,15 +4,6 @@ import ( "strings" ) -// Generate produces a short alias name from a project or group name. -// -// Rules: -// 1. Two parts separated by - or _, each ≤4 chars → join (mm-eh → mmeh). -// 2. Multi-part separated by - or _ → first letter of each (claude-code → cc, -// my-cool-project → mcp). -// 3. Single word → consonants, max 5 chars (limitless → lmtls). -// -// On collision with existing names in `taken`, a numeric suffix is appended. func Generate(name string, taken map[string]struct{}) string { base := generateBase(name) if base == "" { @@ -42,9 +33,6 @@ func generateBase(name string) string { return consonantSqueeze(name) } -// multiPartName applies Rule 1 + Rule 2: two short parts (each ≤4 -// chars) join verbatim ("co-op" → "coop"); anything else collapses to -// the per-part first-letter acronym ("api-gateway" → "ag"). func multiPartName(parts []string) string { if len(parts) == 2 && len(parts[0]) <= 4 && len(parts[1]) <= 4 { return parts[0] + parts[1] @@ -59,9 +47,6 @@ func multiPartName(parts []string) string { return b.String() } -// consonantSqueeze applies Rule 3 to a single-word name: keep the -// first character (even if vowel) and append up to four more -// consonants, capping output at five chars total. func consonantSqueeze(name string) string { var b strings.Builder b.WriteByte(name[0]) @@ -74,9 +59,6 @@ func consonantSqueeze(name string) string { return b.String() } -// splitParts splits `s` on '-' or '_' separators, dropping empty -// fragments. Equivalent to strings.FieldsFunc with a hand-rolled -// predicate that only treats '-' and '_' as separators. func splitParts(s string) []string { return strings.FieldsFunc(s, isSeparator) } diff --git a/internal/alias/install.go b/internal/alias/install.go index 967f0cd..9401393 100644 --- a/internal/alias/install.go +++ b/internal/alias/install.go @@ -11,13 +11,10 @@ import ( ) const ( - // markerStart and markerEnd delimit the ws-managed block in the user's rc file. markerStart = "# >>> ws aliases >>>" markerEnd = "# <<< ws aliases <<<" ) -// StateFilePath returns the path to the generated zsh aliases file. -// Honors $XDG_STATE_HOME, falls back to ~/.local/state/ws/aliases.zsh. func StateFilePath() (string, error) { if env := os.Getenv("XDG_STATE_HOME"); env != "" { return filepath.Join(env, "ws", "aliases.zsh"), nil @@ -29,14 +26,6 @@ func StateFilePath() (string, error) { return filepath.Join(home, ".local", "state", "ws", "aliases.zsh"), nil } -// WriteStateFile regenerates the alias state file from the workspace. -// Safe to call on every workspace save. -// -// We never delete the state file when the workspace has zero aliases — -// the state file is a single global resource shared across every workspace -// root, and removing it on save would let an unrelated empty workspace -// blow away aliases owned by another one. Empty workspaces just write an -// empty (header-only) file, which is a no-op for the shell. func WriteStateFile(ws *config.Workspace, root string) error { path, err := StateFilePath() if err != nil { @@ -58,9 +47,6 @@ func WriteStateFile(ws *config.Workspace, root string) error { return nil } -// InstallZshrc inserts a sourcing block into ~/.zshrc that loads the state file. -// Idempotent: re-running is a no-op once the block is present. -// Returns true if the rc file was modified. func InstallZshrc() (bool, string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/alias/resolve.go b/internal/alias/resolve.go index 2349d32..9440a32 100644 --- a/internal/alias/resolve.go +++ b/internal/alias/resolve.go @@ -8,7 +8,6 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// TargetKind tells whether an alias target is a project or a group. type TargetKind int const ( @@ -18,7 +17,6 @@ const ( TargetRoot ) -// RootTarget is the sentinel target value that resolves to the workspace root. const RootTarget = "." func (k TargetKind) String() string { @@ -33,17 +31,13 @@ func (k TargetKind) String() string { return "unknown" } -// Resolved is a fully-resolved alias entry. type Resolved struct { - Name string // alias name (key) - Target string // raw target (project or group key) + Name string + Target string Kind TargetKind - Path string // absolute filesystem path + Path string } -// ResolveAll returns every alias resolved, sorted by alias name. -// Aliases that fail to resolve are returned with Kind=TargetUnknown -// and an empty Path so callers can flag them. func ResolveAll(ws *config.Workspace, root string) []Resolved { out := make([]Resolved, 0, len(ws.Aliases)) for name, target := range ws.Aliases { @@ -84,4 +78,3 @@ func resolveTarget(ws *config.Workspace, root, name, target string) (Resolved, e } return Resolved{}, fmt.Errorf("alias %q points to unknown target %q", name, target) } - diff --git a/internal/alias/shell_zsh.go b/internal/alias/shell_zsh.go index a5f984f..175ae2c 100644 --- a/internal/alias/shell_zsh.go +++ b/internal/alias/shell_zsh.go @@ -5,9 +5,6 @@ import ( "strings" ) -// RenderZsh produces a zsh-compatible block of `alias` declarations -// for every resolvable entry. Unresolvable aliases are skipped silently — -// they will be cleaned up next time the user opens the TUI or runs archive. func RenderZsh(resolved []Resolved) string { var b strings.Builder b.WriteString("# ws aliases — generated, do not edit\n") @@ -20,7 +17,6 @@ func RenderZsh(resolved []Resolved) string { return b.String() } -// zshQuote single-quotes a string for zsh, escaping embedded single quotes. func zshQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } diff --git a/internal/aliasmgr/model.go b/internal/aliasmgr/model.go index d25d4c2..b39f9de 100644 --- a/internal/aliasmgr/model.go +++ b/internal/aliasmgr/model.go @@ -18,7 +18,6 @@ const ( stepConfirm ) -// kind of an item in the manage list. type itemKind int const ( @@ -28,13 +27,12 @@ const ( ) type item struct { - name string // project or group key + name string kind itemKind - alias string // current alias name (empty if not aliased) - checked bool // selected to have an alias + alias string + checked bool } -// Result is returned to the caller after the TUI exits. type Result struct { Confirmed bool Canceled bool @@ -53,7 +51,7 @@ type Model struct { search textinput.Model editing bool editInput textinput.Model - editTarget int // index in items being edited + editTarget int result Result stepChangedAt time.Time } @@ -78,14 +76,13 @@ func New(ws *config.Workspace, root string) Model { } func buildItems(ws *config.Workspace) []item { - // Reverse map alias→target so we can fill `alias` field per item. aliasFor := make(map[string]string, len(ws.Aliases)) for n, t := range ws.Aliases { aliasFor[t] = n } var items []item - // Synthetic workspace-root row, always present. + { rootAlias := aliasFor[alias.RootTarget] items = append(items, item{ @@ -115,14 +112,13 @@ func buildItems(ws *config.Workspace) []item { } sort.Slice(items, func(i, j int) bool { - // Root row pinned to the top. if items[i].kind == kindRoot { return true } if items[j].kind == kindRoot { return false } - // aliased first, then by name + if items[i].checked != items[j].checked { return items[i].checked } @@ -173,11 +169,8 @@ func (m Model) View() string { return "" } -// GetResult returns the model's result after Quit. func (m Model) GetResult() Result { return m.result } -// generationSeed returns the string used to derive an auto-generated alias. -// For a synthetic root row we don't want to feed "." into Generate. func (it item) generationSeed() string { if it.kind == kindRoot { return "workspace" @@ -185,12 +178,10 @@ func (it item) generationSeed() string { return it.name } -// buildAliasMap collects checked items, generating names for ones the user -// did not edit explicitly. func (m Model) buildAliasMap() map[string]string { out := make(map[string]string) taken := make(map[string]struct{}) - // Pass 1: explicit names + for _, it := range m.items { if !it.checked || it.alias == "" { continue @@ -198,7 +189,7 @@ func (m Model) buildAliasMap() map[string]string { taken[it.alias] = struct{}{} out[it.alias] = it.name } - // Pass 2: generated names + for _, it := range m.items { if !it.checked || it.alias != "" { continue @@ -210,7 +201,6 @@ func (m Model) buildAliasMap() map[string]string { return out } -// Styles var ( titleStyle = lipgloss.NewStyle().Bold(true). Foreground(lipgloss.Color("15")). diff --git a/internal/aliasmgr/step_confirm.go b/internal/aliasmgr/step_confirm.go index 3413507..b947c4c 100644 --- a/internal/aliasmgr/step_confirm.go +++ b/internal/aliasmgr/step_confirm.go @@ -48,7 +48,6 @@ func (m Model) viewConfirm() string { return b.String() } - // Build a temporary workspace view so we can resolve via the alias package. tmp := &config.Workspace{ Projects: m.ws.Projects, Groups: m.ws.Groups, @@ -56,7 +55,6 @@ func (m Model) viewConfirm() string { } resolved := alias.ResolveAll(tmp, m.root) - // Sort by name for stable display. sort.Slice(resolved, func(i, j int) bool { return resolved[i].Name < resolved[j].Name }) for _, r := range resolved { diff --git a/internal/aliasmgr/step_manage.go b/internal/aliasmgr/step_manage.go index bee1a0f..34e7121 100644 --- a/internal/aliasmgr/step_manage.go +++ b/internal/aliasmgr/step_manage.go @@ -10,16 +10,11 @@ import ( "github.com/kuchmenko/workspace/internal/alias" ) -// treeRow is one rendered line of the tree: a reference to an item plus -// the indent/branch prefix to print before it. The cursor and offset -// operate over the slice of treeRows produced by buildTree. type treeRow struct { itemIdx int - prefix string // branch art (e.g. "├── " / "│ └── ") + prefix string } -// itemIndex builds a map keyed by (kind,name) so we can find the slice -// position of a given project/group/root item quickly. func (m Model) itemIndex() map[string]int { out := make(map[string]int, len(m.items)) for i, it := range m.items { @@ -32,22 +27,10 @@ func itemKey(k itemKind, name string) string { return fmt.Sprintf("%d/%s", k, name) } -// buildTree returns the ordered list of tree rows to render, applying the -// current search filter. The structure is: -// -// (workspace root) -// ├── group-a -// │ ├── project-1 -// │ └── project-2 -// ├── group-b -// │ └── project-3 -// ├── ungrouped-project-1 -// └── ungrouped-project-2 func (m Model) buildTree() []treeRow { idx := m.itemIndex() q := strings.ToLower(strings.TrimSpace(m.search.Value())) - // Group projects by their group name; collect ungrouped separately. grouped := make(map[string][]string) var ungrouped []string for name, p := range m.ws.Projects { @@ -64,7 +47,6 @@ func (m Model) buildTree() []treeRow { } sort.Strings(ungrouped) - // Group names ordered alphabetically. groupNames := make([]string, 0, len(m.ws.Groups)) for g := range m.ws.Groups { groupNames = append(groupNames, g) @@ -84,7 +66,6 @@ func (m Model) buildTree() []treeRow { rows = append(rows, treeRow{itemIdx: rootIdx, prefix: ""}) } - // Filter groups: keep group if its name matches OR any project under it matches. type visibleGroup struct { name string projects []string @@ -99,7 +80,6 @@ func (m Model) buildTree() []treeRow { } } if groupMatches || len(keep) > 0 { - // If group itself matches but no project filter, show all of its projects. if groupMatches && q != "" && len(keep) == 0 { keep = append([]string{}, grouped[g]...) } @@ -117,7 +97,6 @@ func (m Model) buildTree() []treeRow { } } - // Render tree under root. totalTop := len(visGroups) + len(visUngrouped) pos := 0 for _, vg := range visGroups { @@ -129,7 +108,7 @@ func (m Model) buildTree() []treeRow { if gi, ok := idx[itemKey(kindGroup, vg.name)]; ok { rows = append(rows, treeRow{itemIdx: gi, prefix: branch}) } - // Children + childIndent := "│ " if isLastTop { childIndent = " " @@ -258,7 +237,6 @@ func (m Model) updateEditing(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -// takenNames returns the set of alias names already in use, excluding `skip`. func (m Model) takenNames(skip int) map[string]struct{} { taken := make(map[string]struct{}) for i, it := range m.items { @@ -305,7 +283,6 @@ func (m Model) viewManage() string { check = checkStyle.Render("●") } - // Resolve raw alias text + how to style it. var aliasRaw, aliasStyled string switch { case m.editing && idx == m.editTarget: @@ -314,7 +291,7 @@ func (m Model) viewManage() string { aliasRaw = it.alias aliasStyled = padRight(selectedStyle.Render(aliasRaw), aliasW, len(aliasRaw)) case it.checked: - // Preview the auto-generated name so the user sees what will be saved. + aliasRaw = alias.Generate(it.generationSeed(), m.takenNames(idx)) aliasStyled = padRight(dimStyle.Render(aliasRaw), aliasW, len(aliasRaw)) default: @@ -360,10 +337,6 @@ func (m Model) viewManage() string { return b.String() } -// padRight pads a possibly-styled string with trailing spaces so that its -// visible width equals `width`. `visibleLen` is the length of the underlying -// raw text (without ANSI escapes). If the raw text is already wider than -// `width`, the styled string is returned unchanged. func padRight(styled string, width, visibleLen int) string { if visibleLen >= width { return styled diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 4c71527..02903a4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -15,7 +15,6 @@ type Token struct { CreatedAt time.Time `json:"created_at"` } -// ConfigDir returns the ws config directory (~/.config/ws). func ConfigDir() (string, error) { dir, err := os.UserConfigDir() if err != nil { @@ -24,7 +23,6 @@ func ConfigDir() (string, error) { return filepath.Join(dir, "ws"), nil } -// TokenPath returns the path to the token file. func TokenPath() (string, error) { dir, err := ConfigDir() if err != nil { @@ -33,7 +31,6 @@ func TokenPath() (string, error) { return filepath.Join(dir, "token"), nil } -// LoadToken reads the stored token. Returns empty token and error if not found. func LoadToken() (Token, error) { path, err := TokenPath() if err != nil { @@ -53,7 +50,6 @@ func LoadToken() (Token, error) { return t, nil } -// SaveToken writes a token to disk with 0600 permissions. func SaveToken(t Token) error { dir, err := ConfigDir() if err != nil { @@ -70,7 +66,6 @@ func SaveToken(t Token) error { return os.WriteFile(path, data, 0o600) } -// DeleteToken removes the stored token. func DeleteToken() error { path, err := TokenPath() if err != nil { @@ -83,7 +78,6 @@ func DeleteToken() error { return err } -// HasToken returns true if a valid token is stored. func HasToken() bool { t, err := LoadToken() return err == nil && t.AccessToken != "" diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 102f72d..21e54a2 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -12,9 +12,6 @@ import ( "time" ) -// ClientID for the ws GitHub OAuth App. -// This is NOT a secret — it identifies the app, not the user. -// Register at: https://github.com/settings/applications/new const ClientID = "Iv23liLjbULITnvRegRh" const ( @@ -38,23 +35,18 @@ type tokenResponse struct { Error string `json:"error"` } -// DeviceFlow runs the GitHub OAuth Device Authorization flow. -// It prints a user code, opens the browser, and polls for authorization. func DeviceFlow() (Token, error) { - // Step 1: Request device code dc, err := requestDeviceCode() if err != nil { return Token{}, err } - // Step 2: Show code and open browser fmt.Printf("\n Open this URL in your browser:\n") fmt.Printf(" %s\n\n", dc.VerificationURI) fmt.Printf(" Enter code: %s\n\n", dc.UserCode) openBrowser(dc.VerificationURI) - // Step 3: Poll for token fmt.Printf(" Waiting for authorization...") token, err := pollForToken(dc) if err != nil { @@ -106,7 +98,7 @@ func pollForToken(dc deviceCodeResponse) (Token, error) { time.Sleep(interval) tr, ok := requestToken(dc) if !ok { - continue // network or decode error — retry next tick + continue } token, retry, retryDelay, err := interpretTokenResponse(tr) if !retry { @@ -117,9 +109,6 @@ func pollForToken(dc deviceCodeResponse) (Token, error) { return Token{}, fmt.Errorf("timed out waiting for authorization") } -// requestToken posts the device-code grant exchange and decodes the -// response. Returns ok=false on network or decode failure (the caller -// retries); ok=true with the parsed body otherwise. func requestToken(dc deviceCodeResponse) (tokenResponse, bool) { data := url.Values{ "client_id": {ClientID}, @@ -145,14 +134,6 @@ func requestToken(dc deviceCodeResponse) (tokenResponse, bool) { return tr, true } -// interpretTokenResponse maps a tokenResponse into the loop driver's -// next action. Returns: -// -// - retry=false: the caller exits with (token, err). On success -// err is nil; on terminal failure err is the caller-facing -// reason ("expired_token", "access_denied", …). -// - retry=true: the caller continues polling, optionally adding -// `retryDelay` to the interval (for "slow_down" responses). func interpretTokenResponse(tr tokenResponse) (token Token, retry bool, retryDelay time.Duration, err error) { switch tr.Error { case "": diff --git a/internal/auth/pat.go b/internal/auth/pat.go index 749d034..d55e490 100644 --- a/internal/auth/pat.go +++ b/internal/auth/pat.go @@ -9,7 +9,6 @@ import ( "time" ) -// PromptPAT reads a GitHub Personal Access Token from stdin and validates it. func PromptPAT() (Token, error) { fmt.Println("\n Create a token at: https://github.com/settings/tokens") fmt.Println(" Required scopes: repo, read:user, read:org") @@ -26,7 +25,6 @@ func PromptPAT() (Token, error) { return Token{}, fmt.Errorf("empty token") } - // Validate by making a test API call fmt.Print(" Validating... ") if err := validateToken(token); err != nil { return Token{}, err diff --git a/internal/benchfixture/fixture.go b/internal/benchfixture/fixture.go index 40f8053..fc5d677 100644 --- a/internal/benchfixture/fixture.go +++ b/internal/benchfixture/fixture.go @@ -21,44 +21,27 @@ import ( "testing" ) -// Options configures a synthetic workspace. type Options struct { - // Projects is the number of [projects.N] entries to register. Projects int - // BranchesPerProject is how many [[projects.N.branches]] entries - // each project gets. Default 1. BranchesPerProject int - // Cloned, when true, runs a real `git clone --bare` per project so - // the bare+worktree layout is present on disk. Set true for benches - // that exercise reconciler.reconcileProjects; false for scan-only - // benches that just need workspace.toml + flat directories. Cloned bool - // AsGitRepo, when true, runs `git init` in the workspace root and - // does an initial commit of workspace.toml. Required for benches - // that exercise reconciler.syncTOML; otherwise Phase 1 no-ops. AsGitRepo bool } -// Workspace is the result of Build. type Workspace struct { Root string ProjectList []Project } -// Project describes one synthetic project for the bench. type Project struct { Name string - Path string // relative to Workspace.Root, e.g. "personal/proj-0" - Remote string // file:// URL of fake remote + Path string + Remote string } -// Build constructs a synthetic workspace with the given options. It uses -// `tb.TempDir()` so cleanup is automatic. All git invocations use a -// hermetic env (no global config, no GPG, fixed identity) inherited from -// the test fixture pattern in internal/testutil. func Build(tb testing.TB, opts Options) *Workspace { tb.Helper() opts = withDefaults(opts) @@ -74,9 +57,6 @@ func Build(tb testing.TB, opts Options) *Workspace { return ws } -// withDefaults applies non-destructive defaults to a zero-valued -// Options. Centralized so callers can pass `Options{}` and get a -// sensible 10-project fixture. func withDefaults(opts Options) Options { if opts.Projects <= 0 { opts.Projects = 10 @@ -87,8 +67,6 @@ func withDefaults(opts Options) Options { return opts } -// prepareRoots creates the workspace tempdir and the `.remotes/` cache -// dir for fake remotes. Returns (root, remotesDir). func prepareRoots(tb testing.TB) (string, string) { tb.Helper() root := tb.TempDir() @@ -102,10 +80,6 @@ func prepareRoots(tb testing.TB) (string, string) { return root, remotes } -// composeProjects creates fake remotes, optionally clones each into the -// bare+worktree layout, appends to `ws.ProjectList`, and returns the -// rendered workspace.toml body. Single-pass so caller does only one -// write. func composeProjects(tb testing.TB, ws *Workspace, opts Options, root, remotes string) string { tb.Helper() var sb strings.Builder @@ -131,8 +105,6 @@ func composeProjects(tb testing.TB, ws *Workspace, opts Options, root, remotes s return sb.String() } -// writeProjectTOMLEntry appends one [projects.] block plus its -// [[projects..branches]] list to sb. Pure formatting — no IO. func writeProjectTOMLEntry(sb *strings.Builder, name, path, remote string, branches int) { fmt.Fprintf(sb, "[projects.%s]\n", name) fmt.Fprintf(sb, "remote = %q\n", "file://"+remote) @@ -148,8 +120,6 @@ func writeProjectTOMLEntry(sb *strings.Builder, name, path, remote string, branc } } -// writeWorkspaceTOML drops the rendered TOML body at the canonical -// location. Failure is fatal — fixture builds are deterministic. func writeWorkspaceTOML(tb testing.TB, root, body string) { tb.Helper() tomlPath := filepath.Join(root, "workspace.toml") @@ -158,9 +128,6 @@ func writeWorkspaceTOML(tb testing.TB, root, body string) { } } -// initWorkspaceGit turns the workspace root into a git repo with -// workspace.toml committed. Required only by benches that exercise -// reconciler.syncTOML (Phase 1). func initWorkspaceGit(tb testing.TB, root string) { tb.Helper() runGit(tb, root, "init", "-q", "-b", "main") @@ -168,14 +135,11 @@ func initWorkspaceGit(tb testing.TB, root string) { runGit(tb, root, "commit", "-q", "-m", "init benchfixture workspace") } -// buildFakeRemote creates a bare git repo with a single commit on `main`. -// Returns the absolute path; caller wraps with file:// for the URL form. func buildFakeRemote(tb testing.TB, parent, name string) string { tb.Helper() bare := filepath.Join(parent, name+".git") runGit(tb, parent, "init", "-q", "--bare", "-b", "main", name+".git") - // Seed via a working clone — bare repos can't accept commits directly. work := filepath.Join(parent, name+".work") runGit(tb, parent, "clone", "-q", bare, name+".work") if err := os.WriteFile(filepath.Join(work, "README.md"), @@ -190,23 +154,18 @@ func buildFakeRemote(tb testing.TB, parent, name string) string { return bare } -// cloneIntoLayout reproduces the bare+worktree layout inline (without -// pulling in internal/clone, which would create a circular dependency -// when reconciler benches eventually live here too). Layout matches what -// `ws migrate` produces post-conversion. func cloneIntoLayout(tb testing.TB, root, projectPath, remoteBare string) { tb.Helper() mainPath := filepath.Join(root, projectPath) barePath := mainPath + ".bare" runGit(tb, root, "clone", "-q", "--bare", remoteBare, barePath) - // fetch refspec is missing on `clone --bare` (matches CloneIntoLayout). + runGit(tb, barePath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") runGit(tb, barePath, "worktree", "add", "-q", mainPath, "main") } -// runGit executes a git command with hermetic env. Failures are tb.Fatal. func runGit(tb testing.TB, dir string, args ...string) { tb.Helper() full := append([]string{"-C", dir}, args...) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index f2d54be..6dff0d6 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -12,45 +12,32 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// State classifies how a project looks on disk relative to wsRoot. Mirrors -// (but is intentionally separate from) migrate.Check — bootstrap cares about -// "do I clone this?" while migrate cares about "do I convert this?". type State string const ( - // StatePresent means .bare exists; the project is fully cloned. StatePresent State = "present" - // StateNeedsMigrate means exists as a plain git checkout. Bootstrap - // won't touch it; the user must run `ws migrate`. + StateNeedsMigrate State = "needs-migrate" - // StateBlocked means exists but isn't a git repo at all. Bootstrap - // won't clobber it; the user must clean up by hand. + StateBlocked State = "blocked" - // StateSelf means proj.Remote points at the same repo that hosts - // workspace.toml itself. Bootstrap skips it to avoid cloning the workspace - // inside its own tree. + StateSelf State = "self" - // StateMissing means nothing exists at ; bootstrap will clone here. + StateMissing State = "missing" ) -// PlanItem is one row in the bootstrap plan. type PlanItem struct { Name string Project config.Project State State - // Reason carries a human-readable explanation for non-clonable states. + Reason string } -// Plan is the result of scanning workspace.toml against the local filesystem. -// It contains every active project, classified into one bucket each. Use the -// Bucket helpers to display them in TUI sections. type Plan struct { Items []PlanItem } -// Bucket returns the items matching the given state, in stable name order. func (p *Plan) Bucket(s State) []PlanItem { var out []PlanItem for _, it := range p.Items { @@ -62,7 +49,6 @@ func (p *Plan) Bucket(s State) []PlanItem { return out } -// ToClone is the list of project names that bootstrap will actually clone. func (p *Plan) ToClone() []string { items := p.Bucket(StateMissing) names := make([]string, len(items)) @@ -72,9 +58,6 @@ func (p *Plan) ToClone() []string { return names } -// ScanPlan walks ws.Projects, classifies each active project, and returns the -// bootstrap plan. Filtering by `only` (when non-empty) restricts the scan to -// the named projects — used by `ws bootstrap `. func ScanPlan(wsRoot string, ws *config.Workspace, only []string) *Plan { wantOnly := map[string]bool{} for _, n := range only { @@ -99,13 +82,6 @@ func ScanPlan(wsRoot string, ws *config.Workspace, only []string) *Plan { return plan } -// classify is the per-project state machine. Order matters: -// -// 1. self-detection (don't clone the workspace into itself) -// 2. .bare exists → present (already cloned) -// 3. is a git repo → needs-migrate -// 4. exists but not a repo → blocked -// 5. nothing → missing (clone candidate) func classify(wsRoot string, proj config.Project, selfRemote string) (State, string) { if selfRemote != "" && remotesEqual(proj.Remote, selfRemote) { return StateSelf, "this is the workspace repository itself" @@ -125,16 +101,11 @@ func classify(wsRoot string, proj config.Project, selfRemote string) (State, str return StateMissing, "" } -// statExists treats any stat error (including permission denied) as -// "not present" — bootstrap is read-only at this stage. func statExists(path string) bool { _, err := os.Stat(path) return err == nil } -// workspaceSelfRemote returns the origin URL of the git repo that contains -// workspace.toml, or "" if workspace.toml is not in a git repo. Used for -// self-detection so we don't clone the workspace inside itself. func workspaceSelfRemote(wsRoot string) string { root := findRepoRoot(wsRoot) if root == "" { @@ -148,9 +119,6 @@ func workspaceSelfRemote(wsRoot string) string { return strings.TrimSpace(string(out)) } -// findRepoRoot walks up from dir looking for the nearest git repo. Mirrors -// the helper in internal/daemon/reconciler.go (kept duplicated rather than -// exported because the daemon's helper is private and tightly scoped). func findRepoRoot(dir string) string { for { if git.IsRepo(dir) { @@ -164,13 +132,6 @@ func findRepoRoot(dir string) string { } } -// remotesEqual normalizes two git remote URLs and compares them. -// -// Equivalences considered equal: -// -// git@github.com:foo/bar.git ≡ https://github.com/foo/bar.git -// git@github.com:foo/bar.git ≡ git@github.com:foo/bar -// https://github.com/foo/bar/ ≡ https://github.com/foo/bar func remotesEqual(a, b string) bool { return normalizeRemote(a) == normalizeRemote(b) } @@ -180,16 +141,16 @@ func normalizeRemote(s string) string { if s == "" { return "" } - // strip ssh prefix: git@host:path → host/path + if strings.HasPrefix(s, "git@") { s = strings.TrimPrefix(s, "git@") s = strings.Replace(s, ":", "/", 1) } - // strip protocol + for _, p := range []string{"https://", "http://", "ssh://", "git://"} { s = strings.TrimPrefix(s, p) } - // strip trailing .git and slashes + s = strings.TrimSuffix(s, "/") s = strings.TrimSuffix(s, ".git") return strings.ToLower(s) diff --git a/internal/bootstrap/sidecar.go b/internal/bootstrap/sidecar.go index 868f53c..215b532 100644 --- a/internal/bootstrap/sidecar.go +++ b/internal/bootstrap/sidecar.go @@ -1,11 +1,3 @@ -// Sidecar bridge for the bootstrap command. The generic file/lock/pid -// machinery lives in internal/sidecar; this file only defines the -// command-specific value type and a thin facade over it. -// -// While the sidecar exists with a live pid the daemon skips its tick for -// the workspace, preventing daemon/bootstrap races on git operations and -// half-bootstrap state being pushed upstream. See internal/sidecar for the -// shared mechanics. package bootstrap import ( @@ -14,30 +6,19 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// DoneEntry captures one project that finished cloning. The bootstrap -// commit step replays these into workspace.toml in a single atomic write -// at the end. type DoneEntry struct { DefaultBranch string `json:"default_branch"` ClonedAt time.Time `json:"cloned_at"` } -// Sidecar is a bootstrap-shaped view over a generic sidecar.Sidecar. It -// hides the json.RawMessage round-trip so callers see strongly-typed -// DoneEntry values. type Sidecar struct { *sidecar.Sidecar } -// New creates a fresh bootstrap sidecar bound to wsRoot, owned by the -// current process. Save() must be called before any work runs — the lock -// isn't real until the file exists on disk. func New(wsRoot string) *Sidecar { return &Sidecar{Sidecar: sidecar.New(wsRoot, sidecar.KindBootstrap)} } -// Load reads an existing bootstrap sidecar for wsRoot. Returns (nil, nil) -// if no sidecar exists, which is the common case. func Load(wsRoot string) (*Sidecar, error) { sc, err := sidecar.Load(wsRoot, sidecar.KindBootstrap) if err != nil || sc == nil { @@ -46,7 +27,6 @@ func Load(wsRoot string) (*Sidecar, error) { return &Sidecar{Sidecar: sc}, nil } -// Save writes the bootstrap sidecar atomically. func Save(sc *Sidecar) error { if sc == nil { return nil @@ -54,12 +34,10 @@ func Save(sc *Sidecar) error { return sidecar.Save(sc.Sidecar) } -// Delete removes the bootstrap sidecar for wsRoot. func Delete(wsRoot string) error { return sidecar.Delete(wsRoot, sidecar.KindBootstrap) } -// IsAlive reports whether the bootstrap recorded in sc is still running. func IsAlive(sc *Sidecar) bool { if sc == nil { return false @@ -67,7 +45,6 @@ func IsAlive(sc *Sidecar) bool { return sidecar.IsAlive(sc.Sidecar) } -// MarkDone records a successful clone for the named project. func (s *Sidecar) MarkDone(name, defaultBranch string) error { return s.Set(name, DoneEntry{ DefaultBranch: defaultBranch, @@ -75,9 +52,6 @@ func (s *Sidecar) MarkDone(name, defaultBranch string) error { }) } -// DoneEntries returns the per-project results recorded so far, decoded. -// Used at commit time to apply default_branch values back into -// workspace.toml in a single atomic write. func (s *Sidecar) DoneEntries() (map[string]DoneEntry, error) { out := make(map[string]DoneEntry, len(s.Done)) for name := range s.Done { diff --git a/internal/branchprompt/branchprompt.go b/internal/branchprompt/branchprompt.go index c7f7ad9..dc69650 100644 --- a/internal/branchprompt/branchprompt.go +++ b/internal/branchprompt/branchprompt.go @@ -22,8 +22,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Model is a standalone bubbletea model. It is a value type — callers -// re-assign after each Update, the same convention bubbles/* uses. type Model struct { project string candidates []string @@ -32,9 +30,6 @@ type Model struct { input textinput.Model } -// NewModel constructs a Model for the given project with the given branch -// candidates. candidates may be empty — the model auto-enters free-text -// mode when the user presses enter on an empty list. func NewModel(project string, candidates []string) Model { ti := textinput.New() ti.Placeholder = "branch name" @@ -46,18 +41,8 @@ func NewModel(project string, candidates []string) Model { } } -// Init returns no initial command; the parent is expected to have already -// switched steps and rendered a frame before this model is consulted. func (m Model) Init() tea.Cmd { return nil } -// Update handles keystrokes. Non-key messages are ignored (the parent's -// Update handles spinners, window resizes, etc.). -// -// On pick/cancel, Update emits PickedMsg or CancelledMsg via a returned -// tea.Cmd. The parent Update is expected to recognize these messages -// and act on them (unblock a channel, change step, etc.). Update does -// NOT mutate any non-UI state of the parent — all side effects flow -// through the emitted message. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { key, ok := msg.(tea.KeyMsg) if !ok { @@ -69,10 +54,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m.updateListMode(key) } -// updateInputMode handles keystrokes while the user is typing a -// free-text branch name. Enter confirms (emits PickedMsg with the -// trimmed value, no-op on empty), Esc returns to the candidate list, -// any other key forwards to the underlying textinput. func (m Model) updateInputMode(msg tea.Msg, key tea.KeyMsg) (Model, tea.Cmd) { switch key.String() { case "enter": @@ -90,10 +71,6 @@ func (m Model) updateInputMode(msg tea.Msg, key tea.KeyMsg) (Model, tea.Cmd) { return m, cmd } -// updateListMode handles keystrokes while the user is browsing the -// candidate-branch list. j/k or up/down move the cursor; Enter -// confirms (or falls through to input mode when the list is empty); -// i opens input mode unconditionally; Esc cancels the prompt. func (m Model) updateListMode(key tea.KeyMsg) (Model, tea.Cmd) { switch key.String() { case "up", "k": @@ -115,9 +92,6 @@ func (m Model) updateListMode(key tea.KeyMsg) (Model, tea.Cmd) { return m, nil } -// confirmListSelection commits the highlighted candidate as the -// picked branch. When the candidate list is empty the user is -// dropped into input mode so they can type one. func (m Model) confirmListSelection() (Model, tea.Cmd) { if len(m.candidates) == 0 { m.inputMode = true @@ -126,27 +100,18 @@ func (m Model) confirmListSelection() (Model, tea.Cmd) { return m, emitPickedCmd(m.project, m.candidates[m.cursor]) } -// emitPickedCmd builds a tea.Cmd that emits PickedMsg. Centralized -// so the closure form lives in one place rather than scattered -// across two return sites. func emitPickedCmd(project, branch string) tea.Cmd { picked := PickedMsg{Project: project, Branch: branch} return func() tea.Msg { return picked } } -// emitCancelledCmd is the symmetric helper for CancelledMsg. func emitCancelledCmd(project string) tea.Cmd { canceled := CancelledMsg{Project: project} return func() tea.Msg { return canceled } } -// Project returns the project name this prompt is for — useful for -// headers rendered by the caller outside of this model's View. func (m Model) Project() string { return m.project } -// View renders the prompt using the shared palette below. Callers that -// want a different look should wrap this in their own styling rather -// than reach into the model. func (m Model) View() string { var b strings.Builder b.WriteString(titleStyle.Render(" Default branch needed ")) @@ -180,10 +145,6 @@ func (m Model) View() string { return b.String() } -// Styles mirror the palette used by cli/bootstrap.go so the visual -// language stays consistent after extraction. Keeping a private copy -// here (rather than importing from cli) keeps the dependency graph -// simple — branchprompt is a leaf. var ( titleStyle = lipgloss.NewStyle(). Bold(true). diff --git a/internal/branchprompt/messages.go b/internal/branchprompt/messages.go index 3926ec4..2a9c4b6 100644 --- a/internal/branchprompt/messages.go +++ b/internal/branchprompt/messages.go @@ -1,18 +1,10 @@ package branchprompt -// PickedMsg is emitted when the user chooses a branch, either by selecting -// from the candidate list or by typing a custom name in free-text mode. -// Callers embed the model and react to this message to unblock whatever -// code was waiting for the branch name (typically a worker goroutine -// parked on a channel). type PickedMsg struct { Project string Branch string } -// CancelledMsg is emitted when the user escapes the prompt without choosing -// a branch. Callers should treat this as "skip this project" — in the -// original bootstrap flow, this surfaces as a per-project clone error. type CancelledMsg struct { Project string } diff --git a/internal/cli/add.go b/internal/cli/add.go index 233c468..e05fd83 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -67,11 +67,7 @@ so new projects land directly in .bare + form. No follow-up case noTUI: mode = add.ModeHeadless case len(urls) == 0: - // No URLs and no explicit mode flag — fall through to - // add.Run's auto handling. add.Run will still error - // with ErrTUINotImplemented in Phase 2 because the - // TUI ships in Phase 3, but the dispatch shape is in - // place for a flag-flip when Phase 3 lands. + default: if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { mode = add.ModeHeadless @@ -100,9 +96,6 @@ so new projects land directly in .bare + form. No follow-up printResult(res) - // Non-zero exit only if something actually failed; per-URL - // failures in Errors are user-visible above. ErrAlreadyRegistered - // is in Skipped, not Errors, so it doesn't trip exit. if len(res.Errors) > 0 { return fmt.Errorf("%d of %d URL(s) failed", len(res.Errors), len(urls)) } @@ -117,20 +110,11 @@ so new projects land directly in .bare + form. No follow-up cmd.Flags().BoolVar(&tui, "tui", false, "force interactive TUI (default when no URLs given on a TTY)") cmd.Flags().BoolVar(&noTUI, "no-tui", false, "force headless mode; error if no URLs are provided") - // cmd.Context() is a no-op default; wire to a real context for now. - // The CLI is invoked synchronously from main, so a Background ctx - // covers ordinary cases. A future signal-aware context can replace - // this without touching add.Run. cmd.SetContext(context.Background()) return cmd } -// collectURLs assembles the URL list from positional args. The dash -// sentinel "-" means "read from stdin, one URL per line, ignoring -// blank lines and shell-style # comments". Mixing "-" with other args -// is allowed: positional URLs come first in the resulting slice, then -// the stdin batch. func collectURLs(args []string) ([]string, error) { var urls []string for _, a := range args { @@ -147,10 +131,6 @@ func collectURLs(args []string) ([]string, error) { return urls, nil } -// readURLsFromStdin reads non-blank, non-comment lines from stdin. -// Comments use '#'. Returns nil + error only on read failure; an -// empty stdin returns (nil, nil) and the caller decides whether to -// treat that as a no-op or an error. func readURLsFromStdin() ([]string, error) { var out []string scanner := bufio.NewScanner(os.Stdin) @@ -167,10 +147,6 @@ func readURLsFromStdin() ([]string, error) { return out, nil } -// printResult renders one human-readable line per Added project, plus -// one line per Skipped/Errored URL. Format mirrors the legacy single-URL -// output ("clone X → Y" / "added X") so existing eyeballs/parsers see -// familiar shapes. func printResult(res *add.Result) { for _, p := range res.Added { fmt.Printf(" added %s (group: %s, %s)\n", projectNameFromPath(p.Path), groupOrCategory(p), p.Status) @@ -186,9 +162,6 @@ func printResult(res *add.Result) { } } -// projectNameFromPath strips the directory from a workspace-relative -// project path. The Project struct has Path = "/" or -// "/"; we render the trailing component. func projectNameFromPath(p string) string { if idx := strings.LastIndex(p, "/"); idx >= 0 { return p[idx+1:] @@ -196,8 +169,6 @@ func projectNameFromPath(p string) string { return p } -// groupOrCategory returns the group when set, else the category, for -// the success-line summary. Matches legacy `ws add` output behavior. func groupOrCategory(p config.Project) string { if p.Group != "" { return p.Group diff --git a/internal/cli/alias.go b/internal/cli/alias.go index 6c5480b..157f338 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -179,7 +179,6 @@ func newAliasInstallCmd() *cobra.Command { "agent:when": "Install alias auto-loading into ~/.zshrc (idempotent, safe to re-run)", }, RunE: func(cmd *cobra.Command, args []string) error { - // Make sure state file exists before installing the source line. if err := alias.WriteStateFile(ws, wsRoot); err != nil { return err } diff --git a/internal/cli/auth.go b/internal/cli/auth.go index c75c9da..af65968 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -104,7 +104,6 @@ func newAuthStatusCmd() *cobra.Command { return nil } - // Fetch username from GitHub API req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) if err != nil { return err diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 348dd59..a3a4206 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -55,7 +55,6 @@ func runBootstrap(args []string, dryRun bool) error { return nil } - // Sidecar pre-check: another bootstrap running? Stale crash to resume? existing, err := bootstrap.Load(wsRoot) if err != nil { return fmt.Errorf("read sidecar: %w", err) @@ -66,7 +65,7 @@ func runBootstrap(args []string, dryRun bool) error { return fmt.Errorf("bootstrap already running (pid %d, started %s)", existing.Meta.PID, existing.Meta.Started.Local().Format(time.RFC3339)) } - // Stale: ask the user what to do. + fmt.Printf("Found incomplete bootstrap from %s (pid %d, %d projects done).\n", existing.Meta.Started.Local().Format(time.RFC3339), existing.Meta.PID, len(existing.Done)) @@ -89,13 +88,11 @@ func runBootstrap(args []string, dryRun bool) error { } } - // Dry-run: render the plan summary and exit. Never touches the sidecar. if dryRun { printPlanText(plan) return nil } - // Filter out anything we already finished in a previous (resumed) run. toClone := []bootstrap.PlanItem{} for _, it := range plan.Bucket(bootstrap.StateMissing) { if _, done := resumeFrom[it.Name]; done { @@ -119,14 +116,11 @@ func runBootstrap(args []string, dryRun bool) error { } final := finalRaw.(bootstrapModel) - // Errors and notifications happen AFTER the TUI exits so the terminal is - // clean and full git stderr can be printed without breaking layout. if final.canceled { fmt.Println("Bootstrap canceled by user.") return nil } - // Per spec, all clone errors are surfaced in full here. if len(final.errors) > 0 { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, errorBannerStyle.Render("Bootstrap finished with errors:")) @@ -136,20 +130,16 @@ func runBootstrap(args []string, dryRun bool) error { } } - // Final commit step: re-read workspace.toml and persist default_branch - // values from the sidecar in one atomic write. if final.sidecar != nil && len(final.sidecar.Done) > 0 { if err := commitBootstrap(final.sidecar); err != nil { return fmt.Errorf("commit bootstrap: %w", err) } - // Best-effort sidecar cleanup. Failure here is non-fatal — the next - // run will treat it as stale. + if err := bootstrap.Delete(wsRoot); err != nil { fmt.Fprintf(os.Stderr, "warning: could not remove sidecar: %v\n", err) } } - // Final summary + system notification. cloned := len(final.successes) failed := len(final.errors) total := cloned + failed @@ -168,10 +158,6 @@ func runBootstrap(args []string, dryRun bool) error { return nil } -// commitBootstrap re-reads workspace.toml from disk (in case the user -// hand-edited it during a long bootstrap), applies default_branch values -// captured in the sidecar, and saves once. Only fields not already populated -// are touched, so we never overwrite the user's intent. func commitBootstrap(sc *bootstrap.Sidecar) error { freshWS, err := config.Load(wsRoot) if err != nil { @@ -191,7 +177,7 @@ func commitBootstrap(sc *bootstrap.Sidecar) error { freshWS.Projects[name] = proj } } - // Swap into the package-level ws so saveWorkspace() picks it up. + ws = freshWS return saveWorkspace() } diff --git a/internal/cli/bootstrap_model.go b/internal/cli/bootstrap_model.go index 0f84a0a..996baa6 100644 --- a/internal/cli/bootstrap_model.go +++ b/internal/cli/bootstrap_model.go @@ -36,7 +36,7 @@ type bootstrapModel struct { plan *bootstrap.Plan toClone []bootstrap.PlanItem - current int // index into toClone + current int successes []string errors []bootstrapError canceled bool @@ -44,9 +44,6 @@ type bootstrapModel struct { 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 } @@ -56,7 +53,6 @@ type branchAnswer struct { err error } -// Custom messages for the async clone loop. type cloneDoneMsg struct { index int project string @@ -71,9 +67,6 @@ type needsBranchMsg struct { 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 { @@ -81,8 +74,6 @@ func newBootstrapModel(plan *bootstrap.Plan, toClone []bootstrap.PlanItem, resum 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) @@ -109,7 +100,7 @@ func (m bootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } @@ -142,7 +133,7 @@ func (m bootstrapModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -160,10 +151,6 @@ func (m bootstrapModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { 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{} } @@ -171,19 +158,12 @@ func (m bootstrapModel) startClone(index int) tea.Cmd { 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, @@ -194,8 +174,7 @@ func (m bootstrapModel) startClone(index int) tea.Cmd { }, } 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} } } @@ -208,10 +187,7 @@ func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -223,14 +199,14 @@ func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { 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))) @@ -249,8 +225,6 @@ func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { } 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) @@ -258,21 +232,18 @@ func (m bootstrapModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/cli/bootstrap_view.go b/internal/cli/bootstrap_view.go index a0d7ef5..8446819 100644 --- a/internal/cli/bootstrap_view.go +++ b/internal/cli/bootstrap_view.go @@ -46,7 +46,7 @@ func (m bootstrapModel) viewPlan() string { 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 @@ -116,7 +116,6 @@ func (m bootstrapModel) viewDone() string { return b.String() } -// renderProgressBar draws a simple [█████░░░░░] bar. func renderProgressBar(done, total, width int) string { if total <= 0 { return strings.Repeat("░", width) @@ -129,8 +128,6 @@ func renderProgressBar(done, total, width int) string { 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 { diff --git a/internal/cli/create.go b/internal/cli/create.go index b53b256..7a9f32a 100644 --- a/internal/cli/create.go +++ b/internal/cli/create.go @@ -12,13 +12,6 @@ import ( "github.com/spf13/cobra" ) -// newCreateCmd wires `ws create` into cobra. Two modes: interactive -// TUI when invoked without --owner/--name on a TTY, headless when -// both are provided or --no-tui is set. -// -// The command holds a `create` sidecar for the workspace while it -// runs, so the daemon pauses for that workspace (no fetch race with -// the about-to-exist remote, no toml-merge race with our save). func newCreateCmd() *cobra.Command { var ( owner string @@ -87,9 +80,7 @@ Requires gh authentication: run 'gh auth login' first.`, case noTUI: mode = create.ModeHeadless default: - // Auto: headless when stdin is not a TTY AND required - // fields are present. Otherwise auto stays — Run will - // pick TUI when fields are missing. + if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { mode = create.ModeHeadless } @@ -109,7 +100,6 @@ Requires gh authentication: run 'gh auth login' first.`, Save: func(*config.Workspace) error { return saveWorkspace() }, }) if errors.Is(err, create.ErrCancelled) { - // User-initiated abort: silent exit 0. return nil } if err != nil { @@ -141,9 +131,6 @@ Requires gh authentication: run 'gh auth login' first.`, return cmd } -// resolveVisibility merges --visibility and --public flags into a -// single create.Visibility. --public overrides --visibility when both -// are set; empty defaults to private. func resolveVisibility(visibility string, isPublic bool) (create.Visibility, error) { if isPublic { return create.VisibilityPublic, nil diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 485b234..d9a7c19 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -74,7 +74,6 @@ func newDaemonStopCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { client, err := daemon.Dial() if err != nil { - // Try to check PID file if pid, running := daemon.IsRunning(); running { proc, _ := os.FindProcess(pid) proc.Signal(os.Interrupt) @@ -102,8 +101,6 @@ func newDaemonRestartCmd() *cobra.Command { "agent:when": "Restart the daemon (stop + start)", }, RunE: func(cmd *cobra.Command, args []string) error { - // Best-effort stop. If the daemon is unreachable we still - // proceed to Start; the user explicitly asked for restart. if client, err := daemon.Dial(); err == nil { _ = client.Stop() client.Close() @@ -232,7 +229,6 @@ WantedBy=default.target } fmt.Printf(" Installed: %s\n", unitPath) - // Enable and start exec.Command("systemctl", "--user", "daemon-reload").Run() if err := exec.Command("systemctl", "--user", "enable", "--now", "ws-daemon").Run(); err != nil { fmt.Println(" Unit installed. Enable manually: systemctl --user enable --now ws-daemon") diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index de9179a..655b03f 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -8,8 +8,6 @@ import ( "github.com/spf13/cobra" ) -// Exit codes. Documented in the --help text so the acceptance criteria -// is self-describing and scriptable. const ( exitDoctorOK = 0 exitDoctorIssues = 1 @@ -63,14 +61,6 @@ and leaves the action to the user.`, SkipRemote: skipRemote, } - // For text mode we stream per-scope blocks as each check - // batch completes. Without this, the user sits in front of - // a silent terminal while every project's remote-reach - // check burns its 10s timeout. --json must not stream - // (partial JSON is invalid); --fix mode also skips - // streaming because fix outcomes need to be shown inline - // with each finding, and we don't know Fixed/FixError - // until ApplyFixes runs after every check completes. streaming := !asJSON && !fix if streaming { first := true @@ -109,16 +99,6 @@ and leaves the action to the user.`, return cmd } -// exitCodeFor maps a (report, flags) pair to the documented exit code. -// The scheme is: -// -// - --fix ran AND at least one fix succeeded → 2 (state changed). -// - any warn/error present in the final report → 1. -// - otherwise → 0. -// -// Note that "fix succeeded but issues remain" still returns 2 — the user -// asked for --fix, we applied what we could, and the shell exit code -// should reflect that state mutation happened. func exitCodeFor(rep *doctor.Report, fixRequested bool, fixesApplied int) int { if fixRequested && fixesApplied > 0 { return exitDoctorFixApplied diff --git a/internal/cli/explorer.go b/internal/cli/explorer.go index 4e96abe..d736702 100644 --- a/internal/cli/explorer.go +++ b/internal/cli/explorer.go @@ -12,7 +12,7 @@ import ( func newExplorerCmd() *cobra.Command { cmd := &cobra.Command{ Use: "explorer", - Aliases: []string{"agent"}, // backwards-compat: ws agent still works + Aliases: []string{"agent"}, Short: "TUI explorer for projects, worktrees, and Claude sessions", Annotations: map[string]string{ "capability": "explorer", @@ -115,8 +115,6 @@ func runExplorerTUI() error { return err } - // 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 { @@ -127,10 +125,6 @@ func runExplorerTUI() 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 index f3a9069..8da9ab3 100644 --- a/internal/cli/favorite.go +++ b/internal/cli/favorite.go @@ -10,13 +10,6 @@ import ( "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", @@ -69,10 +62,6 @@ func newFavoriteRmCmd() *cobra.Command { } } -// 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) @@ -82,8 +71,6 @@ func setFavorite(arg string, fav bool) error { 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{} } @@ -152,11 +139,6 @@ func newFavoriteListCmd() *cobra.Command { } } -// 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 { diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go index bce5b5d..cf1b4d5 100644 --- a/internal/cli/migrate.go +++ b/internal/cli/migrate.go @@ -57,18 +57,12 @@ The reconciler pauses Phase 1+2 while migrate runs (sidecar coordination at return runMigrateCheck(args) } - // Decide: TUI vs non-interactive. - // - // - Any explicit flag (--all/--wip/--no-tui) → non-interactive - // - stdout is not a TTY (pipe, CI) → non-interactive - // - otherwise → TUI interactive := !noTUI && !all && !wip && term.IsTerminal(int(os.Stdout.Fd())) if interactive { return runMigrateTUI(args) } - // Non-interactive: existing flow with --all / single-project / --wip semantics. if !all && len(args) != 1 { return errors.New("specify a project name or use --all") } @@ -117,9 +111,7 @@ The reconciler pauses Phase 1+2 while migrate runs (sidecar coordination at fmt.Printf(" skip %s: status=%s\n", name, proj.Status) continue } - // Pre-check state. For --all we want missing projects to be a - // soft skip (the registry travels between machines, so it's - // normal for some projects to not exist locally yet). + if all { switch migrate.Check(wsRoot, name, proj).State { case "missing": @@ -146,7 +138,7 @@ The reconciler pauses Phase 1+2 while migrate runs (sidecar coordination at anyFailed = true continue } - ws.Projects[name] = proj // proj.DefaultBranch was filled in + ws.Projects[name] = proj anyMigrated = true migratedCount++ fmt.Printf(" done %s → %s (%d branches preserved", name, res.BarePath, res.BranchesPushed) @@ -233,8 +225,6 @@ func runMigrateCheck(args []string) error { return nil } -// ensureMachineName loads the machine config, prompting once if absent. -// Returns the sanitized machine name to use for branch namespacing. func ensureMachineName() (string, error) { mc, err := config.LoadMachineConfig() if err != nil { diff --git a/internal/cli/migrate_model.go b/internal/cli/migrate_model.go index ad867be..1eb2a50 100644 --- a/internal/cli/migrate_model.go +++ b/internal/cli/migrate_model.go @@ -14,9 +14,9 @@ import ( type migrateStep int const ( - mStepPlan migrateStep = iota - mStepDecision // per-project decision (dirty/stash/detached) - mStepMigrating // running migrate.MigrateProject + mStepPlan migrateStep = iota + mStepDecision + mStepMigrating mStepDone ) @@ -31,11 +31,10 @@ type migrateModel struct { machine string plan *migratePlan - queue []migratePlanItem // projects pending action, in order - cursor int // index into queue - current migratePlanItem // active project + queue []migratePlanItem + cursor int + current migratePlanItem - // Decisions accumulated per project before the migration runs. decisions map[string]migrateDecision successes []string @@ -47,8 +46,6 @@ type migrateModel struct { 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 @@ -120,8 +117,7 @@ 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)...) } @@ -129,7 +125,7 @@ func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -145,9 +141,6 @@ func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -156,7 +149,7 @@ func (m migrateModel) advance() (tea.Model, tea.Cmd) { 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)) @@ -165,7 +158,7 @@ func (m migrateModel) advance() (tea.Model, tea.Cmd) { m.stepChangedAt = time.Now() return m, nil } - // Unknown — skip. + m.skipped++ m.cursor++ return m.advance() @@ -230,8 +223,6 @@ func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { 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] diff --git a/internal/cli/migrate_tui.go b/internal/cli/migrate_tui.go index f245e5c..b003b92 100644 --- a/internal/cli/migrate_tui.go +++ b/internal/cli/migrate_tui.go @@ -14,12 +14,6 @@ import ( "github.com/kuchmenko/workspace/internal/migrate" ) -// runMigrateTUI is the entry point used by `ws migrate` (no flags) and -// `ws migrate `. It scans the workspace, builds a plan, and runs the -// per-project flow inside a bubbletea program. -// -// args is either empty (all active projects) or a single project name. The -// CLI dispatcher already validated the count. func runMigrateTUI(args []string) error { machine, err := ensureMachineName() if err != nil { @@ -32,7 +26,6 @@ func runMigrateTUI(args []string) error { return nil } - // Sidecar pre-check: another migrate running? Stale crash to resume? existing, err := migrate.Load(wsRoot) if err != nil { return fmt.Errorf("read migrate sidecar: %w", err) @@ -43,7 +36,7 @@ func runMigrateTUI(args []string) error { return fmt.Errorf("migrate already running (pid %d, started %s)", existing.Meta.PID, existing.Meta.Started.Local().Format(time.RFC3339)) } - // Stale: ask the user what to do. + fmt.Printf("Found incomplete migrate from %s (pid %d, %d projects done).\n", existing.Meta.Started.Local().Format(time.RFC3339), existing.Meta.PID, len(existing.Done)) @@ -79,8 +72,6 @@ func runMigrateTUI(args []string) error { return nil } - // Post-TUI: print full per-project errors. Long git stderr would break - // the TUI box, so we surface it here. if len(final.errors) > 0 { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, errorBannerStyle.Render("Migrate finished with errors:")) @@ -90,7 +81,6 @@ func runMigrateTUI(args []string) error { } } - // Final commit step: persist default_branch values from the sidecar. if final.sidecar != nil && len(final.sidecar.Done) > 0 { if err := commitMigrate(final.sidecar); err != nil { return fmt.Errorf("commit migrate: %w", err) @@ -117,9 +107,6 @@ func runMigrateTUI(args []string) error { return nil } -// buildMigratePlan walks ws.Projects, classifies each into a migrateState, -// and returns the ordered plan. Filtering by `only` (when non-empty) -// restricts the scan to one project name — used by `ws migrate `. func buildMigratePlan(only []string) *migratePlan { wantOnly := map[string]bool{} for _, n := range only { @@ -146,7 +133,7 @@ func buildMigratePlan(only []string) *migratePlan { item.State = mstMissing case "not-a-repo": item.State = mstNotRepo - default: // "needs-migration" + default: switch { case check.HasStash: item.State = mstStash @@ -164,9 +151,6 @@ func buildMigratePlan(only []string) *migratePlan { return plan } -// commitMigrate re-reads workspace.toml from disk and applies default_branch -// values captured in the sidecar in one atomic write. Symmetric with -// commitBootstrap. func commitMigrate(sc *migrate.Sidecar) error { freshWS, err := config.Load(wsRoot) if err != nil { @@ -197,9 +181,9 @@ const ( mstDirty mstStash mstDetached - mstAlready // already migrated, skip - mstMissing // not on disk, skip - mstNotRepo // garbage, skip + mstAlready + mstMissing + mstNotRepo ) func (s migrateState) label() string { diff --git a/internal/cli/path.go b/internal/cli/path.go index 6d88ee3..36555b6 100644 --- a/internal/cli/path.go +++ b/internal/cli/path.go @@ -11,12 +11,6 @@ import ( "github.com/spf13/cobra" ) -// Exit codes documented in --help and the design issue. -// -// 0 — success; absolute path on stdout. -// 1 — outside any workspace, or project registered but directory missing. -// 2 — project name not present in workspace.toml. -// 64 — usage error (>1 positional arg). Matches sysexits.h EX_USAGE. const ( pathExitOK = 0 pathExitMissingDir = 1 @@ -50,8 +44,7 @@ Exit codes: "capability": "observability", "agent:when": "Resolve a project name to its absolute filesystem path. With no argument, prints the workspace root. Designed for shell substitution: cd \"$(ws path foo)\".", }, - // Custom Args validator so we can exit 64 (EX_USAGE) instead of - // cobra's default 1. cobra.MaximumNArgs(1) would map to exit 1. + Args: func(cmd *cobra.Command, args []string) error { if len(args) > 1 { fmt.Fprintf(cmd.ErrOrStderr(), "ws path: too many arguments (got %d, want 0 or 1)\nusage: ws path [project]\n", len(args)) @@ -81,12 +74,8 @@ Exit codes: return cmd } -// osExit is a seam so tests can replace os.Exit with a panic-based stub. var osExit = os.Exit -// runPath holds the pure resolution logic. Returns the exit code; emits -// the success path to stdout and the failure path to stderr. Designed -// to be unit-tested with bytes.Buffer inputs. func runPath(stdout, stderr io.Writer, wsRoot string, ws *config.Workspace, args []string) int { if len(args) == 0 { fmt.Fprintln(stdout, wsRoot) @@ -108,10 +97,6 @@ func runPath(stdout, stderr io.Writer, wsRoot string, ws *config.Workspace, args return pathExitOK } -// writeUnknownProject emits the unknown-project error. When the registry -// is small (<5 projects) it lists every name verbatim — quicker for the -// reader than scanning a longer list. Larger registries get only the -// error line; we have no Levenshtein helper to rank near-matches. func writeUnknownProject(w io.Writer, name string, projects map[string]config.Project) { fmt.Fprintf(w, "ws path: unknown project %q\n", name) if len(projects) == 0 || len(projects) >= suggestionListCutoff { diff --git a/internal/cli/root.go b/internal/cli/root.go index 89981e6..68962e0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,13 +21,10 @@ func NewRootCmd() *cobra.Command { Use: "ws", Short: "Workspace manager — track, sync, and manage development projects", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Skip loading for commands that don't need it - // Commands that don't need workspace.toml if cmd.Name() == "help" || cmd.Name() == "completion" || cmd.Name() == "docs" { return nil } - // Agent TUI loads workspace data lazily from daemon.toml, - // not from the working directory's workspace.toml. + if cmd.Name() == "agent" || cmd.Name() == "ws" { return nil } @@ -38,7 +35,6 @@ func NewRootCmd() *cobra.Command { return nil } - // Setup bootstraps its own workspace — use cwd, create if needed if cmd.Name() == "setup" { var err error if wsRoot == "" { @@ -64,7 +60,7 @@ func NewRootCmd() *cobra.Command { } return nil }, - // 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 runExplorerTUI() @@ -109,13 +105,11 @@ func saveWorkspace() error { if err := config.Save(wsRoot, ws); err != nil { return fmt.Errorf("saving workspace.toml: %w", err) } - // Regenerate alias state file so shells stay in sync. Best-effort. + if err := alias.WriteStateFile(ws, wsRoot); err != nil { fmt.Fprintf(os.Stderr, "warning: could not update alias state file: %v\n", err) } - // Best-effort daemon notification. If the daemon is down or busy - // the next reconciler tick still picks up the workspace.toml diff - // from disk; the IPC kick just shortens the wait. + if client, err := daemon.Dial(); err == nil { _ = client.Notify(wsRoot, "config_changed") client.Close() diff --git a/internal/cli/scan.go b/internal/cli/scan.go index a1e45cb..75e7618 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -22,7 +22,6 @@ func newScanCmd() *cobra.Command { scanDirs := []string{"personal", "work", "playground", "researches", "tools"} var found int - // Build a set of known paths knownPaths := make(map[string]bool) for _, proj := range ws.Projects { knownPaths[proj.Path] = true @@ -50,18 +49,8 @@ func newScanCmd() *cobra.Command { } } -// scanDir walks `absDir` two levels deep looking for git repositories -// not registered in workspace.toml. The two-level depth handles the -// / shape and the // -// shape uniformly: at each entry, if it is itself a repo we report -// it; otherwise we descend one more level and report inside. -// -// Entries beginning with "." or matching the worktree-layout siblings -// (`*.bare`, `*-wt-*`) are silently skipped at every level — those -// are bookkeeping siblings of already-registered projects, not -// orphans. func scanDir(absDir, root, category string, knownPaths map[string]bool, found *int) error { - _ = category // reserved for future filtering + _ = category entries, err := os.ReadDir(absDir) if err != nil { return err @@ -80,9 +69,6 @@ func scanDir(absDir, root, category string, knownPaths map[string]bool, found *i return nil } -// scanGroupDir walks one level inside a non-repo entry (typical -// // shape used for organization-grouped repos). -// Errors reading the dir are non-fatal — scan is best-effort. func scanGroupDir(groupDir, root string, knownPaths map[string]bool, found *int) { entries, err := os.ReadDir(groupDir) if err != nil { @@ -99,10 +85,6 @@ func scanGroupDir(groupDir, root string, knownPaths map[string]bool, found *int) } } -// shouldSkipScanEntry encapsulates the "this directory is not a -// scan candidate" rules applied at every level. Skips dotfiles, -// non-directories, and the bare+worktree bookkeeping siblings of -// registered projects. func shouldSkipScanEntry(entry os.DirEntry) bool { name := entry.Name() if !entry.IsDir() || strings.HasPrefix(name, ".") { @@ -111,9 +93,6 @@ func shouldSkipScanEntry(entry os.DirEntry) bool { return strings.HasSuffix(name, ".bare") || strings.Contains(name, "-wt-") } -// reportIfUnknownRepo prints one "found" line for `repoPath` if its -// workspace-relative path is not already in `knownPaths`. Increments -// `found` for the caller's tally. func reportIfUnknownRepo(repoPath, root string, knownPaths map[string]bool, found *int) { relPath, _ := filepath.Rel(root, repoPath) if knownPaths[relPath] { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 2c9ad34..0da08dc 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -30,18 +30,15 @@ func newSetupCmd() *cobra.Command { final := result.(setup.Model) r := final.GetResult() - // Error from GitHub API or other failure if r.Err != nil { return fmt.Errorf("setup failed: %w", r.Err) } - // User explicitly canceled (ctrl+c, esc, n) if r.Canceled { fmt.Println("Setup canceled by user.") return nil } - // Confirmed — write workspace.toml if !r.Confirmed { fmt.Println("Setup exited without confirmation.") return nil diff --git a/internal/cli/status.go b/internal/cli/status.go index 88c9c4a..c12e7b8 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -31,7 +31,6 @@ func newStatusCmd() *cobra.Command { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "PROJECT\tGROUP\tSTATUS\tBRANCH\tLAST COMMIT\tLAYOUT") - // Sort projects by name names := make([]string, 0, len(ws.Projects)) for name := range ws.Projects { names = append(names, name) @@ -58,7 +57,6 @@ func newStatusCmd() *cobra.Command { } } if _, err := os.Stat(layout.BarePath(absPath)); err == nil { - // Migrated. Count extra worktrees by enumerating siblings. n := countExtraWorktrees(absPath) if n > 0 { layoutInfo = fmt.Sprintf("worktree+%d", n) @@ -81,8 +79,6 @@ func newStatusCmd() *cobra.Command { } } -// countExtraWorktrees counts sibling directories of mainPath that match the -// "-wt-*" naming convention. Cheap O(N) scan of the parent directory. func countExtraWorktrees(mainPath string) int { parent := filepath.Dir(mainPath) base := filepath.Base(mainPath) + "-wt-" diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 944d133..37b4eaf 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -58,9 +58,6 @@ func newSyncResolveCmd() *cobra.Command { } } -// runSyncResolve drives the per-conflict picker. Loads the store, -// surfaces the empty-store message once, then hands off to -// resolveLoop for the iterate-until-done dance. func runSyncResolve() error { store, err := openConflictStore() if err != nil { @@ -77,13 +74,10 @@ func runSyncResolve() error { return resolveLoop(store, conflicts) } -// resolveLoop runs the print-pick-resolve cycle. Returns when the -// user quits or when the store empties out (different end-message -// for "started empty" vs "drained while resolving"). func resolveLoop(store *conflict.Store, conflicts []conflict.Conflict) error { for { if !pickAndResolve(store, conflicts) { - return nil // user quit + return nil } next, err := store.List() if err != nil { @@ -97,10 +91,6 @@ func resolveLoop(store *conflict.Store, conflicts []conflict.Conflict) error { } } -// pickAndResolve prints the menu, reads the selection, dispatches -// the resolver. Returns false when the user quits the picker; true -// to keep the loop running (including on invalid input — the loop -// re-prompts on the next iteration with a fresh List()). func pickAndResolve(store *conflict.Store, conflicts []conflict.Conflict) bool { printConflictList(conflicts) idx, quit := readConflictChoice(len(conflicts)) @@ -108,15 +98,12 @@ func pickAndResolve(store *conflict.Store, conflicts []conflict.Conflict) bool { return false } if idx < 0 { - return true // invalid input + return true } applyConflictResolution(store, conflicts[idx]) return true } -// printConflictList prints the numbered menu of unresolved conflicts. -// Pure formatting; the format mirrors the original `ws sync resolve` -// output ([N] kind — project/branch (timestamp)). func printConflictList(conflicts []conflict.Conflict) { fmt.Printf("\n%d unresolved conflict(s):\n", len(conflicts)) for i, c := range conflicts { @@ -125,9 +112,6 @@ func printConflictList(conflicts []conflict.Conflict) { fmt.Print("\nselect (number, q to quit): ") } -// conflictListLabel renders one conflict's menu label: kind, project, -// branch — falling back to "workspace.toml" when the conflict is not -// project-scoped (TOML merge / push failures). func conflictListLabel(c conflict.Conflict) string { if c.Project == "" { return string(c.Kind) + " — workspace.toml" @@ -139,11 +123,6 @@ func conflictListLabel(c conflict.Conflict) string { return label } -// readConflictChoice reads the user's selection. Returns: -// -// - (-1, true) on q / empty / EOF — user wants to quit -// - (-1, false) on invalid number — caller re-prompts -// - (idx, false) on valid 1-based index — converted to 0-based func readConflictChoice(max int) (idx int, quit bool) { var input string _, _ = fmt.Scanln(&input) @@ -158,10 +137,6 @@ func readConflictChoice(max int) (idx int, quit bool) { return n - 1, false } -// applyConflictResolution dispatches one selected conflict and, on -// successful resolution, removes it from the store. Errors from the -// resolver and the store are logged to stderr but never propagated — -// the caller continues the picker loop either way. func applyConflictResolution(store *conflict.Store, c conflict.Conflict) { resolved, err := handleConflict(c) if err != nil { diff --git a/internal/cli/sync_resolve.go b/internal/cli/sync_resolve.go index 701f06f..cc847c3 100644 --- a/internal/cli/sync_resolve.go +++ b/internal/cli/sync_resolve.go @@ -12,16 +12,10 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// openConflictStore is a tiny shim around conflict.Open so the cli package -// doesn't need to import the package in two files. func openConflictStore() (*conflict.Store, error) { return conflict.Open() } -// handleConflict drives the prompt for one conflict. Returns (resolved, err) -// where resolved=true means the caller should clear the conflict from the -// store. The reconciler may also clear it on the next tick automatically; -// either path is fine. func handleConflict(c conflict.Conflict) (bool, error) { printConflictHeader(c) switch c.Kind { @@ -41,9 +35,6 @@ func handleConflict(c conflict.Conflict) (bool, error) { return false, nil } -// printConflictHeader writes the per-conflict identification block -// (kind, project, branch, workspace, details). Pure formatting; no -// state mutation. func printConflictHeader(c conflict.Conflict) { fmt.Println() fmt.Printf("Conflict: %s\n", c.Kind) @@ -60,9 +51,6 @@ func printConflictHeader(c conflict.Conflict) { fmt.Println() } -// resolveNeedsMigration is a one-shot informational stop: tell the user -// to run ws migrate, wait for a key, leave the conflict for the next -// reconciler tick to clear once migration completes. func resolveNeedsMigration(c conflict.Conflict) (bool, error) { fmt.Println("This project needs migration. Run:") fmt.Printf(" ws migrate %s\n", c.Project) @@ -71,12 +59,6 @@ func resolveNeedsMigration(c conflict.Conflict) (bool, error) { return false, nil } -// resolveBranchDuplicate handles two [[branches]] entries with the same -// name in the same project — typically caused by two machines adding -// the same branch concurrently. Offers to open workspace.toml in $EDITOR -// for manual reconciliation; auto-merge is intentionally not offered -// because correctness depends on knowing which CreatedBy/CreatedAt to -// trust, which the tool cannot decide. func resolveBranchDuplicate(c conflict.Conflict) (bool, error) { return runPromptLoop([]promptAction{ {"e", "open workspace.toml in $EDITOR — pick which entry to keep", @@ -84,9 +66,6 @@ func resolveBranchDuplicate(c conflict.Conflict) (bool, error) { }, "k", "") } -// editWorkspaceForDuplicate opens workspace.toml in $EDITOR (or vi) -// at the workspace root and asks for confirmation that the duplicate -// is resolved. Returns (true, nil) when the user confirms. func editWorkspaceForDuplicate(c conflict.Conflict) (bool, error) { editor := os.Getenv("EDITOR") if editor == "" { @@ -102,10 +81,6 @@ func editWorkspaceForDuplicate(c conflict.Conflict) (bool, error) { return false, nil } -// resolveBranchOrphan handles a registered branch whose origin ref has -// disappeared (typical: PR merged with auto-delete-branch on GitHub). -// Two clean exits: drop the entry+worktree (the merged-PR case) or -// keep the local branch (rare; the user wants to preserve unmerged work). func resolveBranchOrphan(c conflict.Conflict) (bool, error) { wtPath := findOrphanWorktree(c) dropLabel := "drop [[branches]] entry — no local worktree on this machine" @@ -120,14 +95,6 @@ func resolveBranchOrphan(c conflict.Conflict) (bool, error) { }, "s", "") } -// findOrphanWorktree returns the on-disk worktree path for the orphan -// branch on this machine, or "" when this machine never had a local -// checkout of it. Two distinct scenarios converge into "drop": -// - We have a worktree → user must run `ws worktree rm` themselves -// to remove it from disk; the registry entry is dropped after. -// - We never had one (the [[branches]] entry arrived via -// workspace.toml sync from another machine) → there is nothing -// on disk to remove; the registry entry is dropped directly. func findOrphanWorktree(c conflict.Conflict) string { if ws == nil || c.Project == "" { return "" @@ -140,10 +107,6 @@ func findOrphanWorktree(c conflict.Conflict) string { return locateWorktreeForBranch(barePath, c.Branch) } -// dropOrphanEntry handles the "d" path. When a local worktree is -// present, it instructs the user to run ws worktree rm and waits for -// confirmation. The registry entry is then removed in both cases so -// the reconciler stops re-recording the orphan. func dropOrphanEntry(c conflict.Conflict, wtPath string) (bool, error) { if wtPath != "" { fmt.Printf("Run: ws worktree rm %s %s --force\n", c.Project, c.Branch) @@ -159,9 +122,6 @@ func dropOrphanEntry(c conflict.Conflict, wtPath string) (bool, error) { return true, nil } -// removeBranchFromWorkspace drops the [[branches]] entry for `branch` -// from the named project and persists workspace.toml. No-op if the -// state isn't loaded or the branch isn't registered. func removeBranchFromWorkspace(project, branch string) error { if ws == nil || project == "" || branch == "" { return nil @@ -174,10 +134,6 @@ func removeBranchFromWorkspace(project, branch string) error { return saveWorkspace() } -// keepOrphanLocal handles the "k" path. Clears last_pushed_* so the -// reconciler's orphan check skips this branch on the next tick — the -// user is intentionally keeping a local-only branch around. A future -// ws worktree push reinstates the field and normal detection resumes. func keepOrphanLocal(c conflict.Conflict) (bool, error) { if ws == nil || c.Project == "" || c.Branch == "" { return true, nil @@ -200,19 +156,11 @@ func keepOrphanLocal(c conflict.Conflict) (bool, error) { return true, nil } -// promptAction is one entry on the conflict-resolution menu. The -// handler returns (done, err): done=true exits the loop with that -// resolution status; an error short-circuits the loop unchanged. type promptAction struct { key, label string handler func() (done bool, err error) } -// runPromptLoop renders `actions` as a numbered menu, reads one line -// of input, dispatches to the matching handler, and repeats until a -// handler returns done=true (or err) or the user picks one of the -// `skipKeys`. Centralizes the "for { print menu; read; switch }" boilerplate -// shared across every conflict-resolution prompt. func runPromptLoop(actions []promptAction, skipKeys ...string) (bool, error) { for { printPromptMenu(actions, skipKeys) @@ -250,8 +198,6 @@ func isSkipKey(choice string, skipKeys []string) bool { return false } -// dispatchPromptChoice runs the handler whose key matches `choice`. -// Unknown choices are no-ops — the loop re-prompts. func dispatchPromptChoice(choice string, actions []promptAction) (bool, error) { for _, a := range actions { if a.key == choice { @@ -261,9 +207,6 @@ func dispatchPromptChoice(choice string, actions []promptAction) (bool, error) { return false, nil } -// resolveTOMLConflict drives the prompt for workspace.toml-level -// conflicts (rebase failed, push rejected). Both options open -// external tooling (shell or git status); we never auto-merge. func resolveTOMLConflict(c conflict.Conflict) (bool, error) { return runPromptLoop([]promptAction{ {"s", "open shell in workspace repo — fix manually, exit shell to return", @@ -273,9 +216,6 @@ func resolveTOMLConflict(c conflict.Conflict) (bool, error) { }, "k", "") } -// shellAndConfirm spawns a shell rooted at `dir` and asks for -// confirmation that the conflict is resolved when the shell exits. -// Used by both TOML and main-divergence flows. func shellAndConfirm(dir string) (bool, error) { if err := openShell(dir); err != nil { return false, err @@ -287,9 +227,6 @@ func shellAndConfirm(dir string) (bool, error) { return false, nil } -// resolveProjectConflict drives the prompt for project-level -// divergence (main worktree cannot fast-forward). Inspect logs in -// either direction and / or open a shell to fix manually. func resolveProjectConflict(c conflict.Conflict) (bool, error) { wtPath, err := findWorktreePath(c.Workspace, c.Project, c.Branch) if err != nil { @@ -305,9 +242,6 @@ func resolveProjectConflict(c conflict.Conflict) (bool, error) { }, "k", "") } -// openShellAtWorktree is the project-conflict "open shell" handler. -// Prints a clear message and stays in the menu when no worktree is -// known; otherwise spawns the shell + confirmation prompt. func openShellAtWorktree(wtPath string) (bool, error) { if wtPath == "" { fmt.Println("no worktree path; cannot open shell") @@ -316,10 +250,6 @@ func openShellAtWorktree(wtPath string) (bool, error) { return shellAndConfirm(wtPath) } -// runDivergenceLog runs `git log --oneline ` in `wtPath`, -// silently no-op when wtPath is empty. Errors surface to stderr via -// runInTerm rather than as a return value because the caller treats -// the inspection as best-effort. func runDivergenceLog(wtPath, gitRange string) { if wtPath == "" { return @@ -328,7 +258,6 @@ func runDivergenceLog(wtPath, gitRange string) { } func findWorktreePath(workspace, project, branch string) (string, error) { - // Best-effort: ws is loaded, look the project up. if ws == nil { return "", fmt.Errorf("workspace not loaded") } @@ -337,8 +266,7 @@ func findWorktreePath(workspace, project, branch string) (string, error) { return "", fmt.Errorf("project %s not in workspace.toml", project) } mainPath := workspace + string(os.PathSeparator) + proj.Path - // For now, return the main worktree. Branch-specific worktree resolution - // would require parsing `git worktree list` — overkill for the prompt UI. + _ = branch return mainPath, nil } diff --git a/internal/cli/worktree.go b/internal/cli/worktree.go index e1b9fab..bb79dc5 100644 --- a/internal/cli/worktree.go +++ b/internal/cli/worktree.go @@ -32,9 +32,6 @@ func newWorktreeCmd() *cobra.Command { return cmd } -// resolveProject looks up a project by name in the loaded workspace and -// resolves both its main worktree path and its bare repo path. Returns -// an error if the project is not migrated yet. func resolveProject(name string) (config.Project, string, string, error) { proj, ok := ws.Projects[name] if !ok { @@ -48,11 +45,6 @@ func resolveProject(name string) (config.Project, string, string, error) { return proj, mainPath, barePath, nil } -// locateWorktreeForBranch finds the existing worktree directory whose -// HEAD points at `branch`. Returns "" when no such worktree exists. -// Used by `ws worktree rm` and `ws worktree push` to find the path -// independent of the directory-naming heuristic used by `ws worktree -// add` (which may have applied a `-` collision suffix). func locateWorktreeForBranch(barePath, branch string) string { wts, err := git.WorktreeList(barePath) if err != nil { @@ -69,10 +61,6 @@ func locateWorktreeForBranch(barePath, branch string) string { return "" } -// validateBranchName asks git itself whether a branch name is valid. -// Centralized so add/push/list all reject malformed names with the -// canonical message instead of letting later git operations fail with -// noisier output. func validateBranchName(branch string) error { cmd := exec.Command("git", "check-ref-format", "--branch", branch) out, err := cmd.CombinedOutput() diff --git a/internal/cli/worktree_add.go b/internal/cli/worktree_add.go index 62f0b3e..1b2c975 100644 --- a/internal/cli/worktree_add.go +++ b/internal/cli/worktree_add.go @@ -72,33 +72,14 @@ EXAMPLES 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) @@ -122,7 +103,7 @@ EXAMPLES return fmt.Errorf("worktree path already exists: %s", wtPath) } - source := "" // "fetched", "local", or "" for new + source := "" switch { case localExists: if fromBase != "" { @@ -160,11 +141,6 @@ EXAMPLES _ = 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()) { diff --git a/internal/cli/worktree_rm.go b/internal/cli/worktree_rm.go index e104c11..1fae11f 100644 --- a/internal/cli/worktree_rm.go +++ b/internal/cli/worktree_rm.go @@ -38,12 +38,7 @@ func newWorktreeRmCmd() *cobra.Command { 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) } diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 9324b1a..ad2aad1 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -33,24 +33,14 @@ import ( "strings" ) -// ErrUnavailable is returned when no supported clipboard tool is -// present on this platform. Callers treat this as "feature not -// available here" rather than failure. var ErrUnavailable = errors.New("no clipboard tool available") -// Reader is the exported interface so tests (and the future `ws add` -// gather code) can inject a fake. The default implementation lives at -// DefaultReader below. type Reader interface { Read(ctx context.Context) (string, error) } -// DefaultReader is the production Reader. Use it as the zero-config -// choice; tests substitute their own implementation. var DefaultReader Reader = systemReader{} -// systemReader is the concrete dispatcher. It is stateless — each -// Read call probes the environment fresh. type systemReader struct{} func (systemReader) Read(ctx context.Context) (string, error) { @@ -61,11 +51,6 @@ func (systemReader) Read(ctx context.Context) (string, error) { return runTool(ctx, tool, args...) } -// detect returns the command + args of the clipboard tool for the -// current platform, or ErrUnavailable if nothing is usable. -// -// The detector is pure (no side effects beyond env/filesystem stat), -// so it's safe to call repeatedly and cheap to test. func detect() (string, []string, error) { switch runtime.GOOS { case "linux": @@ -76,10 +61,6 @@ func detect() (string, []string, error) { return "", nil, ErrUnavailable } -// detectLinuxClipboard prefers Wayland (wl-paste) when WAYLAND_DISPLAY -// is set, falls back to X11 (xclip) when DISPLAY is set. Returns -// ErrUnavailable when neither display server is active or its tool -// is missing from PATH. func detectLinuxClipboard() (string, []string, error) { if os.Getenv("WAYLAND_DISPLAY") != "" { if p, err := exec.LookPath("wl-paste"); err == nil { @@ -94,9 +75,6 @@ func detectLinuxClipboard() (string, []string, error) { return "", nil, ErrUnavailable } -// detectDarwinClipboard returns the pbpaste binary path. macOS -// always ships it but on minimal images (CI runners) the lookup -// can fail; ErrUnavailable preserves that. func detectDarwinClipboard() (string, []string, error) { if p, err := exec.LookPath("pbpaste"); err == nil { return p, nil, nil @@ -104,13 +82,10 @@ func detectDarwinClipboard() (string, []string, error) { return "", nil, ErrUnavailable } -// runTool executes cmd with args and returns trimmed stdout. ctx -// controls cancellation (either an explicit cancel or a deadline). func runTool(ctx context.Context, cmd string, args ...string) (string, error) { c := exec.CommandContext(ctx, cmd, args...) out, err := c.Output() if err != nil { - // Distinguish "ctx canceled / deadline" from "tool failed". if ctx.Err() != nil { return "", ctx.Err() } @@ -120,9 +95,6 @@ func runTool(ctx context.Context, cmd string, args ...string) (string, error) { } return "", fmt.Errorf("%s: %w", cmd, err) } - // Most tools include a trailing newline by default; wl-paste - // --no-newline suppresses it, pbpaste emits no newline. xclip does - // include one. Trim universally so callers see a clean value. + return strings.TrimRight(string(out), "\n"), nil } - diff --git a/internal/clone/clone.go b/internal/clone/clone.go index 36baa08..1918239 100644 --- a/internal/clone/clone.go +++ b/internal/clone/clone.go @@ -25,15 +25,9 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// Options configures one CloneIntoLayout call. type Options struct { - // PromptDefaultBranch is invoked when the project's default branch can - // not be auto-detected (no proj.DefaultBranch, no origin/HEAD, no - // well-known candidate). nil means non-interactive: the call returns - // ErrNeedsBootstrap so the caller can record a conflict and continue. PromptDefaultBranch func(project string, candidates []string) (string, error) - // Logf is the structured progress sink. nil means silent. Logf func(format string, args ...interface{}) } @@ -43,7 +37,6 @@ func (o Options) logf(format string, args ...interface{}) { } } -// Result describes a successful clone. type Result struct { Project string BarePath string @@ -51,33 +44,16 @@ type Result struct { DefaultBranch string } -// Sentinel errors. Use errors.Is to detect. var ( - // ErrAlreadyCloned is returned when .bare already exists. Treat - // as a no-op skip. ErrAlreadyCloned = errors.New("project already cloned") - // ErrNeedsMigration is returned when exists as a plain git - // checkout (no .bare sibling). The user must run `ws migrate`. ErrNeedsMigration = errors.New("project exists as plain clone, run 'ws migrate'") - // ErrPathBlocked is returned when exists but is not a git - // repository — non-repo files are sitting where the worktree should go. ErrPathBlocked = errors.New("non-repo files present at project path") - // ErrNeedsBootstrap is returned when default_branch can not be inferred - // and the caller passed no PromptDefaultBranch. Surfaces as a - // 'needs-bootstrap' conflict from the daemon path. ErrNeedsBootstrap = errors.New("default branch needs interactive selection") ) -// CloneIntoLayout clones proj.Remote into the canonical -// /.bare + / layout. -// -// On success, proj.DefaultBranch is filled in (if it was empty) and the -// caller is responsible for persisting workspace.toml. On failure, any -// partially-created bare repo is removed and the on-disk state matches what -// it was before the call. func CloneIntoLayout(wsRoot, name string, proj *config.Project, opts Options) (*Result, error) { if err := validateCloneInputs(name, proj); err != nil { return nil, err @@ -111,9 +87,6 @@ func CloneIntoLayout(wsRoot, name string, proj *config.Project, opts Options) (* }, nil } -// validateCloneInputs enforces the not-nil + not-empty preconditions -// on a project before any disk IO. Each error names the specific -// missing field so callers can show the user what's wrong. func validateCloneInputs(name string, proj *config.Project) error { if proj == nil { return fmt.Errorf("clone %s: nil project", name) @@ -127,15 +100,6 @@ func validateCloneInputs(name string, proj *config.Project) error { return nil } -// preflightLayout classifies the on-disk state at the bare and main -// paths so we know which sentinel error (if any) to return before -// kicking off the clone: -// -// - bare exists → ErrAlreadyCloned -// - main exists & is repo → ErrNeedsMigration -// - main exists but is not a repo → ErrPathBlocked -// - any unexpected stat error → wrapped IO error -// - both missing → nil (caller proceeds) func preflightLayout(barePath, mainPath string) error { if _, err := os.Stat(barePath); err == nil { return ErrAlreadyCloned @@ -155,14 +119,7 @@ func preflightLayout(barePath, mainPath string) error { return ErrPathBlocked } -// initBareLayout finishes the bare-side setup that has to land before -// the main worktree can be created: writes the fetch refspec, picks -// the default branch, and pins origin/HEAD. Returns the default -// branch on success. func initBareLayout(name string, proj *config.Project, barePath string, opts Options) (string, error) { - // `git clone --bare` omits remote.origin.fetch. Without it, subsequent - // `git fetch` calls update only FETCH_HEAD, branch@{u} fails to resolve, - // and AheadBehind returns (0, 0, false) for every branch. if err := git.SetFetchRefspec(barePath); err != nil { return "", fmt.Errorf("set fetch refspec: %w", err) } @@ -171,15 +128,11 @@ func initBareLayout(name string, proj *config.Project, barePath string, opts Opt return "", err } opts.logf("clone %s: default branch = %s", name, defaultBranch) - // Pin origin/HEAD so subsequent `git remote show origin` and similar - // agree with what we picked. Best-effort. + _ = git.SetRemoteHead(barePath, defaultBranch) return defaultBranch, nil } -// materializeMainWorktree creates the main worktree at mainPath on -// defaultBranch, verifies it, and wires up upstream tracking. Cleans -// up both bare and main on failure so a partial state never lingers. func materializeMainWorktree(name, barePath, mainPath, defaultBranch string, opts Options) error { opts.logf("clone %s: worktree add %s on %s", name, mainPath, defaultBranch) if err := git.WorktreeAdd(barePath, mainPath, defaultBranch, ""); err != nil { @@ -192,26 +145,13 @@ func materializeMainWorktree(name, barePath, mainPath, defaultBranch string, opt _ = os.RemoveAll(barePath) return fmt.Errorf("verification failed: %s is not a git repo after worktree add", mainPath) } - // SetBranchUpstream writes branch..remote / .merge directly - // instead of `git branch --set-upstream-to=origin/`, which - // would need a second fetch first (we just cloned and haven't - // populated refs/remotes/origin/* yet). Best-effort: a failure - // here leaves the clone usable, just ergonomically annoying. + if err := git.SetBranchUpstream(barePath, defaultBranch, "origin"); err != nil { opts.logf("clone %s: warning: could not set upstream for %s: %v", name, defaultBranch, err) } return nil } -// resolveDefaultBranch picks the project's default branch using: -// -// 1. proj.DefaultBranch if already set -// 2. refs/remotes/origin/HEAD inside the freshly cloned bare -// 3. well-known candidates (main, master, trunk) that exist locally -// 4. opts.PromptDefaultBranch — if nil, returns ErrNeedsBootstrap -// -// Step 4 is the only step that can return ErrNeedsBootstrap, and only when -// the caller is non-interactive. func resolveDefaultBranch(name string, proj *config.Project, barePath string, opts Options) (string, error) { if proj.DefaultBranch != "" { return proj.DefaultBranch, nil @@ -229,9 +169,6 @@ func resolveDefaultBranch(name string, proj *config.Project, barePath string, op return promptForDefaultBranch(name, candidates, opts.PromptDefaultBranch) } -// defaultBranchFromOriginHEAD reads refs/remotes/origin/HEAD and -// returns the bare branch name (strips the "origin/" prefix). -// Returns "" when the symbolic ref is not set. func defaultBranchFromOriginHEAD(barePath string) string { br := git.SymbolicRef(barePath, "refs/remotes/origin/HEAD") if br == "" { @@ -243,10 +180,6 @@ func defaultBranchFromOriginHEAD(barePath string) string { return br } -// wellKnownDefaultCandidates returns the subset of the main / -// master / trunk branch names that exist locally in the bare repo. -// One match is "definitely the default"; zero or multiple kicks -// the resolver into prompt-mode. func wellKnownDefaultCandidates(barePath string) []string { var out []string for _, c := range []string{"main", "master", "trunk"} { @@ -257,8 +190,6 @@ func wellKnownDefaultCandidates(barePath string) []string { return out } -// promptForDefaultBranch invokes the caller-supplied prompt and -// validates the result is a non-empty trimmed string. func promptForDefaultBranch(name string, candidates []string, prompt func(string, []string) (string, error)) (string, error) { picked, err := prompt(name, candidates) if err != nil { diff --git a/internal/config/branch.go b/internal/config/branch.go index eba788c..533a9e9 100644 --- a/internal/config/branch.go +++ b/internal/config/branch.go @@ -2,34 +2,18 @@ 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 { @@ -39,14 +23,6 @@ func (p *Project) LookupBranch(name string) *BranchMeta { 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 @@ -67,11 +43,6 @@ func (p *Project) ClaimBranch(name, machine string) (changed bool, isNew bool) { 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)) @@ -80,12 +51,6 @@ func updateBranchClaim(b *BranchMeta, machine, now string) { 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 { @@ -95,9 +60,6 @@ func (p *Project) ReleaseBranch(name, machine string) (changed bool, removed boo 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) @@ -109,9 +71,7 @@ func (p *Project) releaseAt(idx int, machine string) (changed bool, removed bool 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 = "" @@ -119,8 +79,6 @@ func (p *Project) releaseAt(idx int, machine string) (changed bool, removed bool 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 { @@ -133,8 +91,6 @@ func removeMachine(machines []string, target string) (filtered []string, dropped 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 { @@ -149,19 +105,6 @@ 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 @@ -189,12 +132,6 @@ func (p *Project) StampActivity(name, machine string, when time.Time) bool { 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 { @@ -205,15 +142,6 @@ func (p *Project) RemoveBranch(name string) bool { 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 { diff --git a/internal/config/config.go b/internal/config/config.go index f1efc4e..171f4bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,16 +11,10 @@ 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 @@ -58,26 +52,15 @@ type Workspace struct { 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: @@ -87,9 +70,6 @@ func (w *Workspace) AgentDefaultView() string { } } -// 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 { @@ -105,7 +85,6 @@ func (w *Workspace) SetAgentDefaultView(view string) bool { return true } -// 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 != "" { return rootFromEnv(env) @@ -120,11 +99,6 @@ 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 { @@ -134,9 +108,6 @@ func FindRootFrom(start string) (string, bool) { 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). func rootFromEnv(env string) (string, error) { if _, err := os.Stat(filepath.Join(env, "workspace.toml")); err == nil { return env, nil @@ -144,10 +115,6 @@ func rootFromEnv(env string) (string, error) { return "", fmt.Errorf("WS_ROOT=%s does not contain workspace.toml", env) } -// rootByWalkUp walks upward from `start` to the filesystem root, -// returning the first directory that contains workspace.toml. Returns -// (root, true) on hit; ("", false) when the walk hit the root dir -// without finding one. func rootByWalkUp(start string) (string, bool) { dir := start for { @@ -184,7 +151,6 @@ func Load(root string) (*Workspace, error) { return &ws, nil } -// LoadOrCreate loads workspace.toml if it exists, otherwise creates a default one. func LoadOrCreate(root string) (*Workspace, error) { path := filepath.Join(root, "workspace.toml") if _, err := os.Stat(path); err == nil { @@ -200,11 +166,6 @@ func LoadOrCreate(root string) (*Workspace, error) { return ws, nil } -// Save writes workspace.toml with the new schema. Two cleanup steps run -// before encoding: every BranchMeta with empty Machines is dropped (the -// "no orphan tombstones across save boundaries" invariant), and any -// stray LegacyAutopush field is cleared so the legacy block can never -// round-trip back onto disk. func Save(root string, ws *Workspace) error { path := filepath.Join(root, "workspace.toml") cleaned := cleanForSave(ws) @@ -226,19 +187,12 @@ func cleanForSave(ws *Workspace) *Workspace { return &out } -// projectForSave returns a copy of `p` with the on-disk-only invariants -// applied: empty-machines [[branches]] entries are dropped (the orphan- -// tombstone GC), and the legacy autopush field is nil-ed so it never -// round-trips back into workspace.toml after a Load → Save migration. func projectForSave(p Project) Project { p.Branches = filterEmptyMachines(p.Branches) p.LegacyAutopush = nil return p } -// filterEmptyMachines drops every BranchMeta whose Machines slice is -// empty. Returns nil when nothing survives so the encoder omits the -// [[branches]] block entirely (rather than emitting an empty array). func filterEmptyMachines(branches []BranchMeta) []BranchMeta { if len(branches) == 0 { return branches diff --git a/internal/config/legacy.go b/internal/config/legacy.go index d62a814..9c9ae05 100644 --- a/internal/config/legacy.go +++ b/internal/config/legacy.go @@ -1,7 +1,5 @@ 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"` @@ -13,19 +11,6 @@ type legacyOwnedBranch struct { 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 @@ -39,12 +24,6 @@ func migrateLegacyAutopush(p *Project) { } } -// 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 @@ -65,11 +44,6 @@ func (p *Project) appendLegacyOwned(o legacyOwnedBranch) { }) } -// 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 diff --git a/internal/config/machine.go b/internal/config/machine.go index 2fe6ac2..fac8097 100644 --- a/internal/config/machine.go +++ b/internal/config/machine.go @@ -10,20 +10,12 @@ import ( "github.com/BurntSushi/toml" ) -// MachineConfig holds per-machine settings stored in ~/.config/ws/config.toml. -// This is intentionally separate from the workspace TOML so that machine -// identity travels with the user, not with any particular workspace clone. type MachineConfig struct { - // MachineName is used as the segment in the wt// - // branch convention. It must be a short, stable, filesystem-safe identifier - // (lowercase letters, digits, and dashes). MachineName string `toml:"machine_name"` } var machineNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) -// SanitizeMachineName lowercases and replaces unsafe characters with dashes -// so the result is safe to embed in branch names and filesystem paths. func SanitizeMachineName(raw string) string { s := strings.ToLower(strings.TrimSpace(raw)) s = machineNameSanitizer.ReplaceAllString(s, "-") @@ -31,8 +23,6 @@ func SanitizeMachineName(raw string) string { return s } -// MachineConfigPath returns the canonical location of the machine config file. -// Honors $XDG_CONFIG_HOME, falls back to ~/.config/ws/config.toml. func MachineConfigPath() (string, error) { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "ws", "config.toml"), nil @@ -44,9 +34,6 @@ func MachineConfigPath() (string, error) { return filepath.Join(home, ".config", "ws", "config.toml"), nil } -// LoadMachineConfig reads the machine config from disk. Returns an empty -// (zero-valued) config without error if the file does not exist — callers -// are expected to prompt the user and call SaveMachineConfig in that case. func LoadMachineConfig() (*MachineConfig, error) { path, err := MachineConfigPath() if err != nil { @@ -64,7 +51,6 @@ func LoadMachineConfig() (*MachineConfig, error) { return &cfg, nil } -// SaveMachineConfig writes the machine config, creating parent dirs as needed. func SaveMachineConfig(cfg *MachineConfig) error { path, err := MachineConfigPath() if err != nil { @@ -81,15 +67,12 @@ func SaveMachineConfig(cfg *MachineConfig) error { return toml.NewEncoder(f).Encode(cfg) } -// DefaultMachineName produces a fallback machine name from os.Hostname(). -// The result is sanitized but still may need user confirmation — hostnames -// like "ivans-MacBook-Pro.local" are technically valid but ugly in git history. func DefaultMachineName() string { h, err := os.Hostname() if err != nil || h == "" { return "unknown" } - // Strip common .local suffix and trailing domain pieces. + if i := strings.IndexByte(h, '.'); i > 0 { h = h[:i] } diff --git a/internal/config/project.go b/internal/config/project.go index 3362ab9..32cd0d4 100644 --- a/internal/config/project.go +++ b/internal/config/project.go @@ -22,30 +22,16 @@ type Project struct { 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 @@ -53,10 +39,6 @@ func (p Project) SyncEnabled() bool { 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 diff --git a/internal/config/validate.go b/internal/config/validate.go index 7f37872..7c5f1c4 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -5,16 +5,12 @@ import ( "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 @@ -22,11 +18,6 @@ type ValidationIssue struct { 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 { @@ -41,9 +32,6 @@ func (w *Workspace) Validate() []ValidationIssue { 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 diff --git a/internal/conflict/conflict.go b/internal/conflict/conflict.go index 1fa892b..a241c82 100644 --- a/internal/conflict/conflict.go +++ b/internal/conflict/conflict.go @@ -17,22 +17,20 @@ import ( "github.com/google/uuid" ) -// Kind enumerates the types of conflicts the reconciler can record. type Kind string const ( - KindTOMLMerge Kind = "toml-merge" // workspace.toml rebase failed - KindTOMLPushFailed Kind = "toml-push-failed" // toml push rejected and pull-rebase did not help - KindMainDivergence Kind = "main-divergence" // main worktree cannot fast-forward - KindNeedsMigration Kind = "needs-migration" // project on disk is plain checkout, not yet migrated - KindNeedsBootstrap Kind = "needs-bootstrap" // missing project couldn't be auto-cloned (default branch ambiguous) - KindPathBlocked Kind = "path-blocked" // non-repo files at project path; can't bootstrap - KindCloneFailed Kind = "clone-failed" // git clone of a missing project failed (network/auth/etc) - KindBranchDuplicate Kind = "branch-duplicate" // two [[branches]] entries share the same name in one project - KindBranchOrphan Kind = "branch-orphan" // [[branches]] entry's published branch was deleted on origin + KindTOMLMerge Kind = "toml-merge" + KindTOMLPushFailed Kind = "toml-push-failed" + KindMainDivergence Kind = "main-divergence" + KindNeedsMigration Kind = "needs-migration" + KindNeedsBootstrap Kind = "needs-bootstrap" + KindPathBlocked Kind = "path-blocked" + KindCloneFailed Kind = "clone-failed" + KindBranchDuplicate Kind = "branch-duplicate" + KindBranchOrphan Kind = "branch-orphan" ) -// Conflict is one row in the persisted store. type Conflict struct { ID string `json:"id"` Workspace string `json:"workspace"` @@ -43,15 +41,10 @@ type Conflict struct { Details json.RawMessage `json:"details,omitempty"` } -// Store is the on-disk JSON file. Concurrent writers within a single process -// are serialized via the embedded mutex; cross-process safety relies on the -// reconciler being the only writer. type Store struct { path string } -// Path returns the canonical conflicts.json location. -// Honors $XDG_STATE_HOME, falls back to ~/.local/state/ws/conflicts.json. func Path() (string, error) { if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { return filepath.Join(xdg, "ws", "conflicts.json"), nil @@ -63,7 +56,6 @@ func Path() (string, error) { return filepath.Join(home, ".local", "state", "ws", "conflicts.json"), nil } -// Open returns a Store backed by the canonical path. func Open() (*Store, error) { p, err := Path() if err != nil { @@ -72,7 +64,6 @@ func Open() (*Store, error) { return &Store{path: p}, nil } -// fileShape is the JSON envelope. type fileShape struct { Conflicts []Conflict `json:"conflicts"` } @@ -110,7 +101,6 @@ func (s *Store) save(f *fileShape) error { return os.Rename(tmp, s.path) } -// List returns all currently tracked conflicts. func (s *Store) List() ([]Conflict, error) { f, err := s.load() if err != nil { @@ -119,16 +109,10 @@ func (s *Store) List() ([]Conflict, error) { return f.Conflicts, nil } -// matchKey identifies a conflict for deduplication purposes. Two records -// with the same key represent the same underlying problem and should not -// produce duplicate entries on every reconciler tick. func matchKey(c Conflict) string { return string(c.Kind) + "|" + c.Workspace + "|" + c.Project + "|" + c.Branch } -// Record inserts c if no equivalent conflict already exists, otherwise it -// refreshes the existing record's DetectedAt and Details. Returns true when -// a new conflict was inserted (so callers can decide whether to notify). func (s *Store) Record(c Conflict) (bool, error) { c = ensureRecordDefaults(c) f, err := s.load() @@ -143,8 +127,6 @@ func (s *Store) Record(c Conflict) (bool, error) { return true, s.save(f) } -// ensureRecordDefaults fills in the auto-generated ID and the -// "detected just now" timestamp when the caller didn't set them. func ensureRecordDefaults(c Conflict) Conflict { if c.ID == "" { c.ID = uuid.NewString() @@ -155,8 +137,6 @@ func ensureRecordDefaults(c Conflict) Conflict { return c } -// findMatch returns the index of the first conflict in `xs` whose -// match-key matches `c`, or -1 when none does. func findMatch(xs []Conflict, c Conflict) int { target := matchKey(c) for i := range xs { @@ -167,9 +147,6 @@ func findMatch(xs []Conflict, c Conflict) int { return -1 } -// refreshExisting bumps the existing record's DetectedAt and copies -// the new Details (if any). Skips Details when the caller passed nil -// so a refresh-without-details doesn't blank the recorded reason. func refreshExisting(existing *Conflict, fresh Conflict) { existing.DetectedAt = fresh.DetectedAt if fresh.Details != nil { @@ -177,9 +154,6 @@ func refreshExisting(existing *Conflict, fresh Conflict) { } } -// Clear removes any conflict matching workspace+project+branch+kind. Used -// when a tick proves the previously-recorded condition is now resolved -// (e.g. branch became ff again). func (s *Store) Clear(workspace, project, branch string, kind Kind) error { f, err := s.load() if err != nil { @@ -197,8 +171,6 @@ func (s *Store) Clear(workspace, project, branch string, kind Kind) error { return s.save(f) } -// Remove deletes a conflict by ID. Used by `ws sync resolve` after the user -// confirms a fix. func (s *Store) Remove(id string) error { f, err := s.load() if err != nil { diff --git a/internal/conflict/notify.go b/internal/conflict/notify.go index a69be03..7ce3f9a 100644 --- a/internal/conflict/notify.go +++ b/internal/conflict/notify.go @@ -5,10 +5,6 @@ import ( "os/exec" ) -// Notify sends a desktop notification via notify-send if it is installed. -// Failure (no notify-send, no display, etc.) is silent — the conflict is -// already persisted to conflicts.json, so the notification is purely a -// nice-to-have. func Notify(title, body string) { if _, err := exec.LookPath("notify-send"); err != nil { return @@ -16,8 +12,6 @@ func Notify(title, body string) { _ = exec.Command("notify-send", "-a", "ws", title, body).Run() } -// NotifyNew is a convenience helper for the reconciler: a single-line -// summary of a freshly recorded conflict. func NotifyNew(c Conflict) { title := fmt.Sprintf("ws: new sync conflict (%s)", c.Kind) var body string diff --git a/internal/create/cmd.go b/internal/create/cmd.go index a41ec6e..c0e1a18 100644 --- a/internal/create/cmd.go +++ b/internal/create/cmd.go @@ -14,8 +14,6 @@ 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 { @@ -30,9 +28,6 @@ func (m CreateModel) fetchOwnersCmd() tea.Cmd { } } -// 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 { diff --git a/internal/create/create.go b/internal/create/create.go index 5b14585..990998e 100644 --- a/internal/create/create.go +++ b/internal/create/create.go @@ -10,37 +10,14 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// ErrNoOwner is returned in headless mode when Options.Owner is empty. -// Headless cannot consult the user; the TUI is the usual answer to -// missing fields. var ErrNoOwner = errors.New("no owner provided; pass --owner or run without --no-tui") -// ErrNoName is returned in headless mode when Options.Name is empty. var ErrNoName = errors.New("no repo name provided; pass --name or run without --no-tui") -// ErrInvalidName is returned when Options.Name fails GitHub's -// allowed-character check (alphanumerics, -, _, .). Validated client -// side so the user sees the error before a gh round-trip. var ErrInvalidName = errors.New("invalid repo name") -// nameRegex enforces the subset of GitHub's accepted names that we -// also want as project keys: starts with alphanumeric, then -// alphanumerics, dash, underscore, period. 1–100 chars. GitHub itself -// is slightly more permissive, but allowing leading "." or unusual -// chars complicates path derivation in workspace.toml. var nameRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`) -// Run is the single entry point for `ws create`. Owns the sidecar -// lifecycle and dispatches on Mode: -// -// ModeAuto with both Owner+Name set → headless -// ModeAuto missing Owner or Name → TUI -// ModeHeadless missing fields → ErrNoOwner / ErrNoName -// ModeTUI → TUI regardless of fields -// -// On success, returns the Result describing the new project. The -// project is already registered in workspace.toml and cloned in -// bare+worktree form when Run returns. func Run(ctx context.Context, opts Options) (*Result, error) { if err := ctx.Err(); err != nil { return nil, err @@ -71,7 +48,6 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("create.Run: unknown mode %d", opts.Mode) } - // Sidecar acquire — pauses daemon, blocks concurrent ws create. if _, err := acquireSidecar(opts.WsRoot, opts.Mode, opts.Owner, opts.Name); err != nil { return nil, err } @@ -83,9 +59,6 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return runHeadless(ctx, opts) } -// runHeadless executes the create→register→clone pipeline using only -// Options fields. No prompts, no fallbacks — required fields missing -// is an error. func runHeadless(ctx context.Context, opts Options) (*Result, error) { if err := validateName(opts.Name); err != nil { return nil, err @@ -132,9 +105,6 @@ func runHeadless(ctx context.Context, opts Options) (*Result, error) { regRes, err := add.Register(regOpts, sshURL) if err != nil { - // Clone failed but the GitHub repo exists — surface clearly so - // the user can re-run `ws add` after fixing whatever blocked - // the clone (auth, path conflict, etc). return nil, fmt.Errorf("repo created on GitHub at %s but local register failed: %w", sshURL, err) } @@ -146,16 +116,6 @@ func runHeadless(ctx context.Context, opts Options) (*Result, error) { }, nil } -// buildRegisterOpts wires create.Options into add.Options for the -// final Register call. The transformations: -// -// - ProjectName override → add.Options.Name (Register's name field) -// - Category default → personal (Register also defaults but we want -// a consistent value to surface in our own Result) -// - Group default → owner login when category is work, else category. -// Mirrors the legacy `ws setup` policy that grouped GitHub repos -// by owner; for personal projects the flat "personal/" -// layout matches what `ws add` does today. func buildRegisterOpts(opts Options) add.Options { cat := opts.Category if cat == "" { @@ -188,9 +148,6 @@ func resolveSaveFn(opts Options) func(*config.Workspace) error { } } -// validateName enforces GitHub's accepted-name subset. The TUI calls -// this on every keystroke for inline error rendering; headless calls -// it once at the top of runHeadless. func validateName(name string) error { if name == "" { return ErrNoName diff --git a/internal/create/gh.go b/internal/create/gh.go index 0161847..4689ce7 100644 --- a/internal/create/gh.go +++ b/internal/create/gh.go @@ -15,9 +15,6 @@ import ( "strings" ) -// Visibility maps to gh repo create flags. Empty string is rejected by -// CreateRepo — callers must pick one. Internal omitted: GitHub -// Enterprise-only and not relevant to this user's workflow. type Visibility string const ( @@ -25,17 +22,11 @@ const ( VisibilityPublic Visibility = "public" ) -// Owner names a GitHub account that can hold a repo. Personal accounts -// and orgs share the same shape from the CLI's perspective; the kind -// is only used to render a "(you)" hint in the TUI. type Owner struct { Login string Kind OwnerKind } -// OwnerKind distinguishes the user's personal account from orgs they -// can push to. The TUI surfaces this via a small chip; logic doesn't -// branch on it because `gh repo create /` accepts both. type OwnerKind string const ( @@ -43,11 +34,6 @@ const ( OwnerKindOrg OwnerKind = "org" ) -// CreateRepoOptions captures everything `gh repo create` needs. The -// runner translates these into a single argv. AddReadme defaults true -// for ws create (the resulting repo has a default branch + first -// commit, so clone.CloneIntoLayout doesn't trip on -// ErrNeedsBootstrap). type CreateRepoOptions struct { Owner string Name string @@ -56,19 +42,10 @@ type CreateRepoOptions struct { AddReadme bool } -// ghRunner is the seam tests inject. Production wiring is realGHRunner; -// tests pass a fake whose Run method asserts on argv and returns -// canned stdout/stderr. -// -// Returning a structured error (with stderr) is the runner's job — gh -// prints diagnostics on stderr which the caller maps to user-facing -// messages. type ghRunner interface { Run(args ...string) (stdout []byte, stderr []byte, err error) } -// realGHRunner shells out to the user's `gh` binary. Cheap to allocate; -// no shared state. type realGHRunner struct{} func (realGHRunner) Run(args ...string) ([]byte, []byte, error) { @@ -84,20 +61,10 @@ func (realGHRunner) Run(args ...string) ([]byte, []byte, error) { return out, nil, nil } -// errGHAuth signals that `gh` needs `gh auth login`. Wrapped so -// callers can present a single, actionable message instead of leaking -// the raw stderr. var errGHAuth = errors.New("gh is not authenticated; run `gh auth login`") -// errRepoExists is returned by CreateRepo when GitHub rejects the -// request because / already exists. Detected via stderr -// substring match because `gh repo create` doesn't surface a typed -// error code on this path. var errRepoExists = errors.New("repository already exists on GitHub") -// CurrentUser returns the login of the authenticated `gh` user. Maps -// auth-related stderr ("not logged into") into errGHAuth so the caller -// can render a clean prompt instead of leaking stderr verbatim. func CurrentUser(r ghRunner) (string, error) { out, stderr, err := r.Run("api", "/user", "--jq", ".login") if err != nil { @@ -110,10 +77,6 @@ func CurrentUser(r ghRunner) (string, error) { return login, nil } -// ListOrgs returns the orgs the authenticated user can push to. `gh -// api /user/orgs` returns the membership list directly; we don't -// filter by role because gh already excludes orgs the token can't -// touch. --paginate handles >100 orgs without us doing math. func ListOrgs(r ghRunner) ([]string, error) { out, stderr, err := r.Run("api", "/user/orgs?per_page=100", "--paginate", "--jq", ".[].login") if err != nil { @@ -129,10 +92,6 @@ func ListOrgs(r ghRunner) ([]string, error) { return orgs, nil } -// ListOwners returns the personal account first, then orgs in the -// order gh returned them (which is stable per user). The TUI selector -// uses this order; the personal account at index 0 is the default -// highlight. func ListOwners(r ghRunner) ([]Owner, error) { user, err := CurrentUser(r) if err != nil { @@ -150,16 +109,6 @@ func ListOwners(r ghRunner) ([]Owner, error) { return owners, nil } -// CreateRepo runs `gh repo create` and returns the new repository's -// HTML URL on success. `--clone=false` is always passed because we -// drive the clone ourselves via clone.CloneIntoLayout. -// -// gh's stdout shape on success: -// -// https://github.com// -// -// (single line, possibly trailing whitespace). Older `gh` may print a -// short banner; we take the first https://github.com/ line we find. func CreateRepo(r ghRunner, opts CreateRepoOptions) (string, error) { if opts.Owner == "" { return "", errors.New("CreateRepo: empty Owner") @@ -198,16 +147,10 @@ func CreateRepo(r ghRunner, opts CreateRepoOptions) (string, error) { return url, nil } -// SSHURLFromOwnerRepo returns the canonical SSH form for an owner/repo -// pair. We always register projects with their SSH URL so existing -// reconciler logic (which keys on remote.origin.url) sees a consistent -// shape regardless of what gh printed. func SSHURLFromOwnerRepo(owner, name string) string { return fmt.Sprintf("git@github.com:%s/%s.git", owner, name) } -// extractRepoURL pulls the github.com URL from gh's output. Tolerates -// banners or trailing whitespace by scanning lines. func extractRepoURL(s string) string { for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) @@ -218,9 +161,6 @@ func extractRepoURL(s string) string { return "" } -// classifyGHErr distinguishes auth failures from other gh errors. The -// match list comes from gh's actual stderr strings; gh doesn't expose -// a stable error-code surface, so substring match is what we have. func classifyGHErr(stderr []byte, base error) error { msg := string(stderr) low := strings.ToLower(msg) @@ -237,25 +177,15 @@ func classifyGHErr(stderr []byte, base error) error { return base } -// isAlreadyExistsErr matches the GitHub API duplicate-name error. gh -// surfaces the API message verbatim on stderr; keying on "Name already -// exists" plus "name already exists on this account" covers both -// repo-already-exists and reserved-name responses. func isAlreadyExistsErr(stderr []byte) bool { low := strings.ToLower(string(stderr)) return strings.Contains(low, "name already exists") } -// IsAuthErr reports whether err originated from `gh` not being -// authenticated. CLI/TUI use this to render a one-shot hint instead -// of stack-tracing the user. func IsAuthErr(err error) bool { return errors.Is(err, errGHAuth) } -// IsRepoExistsErr reports whether err signals that / is -// already taken. The TUI catches this and lets the user edit the name -// without abandoning the form. func IsRepoExistsErr(err error) bool { return errors.Is(err, errRepoExists) } diff --git a/internal/create/options.go b/internal/create/options.go index aabd8fe..6d18009 100644 --- a/internal/create/options.go +++ b/internal/create/options.go @@ -2,85 +2,49 @@ package create import "github.com/kuchmenko/workspace/internal/config" -// Mode controls TUI vs headless dispatch. Mirrors `ws add` — the same -// auto-detect rules apply (TTY without required flags → TUI). type Mode int const ( - // ModeAuto picks based on input completeness: TUI when Owner/Name - // are missing on a TTY, headless when both are provided. Default. ModeAuto Mode = iota - // ModeHeadless forces non-interactive. Errors if Owner or Name - // is empty. ModeHeadless - // ModeTUI forces the interactive form even on a non-TTY. ModeTUI ) -// Options is the union of every knob `ws create` exposes. type Options struct { - // Owner is the GitHub account/org the repo will live under. - // Empty triggers TUI selection (or ErrNoOwner in headless). Owner string - // Name is the new repository name. Validated against GitHub's - // allowed character set before any gh call. Empty triggers TUI - // (or ErrNoName in headless). Name string - // Visibility is private|public. Empty defaults to private. Visibility Visibility - // Description is forwarded to gh as --description. Optional. Description string - // AddReadme controls whether to seed the repo with a README so - // it has a default branch + first commit. Defaults true (set by - // Run when zero-value). Without README, clone trips - // ErrNeedsBootstrap. AddReadme *bool - // Category for the workspace.toml entry. Empty → personal. Category config.Category - // Group overrides the auto-inferred group. Empty → owner login - // when category is work, else category. Group string - // ProjectName overrides the derived project key in - // workspace.toml. Empty → repo Name. ProjectName string - // Mode selects TUI vs headless. See Mode. Mode Mode - // WsRoot is the workspace root. Required. WsRoot string - // Workspace is the in-memory toml state. Required. Workspace *config.Workspace - // Save persists the workspace. Defaults to config.Save. Save func(*config.Workspace) error - // GHRunner is injected by tests; nil → real `gh` exec. GHRunner ghRunner - // URLFor builds the clone URL for a freshly-created repo. nil → - // SSHURLFromOwnerRepo. Tests inject a closure that returns a - // file:// URL pointing at a temp bare repo so the clone step - // completes without a network round-trip. URLFor func(owner, name string) string } -// Result describes a successful create operation. Returned only when -// the full pipeline (gh repo create → register → clone) completed. -// Partial failures surface as errors from Run. type Result struct { Project config.Project Name string - URL string // SSH URL the project was registered with + URL string Cloned bool } diff --git a/internal/create/render.go b/internal/create/render.go index 6b30927..37b3abb 100644 --- a/internal/create/render.go +++ b/internal/create/render.go @@ -62,7 +62,7 @@ func (m CreateModel) renderOwnerList() string { 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 { diff --git a/internal/create/runner.go b/internal/create/runner.go index 5575cc4..54b08a7 100644 --- a/internal/create/runner.go +++ b/internal/create/runner.go @@ -8,15 +8,8 @@ import ( 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, diff --git a/internal/create/sidecar.go b/internal/create/sidecar.go index 69d65f5..ef3bb2a 100644 --- a/internal/create/sidecar.go +++ b/internal/create/sidecar.go @@ -7,27 +7,14 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// sidecarPayload describes the in-flight create operation. Stored -// inside the shared sidecar envelope so a second `ws create` running -// concurrently can tell the user what the first one is doing. type sidecarPayload struct { Mode Mode `json:"mode"` Owner string `json:"owner,omitempty"` Name string `json:"name,omitempty"` } -// sidecarPayloadKey is the well-known entry name. The shared Done map -// is keyed by "project name"; ws create operates as a single session, -// so we use a fixed pseudo-entry. const sidecarPayloadKey = "__session__" -// acquireSidecar persists the create sidecar for this Run. Refuses if -// another `ws create` is already live; silently clears stale records -// (dead pid) before acquiring. Mirrors the policy of `ws add` — we -// don't prompt for resume because there's no per-step recoverable -// state: a failed run either left no GitHub repo (gh create not yet -// called) or left a registered+cloned repo (which a re-run will -// detect via ErrAlreadyRegistered/ErrAlreadyCloned). func acquireSidecar(wsRoot string, mode Mode, owner, name string) (*sidecar.Sidecar, error) { existing, err := sidecar.Load(wsRoot, sidecar.KindCreate) if err != nil { @@ -59,7 +46,6 @@ func acquireSidecar(wsRoot string, mode Mode, owner, name string) (*sidecar.Side return sc, nil } -// releaseSidecar removes the file. Best-effort. func releaseSidecar(wsRoot string) { _ = sidecar.Delete(wsRoot, sidecar.KindCreate) } diff --git a/internal/create/tui.go b/internal/create/tui.go index 4484030..c701a2a 100644 --- a/internal/create/tui.go +++ b/internal/create/tui.go @@ -10,9 +10,6 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// state tracks where the TUI is in its lifecycle. The transitions are -// linear modulo retry: loadingOwners → form ⇄ errored → creating → -// done. Esc from any state returns to errored=canceled or quits. type state int const ( @@ -23,9 +20,6 @@ const ( stateErrored ) -// focus identifies the active form field. Tab cycles forward, -// Shift-Tab backward. The integer order also drives keyboard handling -// inside Update — keep it stable. const ( focusOwner = iota focusName @@ -37,17 +31,12 @@ const ( focusCount ) -// CreateModelOptions is the constructor input for tests + production. -// Tests inject GHRunner (fake) and Save (capture); production wiring -// passes a realGHRunner and config.Save closure. type CreateModelOptions struct { WsRoot string Workspace *config.Workspace Save func(*config.Workspace) error GHRunner ghRunner - // Defaults sourced from cobra flags. Empty values are unbound - // fields the user fills in via the form. Owner string Name string Visibility Visibility @@ -58,9 +47,6 @@ type CreateModelOptions struct { URLFor func(owner, name string) string } -// CreateModel is the bubbletea Model for ws create. Single-screen -// form: top — title; middle — owner list + form fields; bottom — -// help/error/spinner. Update is a state-machine over `state`. type CreateModel struct { opts CreateModelOptions @@ -85,15 +71,10 @@ type CreateModel struct { width int height int - // Outputs collected by Run after Program exits. result *Result canceled bool } -// NewCreateModel constructs the model with sane defaults wired from -// CreateModelOptions. Field defaults: visibility=private, category= -// personal, group=owner login (filled when owners load if Group is -// empty and category becomes work). func NewCreateModel(opts CreateModelOptions) CreateModel { cat := opts.Category if cat == "" { @@ -155,12 +136,11 @@ func NewCreateModel(opts CreateModelOptions) CreateModel { visIdx: visIdx, categories: categories, catIdx: catIdx, - focus: focusName, // owner selection handled separately when list arrives + focus: focusName, } return m } -// Init kicks off the async owner fetch + spinner tick. func (m CreateModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, m.fetchOwnersCmd()) } @@ -179,7 +159,7 @@ func (m CreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ownersLoadedMsg: m.owners = msg.owners - // Pre-select owner from flag if matched. + if m.opts.Owner != "" { for i, o := range m.owners { if o.Login == m.opts.Owner { @@ -189,9 +169,7 @@ func (m CreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.st = stateForm - // Default focus on Name unless owner was provided via flag — - // in which case the user is still likely to want to type a - // name first. + m.focus = focusName m.nameInput.Focus() return m, nil @@ -233,8 +211,7 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.canceled = true return m, tea.Quit case "enter": - // Retry: if owners failed to load, try again; otherwise - // drop back to form so the user can edit fields. + if len(m.owners) == 0 { m.err = nil m.st = stateLoadingOwners @@ -247,11 +224,11 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case stateDone: - // Any key dismisses the success screen. + return m, tea.Quit case stateCreating: - // Disallow input mid-creation; only Ctrl+C escapes. + if msg.String() == "ctrl+c" { m.canceled = true return m, tea.Quit @@ -259,13 +236,12 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // stateForm — main path. switch msg.String() { case "ctrl+c": m.canceled = true return m, tea.Quit case "esc": - // Esc on Create button cancels; otherwise blurs current input. + if m.focus == focusCreate { m.canceled = true return m, tea.Quit @@ -280,7 +256,6 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.refocus() } - // Field-specific handling. switch m.focus { case focusOwner: return m.handleOwnerKey(msg) @@ -355,8 +330,6 @@ func (m CreateModel) handleToggleKey(msg tea.KeyMsg, idx *int, max int) (tea.Mod return m, nil } -// refocus drives textinput Focus/Blur based on the current m.focus. -// Returns a tea.Cmd because Focus emits a blink cmd. func (m *CreateModel) refocus() tea.Cmd { m.nameInput.Blur() m.descInput.Blur() @@ -372,9 +345,6 @@ func (m *CreateModel) refocus() tea.Cmd { return nil } -// validateForm runs client-side checks before launching the gh call. -// Cheap to fail here — the alternative is a 1-2s gh round-trip and a -// stderr-driven error. func (m CreateModel) validateForm() error { if len(m.owners) == 0 { return errors.New("no owners available; check `gh auth status`") @@ -389,8 +359,6 @@ func (m CreateModel) validateForm() error { return nil } -// currentOwner returns the login of the highlighted owner. Empty -// string is valid only when called before owners load. func (m CreateModel) currentOwner() string { if m.ownerCursor < 0 || m.ownerCursor >= len(m.owners) { return "" diff --git a/internal/daemon/config.go b/internal/daemon/config.go index f0672b8..48d1539 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -14,28 +14,14 @@ type WorkspaceEntry struct { Root string `toml:"root"` AutoSync bool `toml:"auto_sync"` PollInterval string `toml:"poll_interval,omitempty"` - // AutoBootstrap controls whether the daemon clones missing projects on - // each tick. Pointer so we can distinguish "unset" (default true) from - // an explicit false written to daemon.toml. + AutoBootstrap *bool `toml:"auto_bootstrap,omitempty"` - // PushCooldown coalesces consecutive auto-sync commits of workspace.toml - // into a single squashed commit. Empty string → default 1h; literal "0" - // (or any zero-valued duration like "0s") disables coalescing — legacy - // behavior, push every commit immediately. Parsed via time.ParseDuration; - // unparseable values fall back to the default. + PushCooldown string `toml:"push_cooldown,omitempty"` } -// DefaultPushCooldown is the daemon's default coalescing window when the -// workspace entry does not override it. One hour keeps git history almost -// free of auto-sync noise on machines that edit workspace.toml frequently, -// while still bounding the worst-case "my change isn't on origin yet" gap. const DefaultPushCooldown = time.Hour -// ResolvedPushCooldown returns the duration parsed from PushCooldown, with -// DefaultPushCooldown as the fallback for empty/invalid values. The literal -// "0" is honored as "disable coalescing" — time.ParseDuration rejects a bare -// "0" because it has no unit, so this is the natural way to spell it. func (w WorkspaceEntry) ResolvedPushCooldown() time.Duration { if w.PushCooldown == "" { return DefaultPushCooldown @@ -50,8 +36,6 @@ func (w WorkspaceEntry) ResolvedPushCooldown() time.Duration { return d } -// AutoBootstrapEnabled reports whether auto-clone of missing projects is on. -// Defaults to true when the field is unset. func (w WorkspaceEntry) AutoBootstrapEnabled() bool { if w.AutoBootstrap == nil { return true diff --git a/internal/daemon/conflicts.go b/internal/daemon/conflicts.go index 0f2dbdb..4ad188d 100644 --- a/internal/daemon/conflicts.go +++ b/internal/daemon/conflicts.go @@ -8,11 +8,6 @@ import ( "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 { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e89c735..ca60a4f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -25,7 +25,6 @@ type Daemon struct { watcher *Watcher } -// Run starts the daemon in the foreground (blocking). func Run() error { cfg, err := LoadConfig() if err != nil { @@ -66,9 +65,6 @@ func Run() error { return nil } -// openDaemonLog opens the append-only daemon log file and returns -// it alongside a logger writing to it. Caller is responsible for -// closing the file (typical: defer). func openDaemonLog() (*os.File, *log.Logger, error) { logPath, err := LogPath() if err != nil { @@ -81,9 +77,6 @@ func openDaemonLog() (*os.File, *log.Logger, error) { return logFile, log.New(logFile, "", log.LstdFlags), nil } -// openDaemonSocket resolves the IPC socket path and binds a -// listener on it. Returns both so the caller can record the path -// in startup logs. func openDaemonSocket() (string, net.Listener, error) { socketPath, err := SocketPath() if err != nil { @@ -96,17 +89,12 @@ func openDaemonSocket() (string, net.Listener, error) { return socketPath, ln, nil } -// startReconcilers spins up one reconciler per registered workspace. -// Each goroutine is owned by the Daemon's startWorkspace helper. func (d *Daemon) startReconcilers(cfg *DaemonConfig) { for _, ws := range cfg.Workspaces { d.startWorkspace(ws) } } -// startWatcher launches the filesystem watcher goroutine. -// Watching is best-effort: it amplifies the reconciler ticks but -// the daemon stays correct without it. func (d *Daemon) startWatcher(cfg *DaemonConfig) { d.watcher = NewWatcher(d.logger) for _, ws := range cfg.Workspaces { @@ -119,8 +107,6 @@ func (d *Daemon) startWatcher(cfg *DaemonConfig) { }() } -// installSignalHandler subscribes a goroutine to SIGINT / SIGTERM -// that triggers the orderly Shutdown path on receipt. func (d *Daemon) installSignalHandler() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -130,18 +116,11 @@ func (d *Daemon) installSignalHandler() { }() } -// startAcceptLoop takes the listener and serves IPC connections in -// a goroutine until d.quit is closed. Accept errors during normal -// shutdown are swallowed (signaled via select on d.quit). func (d *Daemon) startAcceptLoop() { d.wg.Add(1) go d.runAcceptLoop() } -// runAcceptLoop is the goroutine body. Pulled out so the for/select -// nesting doesn't push startAcceptLoop's cognitive complexity over -// the threshold; the loop itself is straightforward but `for` plus -// `select` plus `if err` plus `case <-d.quit` add up fast. func (d *Daemon) runAcceptLoop() { defer d.wg.Done() for { @@ -157,10 +136,6 @@ func (d *Daemon) runAcceptLoop() { } } -// shouldStopAccept reports whether the Accept loop should terminate. -// True when d.quit has been closed; false during normal operation. -// Non-blocking: a transient error path checks quit-state without -// blocking on the channel. func (d *Daemon) shouldStopAccept() bool { select { case <-d.quit: @@ -196,14 +171,11 @@ func (d *Daemon) handleNotify(workspace, event string) { switch event { case "config_changed": if r, ok := d.reconcilers[workspace]; ok { - // Run async so the IPC handler returns immediately. go r.Tick() } } } -// parseInterval parses a duration string like "5m" or "1h30m". Falls back -// to 5 minutes on parse error. func parseInterval(s string) time.Duration { d, err := time.ParseDuration(s) if err != nil || d < time.Minute { @@ -236,7 +208,6 @@ func (d *Daemon) cleanupPID() { os.Remove(socketPath) } -// IsRunning checks if a daemon process is alive. func IsRunning() (int, bool) { path, err := PidPath() if err != nil { @@ -254,19 +225,17 @@ func IsRunning() (int, bool) { if err != nil { return pid, false } - // Signal 0 checks if process exists + err = proc.Signal(syscall.Signal(0)) return pid, err == nil } -// StartBackground starts the daemon as a background process. func StartBackground() (int, error) { exe, err := os.Executable() if err != nil { return 0, err } - // Resolve symlinks to get actual binary exe, err = filepath.EvalSymlinks(exe) if err != nil { return 0, err @@ -286,7 +255,6 @@ func StartBackground() (int, error) { return 0, fmt.Errorf("starting daemon: %w", err) } - // Detach proc.Release() fmt.Printf(" Daemon started (pid %d)\n", proc.Pid) diff --git a/internal/daemon/git.go b/internal/daemon/git.go index 0529664..ffe4ea6 100644 --- a/internal/daemon/git.go +++ b/internal/daemon/git.go @@ -11,8 +11,6 @@ import ( "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) { @@ -45,8 +43,6 @@ func runIn(dir, name string, args ...string) error { 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 { diff --git a/internal/daemon/ipc_client.go b/internal/daemon/ipc_client.go index 4c1108a..a8df986 100644 --- a/internal/daemon/ipc_client.go +++ b/internal/daemon/ipc_client.go @@ -12,7 +12,6 @@ type IPCClient struct { conn net.Conn } -// Dial connects to the daemon socket. func Dial() (*IPCClient, error) { socketPath, err := SocketPath() if err != nil { @@ -49,7 +48,6 @@ func (c *IPCClient) send(req Request) (Response, error) { return resp, nil } -// Status queries the daemon for its current state. func (c *IPCClient) Status() (StatusData, error) { resp, err := c.send(Request{Cmd: "status"}) if err != nil { @@ -58,14 +56,13 @@ func (c *IPCClient) Status() (StatusData, error) { if !resp.OK { return StatusData{}, fmt.Errorf("daemon error: %s", resp.Error) } - // Re-marshal Data to decode into StatusData + raw, _ := json.Marshal(resp.Data) var status StatusData json.Unmarshal(raw, &status) return status, nil } -// Notify tells the daemon about a workspace event. func (c *IPCClient) Notify(workspace, event string) error { resp, err := c.send(Request{Cmd: "notify", Workspace: workspace, Event: event}) if err != nil { @@ -77,7 +74,6 @@ func (c *IPCClient) Notify(workspace, event string) error { return nil } -// Stop tells the daemon to shut down. func (c *IPCClient) Stop() error { resp, err := c.send(Request{Cmd: "stop"}) if err != nil { diff --git a/internal/daemon/projects.go b/internal/daemon/projects.go index cbb328e..195bd9c 100644 --- a/internal/daemon/projects.go +++ b/internal/daemon/projects.go @@ -40,10 +40,6 @@ func (r *Reconciler) reconcileProjects(ws *config.Workspace) { } } 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) } @@ -54,7 +50,6 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri 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) { @@ -65,11 +60,6 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri } 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 } @@ -77,17 +67,10 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri } 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) @@ -95,10 +78,9 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri } if err := git.Fetch(barePath); err != nil { - return err // counts toward backoff + return err } - // auto_sync=false → fetch only, no push or pull. if !proj.SyncEnabled() { return nil } @@ -113,18 +95,12 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri 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 @@ -146,12 +122,6 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri 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 { @@ -162,16 +132,6 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri } } - // 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) @@ -189,28 +149,11 @@ func (r *Reconciler) syncProject(name string, proj *config.Project, machine stri 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 { @@ -223,9 +166,7 @@ func (r *Reconciler) autoCloneMissing(name string, proj config.Project) error { "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()) @@ -234,13 +175,10 @@ func (r *Reconciler) autoCloneMissing(name string, proj config.Project) error { } 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 { @@ -249,7 +187,7 @@ func (r *Reconciler) autoCloneMissing(name string, proj config.Project) error { } stored, ok := fresh.Projects[name] if !ok { - return nil // project was removed from registry mid-tick; nothing to update + return nil } if stored.DefaultBranch == "" { stored.DefaultBranch = proj.DefaultBranch diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index d35180b..0ec508b 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -1,15 +1,3 @@ -// Reconciler is the unified replacement for the legacy Syncer + Poller pair. -// -// On every tick it: -// 1. Synchronizes workspace.toml with the workspace's git remote (commit -// local edits, pull remote changes, surface conflicts). -// 2. Reloads workspace.toml if the pull changed it. -// 3. Walks every active project and brings its on-disk state in line with -// the registry, never doing destructive operations inside project repos. -// -// The reconciler is intentionally idempotent: a missed tick or duplicate -// trigger never breaks state, because each tick computes the desired state -// from scratch and converges toward it. package daemon import ( @@ -22,30 +10,20 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// Reconciler manages one workspace. type Reconciler struct { root string logger *log.Logger store *conflict.Store - mu sync.Mutex // serialize Tick() invocations + mu sync.Mutex - // Per-project exponential backoff. Keyed by project name. backoff map[string]*backoffState interval time.Duration maxInterval time.Duration - // autoBootstrap controls whether the daemon clones missing projects on - // each tick. Default true; set false via daemon.toml to disable. autoBootstrap bool - // pushCooldown coalesces consecutive auto-sync commits of workspace.toml - // into a single squashed commit. While the most recent local commit is - // our own auto-sync and younger than this duration, syncTOML amends - // further dirty changes into it and defers the push. Zero disables - // coalescing (push immediately after each commit) — that's the safe - // default for `ws sync`, while the daemon opts in via SetPushCooldown. pushCooldown time.Duration } @@ -54,9 +32,6 @@ type backoffState struct { currentDelay time.Duration } -// NewReconciler builds a Reconciler for the given workspace root. -// `interval` is the base poll interval; failed projects back off up to -// `maxInterval`. func NewReconciler(root string, interval time.Duration, logger *log.Logger) *Reconciler { if interval < time.Minute { interval = 5 * time.Minute @@ -76,15 +51,10 @@ func NewReconciler(root string, interval time.Duration, logger *log.Logger) *Rec } } -// SetAutoBootstrap toggles auto-cloning of missing projects. Wired from -// daemon.toml during workspace registration. func (r *Reconciler) SetAutoBootstrap(v bool) { r.autoBootstrap = v } -// SetPushCooldown configures how long a local auto-sync commit may be amended -// before it must be pushed. Zero disables amend+defer (push every commit -// immediately). Negative values are clamped to zero. func (r *Reconciler) SetPushCooldown(d time.Duration) { if d < 0 { d = 0 @@ -92,9 +62,6 @@ func (r *Reconciler) SetPushCooldown(d time.Duration) { r.pushCooldown = d } -// Run starts the reconciler loop. It performs an immediate tick at startup -// (closing the "I just got back to my machine" gap) and then ticks on the -// configured interval until quit is closed. func (r *Reconciler) Run(quit <-chan struct{}) { r.logger.Printf("reconciler: starting for %s (interval=%s)", r.root, r.interval) r.Tick() @@ -110,29 +77,20 @@ func (r *Reconciler) Run(quit <-chan struct{}) { } } -// Tick performs one full reconciliation pass. Safe to call concurrently; -// invocations are serialized. func (r *Reconciler) Tick() { r.mu.Lock() defer r.mu.Unlock() - // Interactive-command coordination: if any sidecar exists for this - // workspace with a live pid (currently bootstrap or migrate), pause - // both phases entirely. Sidecar existence + live pid is the lock; - // daemon never writes to those files. Other workspaces in daemon.toml - // have their own reconcilers and are unaffected (each has its own r.mu). if sc := sidecar.AnyActive(r.root); sc != nil { r.logger.Printf("reconciler: %s in progress for %s (pid %d), skipping tick", sc.Meta.Kind, r.root, sc.Meta.PID) return } - // Phase 1: workspace.toml sync tomlChanged, err := r.syncTOML() if err != nil { r.logger.Printf("reconciler: toml sync error: %v", err) } - // Phase 2: load (or reload) the workspace and reconcile projects. ws, err := config.Load(r.root) if err != nil { r.logger.Printf("reconciler: load workspace: %v", err) diff --git a/internal/daemon/socket.go b/internal/daemon/socket.go index 246530b..20a6da4 100644 --- a/internal/daemon/socket.go +++ b/internal/daemon/socket.go @@ -27,7 +27,6 @@ type StatusData struct { } func listenSocket(socketPath string) (net.Listener, error) { - // Clean stale socket if _, err := os.Stat(socketPath); err == nil { os.Remove(socketPath) } @@ -37,7 +36,6 @@ func listenSocket(socketPath string) (net.Listener, error) { return nil, fmt.Errorf("listen %s: %w", socketPath, err) } - // Restrict permissions os.Chmod(socketPath, 0o600) return ln, nil } diff --git a/internal/daemon/toml.go b/internal/daemon/toml.go index 887987a..9c37122 100644 --- a/internal/daemon/toml.go +++ b/internal/daemon/toml.go @@ -10,8 +10,6 @@ import ( "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) @@ -20,15 +18,12 @@ func (r *Reconciler) syncTOML() (bool, error) { } repoRoot := findGitRoot(filepath.Dir(realPath)) if repoRoot == "" { - return false, nil // not in a git repo, nothing to sync + return false, nil } 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) } @@ -38,11 +33,9 @@ func (r *Reconciler) syncTOML() (bool, error) { 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 } @@ -57,16 +50,11 @@ func (r *Reconciler) syncTOML() (bool, error) { 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. When HEAD is already an unpushed auto-sync commit from - // this host, amend into it instead of stacking another one — see the - // pushCooldown design note in reconciler.go. autoSyncMsg := fmt.Sprintf("ws: auto-sync workspace.toml from %s", machineHostname()) if localDirty { if err := git.Add(repoRoot, relFile); err != nil { @@ -74,13 +62,6 @@ func (r *Reconciler) syncTOML() (bool, error) { } headMsg, _ := git.LastCommitMessage(repoRoot) if ahead > 0 && headMsg == autoSyncMsg { - // If the staged tree now matches HEAD's parent, the held commit's - // net change has been undone (e.g. a favorite toggled on then off - // inside the cooldown). git refuses an amend that produces an - // empty diff vs parent; without this branch we'd return an error - // every subsequent tick and leave workspace.toml staged forever. - // Drop the held commit instead — the right history outcome is - // "no commit at all". if err := runIn(repoRoot, "git", "diff", "--cached", "--quiet", "HEAD~1"); err == nil { if err := runIn(repoRoot, "git", "reset", "--mixed", "HEAD~1"); err != nil { return false, fmt.Errorf("drop empty held auto-sync: %w", err) @@ -97,10 +78,8 @@ func (r *Reconciler) syncTOML() (bool, error) { } } - // 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) @@ -109,15 +88,10 @@ func (r *Reconciler) syncTOML() (bool, error) { _ = r.clearTOMLConflicts() } - // Push if anything to push — unless the pushCooldown gate is holding our - // auto-sync commit open for further amending. The held commit will be - // pushed on a later tick once its age exceeds the cooldown, or sooner if - // a non-auto-sync commit lands on top of it. if ahead > 0 || behind > 0 { if r.shouldHoldPush(repoRoot, autoSyncMsg, ahead) { r.logger.Printf("reconciler: %s holding auto-sync commit for amend (cooldown %s)", repoRoot, r.pushCooldown) } else 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 @@ -133,20 +107,6 @@ func (r *Reconciler) syncTOML() (bool, error) { return newHead != originalHead, nil } -// shouldHoldPush reports whether HEAD is our own auto-sync commit that is -// still young enough to absorb further amends. Zero pushCooldown disables -// the gate entirely (the historical behavior, kept for `ws sync`). -// -// The age check uses the author date — preserved by `git commit --amend -// --no-edit` — so continuous activity that keeps amending into the held -// commit cannot indefinitely defer the push. The committer date would -// refresh on every amend and silently turn the cooldown into "never push -// while busy", which is the failure mode this gate exists to prevent. -// -// The ahead==1 guard prevents the gate from withholding a user's manual -// commit that sits below the auto-sync: in that case `git push` would -// publish *both* commits, and the cooldown is only entitled to defer the -// auto-sync one. When ahead > 1 we always push. func (r *Reconciler) shouldHoldPush(repoRoot, autoSyncMsg string, ahead int) bool { if r.pushCooldown <= 0 { return false diff --git a/internal/daemon/watcher.go b/internal/daemon/watcher.go index 4109244..a29368d 100644 --- a/internal/daemon/watcher.go +++ b/internal/daemon/watcher.go @@ -16,7 +16,7 @@ type Watcher struct { fsw *fsnotify.Watcher logger *log.Logger mu sync.Mutex - // debounce: track recently seen events to avoid duplicates + seen map[string]time.Time } @@ -29,7 +29,6 @@ func NewWatcher(logger *log.Logger) *Watcher { return &Watcher{fsw: fsw, logger: logger, seen: make(map[string]time.Time)} } -// Add watches a workspace root directory for new git repos. func (w *Watcher) Add(root string) { if w.fsw == nil { return @@ -41,10 +40,6 @@ func (w *Watcher) Add(root string) { } } -// topLevelGroupDirs lists immediate children of `root` that are -// candidates for the watcher: directories, non-dotfile, non-empty. -// Errors reading the root return an empty slice — the caller treats -// the watcher as best-effort. func topLevelGroupDirs(root string) []string { entries, err := os.ReadDir(root) if err != nil { @@ -72,9 +67,6 @@ func (w *Watcher) Run(quit <-chan struct{}) { } } -// dispatchOne reads one event from the watcher and dispatches it. -// Returns false to signal "stop the Run loop" (quit closed or one of -// the fsnotify channels closed); true to keep going. func (w *Watcher) dispatchOne(quit <-chan struct{}) bool { select { case <-quit: @@ -97,7 +89,6 @@ func (w *Watcher) dispatchOne(quit <-chan struct{}) bool { } func (w *Watcher) handleCreate(path string) { - // Debounce: ignore if we saw this path in the last second w.mu.Lock() if last, ok := w.seen[path]; ok && time.Since(last) < time.Second { w.mu.Unlock() @@ -106,13 +97,11 @@ func (w *Watcher) handleCreate(path string) { w.seen[path] = time.Now() w.mu.Unlock() - // Check if it's a directory info, err := os.Stat(path) if err != nil || !info.IsDir() { return } - // Give git init a moment to complete time.Sleep(500 * time.Millisecond) if !git.IsRepo(path) { diff --git a/internal/docs/agent.go b/internal/docs/agent.go index 9155b41..7771e4a 100644 --- a/internal/docs/agent.go +++ b/internal/docs/agent.go @@ -8,17 +8,12 @@ import ( "github.com/spf13/pflag" ) -// Annotation keys recognized by the collector. Commands without at least -// "capability" and "agent:when" are excluded from agent output. const ( KeyCapability = "capability" KeyAgentWhen = "agent:when" KeyAgentSafety = "agent:safety" ) -// capabilityMeta provides a human-readable description and a sort order -// for each known capability group. Unknown groups are still collected but -// land at the end in alphabetical order. var capabilityMeta = map[string]struct { Description string Order int @@ -33,9 +28,6 @@ var capabilityMeta = map[string]struct { "agent": {"Launch and manage Claude Code sessions", 8}, } -// Constraints are project-wide invariants that any agent operating on a -// ws-managed workspace should respect. They are not tied to individual -// commands, so we keep them as a static list. var constraints = []string{ "Never run git rebase, reset --hard, or push --force inside a project the daemon is reconciling.", "Branches outside the wt//* namespace are private and never pushed by the reconciler.", @@ -44,11 +36,6 @@ var constraints = []string{ "Bare repo directories (*.bare/) must not be modified directly.", } -// GenerateAgentCapabilityMap walks the Cobra command tree rooted at root -// and returns a capability map built from command annotations. -// -// Only commands that carry both "capability" and "agent:when" annotations -// are included. Hidden commands are excluded. func GenerateAgentCapabilityMap(root *cobra.Command) *AgentCapabilityMap { groups := map[string]*CapabilityGroup{} @@ -64,7 +51,7 @@ func GenerateAgentCapabilityMap(root *cobra.Command) *AgentCapabilityMap { grp, ok := groups[cap] if !ok { - desc := cap // fallback for unknown groups + desc := cap if meta, known := capabilityMeta[cap]; known { desc = meta.Description } @@ -89,7 +76,6 @@ func GenerateAgentCapabilityMap(root *cobra.Command) *AgentCapabilityMap { } } -// walkCommands visits every command in the tree depth-first. func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) { fn(cmd) for _, child := range cmd.Commands() { @@ -97,8 +83,6 @@ func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) { } } -// fullCommandUse builds the full invocation string, -// e.g. "ws worktree new ". func fullCommandUse(cmd *cobra.Command) string { parts := []string{} for c := cmd; c != nil; c = c.Parent() { @@ -108,13 +92,11 @@ func fullCommandUse(cmd *cobra.Command) string { result := "" for i, p := range parts { if i == len(parts)-1 { - // Leaf: keep the full Use including args. if result != "" { result += " " } result += p } else { - // Intermediate: strip args, keep only the command name. name := commandName(p) if result != "" { result += " " @@ -125,7 +107,6 @@ func fullCommandUse(cmd *cobra.Command) string { return result } -// commandName extracts the command name from a Use string (strips args). func commandName(use string) string { for i, c := range use { if c == ' ' { @@ -135,8 +116,6 @@ func commandName(use string) string { return use } -// collectFlags returns "--name" strings for every non-hidden, -// non-inherited flag on cmd. func collectFlags(cmd *cobra.Command) []string { var out []string cmd.NonInheritedFlags().VisitAll(func(f *pflag.Flag) { @@ -151,10 +130,6 @@ func collectFlags(cmd *cobra.Command) []string { return out } -// toSortedMap converts the accumulator into the final map, preserving -// the order defined in capabilityMeta. JSON object key order is not -// guaranteed, but the struct fields are ordered for deterministic output -// in tests when marshaled with sorted keys. func toSortedMap(groups map[string]*CapabilityGroup) map[string]CapabilityGroup { out := make(map[string]CapabilityGroup, len(groups)) for k, v := range groups { @@ -163,7 +138,6 @@ func toSortedMap(groups map[string]*CapabilityGroup) map[string]CapabilityGroup return out } -// SortedCapabilityKeys returns capability group names in display order. func SortedCapabilityKeys(m map[string]CapabilityGroup) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/internal/docs/schema.go b/internal/docs/schema.go index 2abf48d..a22456c 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -1,6 +1,5 @@ package docs -// AgentCapabilityMap is the top-level JSON structure emitted by `ws docs --agent`. type AgentCapabilityMap struct { Tool string `json:"tool"` Version string `json:"version,omitempty"` @@ -9,13 +8,11 @@ type AgentCapabilityMap struct { Constraints []string `json:"constraints"` } -// CapabilityGroup clusters related commands under a human-readable label. type CapabilityGroup struct { Description string `json:"description"` Commands []AgentCommand `json:"commands"` } -// AgentCommand describes a single CLI invocation an agent can use. type AgentCommand struct { Command string `json:"command"` When string `json:"when"` diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 0ee8ee3..368f6c3 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -23,27 +23,18 @@ import ( "github.com/kuchmenko/workspace/internal/config" ) -// Severity classifies how urgent a Finding is. The ordering matters: Report -// aggregates determine exit codes based on the highest severity observed. type Severity int const ( - // OK means the check passed. Still emitted so the user can see a full - // picture of what was inspected. OK Severity = iota - // Info is a neutral observation — e.g. a project classified as "self" - // that doctor intentionally skips. + Info - // Warn is a non-blocking issue that the user should know about. The - // daemon may continue to function but degraded. + Warn - // Error is a blocking problem. If left unfixed, core operations fail - // (daemon stops syncing a project, push/pull cannot resolve upstream, - // etc.). + Error ) -// String returns the short symbolic form used by the text formatter. func (s Severity) String() string { switch s { case OK: @@ -58,16 +49,10 @@ func (s Severity) String() string { return "unknown" } -// MarshalJSON emits the severity as its string form so JSON consumers -// (agents, scripts) see "ok"/"warn"/"error" rather than a brittle int -// that would shift if enum values are ever reordered. func (s Severity) MarshalJSON() ([]byte, error) { return json.Marshal(s.String()) } -// UnmarshalJSON parses the string form back into the enum. Accepts the -// exact strings produced by MarshalJSON; anything else is rejected to -// make schema drift loud. func (s *Severity) UnmarshalJSON(data []byte) error { var raw string if err := json.Unmarshal(data, &raw); err != nil { @@ -88,37 +73,28 @@ func (s *Severity) UnmarshalJSON(data []byte) error { return nil } -// Finding is one row in the doctor report. Fix is nil for findings that -// require the user to decide what to do (removing an index.lock, resolving -// a conflict, investigating a detached HEAD) — doctor never takes risky -// actions implicitly. type Finding struct { - // Scope is either "system" or a project name. Formatters group on it. Scope string `json:"scope"` - // Check is the identifier from the catalog (e.g. "fetch-refspec"). + Check string `json:"check"` - // Severity is OK / Info / Warn / Error. + Severity Severity `json:"severity"` - // Message is human-readable. Keep it to one line. + Message string `json:"message"` - // FixHint is a short suggestion for what to do — a command, or - // "investigate X". Empty when Fix is non-nil and obvious from Message. + FixHint string `json:"fix_hint,omitempty"` - // Fixed is set by ApplyFixes when the attached Fix ran successfully. + Fixed bool `json:"fixed,omitempty"` - // FixError is set by ApplyFixes when the attached Fix returned an error. + FixError string `json:"fix_error,omitempty"` - // Fix is the auto-fix function. nil means "manual only". Not serialized. + Fix func() error `json:"-"` } -// Report is the collected output of a Runner pass. type Report struct { Findings []Finding `json:"findings"` } -// MaxSeverity returns the highest severity observed in the report. Used -// by the CLI to choose an exit code. func (r *Report) MaxSeverity() Severity { m := OK for _, f := range r.Findings { @@ -129,7 +105,6 @@ func (r *Report) MaxSeverity() Severity { return m } -// AutoFixable returns every finding that has a non-nil Fix. func (r *Report) AutoFixable() []*Finding { var out []*Finding for i := range r.Findings { @@ -140,36 +115,18 @@ func (r *Report) AutoFixable() []*Finding { return out } -// Runner drives one pass of system + project checks. Zero-value is not -// usable; callers must populate WsRoot and WS. type Runner struct { - // WsRoot is the absolute path of the workspace (same as config.FindRoot). WsRoot string - // WS is the parsed workspace.toml. + WS *config.Workspace - // Only, when non-empty, restricts project checks to that single project. - // System checks always run regardless. + Only string - // SkipRemote disables network-touching checks (remote-reach). Useful - // for offline invocations and for tests. + SkipRemote bool - // OnScope, when non-nil, is invoked after each scope completes (first - // with "system", then once per active project in sort order). The - // findings slice passed in is the same one that lands in the returned - // Report — callers can use it to stream progress to a terminal while - // checks that touch the network (remote-reach) are still in flight - // for later projects. Must not retain the slice past the call; the - // Runner may append to it afterwards. + OnScope func(scope string, findings []Finding) } -// Run executes every check and returns the aggregated report. When -// OnScope is set it also streams findings per scope as they complete, -// so interactive callers can show progress without waiting for every -// project's network check to finish. -// -// The Runner does not mutate any state — callers are responsible for -// invoking ApplyFixes on the returned Report if --fix was requested. func (r *Runner) Run() *Report { rep := &Report{} emit := func(scope string, findings []Finding) { @@ -187,9 +144,6 @@ func (r *Runner) Run() *Report { return rep } -// projectNames returns the names of projects the Runner should inspect, -// sorted for deterministic output. Only active projects are considered -// — archived and dormant projects are out of scope for doctor. func (r *Runner) projectNames() []string { var names []string for name, p := range r.WS.Projects { @@ -205,9 +159,6 @@ func (r *Runner) projectNames() []string { return names } -// ApplyFixes runs every Finding's Fix in report order and records the -// result in-place (Fixed / FixError). Returns the number of fixes that -// ran successfully. Findings without a Fix are skipped silently. func ApplyFixes(rep *Report) int { fixed := 0 for i := range rep.Findings { diff --git a/internal/doctor/format.go b/internal/doctor/format.go index 3ae9722..5e3fce5 100644 --- a/internal/doctor/format.go +++ b/internal/doctor/format.go @@ -7,9 +7,6 @@ import ( "strings" ) -// Symbols used in the text formatter. Kept ASCII so terminals without -// unicode support stay readable; the tick/cross are in the BMP and -// render fine on any modern terminal. var severitySymbol = map[Severity]string{ OK: "✓", Info: "ℹ", @@ -17,28 +14,15 @@ var severitySymbol = map[Severity]string{ Error: "✗", } -// WriteText renders the report in the human-readable format shown in the -// issue. Grouping is: "System" block first, then one block per project -// in insertion order. Findings inside a block are printed in the order -// the Runner produced them, which is the catalog order — stable across -// runs. -// -// When a fix was attempted (either applied or failed) the line is -// annotated so the user can see the outcome at a glance without rerunning. -// -// For interactive runs prefer WriteScope + WriteFooter so blocks -// stream as each scope completes. func WriteText(w io.Writer, rep *Report) { groups := groupByScope(rep.Findings) fixable := fixableCount(rep.Findings) - // Print the system block first if it exists. if findings, ok := groups["system"]; ok { writeBlock(w, "System", findings) delete(groups, "system") } - // Remaining blocks in scope-order (matches Runner.projectNames sort). for _, scope := range scopeOrder(rep.Findings) { if scope == "system" { continue @@ -54,11 +38,6 @@ func WriteText(w io.Writer, rep *Report) { WriteFooter(w, rep, fixable) } -// WriteScope renders a single scope block exactly as WriteText would. -// Used by the CLI command to stream per-scope results as the Runner -// completes each check batch. Pass leading=true for the first scope of -// a run so no leading blank line is emitted (the "System" header -// should sit flush against the top of the output). func WriteScope(w io.Writer, scope string, findings []Finding, leading bool) { if !leading { fmt.Fprintln(w) @@ -66,9 +45,6 @@ func WriteScope(w io.Writer, scope string, findings []Finding, leading bool) { writeBlock(w, scopeTitle(scope), findings) } -// FixableCount returns the number of findings in rep that advertise an -// auto-fix. Exposed so the CLI can compute the footer-line suffix -// ("N auto-fixable") after streaming has already flushed each block. func FixableCount(rep *Report) int { return fixableCount(rep.Findings) } @@ -83,8 +59,6 @@ func fixableCount(findings []Finding) int { return n } -// scopeTitle maps the Runner's scope identifier to the user-facing -// heading. "system" becomes "System"; project names are displayed as-is. func scopeTitle(scope string) string { if scope == "system" { return "System" @@ -110,12 +84,6 @@ func writeBlock(w io.Writer, title string, findings []Finding) { } } -// WriteFooter is the summary line at the bottom of the report. Its shape -// changes depending on whether any fixes were applied — the acceptance -// criteria distinguishes "before --fix" ("N issues found, K auto-fixable") -// from "after --fix" ("Applied K fixes"). Exposed so the streaming -// caller can flush its final line after every scope has already been -// written. func WriteFooter(w io.Writer, rep *Report, fixable int) { fmt.Fprintln(w) fmt.Fprintln(w, strings.Repeat("━", 21)) @@ -127,17 +95,12 @@ func WriteFooter(w io.Writer, rep *Report, fixable int) { writeIssueSummary(w, stats.issues, fixable) } -// footerCounts is the aggregate the footer renderer needs from a -// Report: count of issues (severity ≥ Warn), count of successfully- -// applied fixes, count of attempted-but-failed fixes. type footerCounts struct { issues int fixesApplied int fixesFailed int } -// footerStats walks the findings once and tallies the three counters -// the footer cares about. Single pass, no second sort. func footerStats(rep *Report) footerCounts { var c footerCounts for _, f := range rep.Findings { @@ -154,9 +117,6 @@ func footerStats(rep *Report) footerCounts { return c } -// writeFixSummary handles the post-`--fix` footer: "Applied N fixes" -// and / or "M fixes failed" depending on which counters are non-zero. -// Used only when at least one fix was attempted. func writeFixSummary(w io.Writer, stats footerCounts) { if stats.fixesApplied > 0 { fmt.Fprintf(w, "Applied %d fix(es).\n", stats.fixesApplied) @@ -166,9 +126,6 @@ func writeFixSummary(w io.Writer, stats footerCounts) { } } -// writeIssueSummary is the no-fix-attempted footer. Three branches: -// clean ("All checks passed"), unfixable issues, or some-auto-fixable -// (with a hint at `ws doctor --fix`). func writeIssueSummary(w io.Writer, issues, fixable int) { switch { case issues == 0: @@ -181,10 +138,6 @@ func writeIssueSummary(w io.Writer, issues, fixable int) { } } -// WriteJSON serializes the report. Fix functions are not part of the -// output (Finding.Fix has a `json:"-"` tag); the presence of an -// auto-fix is conveyed by the "fix_hint" field plus the top-level -// meta summary. func WriteJSON(w io.Writer, rep *Report) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -200,9 +153,6 @@ func groupByScope(findings []Finding) map[string][]Finding { return out } -// scopeOrder returns scopes in the order they first appear in findings. -// This preserves the Runner's project-name sort for the report output -// without needing a second sort pass. func scopeOrder(findings []Finding) []string { seen := map[string]bool{} var out []string diff --git a/internal/doctor/project.go b/internal/doctor/project.go index d4940b9..0a0d9fb 100644 --- a/internal/doctor/project.go +++ b/internal/doctor/project.go @@ -14,20 +14,10 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// projectChecks runs every per-project check for one active project. -// Order mirrors the natural "does it exist → does its git state look -// right → does its branch config look right" progression. -// -// Checks that don't make sense for the current layout (e.g. fetch-refspec -// on a plain checkout) short-circuit inside layoutOnlyInspectable so the -// report doesn't pile up "not applicable" findings. func (r *Runner) projectChecks(name string, proj config.Project) []Finding { barePath, layoutFinding := r.checkLayout(name, proj) findings := []Finding{layoutFinding} - // If the bare repo isn't present we cannot inspect any git state. Skip - // the rest — running them would raise a flood of secondary errors that - // are just symptoms of the layout problem. if barePath == "" { return findings } @@ -47,19 +37,10 @@ func (r *Runner) projectChecks(name string, proj config.Project) []Finding { return findings } -// checkLayout uses the bootstrap package's state machine to classify the -// project. Returns the absolute bare path when the project is present -// (so downstream checks can reuse it), or "" otherwise. func (r *Runner) checkLayout(name string, proj config.Project) (string, Finding) { mainPath := filepath.Join(r.WsRoot, proj.Path) barePath := layout.BarePath(mainPath) - // Mirror bootstrap.classify without calling it directly — classify is - // unexported in the bootstrap package, and duplicating the four-line - // decision here avoids exposing the helper for one caller. We - // intentionally don't replicate the "self" detection: workspaces - // can't register themselves as projects in practice, and even if - // they could, the bare-present branch below handles it correctly. bareExists := pathExists(barePath) mainExists := pathExists(mainPath) @@ -98,19 +79,6 @@ func (r *Runner) checkLayout(name string, proj config.Project) (string, Finding) } } -// checkFetchRefspec verifies remote.origin.fetch is configured in the -// bare. This is the #14/PR#16 bug: without the refspec, `git fetch` in a -// bare only updates FETCH_HEAD, so @{u} cannot resolve and AheadBehind -// silently returns (0, 0, false) for every branch. Auto-fix reuses the -// helper from internal/git. -// -// The follow-up fetch that actually populates refs/remotes/origin/* -// lives in checkBranchUpstream — that's where the tracking ref is -// needed to make HasUpstream return true, and moving the fetch there -// means it runs in both "fix-refspec-too" and "only-upstream-broken" -// passes (otherwise bares that had their refspec fixed in an earlier -// tick keep failing branch-upstream forever until someone runs a -// manual fetch). func (r *Runner) checkFetchRefspec(name, barePath string) Finding { if git.HasFetchRefspec(barePath) { return Finding{ @@ -132,16 +100,6 @@ func (r *Runner) checkFetchRefspec(name, barePath string) Finding { } } -// checkRemoteURL compares the bare's origin URL against workspace.toml's -// declared remote. They should match exactly — `ws add` / migrate write -// them both in lockstep, and any drift means someone edited one but not -// the other. -// -// We do not attempt normalisation (SSH vs HTTPS, trailing .git) on -// purpose: a mismatch here is almost always a typo in workspace.toml, -// and silently treating "git@github.com:a/b" ≡ "https://github.com/a/b" -// would hide the fact that the two refer to different transports (and -// therefore different credentials / access paths). func (r *Runner) checkRemoteURL(name string, proj config.Project, barePath string) Finding { actual, err := git.RemoteURL(barePath) if err != nil { @@ -174,10 +132,6 @@ func (r *Runner) checkRemoteURL(name string, proj config.Project, barePath strin } } -// checkRemoteReach runs `git ls-remote --exit-code origin HEAD` with a -// short timeout. A failure here can be network-level (offline, DNS) or -// auth-level (bad SSH key, token expired), but in either case the user -// must fix it — doctor has no business trying to renegotiate credentials. func (r *Runner) checkRemoteReach(name, barePath string) Finding { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -208,16 +162,6 @@ func (r *Runner) checkRemoteReach(name, barePath string) Finding { } } -// checkDefaultBranch surfaces projects whose workspace.toml entry is -// missing default_branch. That field is the anchor for ff-pulls, `ws -// worktree new` base resolution, and bootstrap; leaving it empty forces -// every consumer to guess. -// -// The fix is mostly mechanical: `refs/remotes/origin/HEAD` usually -// points at the right branch after a recent fetch. If it doesn't -// resolve, we fall back to "main" / "master" probe — same order git's -// own ls-remote uses. If nothing resolves, we emit the warning without -// an auto-fix so the user picks manually. func (r *Runner) checkDefaultBranch(name string, proj config.Project, barePath string) Finding { if strings.TrimSpace(proj.DefaultBranch) != "" { return Finding{ @@ -240,7 +184,7 @@ func (r *Runner) checkDefaultBranch(name string, proj config.Project, barePath s FixHint: "edit workspace.toml manually", } } - // SymbolicRef returns "origin/main"; strip the "origin/" prefix. + if i := strings.Index(detected, "/"); i >= 0 { detected = detected[i+1:] } @@ -261,8 +205,6 @@ func (r *Runner) checkDefaultBranch(name string, proj config.Project, barePath s } } -// probeFallbackBranch checks the usual suspects when refs/remotes/origin/HEAD -// isn't configured (which is the common case for bares cloned before PR#16). func probeFallbackBranch(barePath string) string { for _, b := range []string{"main", "master"} { if git.HasBranch(barePath, b) { @@ -272,27 +214,9 @@ func probeFallbackBranch(barePath string) string { return "" } -// checkBranchUpstream ensures the default branch has branch..remote -// and branch..merge configured AND that refs/remotes/origin/ -// actually exists. Config alone isn't enough — git's @{upstream} -// resolution needs the tracking ref, so HasUpstream keeps reporting -// "missing" even after a config write if the ref is empty. -// -// Auto-fix therefore does both: SetBranchUpstream (idempotent) then -// a best-effort Fetch to populate the tracking ref. Gated on -// Runner.SkipRemote so offline users still get the config write -// without a network call. A fetch failure does not invalidate the -// fix — subsequent runs can retry — so the error is swallowed; the -// next `ws doctor` will flag it again via remote-reach. -// -// There is one pre-condition we cannot repair: if HasBranch returns -// false, the local default branch simply does not exist in this -// bare, and there is nothing upstream-configuration-wise to set. -// In that case we warn without an auto-fix. func (r *Runner) checkBranchUpstream(name string, proj config.Project, barePath string) Finding { branch := strings.TrimSpace(proj.DefaultBranch) if branch == "" { - // Already covered by the default-branch check; don't double-report. return Finding{ Scope: name, Check: "branch-upstream", @@ -331,22 +255,13 @@ func (r *Runner) checkBranchUpstream(name string, proj config.Project, barePath if skipRemote { return nil } - // Populate refs/remotes/origin/ so @{upstream} - // resolution works. Best-effort — config is the primary - // mutation and remains valid even if the fetch fails. + _ = git.Fetch(barePath) return nil }, } } -// checkIndexLock walks every worktree under this project and reports any -// that carry a stale .git/index.lock. Removing the lock is risky (could -// corrupt an ongoing git operation), so there is no auto-fix — doctor -// just surfaces them so the user can investigate. -// -// Returns a slice so a multi-worktree project with multiple locks emits -// one finding per worktree rather than concatenating messages. func (r *Runner) checkIndexLock(name, barePath string) []Finding { wts, err := git.WorktreeList(barePath) if err != nil { @@ -379,9 +294,6 @@ func (r *Runner) checkIndexLock(name, barePath string) []Finding { return out } -// lockedWorktrees returns the absolute paths of non-bare worktrees -// whose index.lock is present. Hides the bare-skip + path collection -// loop from checkIndexLock. func lockedWorktrees(wts []git.Worktree) []string { var out []string for _, wt := range wts { diff --git a/internal/doctor/system.go b/internal/doctor/system.go index d0214e2..411d611 100644 --- a/internal/doctor/system.go +++ b/internal/doctor/system.go @@ -13,10 +13,6 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// systemChecks runs the four system-level checks once per invocation. -// Order is display order — daemon first because it's the broadest context, -// then sidecar → conflicts → config drills from "environment" to -// "configuration". func (r *Runner) systemChecks() []Finding { return []Finding{ checkDaemon(), @@ -26,10 +22,6 @@ func (r *Runner) systemChecks() []Finding { } } -// checkDaemon reports whether the background daemon is alive. We do not -// offer to start it automatically: starting a daemon is an explicit user -// action (ws daemon start), and the user's reasons for keeping it off -// (laptop battery, intentional manual sync) are outside doctor's scope. func checkDaemon() Finding { pid, alive := daemon.IsRunning() if alive { @@ -49,13 +41,6 @@ func checkDaemon() Finding { } } -// checkStaleSidecars returns one Finding per known sidecar kind that has -// a dead pid recorded on disk. A stale sidecar would normally block the -// reconciler for this workspace until removed, so this is important to -// surface even though the recovery path is trivial. -// -// If every kind is either absent or live, returns a single OK finding so -// the user sees that the check actually ran. func checkStaleSidecars(wsRoot string) Finding { stale := findStaleSidecars(wsRoot) if len(stale) == 0 { @@ -76,8 +61,6 @@ func checkStaleSidecars(wsRoot string) Finding { } } -// findStaleSidecars returns the sidecar kinds whose pid is no longer -// alive in `wsRoot`. Skips kinds with no sidecar present and live ones. func findStaleSidecars(wsRoot string) []sidecar.Kind { kinds := []sidecar.Kind{sidecar.KindBootstrap, sidecar.KindMigrate} var stale []sidecar.Kind @@ -93,8 +76,6 @@ func findStaleSidecars(wsRoot string) []sidecar.Kind { return stale } -// sidecarKindNames maps a sidecar.Kind slice to a string slice for -// the message-formatting path. func sidecarKindNames(kinds []sidecar.Kind) []string { out := make([]string, 0, len(kinds)) for _, k := range kinds { @@ -103,9 +84,6 @@ func sidecarKindNames(kinds []sidecar.Kind) []string { return out } -// deleteSidecars removes every sidecar in `kinds` from `wsRoot`. -// Used as the Fix function for the stale-sidecar Finding; collapses -// the multi-kind cleanup into one user action. func deleteSidecars(wsRoot string, kinds []sidecar.Kind) error { for _, k := range kinds { if err := sidecar.Delete(wsRoot, k); err != nil { @@ -115,10 +93,6 @@ func deleteSidecars(wsRoot string, kinds []sidecar.Kind) error { return nil } -// checkConflicts surfaces any entries in ~/.local/state/ws/conflicts.json -// that belong to this workspace. Doctor never auto-resolves — the -// FixHint points at `ws sync resolve`, which is the single entry point -// for conflict resolution. func checkConflicts(wsRoot string) Finding { mine, err := loadProjectConflicts(wsRoot) if err != nil { @@ -153,10 +127,6 @@ func checkConflicts(wsRoot string) Finding { } } -// loadProjectConflicts opens the conflict store and returns the -// conflicts whose Workspace path resolves to wsRoot. Errors from the -// store are wrapped with a user-readable prefix so the caller can -// drop them straight into Finding.Message. func loadProjectConflicts(wsRoot string) ([]conflict.Conflict, error) { store, err := conflict.Open() if err != nil { @@ -177,9 +147,6 @@ func loadProjectConflicts(wsRoot string) ([]conflict.Conflict, error) { return mine, nil } -// oldestConflict picks the lowest-DetectedAt entry from a non-empty -// slice. Used by checkConflicts to surface the most-aged conflict in -// the doctor message; the full list lives in `ws sync resolve`. func oldestConflict(conflicts []conflict.Conflict) conflict.Conflict { oldest := conflicts[0] for _, c := range conflicts[1:] { @@ -197,8 +164,6 @@ func projectOrGlobal(c conflict.Conflict) string { return "workspace" } -// humanizeAge renders a duration in the same style as status.go's -// humanizeTime but focused on "how long has this been broken" framing. func humanizeAge(d time.Duration) string { switch { case d < time.Minute: @@ -212,15 +177,6 @@ func humanizeAge(d time.Duration) string { } } -// checkConfig validates the currently loaded workspace.toml: every active -// project must have a non-empty Remote and Path, its Status / Category -// must be a known enum value, and the daemon duration strings (if set) -// must parse. The goal is to catch hand-edited typos; the TOML parser -// already rejects structural errors. -// -// Duration validation mirrors status.go's parseDuration — "30d" suffix -// plus anything time.ParseDuration accepts — rather than re-deriving the -// grammar, which would drift. func checkConfig(ws *config.Workspace) Finding { if ws == nil { return Finding{ @@ -248,10 +204,6 @@ func checkConfig(ws *config.Workspace) Finding { } } -// collectConfigIssues runs every per-field validator in the workspace -// and concatenates their issue messages, sorted-by-project for -// deterministic output. Drives both the OK / Error split in -// checkConfig and unit tests that assert specific issue strings. func collectConfigIssues(ws *config.Workspace) []string { var issues []string for _, name := range sortedProjectNames(ws.Projects) { @@ -261,8 +213,6 @@ func collectConfigIssues(ws *config.Workspace) []string { return issues } -// sortedProjectNames returns the project names of `projects` in -// lexical order. Used for stable check ordering in the report. func sortedProjectNames(projects map[string]config.Project) []string { out := make([]string, 0, len(projects)) for n := range projects { @@ -272,8 +222,6 @@ func sortedProjectNames(projects map[string]config.Project) []string { return out } -// validateProject returns one issue string per per-field problem in -// the given project. Empty slice when the project record is well-formed. func validateProject(name string, p config.Project) []string { var issues []string if strings.TrimSpace(p.Remote) == "" { @@ -304,15 +252,12 @@ func validateProjectStatus(name string, s config.Status) string { func validateProjectCategory(name string, c config.Category) string { switch c { case config.CategoryPersonal, config.CategoryWork, "": - // "" is tolerated — category is optional. + return "" } return fmt.Sprintf("%s: unknown category %q", name, c) } -// validateDaemonDurations checks that any non-empty daemon duration -// strings parse as accepted Go durations (with the optional "Nd" -// extension). Returns one issue per malformed entry. func validateDaemonDurations(d config.Daemon) []string { var issues []string for _, pair := range []struct{ name, val string }{ @@ -329,9 +274,6 @@ func validateDaemonDurations(d config.Daemon) []string { return issues } -// validDuration mirrors status.go's parseDuration — accepts a trailing -// "d" suffix for day-granularity values (e.g. "30d") plus anything the -// stdlib time.ParseDuration accepts ("5m", "1h30m"). func validDuration(s string) bool { s = strings.TrimSpace(s) if s == "" { diff --git a/internal/git/bare.go b/internal/git/bare.go index e9d42c5..b9ba067 100644 --- a/internal/git/bare.go +++ b/internal/git/bare.go @@ -6,9 +6,6 @@ import ( "strings" ) -// CloneBare clones `remote` into `dest` as a bare repository. The destination -// must not already exist. Used by `ws add` (network clone) and by `ws migrate` -// (when wrapping an existing checkout, see CloneBareLocal). func CloneBare(remote, dest string) error { cmd := exec.Command("git", "clone", "--bare", remote, dest) out, err := cmd.CombinedOutput() @@ -18,10 +15,6 @@ func CloneBare(remote, dest string) error { return nil } -// CloneBareLocal clones a local plain repo into a bare repo without going -// through the network. --no-local is used so git copies all objects rather -// than hardlinking them — important because the source .git is going to be -// deleted by the migration step that follows. func CloneBareLocal(srcRepoPath, destBarePath string) error { cmd := exec.Command("git", "clone", "--bare", "--no-local", srcRepoPath, destBarePath) out, err := cmd.CombinedOutput() @@ -31,7 +24,6 @@ func CloneBareLocal(srcRepoPath, destBarePath string) error { return nil } -// IsBare reports whether path is a bare git repository. func IsBare(path string) bool { cmd := exec.Command("git", "-C", path, "rev-parse", "--is-bare-repository") out, err := cmd.Output() @@ -41,10 +33,7 @@ func IsBare(path string) bool { return strings.TrimSpace(string(out)) == "true" } -// SetRemoteURL points origin at a new URL. Used post-CloneBareLocal so the -// freshly created bare repo talks to the actual remote, not the local source. func SetRemoteURL(repoPath, url string) error { - // First try to update an existing origin; if that fails, add it. cmd := exec.Command("git", "-C", repoPath, "remote", "set-url", "origin", url) if err := cmd.Run(); err == nil { return nil @@ -57,8 +46,6 @@ func SetRemoteURL(repoPath, url string) error { return nil } -// SetRemoteHead pins origin/HEAD to the named branch. Used during migration -// so the bare repo knows what the project's default branch is. func SetRemoteHead(repoPath, branch string) error { cmd := exec.Command("git", "-C", repoPath, "remote", "set-head", "origin", branch) out, err := cmd.CombinedOutput() @@ -68,8 +55,6 @@ func SetRemoteHead(repoPath, branch string) error { return nil } -// FetchRefspec fetches a specific refspec from a source repo into the current -// repo. Used by migration to ensure local-only branches make it into the bare. func FetchRefspec(repoPath, source, refspec string) error { cmd := exec.Command("git", "-C", repoPath, "fetch", source, refspec) out, err := cmd.CombinedOutput() @@ -79,48 +64,27 @@ func FetchRefspec(repoPath, source, refspec string) error { return nil } -// standardFetchRefspec is the refspec normal `git clone` writes into -// remote.origin.fetch. `git clone --bare` omits it, which is the bug -// SetFetchRefspec exists to work around — without it, `git fetch` in a -// bare repo updates only FETCH_HEAD (no refs/remotes/origin/*), and -// branch@{u} cannot resolve, so AheadBehind() returns (0, 0, false) for -// every branch with configured upstream. const standardFetchRefspec = "+refs/heads/*:refs/remotes/origin/*" -// SetFetchRefspec writes the standard fetch refspec into repoPath's config. -// Idempotent: overwrites any single-valued existing setting. No-op on -// multi-valued refspecs (rare custom config) — HasFetchRefspec returns -// true for those, so the one-time repair in the reconciler skips them. func SetFetchRefspec(repoPath string) error { return setConfig(repoPath, "remote.origin.fetch", standardFetchRefspec) } -// HasFetchRefspec reports whether remote.origin.fetch has at least one -// value configured in repoPath. Used by the daemon's one-time repair to -// skip bare repos that already have a refspec (either from being cloned -// post-fix or from user-customized config). func HasFetchRefspec(repoPath string) bool { cmd := exec.Command("git", "-C", repoPath, "config", "--get-all", "remote.origin.fetch") out, err := cmd.Output() if err != nil { - // Exit code 1 means "key not found" — treated as absent. return false } return strings.TrimSpace(string(out)) != "" } -// HasBranch reports whether refs/heads/ exists in the repo. func HasBranch(repoPath, branch string) bool { cmd := exec.Command("git", "-C", repoPath, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) return cmd.Run() == nil } -// HasRemoteBranch reports whether refs/remotes// exists -// in repoPath. The reconciler uses this post-fetch to detect branches -// that were deleted on origin (PR-merge auto-delete, manual -// `git push origin --delete`) and surface them as KindBranchOrphan. func HasRemoteBranch(repoPath, remote, branch string) bool { cmd := exec.Command("git", "-C", repoPath, "show-ref", "--verify", "--quiet", "refs/remotes/"+remote+"/"+branch) return cmd.Run() == nil } - diff --git a/internal/git/git.go b/internal/git/git.go index d7a7bc8..551bae2 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -66,10 +66,6 @@ func LastCommitTime(repoPath string) (time.Time, error) { return time.Parse(time.RFC3339, strings.TrimSpace(string(out))) } -// LastCommitAuthorTime returns the author date of HEAD. Unlike LastCommitTime -// (committer date), this is preserved across `git commit --amend --no-edit`, -// which makes it the right anchor for cooldowns that must bound the maximum -// time a coalesced commit can sit unpushed while activity keeps refreshing it. func LastCommitAuthorTime(repoPath string) (time.Time, error) { cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%aI") out, err := cmd.Output() @@ -124,8 +120,6 @@ func Push(repoPath string) error { return nil } -// PushBranch pushes a single named branch to origin, setting upstream if -// it does not already track a remote branch. func PushBranch(repoPath, branch string) error { cmd := exec.Command("git", "-C", repoPath, "push", "--set-upstream", "origin", branch) out, err := cmd.CombinedOutput() @@ -135,8 +129,6 @@ func PushBranch(repoPath, branch string) error { return nil } -// Fetch runs `git fetch --all --prune --tags` against repoPath. Used by the -// reconciler to refresh remote refs without touching any working tree. func Fetch(repoPath string) error { cmd := exec.Command("git", "-C", repoPath, "fetch", "--all", "--prune", "--tags") out, err := cmd.CombinedOutput() @@ -146,9 +138,6 @@ func Fetch(repoPath string) error { return nil } -// RevParse resolves a ref to its full SHA. Returns "" on failure rather than -// erroring — callers typically want to treat "ref does not exist" as a normal -// state, not an exceptional one. func RevParse(repoPath, ref string) string { cmd := exec.Command("git", "-C", repoPath, "rev-parse", ref) out, err := cmd.Output() @@ -158,8 +147,6 @@ func RevParse(repoPath, ref string) string { return strings.TrimSpace(string(out)) } -// AheadBehind returns how many commits `branch` is ahead of and behind its -// upstream. Returns (0, 0, false) if the branch has no upstream configured. func AheadBehind(repoPath, branch string) (ahead, behind int, hasUpstream bool) { upstream := branch + "@{u}" if RevParse(repoPath, upstream) == "" { @@ -179,24 +166,15 @@ func AheadBehind(repoPath, branch string) (ahead, behind int, hasUpstream bool) return ahead, behind, true } -// IsDirty reports whether repoPath has uncommitted changes (tracked or -// untracked, excluding ignored). Reconciler uses this to skip ff-pull when -// the user is mid-edit. func IsDirty(repoPath string) bool { cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain") out, err := cmd.Output() if err != nil { - // On error, err on the side of "looks dirty" so we don't accidentally - // pull-and-overwrite a working tree we couldn't inspect. return true } return strings.TrimSpace(string(out)) != "" } -// HasIndexLock reports whether .git/index.lock is present, indicating -// another git process is currently mid-write. Reconciler skips operations -// on the worktree when this is true to avoid colliding with an editor or -// interactive shell command. func HasIndexLock(repoPath string) bool { gitDir := RevParse(repoPath, "--git-dir") if gitDir == "" { @@ -209,26 +187,11 @@ func HasIndexLock(repoPath string) bool { return err == nil } -// HasUpstream reports whether the named branch has an upstream tracking -// branch configured. func HasUpstream(repoPath, branch string) bool { cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--abbrev-ref", branch+"@{upstream}") return cmd.Run() == nil } -// SetBranchUpstream wires up branch..remote and -// branch..merge in the repo's config so plain `git push` and -// `git pull` work without arguments. Used by both clone (after worktree -// add) and migrate (after bare clone). We write the config keys directly -// instead of `git branch --set-upstream-to=origin/` so the call -// works in the narrow window after CloneBare + SetFetchRefspec but before -// the first Fetch — that window has no refs/remotes/origin/ yet -// for --set-upstream-to to point at, whereas the raw config write is -// accepted unconditionally and becomes valid as soon as the next fetch -// populates the tracking ref. -// -// repoPath can be either the bare or any of its worktrees — branch config -// is shared across them through the bare's config file. func SetBranchUpstream(repoPath, branch, remote string) error { if branch == "" || remote == "" { return fmt.Errorf("SetBranchUpstream: empty branch or remote") @@ -248,16 +211,10 @@ func setConfig(repoPath, key, value string) error { return nil } -// HasStash reports whether `git stash list` has any entries. ws migrate -// uses this as a pre-flight check — stash is bound to the working .git and -// would be lost when we replace it with a worktree, unless we first -// convert each stash entry to a side branch via `git stash branch`. func HasStash(repoPath string) bool { return StashCount(repoPath) > 0 } -// StashCount returns the number of entries in `git stash list`. Used by -// migrate to walk N stashes and convert each into a side branch. func StashCount(repoPath string) int { cmd := exec.Command("git", "-C", repoPath, "stash", "list") out, err := cmd.Output() @@ -271,8 +228,6 @@ func StashCount(repoPath string) int { return strings.Count(s, "\n") + 1 } -// SymbolicRef resolves a symbolic ref like refs/remotes/origin/HEAD to its -// target (e.g. "main"). Returns "" if the ref does not exist or is not symbolic. func SymbolicRef(repoPath, ref string) string { cmd := exec.Command("git", "-C", repoPath, "symbolic-ref", "--short", ref) out, err := cmd.Output() @@ -282,8 +237,6 @@ func SymbolicRef(repoPath, ref string) string { return strings.TrimSpace(string(out)) } -// ParseRepoName extracts repo name from a git remote URL. -// e.g. "git@github.com:user/repo.git" → "repo" func ParseRepoName(remote string) string { remote = strings.TrimSuffix(remote, ".git") if idx := strings.LastIndex(remote, "/"); idx >= 0 { @@ -294,4 +247,3 @@ func ParseRepoName(remote string) string { } return remote } - diff --git a/internal/git/worktree.go b/internal/git/worktree.go index fd805fa..e5adc74 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -6,18 +6,14 @@ import ( "strings" ) -// Worktree describes one entry from `git worktree list --porcelain`. type Worktree struct { - Path string // absolute path to the worktree directory - HEAD string // commit SHA HEAD points to - Branch string // short branch name; empty if detached - Bare bool // true for the bare repo entry itself + Path string + HEAD string + Branch string + Bare bool Detached bool } -// WorktreeAdd creates a new worktree at `wtPath` checking out `branch`. -// If `createFromBase` is non-empty, the branch is created from that base ref; -// otherwise the branch must already exist. func WorktreeAdd(repoPath, wtPath, branch, createFromBase string) error { args := []string{"-C", repoPath, "worktree", "add"} if createFromBase != "" { @@ -33,15 +29,6 @@ func WorktreeAdd(repoPath, wtPath, branch, createFromBase string) error { return nil } -// WorktreeAddNoCheckout creates a worktree at wtPath checked out on branch, -// but skips writing the working-tree files. The result is a directory -// containing only a .git pointer file (and the matching admin dir under -// repoPath/worktrees//). Used by migrate to materialize a worktree's -// metadata without overwriting the user's existing files. -// -// wtPath must NOT already exist — git enforces this even with --no-checkout. -// The migrate flow uses a sibling temp path and then moves the .git pointer -// file into the real (existing) main path. func WorktreeAddNoCheckout(repoPath, wtPath, branch string) error { cmd := exec.Command("git", "-C", repoPath, "worktree", "add", "--no-checkout", wtPath, branch) out, err := cmd.CombinedOutput() @@ -51,12 +38,6 @@ func WorktreeAddNoCheckout(repoPath, wtPath, branch string) error { return nil } -// WorktreeRepair tells git to update its worktree admin directory entries -// after their working trees have been moved. Used by migrate after we -// physically rename a freshly-created worktree's .git pointer file from a -// temp sibling into the real main path: without WorktreeRepair the bare -// repo's worktrees//gitdir still points at the temp location, which -// then gets pruned and silently breaks the worktree. func WorktreeRepair(repoPath string) error { cmd := exec.Command("git", "-C", repoPath, "worktree", "repair") out, err := cmd.CombinedOutput() @@ -66,8 +47,6 @@ func WorktreeRepair(repoPath string) error { return nil } -// WorktreeRemove removes a worktree. With force=false, git refuses if the -// worktree has uncommitted changes. func WorktreeRemove(repoPath, wtPath string, force bool) error { args := []string{"-C", repoPath, "worktree", "remove"} if force { @@ -82,8 +61,6 @@ func WorktreeRemove(repoPath, wtPath string, force bool) error { return nil } -// WorktreeList parses `git worktree list --porcelain` output. Works on either -// a bare repo or a regular checkout — git resolves to the same shared list. func WorktreeList(repoPath string) ([]Worktree, error) { cmd := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain") out, err := cmd.Output() @@ -93,9 +70,6 @@ func WorktreeList(repoPath string) ([]Worktree, error) { return parsePorcelainWorktreeList(string(out)), nil } -// parsePorcelainWorktreeList consumes the porcelain output of -// `git worktree list --porcelain` and returns one Worktree per -// blank-line-delimited record. Pure parser: no IO, fully unit-testable. func parsePorcelainWorktreeList(text string) []Worktree { var ( result []Worktree @@ -122,10 +96,6 @@ func parsePorcelainWorktreeList(text string) []Worktree { return result } -// applyWorktreeLine routes one porcelain line into the matching -// Worktree field. Unknown prefixes are silently ignored — git may -// add new attributes (e.g. `locked`, `prunable`) without breaking -// the parser. func applyWorktreeLine(cur *Worktree, line string) { switch { case strings.HasPrefix(line, "worktree "): diff --git a/internal/github/app_provider.go b/internal/github/app_provider.go index 671f485..458fcb3 100644 --- a/internal/github/app_provider.go +++ b/internal/github/app_provider.go @@ -2,24 +2,8 @@ package github import "context" -// GhAppProvider is a placeholder for the future GitHub App-backed -// Provider that will ship with its own installation flow, encrypted -// token storage, and rotation. Currently a pure stub: any -// SuggestRepos call returns ErrNotImplemented. -// -// The stub exists so the Provider interface shape stays stable -// before the App integration lands, and so callers can write -// `case *GhAppProvider:` switches that compile today. -// -// ResolveProvider does NOT wire this provider in — it picks -// httpClient → ghClient → noop. The future App integration will -// extend ResolveProvider to read ~/.config/ws/github-app.toml and -// return a real GhAppProvider when configured. type GhAppProvider struct{} -// NewGhAppProviderStub constructs the placeholder. Named with an -// explicit "Stub" suffix so it's obvious at call sites that this -// does not actually talk to GitHub. func NewGhAppProviderStub() *GhAppProvider { return &GhAppProvider{} } func (*GhAppProvider) Name() string { return "gh-app" } diff --git a/internal/github/cache.go b/internal/github/cache.go index 88701c5..abd8f4b 100644 --- a/internal/github/cache.go +++ b/internal/github/cache.go @@ -8,10 +8,6 @@ import ( "time" ) -// cacheFile is the on-disk envelope for a github-suggestion cache. -// We embed StoredAt + a schema Version so future changes to the Repo -// shape can be detected and the cache invalidated automatically -// without crashing on JSON-unmarshal mismatches. type cacheFile struct { Version int `json:"version"` StoredAt time.Time `json:"stored_at"` @@ -19,27 +15,13 @@ type cacheFile struct { } const ( - // cacheVersion bumps any time the on-disk Repo schema changes in - // a way that older versions cannot decode. Reads from a different - // version are treated as a cache miss. cacheVersion = 1 - // cacheTTL caps how long a cache is considered fresh enough to - // serve in lieu of a live fetch. One hour is a good balance: long - // enough to make repeated `ws add` invocations feel instant, short - // enough that newly-created GitHub repos surface within a typical - // workday. cacheTTL = time.Hour ) -// CacheTTL returns the freshness window. Exported for tests and for -// callers that want to surface "cached, X minutes old" diagnostics. func CacheTTL() time.Duration { return cacheTTL } -// cachePath resolves the cache file location, honoring XDG state. -// Single file per user, not per workspace — the cached data is the -// user's own GitHub repos and doesn't depend on which workspace is -// active. func cachePath() (string, error) { state := os.Getenv("XDG_STATE_HOME") if state == "" { @@ -52,13 +34,6 @@ func cachePath() (string, error) { return filepath.Join(state, "ws", "github-cache.json"), nil } -// LoadCache reads the cached repo list. Returns (nil, 0, nil) when no -// cache exists (the common cold-start path) — not treated as an error. -// The returned age lets callers decide whether to use the cache, -// refresh it in the background, or force a fresh fetch. -// -// Schema-version mismatches and JSON parse errors are treated as a -// cache miss so a malformed file never blocks the user. func LoadCache() ([]Repo, time.Duration, error) { p, err := cachePath() if err != nil { @@ -78,12 +53,6 @@ func LoadCache() ([]Repo, time.Duration, error) { return cf.Repos, time.Since(cf.StoredAt), nil } -// parseCacheFile decodes a cache blob and applies the schema-version -// + sanity-shape gates. Returns ok=false when the file is corrupt, -// uses a stale schema, or contains entries whose Owner+SSHURL are -// both empty (a real GitHub repo always has at least one). The -// false branch is the documented cache-miss path: callers act as if -// no cache existed and let the next live fetch overwrite cleanly. func parseCacheFile(data []byte) (cacheFile, bool) { var cf cacheFile if err := json.Unmarshal(data, &cf); err != nil { @@ -98,11 +67,6 @@ func parseCacheFile(data []byte) (cacheFile, bool) { return cf, true } -// cacheReposLookSane is the "real GitHub repo" shape gate: every -// entry must carry at least one of Owner or SSHURL. Empty-and-empty -// rows have been observed from partial writes and test fixtures -// landing in the user's real cache before t.Setenv() isolation was -// added; the gate stops them from being served as live data. func cacheReposLookSane(repos []Repo) bool { for _, r := range repos { if r.Owner == "" && r.SSHURL == "" { @@ -112,17 +76,8 @@ func cacheReposLookSane(repos []Repo) bool { return true } -// SaveCache atomically writes the repo list to the cache file. Writes -// are best-effort: a failed cache save never blocks the live result -// from reaching the caller, so this returns its error but most -// callers should ignore it. -// -// Atomic via tmp + rename so a crash mid-write leaves the previous -// cache intact rather than producing a half-written file. func SaveCache(repos []Repo) error { if len(repos) == 0 { - // Don't persist empty caches — they'd just produce false - // "fresh" hits that hide the user's actual repos. return nil } p, err := cachePath() @@ -147,10 +102,6 @@ func SaveCache(repos []Repo) error { return os.Rename(tmp, p) } -// PurgeCache removes the cache file. Called by `ws auth login` or -// other commands that change the GitHub identity, so the next `ws add` -// fetches fresh data scoped to the new account. No-op if the file is -// already gone. func PurgeCache() error { p, err := cachePath() if err != nil { @@ -162,9 +113,6 @@ func PurgeCache() error { return nil } -// CacheFresh reports whether the on-disk cache is younger than CacheTTL. -// Convenience for callers that don't need the actual repo list — useful -// in diagnostics ("github: cached, 12m old"). func CacheFresh() (bool, time.Duration) { _, age, err := LoadCache() if err != nil { diff --git a/internal/github/client.go b/internal/github/client.go index b94f983..bd83d71 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -1,6 +1,5 @@ package github -// Client abstracts GitHub API access. type Client interface { CurrentUser() (string, error) FetchRepos() ([]Repo, error) diff --git a/internal/github/gh_client.go b/internal/github/gh_client.go index 2ef38a6..06d2be0 100644 --- a/internal/github/gh_client.go +++ b/internal/github/gh_client.go @@ -11,7 +11,6 @@ import ( type ghClient struct{} -// NewGHClient creates a GitHub API client using the gh CLI. func NewGHClient() Client { return &ghClient{} } diff --git a/internal/github/github.go b/internal/github/github.go index 180a042..4b930b9 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -7,14 +7,14 @@ import ( type Repo struct { Name string - FullName string // owner/repo + FullName string Owner string SSHURL string Description string Private bool Fork bool PushedAt time.Time - Activity int // event count from Events API (last 90 days) + Activity int } type rawRepo struct { @@ -37,7 +37,6 @@ type rawEvent struct { } `json:"repo"` } -// FetchAll resolves a client, fetches repos and activity, merges them, and returns sorted by activity. func FetchAll() ([]Repo, string, error) { client, err := ResolveClient() if err != nil { @@ -70,7 +69,6 @@ func FetchAll() ([]Repo, string, error) { return repos, username, nil } -// Orgs extracts unique org/owner names from repos. func Orgs(repos []Repo) []string { seen := make(map[string]bool) var orgs []string diff --git a/internal/github/http_client.go b/internal/github/http_client.go index 4b50c0e..e0b12a9 100644 --- a/internal/github/http_client.go +++ b/internal/github/http_client.go @@ -14,7 +14,6 @@ type httpClient struct { client *http.Client } -// NewHTTPClient creates a GitHub API client using a bearer token. func NewHTTPClient(token string) Client { return &httpClient{ token: token, @@ -72,9 +71,6 @@ func (c *httpClient) FetchRepos() ([]Repo, error) { return repos, nil } -// rawRepoToRepo converts the wire shape into the package's typed -// Repo. Pulled out so the parsing-decision (what becomes what) lives -// in one place rather than being embedded in the page loop. func rawRepoToRepo(r rawRepo) Repo { pushed, _ := time.Parse(time.RFC3339, r.PushedAt) return Repo{ @@ -89,17 +85,13 @@ func rawRepoToRepo(r rawRepo) Repo { } } -// FetchActivity polls /users//events for activity counts per repo. -// Best-effort: any error along the way (network, decode, non-200) -// returns whatever counts were collected up to that point with no -// caller-visible error. func (c *httpClient) FetchActivity(username string) (map[string]int, error) { url := fmt.Sprintf("https://api.github.com/users/%s/events?per_page=100", username) counts := make(map[string]int) _ = c.fetchPaged(url, func(body []byte) error { var events []rawEvent if err := json.Unmarshal(body, &events); err != nil { - return nil // skip malformed page; keep walking + return nil } tallyActivityEvents(counts, events) return nil @@ -107,9 +99,6 @@ func (c *httpClient) FetchActivity(username string) (map[string]int, error) { return counts, nil } -// activityEventTypes lists the GitHub event Type strings that count -// as user-visible activity for the suggestion ranker. Any new type -// goes here — the loop body doesn't change. var activityEventTypes = map[string]bool{ "PushEvent": true, "PullRequestEvent": true, @@ -119,8 +108,6 @@ var activityEventTypes = map[string]bool{ "CommitCommentEvent": true, } -// tallyActivityEvents bumps the per-repo activity counter for every -// event whose Type is in activityEventTypes. func tallyActivityEvents(counts map[string]int, events []rawEvent) { for _, e := range events { if activityEventTypes[e.Type] { @@ -129,10 +116,6 @@ func tallyActivityEvents(counts map[string]int, events []rawEvent) { } } -// fetchPaged drives a Link-paginated GitHub API endpoint. For each -// successfully-fetched page, calls onPage with the response body; -// the loop stops when GitHub stops emitting a `rel="next"` link or -// when onPage returns an error. func (c *httpClient) fetchPaged(startURL string, onPage func(body []byte) error) error { url := startURL for url != "" { @@ -148,9 +131,6 @@ func (c *httpClient) fetchPaged(startURL string, onPage func(body []byte) error) return nil } -// fetchOnce performs a single GET against url, returning the raw body -// and the response Header (so callers can read pagination links). -// Non-200 responses become errors carrying the URL + status + body. func (c *httpClient) fetchOnce(url string) ([]byte, http.Header, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -168,8 +148,6 @@ func (c *httpClient) fetchOnce(url string) ([]byte, http.Header, error) { return body, resp.Header, nil } -// nextPageURL parses the GitHub Link header and returns the "next" URL, or "" if none. -// Format: ; rel="next", <...>; rel="last" func nextPageURL(link string) string { if link == "" { return "" @@ -182,8 +160,6 @@ func nextPageURL(link string) string { return "" } -// extractRelNext checks whether a single Link header part is the -// `rel="next"` segment and, if so, returns the URL between < and >. func extractRelNext(part string) (string, bool) { if !strings.Contains(part, `rel="next"`) { return "", false diff --git a/internal/github/provider.go b/internal/github/provider.go index f30a2c0..89fb711 100644 --- a/internal/github/provider.go +++ b/internal/github/provider.go @@ -11,74 +11,24 @@ import ( "github.com/kuchmenko/workspace/internal/auth" ) -// Provider is the high-level interface the `ws add` TUI (and any other -// consumer that wants "top-N relevant repos for this user") talks to. -// -// It sits above the low-level Client interface — Client exposes the -// individual GitHub API calls (CurrentUser, FetchRepos, FetchActivity), -// and Provider composes them into a single "give me N suggestions" -// operation with sorting and limiting baked in. -// -// Today there's one real implementation (wraps either the OAuth -// httpClient or the gh-CLI client, whichever ResolveProvider picks) -// plus NoopProvider for the no-auth-configured case and GhAppProvider -// as a placeholder for the future GitHub App integration. type Provider interface { - // SuggestRepos returns up to `limit` repos sorted by recent activity. - // ctx is honored for cancellation; implementations should bail out - // promptly on ctx.Done(). SuggestRepos(ctx context.Context, limit int) ([]Repo, error) - // Name returns a short machine-readable identifier of the underlying - // backend — "http-oauth", "gh-cli", "gh-app", "noop". Used for - // diagnostics ("GitHub suggestions via gh-cli") and for telemetry - // that shouldn't leak through the abstraction. Name() string } -// ErrNotAuthed is returned by NoopProvider.SuggestRepos when no GitHub -// authentication is configured. Callers should surface this to the user -// as "run `ws auth login` or `gh auth login`", not as a fatal error — -// the `ws add` TUI degrades gracefully when GitHub is unavailable. var ErrNotAuthed = errors.New("no GitHub authentication configured") -// ErrNotImplemented is returned by GhAppProvider until the GitHub -// App integration lands. The stub exists now so callers can code -// against the interface without conditional compilation. var ErrNotImplemented = errors.New("not implemented (GitHub App)") -// ResolveProvider picks the best available Provider for this environment. -// -// Resolution order: -// 1. ws OAuth token present → clientProvider wrapping the HTTP client -// (matches current github.ResolveClient behavior — keeps the -// `ws auth login` flow as primary). -// 2. `gh auth status` succeeds → clientProvider wrapping the gh CLI -// client (gh CLI becomes a real fallback, no longer orphan code). -// 3. Neither → NoopProvider. Callers see ErrNotAuthed on SuggestRepos -// and render a "run `ws auth login` / `gh auth login`" hint. -// -// The OAuth path is health-probed before being returned: ws OAuth tokens -// from the device-flow path are user-to-server (`ghu_*`) with an 8-hour -// lifetime, and we don't refresh them. A stale token would silently 401 -// every API call. The probe is one HEAD-style request to /user with a -// 2-second budget; if it fails (401, network, timeout), we fall through -// to gh CLI rather than wedge the TUI on a dead session. -// -// Does NOT read ~/.config/ws/github-app.toml. That reader lands with the -// future GitHub App integration so an empty/malformed token file cannot -// silently knock out httpClient/ghClient suggestions. func ResolveProvider() Provider { - // 1. ws OAuth token (preferred when valid). if token, err := loadOAuthToken(); err == nil && token != "" { client := NewHTTPClient(token) if oauthProbe(client) { return &clientProvider{client: client, name: "http-oauth"} } - // Stale or rejected — silently fall through. } - // 2. gh CLI fallback. if ghAuthStatus() { return &clientProvider{ client: NewGHClient(), @@ -86,25 +36,9 @@ func ResolveProvider() Provider { } } - // 3. No auth available. return noopProvider{} } -// oauthProbeNetwork performs a quick /user round-trip to validate the -// token before we hand the provider to the rest of the gather pipeline. -// Avoids the common "expired ghu_* token" trap where the user ran -// `ws auth login` weeks ago and the device-flow user-to-server token -// has long since lapsed. -// -// Returns true on a successful CurrentUser() call. Any error — 401, -// network, timeout — returns false, prompting the resolver to try the -// next path. -// -// 2s budget is generous enough for a transcontinental round-trip and -// strict enough that an unreachable api.github.com doesn't block the -// TUI cold-start. -// -// Swappable via the package-level `oauthProbe` variable for tests. func oauthProbeNetwork(c Client) bool { done := make(chan bool, 1) go func() { @@ -119,9 +53,6 @@ func oauthProbeNetwork(c Client) bool { } } -// loadOAuthToken, ghAuthStatus, and oauthProbe are package-level -// variables so tests can swap the environment probes without touching -// real auth state. Production defaults below. var ( loadOAuthToken = func() (string, error) { token, err := auth.LoadToken() @@ -132,21 +63,14 @@ var ( } ghAuthStatus = func() bool { - // `gh auth status` exits 0 when any host has a valid token. - // We don't parse the output — exit code is the contract. + cmd := exec.Command("gh", "auth", "status") return cmd.Run() == nil } - // oauthProbe validates a freshly-built httpClient by hitting /user - // once. Swappable for tests that don't want to touch the network. oauthProbe = oauthProbeNetwork ) -// clientProvider adapts any Client (httpClient or ghClient today; more -// in the future) into a Provider. The logic here is identical to what -// FetchAll does today — we moved it behind the interface so callers -// don't have to care which backend is in use. type clientProvider struct { client Client name string @@ -162,22 +86,11 @@ func (p *clientProvider) SuggestRepos(ctx context.Context, limit int) ([]Repo, e if err != nil { return nil, err } - // Persist the full result (pre-limit) so future requests for any - // cap can be served from cache. Save errors are non-fatal — log - // would be nice but the package has no logger; silent fallback - // matches the rest of the cache layer. + _ = SaveCache(repos) return applyLimit(repos, limit), nil } -// freshCache returns the most-recent cached suggestion list when one -// exists and is still inside cacheTTL. Cache misses, expired entries, -// and read errors all return ok=false; callers fall through to a -// live fetch. -// -// Cache hit short-circuit serves repeated `ws add` invocations on -// large accounts (paginated GitHub fetches take 5-10s) without -// network roundtrips. func freshCache(limit int) ([]Repo, bool) { cached, age, err := LoadCache() if err != nil || len(cached) == 0 || age >= cacheTTL { @@ -186,10 +99,6 @@ func freshCache(limit int) ([]Repo, bool) { return applyLimit(cached, limit), true } -// fetchAllRanked performs the live live-fetch + activity-merge + -// activity-sort sequence. The two ctx.Err() checks bracket the -// CurrentUser and FetchRepos calls — Client methods predate ctx -// plumbing, so those are the natural cancellation boundaries. func (p *clientProvider) fetchAllRanked(ctx context.Context) ([]Repo, error) { if err := ctx.Err(); err != nil { return nil, err @@ -205,8 +114,7 @@ func (p *clientProvider) fetchAllRanked(ctx context.Context) ([]Repo, error) { if err != nil { return nil, fmt.Errorf("%s: %w", p.name, err) } - // Activity fetch is best-effort — sort falls back to PushedAt - // alone when it fails. Matches the legacy FetchAll behavior. + activity, _ := p.client.FetchActivity(username) for i := range repos { repos[i].Activity = activity[repos[i].FullName] @@ -215,10 +123,6 @@ func (p *clientProvider) fetchAllRanked(ctx context.Context) ([]Repo, error) { return repos, nil } -// sortByActivityThenPushed orders repos by Activity descending, -// breaking ties by PushedAt descending. The two-key compare lives -// in one place so both the live and (in tests) cache-build paths -// agree on ordering. func sortByActivityThenPushed(repos []Repo) { sort.SliceStable(repos, func(i, j int) bool { if repos[i].Activity != repos[j].Activity { @@ -228,13 +132,8 @@ func sortByActivityThenPushed(repos []Repo) { }) } -// applyLimit caps the returned slice at `limit` entries when limit > 0. -// Used at both cache-read and live-fetch return paths so the public -// surface honors the cap regardless of the source. func applyLimit(repos []Repo, limit int) []Repo { if limit > 0 && len(repos) > limit { - // Defensive copy: callers may not expect the slice to alias - // the cache's underlying array. out := make([]Repo, limit) copy(out, repos[:limit]) return out @@ -242,8 +141,6 @@ func applyLimit(repos []Repo, limit int) []Repo { return repos } -// noopProvider is the terminal "GitHub unavailable" fallback. Name is -// "noop" so callers can test for it without importing sentinel values. type noopProvider struct{} func (noopProvider) Name() string { return "noop" } diff --git a/internal/github/resolve.go b/internal/github/resolve.go index fcd4369..06ed9fa 100644 --- a/internal/github/resolve.go +++ b/internal/github/resolve.go @@ -6,11 +6,6 @@ import ( "github.com/kuchmenko/workspace/internal/auth" ) -// ResolveClient returns a GitHub API client backed by the ws OAuth -// token. The ws CLI has its own GitHub OAuth App (see -// internal/auth/device_flow.go) and that is the single source of -// truth for authentication — there is no fallback to `gh` CLI. If -// you need to (re)authenticate, run `ws auth login`. func ResolveClient() (Client, error) { token, err := auth.LoadToken() if err == nil && token.AccessToken != "" { diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 586752d..3a0693a 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -22,57 +22,21 @@ import ( "strings" ) -// BarePath returns the absolute path to the bare repo for a project whose -// main worktree lives at mainWorktree. The bare repo is a sibling with a -// `.bare` suffix on the basename. func BarePath(mainWorktree string) string { return mainWorktree + ".bare" } -// WorktreeDirName builds the filesystem-safe directory name for an extra -// worktree of the given project. The directory lives as a sibling of the -// main worktree. -// -// Example: project "myapp", machine "asahi", topic "auth/refactor" → -// -// "myapp-wt-asahi-auth-refactor" -// -// Slashes in the topic are flattened to dashes so the result is a single -// path segment that can sit next to "myapp" and "myapp.bare". func WorktreeDirName(projectBaseName, machine, topic string) string { safeTopic := strings.ReplaceAll(topic, "/", "-") return projectBaseName + "-wt-" + machine + "-" + safeTopic } -// WorktreePath returns the absolute path of an extra worktree given the -// main worktree path and the worktree's machine + topic. This is the -// raw-name variant; for branches that may slug-collide with another -// branch already present in the parent dir, use WorktreePathForBranch -// which picks a deterministic suffix. func WorktreePath(mainWorktree, machine, topic string) string { dir := filepath.Dir(mainWorktree) base := filepath.Base(mainWorktree) return filepath.Join(dir, WorktreeDirName(base, machine, topic)) } -// WorktreePathForBranch returns the absolute worktree path for `branch` -// on `machine`, choosing a directory name that does not collide with -// any pre-existing entry in the parent of `mainWorktree`. -// -// Two distinct branches whose slugs collide (`feat/foo-bar` and -// `feat/foo/bar` both flatten to `feat-foo-bar`) would otherwise share -// the same directory name. The resolution is to append `-` from -// SHA-1(branch) when the unsuffixed candidate already exists. The hash -// is deterministic per branch, so two machines independently adding -// the same branch land on the same path even when each machine sees a -// different "first claimant" in its local filesystem. -// -// The first claimant on a given machine gets the unsuffixed path; every -// subsequent slug-colliding branch on the same machine receives the -// hash suffix. Order of arrival across machines is not coordinated, so -// two machines that add the same pair of slug-colliding branches in -// opposite orders will end up with mirror-image directory names — the -// cross-machine guarantee is per-branch identity, not per-pair-order. func WorktreePathForBranch(mainWorktree, machine, branch string) string { dir := filepath.Dir(mainWorktree) base := filepath.Base(mainWorktree) @@ -82,24 +46,14 @@ func WorktreePathForBranch(mainWorktree, machine, branch string) string { return candidate } sum := sha1.Sum([]byte(branch)) - suffix := hex.EncodeToString(sum[:4]) // 8 hex chars + suffix := hex.EncodeToString(sum[:4]) return filepath.Join(dir, name+"-"+suffix) } -// BranchName builds the canonical wt// branch name. -// Used by `ws migrate` for migration-internal WIP/stash/detached branches -// that go straight into the bare repo and never face the daemon's push -// path. New worktrees no longer use this naming scheme — `ws worktree -// add` accepts a literal branch name from the user. func BranchName(machine, topic string) string { return "wt/" + machine + "/" + topic } -// SlugifyBranch converts a branch name to a filesystem-safe directory -// component: slashes → dashes, strip leading/trailing dashes. -// -// "feat/buddy" → "feat-buddy" -// "fix/amm-prices-chunking" → "fix-amm-prices-chunking" func SlugifyBranch(branch string) string { s := strings.ReplaceAll(branch, "/", "-") s = strings.Trim(s, "-") diff --git a/internal/migrate/check.go b/internal/migrate/check.go index 6c326c0..eede4ba 100644 --- a/internal/migrate/check.go +++ b/internal/migrate/check.go @@ -9,11 +9,9 @@ import ( "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" + State string MainPath string BarePath string HasStash bool @@ -23,8 +21,6 @@ type CheckResult struct { 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) diff --git a/internal/migrate/git_helpers.go b/internal/migrate/git_helpers.go index cfddc64..5ce316f 100644 --- a/internal/migrate/git_helpers.go +++ b/internal/migrate/git_helpers.go @@ -6,11 +6,6 @@ import ( "strings" ) -// runGit runs a git command in repoPath and discards stdout. The migration -// step uses this for state-mutating commands (checkout, add, commit) that -// don't already have a wrapper in internal/git — the goal is to keep -// internal/git focused on stable, reusable wrappers and let one-shot -// migration plumbing live here. func runGit(repoPath string, args ...string) error { full := append([]string{"-C", repoPath}, args...) cmd := exec.Command("git", full...) diff --git a/internal/migrate/hooks.go b/internal/migrate/hooks.go index e001458..0ea0295 100644 --- a/internal/migrate/hooks.go +++ b/internal/migrate/hooks.go @@ -8,8 +8,6 @@ import ( "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 { @@ -39,8 +37,6 @@ func listActiveHooks(dir string) ([]string, error) { 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 diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index 7d0f23b..6562a0e 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -20,32 +20,17 @@ import ( "github.com/kuchmenko/workspace/internal/layout" ) -// Options controls a migration run. type Options struct { - // WIP: when true, dirty working trees are auto-committed to a - // wt//migration-wip- branch instead of aborting. WIP bool - // StashBranch: when true, stash entries are converted into - // wt//migration-stash--N branches via `git stash branch` - // before the bare clone, so they survive into the new layout. Without - // this flag, the presence of any stash entries aborts the migration - // (stash refs are not copied by `clone --bare`). + StashBranch bool - // CheckoutDefault: when true, a project that is in detached HEAD has - // its current commit preserved into a wt//migration-detached- - // branch (only if it isn't already reachable from a local branch), - // then the working copy switches to default_branch and migration - // proceeds. Without this flag, detached HEAD aborts the migration. + CheckoutDefault bool - // Machine is the sanitized machine name for branch namespacing. Required - // when WIP, StashBranch, or CheckoutDefault is true. + Machine string - // PromptDefaultBranch is invoked when the project's default branch can - // not be determined automatically. Implementations should pick from - // `candidates` (which may be empty) or return a free-form branch name. - // Returning an error aborts the migration. + PromptDefaultBranch func(project string, candidates []string) (string, error) - // Logf is the structured progress sink. nil means silent. + Logf func(format string, args ...interface{}) } @@ -55,33 +40,25 @@ func (o Options) logf(format string, args ...interface{}) { } } -// Result describes the outcome of migrating one project. type Result struct { Project string BarePath string MainWorktree string DefaultBranch string HooksMigrated []string - WIPBranch string // non-empty when --wip created a snapshot branch - WIPWorktree string // non-empty when --wip created an extra worktree - StashBranches []string // wt//migration-stash-* branches created from stashes - DetachedBranch string // wt//migration-detached-* preserving orphaned commits - BranchesPushed int // count of local branches preserved into bare + WIPBranch string + WIPWorktree string + StashBranches []string + DetachedBranch string + BranchesPushed int } -// ErrAlreadyMigrated is returned when the project already has a sibling .bare -// directory. Callers (notably MigrateAll) should treat this as a skip, not -// a hard error. var ErrAlreadyMigrated = errors.New("project already migrated") -// 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. func MigrateProject(wsRoot string, name string, proj *config.Project, opts Options) (*Result, error) { mainPath := filepath.Join(wsRoot, proj.Path) barePath := layout.BarePath(mainPath) - // Step 1: validate if _, err := os.Stat(barePath); err == nil { return nil, ErrAlreadyMigrated } @@ -97,22 +74,17 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio opts.logf("migrate %s: starting at %s", name, mainPath) - // Step 2: determine default_branch defaultBranch, err := resolveDefaultBranch(name, proj, mainPath, opts) if err != nil { return nil, err } opts.logf("migrate %s: default branch = %s", name, defaultBranch) - // Step 3: pre-flight — handle detached HEAD, stash, dirty in this order. - // Order matters: detached → checkout → stash → dirty. Each conversion - // creates a side branch that becomes part of the bare clone. ts := time.Now().Unix() originalBranch, _ := git.CurrentBranch(mainPath) detachedBranch := "" if originalBranch == "" { - // Detached HEAD. Either preserve and check out default_branch, or abort. if !opts.CheckoutDefault { return nil, fmt.Errorf("%s is in detached HEAD; check out a branch first or re-run with the interactive TUI", name) } @@ -120,9 +92,7 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("detached-HEAD recovery requires a configured machine name") } head := git.RevParse(mainPath, "HEAD") - // If the current commit is reachable from any local branch, we can - // safely walk away from it. Otherwise, snapshot it onto a side - // branch so the bare clone picks it up. + reachable, _ := commitReachableFromAnyBranch(mainPath, head) if !reachable { topic := fmt.Sprintf("migration-detached-%d", ts) @@ -141,7 +111,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio originalBranch = defaultBranch } - // Stash entries: convert to side branches via `git stash branch`, or abort. stashCount := git.StashCount(mainPath) stashBranches := []string{} if stashCount > 0 { @@ -151,9 +120,7 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio if opts.Machine == "" { return nil, fmt.Errorf("stash-to-branch requires a configured machine name") } - // `git stash branch ` always pops the most recent (stash@{0}), - // so we walk N times. Each call leaves us on the new branch with the - // stash applied — we commit it and then checkout originalBranch again. + for i := 0; i < stashCount; i++ { topic := fmt.Sprintf("migration-stash-%d-%d", ts, i) br := layout.BranchName(opts.Machine, topic) @@ -161,9 +128,7 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio if err := runGit(mainPath, "stash", "branch", br); err != nil { return nil, fmt.Errorf("stash branch %s: %w", br, err) } - // stash branch leaves the worktree dirty with the popped index - // staged. Commit it so the branch carries real history rather - // than just a checkout. + if err := runGit(mainPath, "add", "-A"); err != nil { return nil, fmt.Errorf("stage stash branch %s: %w", br, err) } @@ -171,14 +136,13 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("commit stash branch %s: %w", br, err) } stashBranches = append(stashBranches, br) - // Return to original branch before processing the next stash. + if err := runGit(mainPath, "checkout", originalBranch); err != nil { return nil, fmt.Errorf("restore %s after stash branch: %w", originalBranch, err) } } } - // Dirty working tree: snapshot to WIP branch or abort. dirty := git.IsDirty(mainPath) wipBranch := "" wipTopic := "" @@ -201,15 +165,12 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio if err := runGit(mainPath, "commit", "-m", "ws: migration WIP snapshot"); err != nil { return nil, fmt.Errorf("commit WIP: %w", err) } - // Return main worktree to the original branch so the post-migration - // state matches what the user expects. The WIP commit lives only on - // the WIP branch, which becomes a sibling worktree below. + if err := runGit(mainPath, "checkout", originalBranch); err != nil { return nil, fmt.Errorf("restore original branch %s: %w", originalBranch, err) } } - // Step 4: capture state currentBranch := originalBranch localBranches, err := git.Branches(mainPath) if err != nil { @@ -220,23 +181,17 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("could not resolve HEAD in %s", mainPath) } - // Step 5: detect hooks (pre-bare so we read from the original .git) hooksDir := filepath.Join(mainPath, ".git", "hooks") activeHooks, _ := listActiveHooks(hooksDir) if len(activeHooks) > 0 { opts.logf("migrate %s: found %d active hook(s): %s", name, len(activeHooks), strings.Join(activeHooks, ", ")) } - // Step 6: clone --bare --no-local into sibling opts.logf("migrate %s: cloning bare → %s", name, barePath) if err := git.CloneBareLocal(mainPath, barePath); err != nil { return nil, err } - // Step 7: ensure all local branches exist in bare. clone --bare copies - // HEAD's branch and everything reachable via refs, but a branch with no - // upstream and no other refs pointing at it can theoretically be missed - // in pathological cases. Belt and suspenders. for _, b := range localBranches { if git.HasBranch(barePath, b) { continue @@ -248,11 +203,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio } } - // Step 8: point bare at the actual remote (clone --no-local set origin - // to mainPath, which is about to disappear), install the standard - // fetch refspec (clone --bare omits it — without it fetch only updates - // FETCH_HEAD and branch@{u} can never resolve), then fetch so - // refs/remotes/origin/* is populated from the real remote. if proj.Remote != "" { if err := git.SetRemoteURL(barePath, proj.Remote); err != nil { rollbackBare(barePath) @@ -263,84 +213,35 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("set fetch refspec: %w", err) } if err := git.Fetch(barePath); err != nil { - // Network failure here is recoverable — we still have a valid - // bare with all local objects. Just log and continue. opts.logf("migrate %s: warning: initial fetch failed: %v", name, err) } - // best-effort: pin origin/HEAD to default_branch + _ = git.SetRemoteHead(barePath, defaultBranch) } - // Step 9: upstream tracking for the default branch so plain `git push` - // and `git pull` work in the main worktree without arguments. - // SetBranchUpstream writes branch..{remote,merge} via - // `git config`, which works even if the Step-8 fetch failed (offline - // migration) or hasn't populated refs/remotes/origin/ yet. - // We don't restore upstream for every local branch — the reconciler - // only pushes wt//* and ordinary `git pull` resolves upstream - // lazily. Best-effort: a failure here doesn't abort migration. if err := git.SetBranchUpstream(barePath, defaultBranch, "origin"); err != nil { opts.logf("migrate %s: warning: could not set upstream for %s: %v", name, defaultBranch, err) } - // Step 10: migrate hooks migratedHooks, err := copyHooks(hooksDir, filepath.Join(barePath, "hooks"), activeHooks) if err != nil { opts.logf("migrate %s: warning: hook migration partial: %v", name, err) } - // Step 11: replace the working dir's .git with a worktree pointer. - // - // `git worktree add --force ` does NOT - // work — modern git refuses to attach a worktree to a directory that - // already has files, regardless of --force. (--force only relaxes the - // "branch already checked out elsewhere" and "registered worktree - // missing" checks.) - // - // Working strategy: - // 1. Move existing .git aside to .git.migrating- (recoverable). - // 2. Create the worktree at a sibling tmp path under a hidden parent - // dir, with --no-checkout. The worktree's basename matches - // mainPath's basename so git's admin dir at /worktrees/ - // gets a clean name (not ".wt-tmp" or similar). git - // materializes the .git pointer file there but writes no - // working-tree files (and, importantly for step 6, populates no - // index entries either — see below). - // 3. Move that .git pointer file from the tmp dir into mainPath - // (which still contains the user's untouched files). - // 4. Remove the now-empty tmp dir AND its hidden parent. - // 5. `git worktree repair` so the bare repo's worktrees//gitdir - // points at mainPath instead of the tmp location. - // 6. `git reset --mixed HEAD` to populate the index from HEAD. - // `--no-checkout` leaves the index EMPTY (it only sets up the - // worktree's HEAD pointer). Without this step `git status` shows - // every file as both "deleted in index" and "untracked", which - // is technically a working repo but completely broken UX. - // 7. Verify HEAD didn't shift and the worktree is clean. - // - // On any failure between steps 2–6 we restore the original .git from - // .git.migrating- and tear down the bare. Step 7 is the last point - // where a rollback is feasible. movedGit := filepath.Join(mainPath, fmt.Sprintf(".git.migrating-%d", ts)) if err := os.Rename(filepath.Join(mainPath, ".git"), movedGit); err != nil { rollbackBare(barePath) return nil, fmt.Errorf("move .git aside: %w", err) } - // Helper closure: rollback the .git move and clean up the bare. Used - // from every failure branch below. restore := func() { _ = os.Rename(movedGit, filepath.Join(mainPath, ".git")) rollbackBare(barePath) } - // Sibling hidden parent dir. The tmp worktree lives inside it with the - // SAME basename as mainPath, so git's admin dir at - // /worktrees/ gets a sensible name. We rm -rf the - // parent at the end, so the user never sees this dir. tmpParent := filepath.Join(filepath.Dir(mainPath), fmt.Sprintf(".ws-migrate-%d", ts)) tmpWT := filepath.Join(tmpParent, filepath.Base(mainPath)) - // Defensive: stale dir from a previous crashed run. + _ = os.RemoveAll(tmpParent) if err := os.MkdirAll(tmpParent, 0o755); err != nil { restore() @@ -353,9 +254,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("create tmp worktree: %w", err) } - // Move the .git pointer file (a regular file containing `gitdir: ...`, - // not a directory) from the tmp dir into mainPath. The user's working - // tree files in mainPath are untouched. tmpDotGit := filepath.Join(tmpWT, ".git") if err := os.Rename(tmpDotGit, filepath.Join(mainPath, ".git")); err != nil { _ = os.RemoveAll(tmpParent) @@ -363,33 +261,22 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("move .git pointer from tmp: %w", err) } - // Tmp parent should be empty (--no-checkout wrote nothing else). if err := os.RemoveAll(tmpParent); err != nil { opts.logf("migrate %s: warning: could not remove %s: %v", name, tmpParent, err) } - // Tell git to update the worktrees admin dir so gitdir → mainPath, not - // the now-removed tmp path. if err := git.WorktreeRepair(mainPath); err != nil { _ = os.RemoveAll(filepath.Join(mainPath, ".git")) restore() return nil, fmt.Errorf("worktree repair: %w", err) } - // Populate the index from HEAD. `git worktree add --no-checkout` - // creates the worktree with HEAD set but the index EMPTY — without - // this `git status` would show every tracked file as both - // "deleted in index" and "untracked on disk". `reset --mixed HEAD` - // reads HEAD into the index without touching the working tree, which - // is exactly what we need: the existing files match HEAD, so after - // the index is populated, status reports clean. if err := runGit(mainPath, "reset", "--mixed", "HEAD"); err != nil { _ = os.RemoveAll(filepath.Join(mainPath, ".git")) restore() return nil, fmt.Errorf("populate index from HEAD: %w", err) } - // Verify the new worktree is functional and HEAD didn't shift. if !git.IsRepo(mainPath) { _ = os.RemoveAll(filepath.Join(mainPath, ".git")) restore() @@ -401,13 +288,10 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio return nil, fmt.Errorf("worktree verification failed: HEAD shifted from %s to %s", originalHead, newHead) } - // Verification passed — irreversible step. if err := os.RemoveAll(movedGit); err != nil { opts.logf("migrate %s: warning: could not remove %s: %v", name, movedGit, err) } - // Step 12: if WIP was created, attach it as a sibling worktree so the - // user can find their snapshot. wipWorktree := "" if wipBranch != "" { wipWorktree = layout.WorktreePath(mainPath, opts.Machine, wipTopic) @@ -417,7 +301,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio } } - // Mutate proj so the caller can persist default_branch. proj.DefaultBranch = defaultBranch opts.logf("migrate %s: done", name) @@ -436,7 +319,6 @@ func MigrateProject(wsRoot string, name string, proj *config.Project, opts Optio }, nil } -// 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 index 1a33da1..4d8dfbb 100644 --- a/internal/migrate/resolve.go +++ b/internal/migrate/resolve.go @@ -8,10 +8,6 @@ import ( "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 @@ -28,20 +24,17 @@ func commitReachableFromAnyBranch(repoPath, sha string) (bool, error) { 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) { diff --git a/internal/migrate/sidecar.go b/internal/migrate/sidecar.go index 91f05d0..edc1d7b 100644 --- a/internal/migrate/sidecar.go +++ b/internal/migrate/sidecar.go @@ -1,11 +1,3 @@ -// Sidecar bridge for the migrate command. The generic file/lock/pid -// machinery lives in internal/sidecar; this file only defines the -// command-specific value type and a thin facade over it. -// -// While the sidecar exists with a live pid the daemon skips its tick for -// the workspace, preventing daemon/migrate races on git operations and -// half-migrated state being pushed upstream. See internal/sidecar for the -// shared mechanics. package migrate import ( @@ -14,28 +6,19 @@ import ( "github.com/kuchmenko/workspace/internal/sidecar" ) -// DoneEntry captures one project that finished migrating. Recorded so a -// crashed mid-batch migrate can resume from where it left off without -// re-doing already-converted projects. type DoneEntry struct { DefaultBranch string `json:"default_branch"` MigratedAt time.Time `json:"migrated_at"` } -// Sidecar is a migrate-shaped view over a generic sidecar.Sidecar. type Sidecar struct { *sidecar.Sidecar } -// New creates a fresh migrate sidecar bound to wsRoot, owned by the -// current process. Save() must be called before any project is migrated — -// the lock isn't real until the file exists on disk. func New(wsRoot string) *Sidecar { return &Sidecar{Sidecar: sidecar.New(wsRoot, sidecar.KindMigrate)} } -// Load reads an existing migrate sidecar for wsRoot. Returns (nil, nil) -// if no sidecar exists, which is the common case. func Load(wsRoot string) (*Sidecar, error) { sc, err := sidecar.Load(wsRoot, sidecar.KindMigrate) if err != nil || sc == nil { @@ -44,7 +27,6 @@ func Load(wsRoot string) (*Sidecar, error) { return &Sidecar{Sidecar: sc}, nil } -// Save writes the migrate sidecar atomically. func Save(sc *Sidecar) error { if sc == nil { return nil @@ -52,12 +34,10 @@ func Save(sc *Sidecar) error { return sidecar.Save(sc.Sidecar) } -// Delete removes the migrate sidecar for wsRoot. func Delete(wsRoot string) error { return sidecar.Delete(wsRoot, sidecar.KindMigrate) } -// IsAlive reports whether the migrate recorded in sc is still running. func IsAlive(sc *Sidecar) bool { if sc == nil { return false @@ -65,8 +45,6 @@ func IsAlive(sc *Sidecar) bool { return sidecar.IsAlive(sc.Sidecar) } -// MarkDone records a successful migration of name with its resolved -// default_branch. func (s *Sidecar) MarkDone(name, defaultBranch string) error { return s.Set(name, DoneEntry{ DefaultBranch: defaultBranch, @@ -74,7 +52,6 @@ func (s *Sidecar) MarkDone(name, defaultBranch string) error { }) } -// DoneEntries returns the per-project results recorded so far, decoded. func (s *Sidecar) DoneEntries() (map[string]DoneEntry, error) { out := make(map[string]DoneEntry, len(s.Done)) for name := range s.Done { diff --git a/internal/setup/setup.go b/internal/setup/setup.go index afa1c16..94a5c86 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -18,7 +18,6 @@ const ( stepConfirm ) -// Result holds the final output of the setup wizard. type Result struct { Confirmed bool Canceled bool @@ -32,7 +31,6 @@ type GroupEntry struct { Repos []gh.Repo } -// fetchDoneMsg is sent when GitHub data is fetched. type fetchDoneMsg struct { repos []gh.Repo username string @@ -47,7 +45,7 @@ type Model struct { err error result Result username string - stepChangedAt time.Time // debounce key events on step transitions + stepChangedAt time.Time selectModel selectModel groupModel groupModel @@ -88,7 +86,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - // Ignore key events within 100ms of a step transition to prevent phantom inputs + if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { return m, nil } @@ -169,7 +167,7 @@ func (m Model) updateGroup(msg tea.Msg) (tea.Model, tea.Cmd) { m.groupModel.editing = false return m, nil } - // Go back to select + m.step = stepSelect m.stepChangedAt = time.Now() return m, m.selectModel.search.Focus() @@ -220,12 +218,10 @@ func (m Model) View() string { return "" } -// GetResult returns the final result after the program exits. func (m Model) GetResult() Result { return m.result } -// Styles var ( titleStyle = lipgloss.NewStyle(). Bold(true). diff --git a/internal/setup/step_group.go b/internal/setup/step_group.go index ae4e342..b1f3589 100644 --- a/internal/setup/step_group.go +++ b/internal/setup/step_group.go @@ -11,20 +11,19 @@ import ( type groupModel struct { groups []GroupEntry - cursor int // group index - repoCursor int // repo index within group (-1 = group header) + cursor int + repoCursor int editing bool editInput textinput.Model - moving bool // moving a repo - moveFrom int // source group index - moveRepoIdx int // source repo index + moving bool + moveFrom int + moveRepoIdx int width int height int username string } func newGroupModel(repos []gh.Repo, username string, w, h int) groupModel { - // Auto-group by owner groupMap := make(map[string][]gh.Repo) var order []string @@ -63,12 +62,11 @@ func newGroupModel(repos []gh.Repo, username string, w, h int) groupModel { func (m groupModel) totalItems() int { n := 0 for _, g := range m.groups { - n += 1 + len(g.Repos) // header + repos + n += 1 + len(g.Repos) } return n } -// flatIndex returns (groupIdx, repoIdx) where repoIdx=-1 means the group header. func (m groupModel) flatToGroupRepo(flat int) (int, int) { pos := 0 for gi, g := range m.groups { @@ -153,7 +151,7 @@ func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { } case "r": - // Rename current group + if m.repoCursor == -1 && m.cursor < len(m.groups) { m.editing = true m.editInput.SetValue(m.groups[m.cursor].Name) @@ -162,7 +160,7 @@ func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { } case "m": - // Move current repo to another group + if m.repoCursor >= 0 { m.moving = true m.moveFrom = m.cursor @@ -170,7 +168,7 @@ func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { } case "n": - // New empty group + m.groups = append(m.groups, GroupEntry{Name: "new-group"}) m.cursor = len(m.groups) - 1 m.repoCursor = -1 @@ -220,7 +218,6 @@ func (m groupModel) updateMoving(msg tea.Msg) (groupModel, tea.Cmd) { } case "enter": if m.cursor != m.moveFrom { - // Move repo repo := m.groups[m.moveFrom].Repos[m.moveRepoIdx] m.groups[m.moveFrom].Repos = append( m.groups[m.moveFrom].Repos[:m.moveRepoIdx], @@ -228,7 +225,6 @@ func (m groupModel) updateMoving(msg tea.Msg) (groupModel, tea.Cmd) { ) m.groups[m.cursor].Repos = append(m.groups[m.cursor].Repos, repo) - // Remove empty groups if len(m.groups[m.moveFrom].Repos) == 0 { m.groups = append(m.groups[:m.moveFrom], m.groups[m.moveFrom+1:]...) if m.cursor > m.moveFrom { diff --git a/internal/setup/step_select.go b/internal/setup/step_select.go index 1a4cca7..0a9249a 100644 --- a/internal/setup/step_select.go +++ b/internal/setup/step_select.go @@ -39,7 +39,7 @@ type repoItem struct { type selectModel struct { all []repoItem orgs []string - orgFilter int // 0 = all, 1+ = specific org + orgFilter int sortBy sortMode cursor int offset int @@ -89,7 +89,6 @@ func (m selectModel) filtered() []int { indices = append(indices, i) } - // Sort sort.SliceStable(indices, func(a, b int) bool { ra := m.all[indices[a]].repo rb := m.all[indices[b]].repo @@ -98,7 +97,7 @@ func (m selectModel) filtered() []int { return ra.FullName < rb.FullName case sortPushed: return ra.PushedAt.After(rb.PushedAt) - default: // sortActivity + default: if ra.Activity != rb.Activity { return ra.Activity > rb.Activity } @@ -167,7 +166,7 @@ func (m selectModel) update(msg tea.Msg) (selectModel, tea.Cmd) { return m, nil case "ctrl+a": - // Toggle all visible + allChecked := true for _, idx := range filtered { if !m.all[idx].checked { @@ -188,7 +187,6 @@ func (m selectModel) update(msg tea.Msg) (selectModel, tea.Cmd) { } } - // Pass to text input var cmd tea.Cmd prevVal := m.search.Value() m.search, cmd = m.search.Update(msg) @@ -200,7 +198,6 @@ func (m selectModel) update(msg tea.Msg) (selectModel, tea.Cmd) { } func (m selectModel) maxVisible() int { - // header(3) + search(1) + orgs(1) + blank(1) + help(2) + status(1) = 9 h := m.height - 9 if h < 5 { h = 5 @@ -211,17 +208,14 @@ func (m selectModel) maxVisible() int { func (m selectModel) view() string { var b strings.Builder - // Title b.WriteString(titleStyle.Render(" ws setup ")) b.WriteString(" Select repos\n\n") - // Search b.WriteString(" " + m.search.View() + " ") b.WriteString(dimStyle.Render("sort: ") + selectedStyle.Render(m.sortBy.String())) b.WriteString(dimStyle.Render(" (ctrl+s)")) b.WriteString("\n") - // Org tabs b.WriteString(" ") if m.orgFilter == 0 { b.WriteString(activeTabStyle.Render("all")) @@ -239,7 +233,6 @@ func (m selectModel) view() string { b.WriteString(dimStyle.Render(" (tab)")) b.WriteString("\n\n") - // List filtered := m.filtered() maxVisible := m.maxVisible() @@ -285,7 +278,6 @@ func (m selectModel) view() string { dimStyle.Render(pushed), activity) } - // Scrollbar hint if len(filtered) > maxVisible { above := m.offset below := len(filtered) - end @@ -297,7 +289,6 @@ func (m selectModel) view() string { } } - // Footer b.WriteString("\n") selCount := m.selectedCount() fmt.Fprintf(&b, " Selected: %s / %d", diff --git a/internal/sidecar/sidecar.go b/internal/sidecar/sidecar.go index c0971b5..0e7a2a8 100644 --- a/internal/sidecar/sidecar.go +++ b/internal/sidecar/sidecar.go @@ -41,8 +41,6 @@ import ( "github.com/BurntSushi/toml" ) -// Kind identifies which command owns the sidecar. Used as a subdirectory -// under the state dir so different sidecars don't collide on filename. type Kind string const ( @@ -52,9 +50,6 @@ const ( KindCreate Kind = "create" ) -// Meta is the common header every sidecar file carries. It records who -// created the sidecar and when, plus the workspace it belongs to so we can -// find it again from the wsRoot alone. type Meta struct { PID int `toml:"pid"` Started time.Time `toml:"started"` @@ -62,17 +57,11 @@ type Meta struct { Kind Kind `toml:"kind"` } -// Sidecar is the on-disk envelope. Done holds command-specific per-project -// entries as raw bytes; callers (un)marshal their own value type via the -// Get/Set helpers below. type Sidecar struct { Meta Meta `toml:"meta"` Done map[string]json.RawMessage `toml:"done"` } -// New constructs a fresh sidecar bound to wsRoot, marked as owned by the -// current process. The caller is expected to Save() it before starting -// work — the lock isn't real until the file exists on disk. func New(wsRoot string, kind Kind) *Sidecar { abs, _ := filepath.Abs(wsRoot) return &Sidecar{ @@ -86,8 +75,6 @@ func New(wsRoot string, kind Kind) *Sidecar { } } -// Set marshals v as JSON and stores it under the project name. Each command -// defines its own DoneEntry-type and round-trips it through Set/Get. func (s *Sidecar) Set(name string, v interface{}) error { if s.Done == nil { s.Done = make(map[string]json.RawMessage) @@ -100,7 +87,6 @@ func (s *Sidecar) Set(name string, v interface{}) error { return nil } -// Get unmarshals the entry for name into v. Returns false if no such entry. func (s *Sidecar) Get(name string, v interface{}) (bool, error) { raw, ok := s.Done[name] if !ok { @@ -112,14 +98,11 @@ func (s *Sidecar) Get(name string, v interface{}) (bool, error) { return true, nil } -// Has reports whether name has a recorded entry. func (s *Sidecar) Has(name string) bool { _, ok := s.Done[name] return ok } -// stateDir returns the directory containing all sidecar files for kind. -// Honors $XDG_STATE_HOME, falls back to ~/.local/state/ws/. func stateDir(kind Kind) (string, error) { if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { return filepath.Join(xdg, "ws", string(kind)), nil @@ -131,8 +114,6 @@ func stateDir(kind Kind) (string, error) { return filepath.Join(home, ".local", "state", "ws", string(kind)), nil } -// hashWorkspace produces a stable, filesystem-safe identifier for a -// workspace root. Two distinct absolute paths can never collide on filename. func hashWorkspace(wsRoot string) string { abs, err := filepath.Abs(wsRoot) if err != nil { @@ -142,8 +123,6 @@ func hashWorkspace(wsRoot string) string { return hex.EncodeToString(sum[:])[:16] } -// Path returns the absolute filesystem location of the sidecar for -// (wsRoot, kind). Does not check whether the file exists. func Path(wsRoot string, kind Kind) (string, error) { dir, err := stateDir(kind) if err != nil { @@ -152,9 +131,6 @@ func Path(wsRoot string, kind Kind) (string, error) { return filepath.Join(dir, hashWorkspace(wsRoot)+".toml"), nil } -// Load reads the sidecar for (wsRoot, kind). Returns (nil, nil) if no -// sidecar exists — the common case, since most ticks have no command in -// progress. func Load(wsRoot string, kind Kind) (*Sidecar, error) { p, err := Path(wsRoot, kind) if err != nil { @@ -180,9 +156,6 @@ func Load(wsRoot string, kind Kind) (*Sidecar, error) { return &sc, nil } -// Save writes the sidecar atomically (tmp file + rename). Filename is -// derived from sc.Meta.WorkspaceRoot + sc.Meta.Kind, so the caller doesn't -// pass them again. func Save(sc *Sidecar) error { if err := validateSidecarForSave(sc); err != nil { return err @@ -200,9 +173,6 @@ func Save(sc *Sidecar) error { return atomicWriteSidecar(sc, p) } -// validateSidecarForSave enforces the non-nil + non-empty required -// fields before any IO happens. Each branch returns a distinct error -// so callers can tell what they forgot to set. func validateSidecarForSave(sc *Sidecar) error { if sc == nil { return errors.New("save nil sidecar") @@ -216,16 +186,13 @@ func validateSidecarForSave(sc *Sidecar) error { return nil } -// atomicWriteSidecar encodes `sc` into a sibling tmp file and renames -// it to `dest`. The tmp file is deleted on every failure path so a -// crashed write never leaves a half-written file behind. func atomicWriteSidecar(sc *Sidecar, dest string) error { tmp, err := os.CreateTemp(filepath.Dir(dest), "."+string(sc.Meta.Kind)+"-*.tmp") if err != nil { return fmt.Errorf("create tmp: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) // best-effort cleanup if rename never happens + defer os.Remove(tmpName) if err := toml.NewEncoder(tmp).Encode(sc); err != nil { tmp.Close() return fmt.Errorf("encode sidecar: %w", err) @@ -239,7 +206,6 @@ func atomicWriteSidecar(sc *Sidecar, dest string) error { return nil } -// Delete removes the sidecar for (wsRoot, kind). No-op if it doesn't exist. func Delete(wsRoot string, kind Kind) error { p, err := Path(wsRoot, kind) if err != nil { @@ -251,15 +217,6 @@ func Delete(wsRoot string, kind Kind) error { return nil } -// IsAlive reports whether the pid recorded in sc is still running. Sends -// signal 0 (no-op) and inspects the result: -// -// - nil error → process exists, alive -// - ESRCH → no such process, definitely dead -// - os.ErrProcessDone → already reaped -// - any other (e.g. EPERM) → conservatively say alive (the pid is in use -// by something, even if not by our crashed run, and we'd rather pause -// the daemon for a tick than race) func IsAlive(sc *Sidecar) bool { if sc == nil || sc.Meta.PID <= 0 { return false @@ -271,14 +228,6 @@ func IsAlive(sc *Sidecar) bool { return interpretSignalError(proc.Signal(syscall.Signal(0))) } -// interpretSignalError maps a signal-0 result to "process alive": -// -// - nil err → process exists. -// - os.ErrProcessDone → already reaped. -// - ESRCH → no such process. -// - anything else (EPERM, EINVAL, …) → conservatively assume alive -// (the pid is in use by something even if not by our crashed -// run; pausing the daemon for a tick is safer than racing). func interpretSignalError(err error) bool { if err == nil { return true @@ -293,9 +242,6 @@ func interpretSignalError(err error) bool { return true } -// AnyActive checks every known sidecar kind for wsRoot and reports the -// first one whose pid is alive. Used by the daemon's tick pre-check. -// Returns nil if no sidecars block this workspace. func AnyActive(wsRoot string) *Sidecar { for _, k := range []Kind{KindBootstrap, KindMigrate, KindAdd, KindCreate} { sc, err := Load(wsRoot, k) diff --git a/internal/testutil/gitfixture.go b/internal/testutil/gitfixture.go index 637fb8b..1929991 100644 --- a/internal/testutil/gitfixture.go +++ b/internal/testutil/gitfixture.go @@ -19,9 +19,6 @@ import ( "testing" ) -// GitConfig returns a minimal git env that prevents the user's global -// config from leaking in (commit signing, GPG, hooks, identity). Tests need -// determinism above all else. func GitConfig() []string { return []string{ "GIT_AUTHOR_NAME=ws-test", @@ -31,13 +28,11 @@ func GitConfig() []string { "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_CONFIG_NOSYSTEM=1", - "HOME=" + os.TempDir(), // last-resort fallback for any tool that ignores GIT_CONFIG_GLOBAL + "HOME=" + os.TempDir(), "PATH=" + os.Getenv("PATH"), } } -// RunGit executes `git -C dir args...` with the test git env. Fails the -// test on non-zero exit, including stderr in the failure message. func RunGit(t *testing.T, dir string, args ...string) string { t.Helper() full := append([]string{"-C", dir}, args...) @@ -50,8 +45,6 @@ func RunGit(t *testing.T, dir string, args ...string) string { return strings.TrimSpace(string(out)) } -// RunGitTry is like RunGit but returns the error instead of failing. -// Useful for tests that exercise expected failure modes. func RunGitTry(t *testing.T, dir string, args ...string) error { t.Helper() full := append([]string{"-C", dir}, args...) @@ -74,10 +67,6 @@ func (e *gitError) Error() string { return "git " + strings.Join(e.args, " ") + " in " + e.dir + ": " + e.inner.Error() + "\n" + e.output } -// InitFakeRemote creates a bare git repo at t.TempDir()/.git, seeds -// it with one commit on `defaultBranch`, and returns its absolute path. -// Used as a fake `proj.Remote` for clone tests. The bare is also configured -// with HEAD → defaultBranch so origin/HEAD can be auto-detected by callers. func InitFakeRemote(t *testing.T, name, defaultBranch string) string { t.Helper() if defaultBranch == "" { @@ -90,9 +79,6 @@ func InitFakeRemote(t *testing.T, name, defaultBranch string) string { } RunGit(t, bareDir, "init", "--bare", "--initial-branch="+defaultBranch) - // Seed: init a separate work dir, commit, push to bare. We cannot - // `clone --branch
` from an empty bare because the branch - // doesn't exist yet, hence the manual init+push. seedDir := filepath.Join(tmp, "seed") if err := os.MkdirAll(seedDir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", seedDir, err) @@ -106,16 +92,10 @@ func InitFakeRemote(t *testing.T, name, defaultBranch string) string { RunGit(t, seedDir, "remote", "add", "origin", bareDir) RunGit(t, seedDir, "push", "-u", "origin", defaultBranch) - // Pin HEAD in the bare so origin/HEAD resolves for clone tests. RunGit(t, bareDir, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch) return bareDir } -// InitFakePlainCheckout creates a non-bare git repo at parent/ with -// the given branches. The first branch in `branches` is the default. Each -// branch gets one unique commit so the bare clone preserves real history. -// -// The returned path is the working tree root (parent/name). func InitFakePlainCheckout(t *testing.T, parent, name string, branches []string) string { t.Helper() if len(branches) == 0 { @@ -127,14 +107,12 @@ func InitFakePlainCheckout(t *testing.T, parent, name string, branches []string) } RunGit(t, dir, "init", "--initial-branch="+branches[0]) - // Seed initial commit on the default branch. if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0o644); err != nil { t.Fatalf("write README: %v", err) } RunGit(t, dir, "add", "README.md") RunGit(t, dir, "commit", "-m", "initial") - // Create extra branches with one commit each. for _, b := range branches[1:] { RunGit(t, dir, "checkout", "-b", b) fname := filepath.Join(dir, b+".txt") @@ -144,13 +122,11 @@ func InitFakePlainCheckout(t *testing.T, parent, name string, branches []string) RunGit(t, dir, "add", b+".txt") RunGit(t, dir, "commit", "-m", "branch "+b) } - // Return to the default branch so callers see a familiar starting state. + RunGit(t, dir, "checkout", branches[0]) return dir } -// AddDirty makes the working tree of repoPath dirty by writing an -// untracked file. Used by migrate tests to exercise the dirty-tree path. func AddDirty(t *testing.T, repoPath string) { t.Helper() if err := os.WriteFile(filepath.Join(repoPath, "uncommitted.txt"), []byte("dirty\n"), 0o644); err != nil { @@ -159,9 +135,6 @@ func AddDirty(t *testing.T, repoPath string) { RunGit(t, repoPath, "add", "uncommitted.txt") } -// AddStash creates a stash entry in repoPath. Requires the repo to be -// non-empty (have at least one commit). Returns after restoring a clean -// working tree. func AddStash(t *testing.T, repoPath string) { t.Helper() if err := os.WriteFile(filepath.Join(repoPath, "stash-me.txt"), []byte("stash\n"), 0o644); err != nil { From c7556f1b5b326b95e224b2347d209e919b876779 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 12:05:26 +0300 Subject: [PATCH 3/9] feat(tui): framework-agnostic TUI primitives in internal/tui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package internal/tui consolidates the four TUI codebases (agent, add, cli modal flows, branchprompt) onto shared primitives. This commit ships the primitives only; consumer migrations land in follow-up commits. Files: - tui.go type aliases (Msg, Cmd, Model, Style) — the eviction seam: consumers import these instead of bubbletea/lipgloss, so future bubbletea eviction redefines the aliases without touching consumer code - palette.go Palette struct + Amber (agent's warm) + Cyan (add's cool) instances replacing the per-package styles.go duplicates - list.go FilterableList: cursor, scroll, type-to-filter - form.go ModalForm: fields, validators, focus, submit/cancel msgs - dialog.go ConfirmDialog: y/n with ConfirmedMsg/CancelledMsg - state.go Steppable interface + Stepper for multi-step flows Total: 614 LOC including tests. Each primitive has its own _test.go exercising the public API (cursor movement, filter mode, form submit with validation, esc-cancels, dialog yes/no, stepper advance on done). Build, race tests, golangci-lint all green. Note on scope (per atom 2): primitives were extracted from patterns the explore agent identified across all 4 TUI codebases. They are minimal — no features added pre-emptively. If consumer migrations reveal gaps, extend in the migration commits. --- internal/tui/dialog.go | 37 +++++++++ internal/tui/dialog_test.go | 29 +++++++ internal/tui/form.go | 101 +++++++++++++++++++++++ internal/tui/form_test.go | 56 +++++++++++++ internal/tui/list.go | 159 ++++++++++++++++++++++++++++++++++++ internal/tui/list_test.go | 80 ++++++++++++++++++ internal/tui/palette.go | 45 ++++++++++ internal/tui/state.go | 46 +++++++++++ internal/tui/state_test.go | 40 +++++++++ internal/tui/tui.go | 21 +++++ 10 files changed, 614 insertions(+) create mode 100644 internal/tui/dialog.go create mode 100644 internal/tui/dialog_test.go create mode 100644 internal/tui/form.go create mode 100644 internal/tui/form_test.go create mode 100644 internal/tui/list.go create mode 100644 internal/tui/list_test.go create mode 100644 internal/tui/palette.go create mode 100644 internal/tui/state.go create mode 100644 internal/tui/state_test.go create mode 100644 internal/tui/tui.go diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go new file mode 100644 index 0000000..b3a575c --- /dev/null +++ b/internal/tui/dialog.go @@ -0,0 +1,37 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +type ConfirmedMsg struct{} +type CancelledMsg struct{} + +type ConfirmDialog struct { + prompt string + palette Palette +} + +func NewConfirmDialog(palette Palette, prompt string) ConfirmDialog { + return ConfirmDialog{palette: palette, prompt: prompt} +} + +func (d ConfirmDialog) Init() Cmd { return nil } + +func (d ConfirmDialog) Update(msg Msg) (Model, Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return d, nil + } + switch key.String() { + case "y", "Y", "enter": + return d, func() Msg { return ConfirmedMsg{} } + case "n", "N", "esc": + return d, func() Msg { return CancelledMsg{} } + } + return d, nil +} + +func (d ConfirmDialog) View() string { + return d.palette.Title.Render(d.prompt) + " " + d.palette.Dim.Render("[y/N]") +} diff --git a/internal/tui/dialog_test.go b/internal/tui/dialog_test.go new file mode 100644 index 0000000..eaf8117 --- /dev/null +++ b/internal/tui/dialog_test.go @@ -0,0 +1,29 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestConfirmDialog_YesNoCancel(t *testing.T) { + d := NewConfirmDialog(Amber, "Delete?") + cases := []struct { + key tea.KeyMsg + want Msg + }{ + {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, ConfirmedMsg{}}, + {tea.KeyMsg{Type: tea.KeyEnter}, ConfirmedMsg{}}, + {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, CancelledMsg{}}, + {tea.KeyMsg{Type: tea.KeyEsc}, CancelledMsg{}}, + } + for _, c := range cases { + _, cmd := d.Update(c.key) + if cmd == nil { + t.Fatalf("no cmd for %v", c.key) + } + if got := cmd(); got != c.want { + t.Errorf("for %v: got %T, want %T", c.key, got, c.want) + } + } +} diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..b73468c --- /dev/null +++ b/internal/tui/form.go @@ -0,0 +1,101 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type Field struct { + Name string + Value string + Validator func(string) error +} + +type FormSubmittedMsg struct { + Values map[string]string +} + +type FormCancelledMsg struct{} + +type ModalForm struct { + title string + fields []Field + focus int + errMsg string + palette Palette +} + +func NewModalForm(palette Palette, title string, fields []Field) ModalForm { + return ModalForm{palette: palette, title: title, fields: fields} +} + +func (f ModalForm) Init() Cmd { return nil } + +func (f ModalForm) Update(msg Msg) (Model, Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return f, nil + } + switch key.String() { + case "esc": + return f, func() Msg { return FormCancelledMsg{} } + case "tab", "down": + f.focus = (f.focus + 1) % len(f.fields) + f.errMsg = "" + case "shift+tab", "up": + f.focus = (f.focus - 1 + len(f.fields)) % len(f.fields) + f.errMsg = "" + case "enter": + return f.trySubmit() + case "backspace": + v := f.fields[f.focus].Value + if len(v) > 0 { + f.fields[f.focus].Value = v[:len(v)-1] + } + default: + if len(key.Runes) > 0 { + f.fields[f.focus].Value += string(key.Runes) + } + } + return f, nil +} + +func (f ModalForm) trySubmit() (Model, Cmd) { + for _, fd := range f.fields { + if fd.Validator == nil { + continue + } + if err := fd.Validator(fd.Value); err != nil { + f.errMsg = fd.Name + ": " + err.Error() + return f, nil + } + } + values := make(map[string]string, len(f.fields)) + for _, fd := range f.fields { + values[fd.Name] = fd.Value + } + return f, func() Msg { return FormSubmittedMsg{Values: values} } +} + +func (f ModalForm) View() string { + var b strings.Builder + if f.title != "" { + b.WriteString(f.palette.Title.Render(f.title)) + b.WriteString("\n\n") + } + for i, fd := range f.fields { + label := fd.Name + ": " + if i == f.focus { + b.WriteString(f.palette.Accent.Render(label) + fd.Value + "█") + } else { + b.WriteString(f.palette.Dim.Render(label) + fd.Value) + } + b.WriteByte('\n') + } + if f.errMsg != "" { + b.WriteByte('\n') + b.WriteString(f.palette.Error.Render(f.errMsg)) + } + return b.String() +} diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go new file mode 100644 index 0000000..c97208a --- /dev/null +++ b/internal/tui/form_test.go @@ -0,0 +1,56 @@ +package tui + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestModalForm_TypingAndSubmit(t *testing.T) { + f := NewModalForm(Cyan, "test", []Field{ + {Name: "branch", Value: ""}, + }) + for _, c := range "feat/x" { + m, _ := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}}) + f = m.(ModalForm) + } + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected submit cmd") + } + msg := cmd() + sub, ok := msg.(FormSubmittedMsg) + if !ok { + t.Fatalf("expected FormSubmittedMsg, got %T", msg) + } + if sub.Values["branch"] != "feat/x" { + t.Errorf("branch = %q, want feat/x", sub.Values["branch"]) + } +} + +func TestModalForm_ValidatorBlocksSubmit(t *testing.T) { + f := NewModalForm(Cyan, "", []Field{ + {Name: "n", Value: "", Validator: func(s string) error { + if s == "" { + return errors.New("required") + } + return nil + }}, + }) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + t.Errorf("expected no cmd on validation failure, got %v", cmd()) + } +} + +func TestModalForm_EscCancels(t *testing.T) { + f := NewModalForm(Cyan, "", []Field{{Name: "n", Value: "x"}}) + _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected cancel cmd") + } + if _, ok := cmd().(FormCancelledMsg); !ok { + t.Errorf("expected FormCancelledMsg, got %T", cmd()) + } +} diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..11409e4 --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,159 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type ListItem interface { + Title() string + FilterValue() string +} + +type FilterableList struct { + items []ListItem + filtered []int + cursor int + offset int + height int + filter string + filterMode bool + palette Palette +} + +func NewFilterableList(palette Palette, items []ListItem) FilterableList { + l := FilterableList{palette: palette, height: 10} + l.SetItems(items) + return l +} + +func (l *FilterableList) SetItems(items []ListItem) { + l.items = items + l.refilter() + if l.cursor >= len(l.filtered) { + l.cursor = max(0, len(l.filtered)-1) + } +} + +func (l *FilterableList) SetHeight(h int) { + if h < 1 { + h = 1 + } + l.height = h +} + +func (l FilterableList) Cursor() int { return l.cursor } + +func (l FilterableList) Selected() (ListItem, bool) { + if l.cursor < 0 || l.cursor >= len(l.filtered) { + return nil, false + } + return l.items[l.filtered[l.cursor]], true +} + +func (l FilterableList) FilterMode() bool { return l.filterMode } +func (l FilterableList) Filter() string { return l.filter } + +func (l FilterableList) Init() Cmd { return nil } + +func (l FilterableList) Update(msg Msg) (Model, Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return l, nil + } + if l.filterMode { + switch key.String() { + case "esc": + l.filterMode = false + l.filter = "" + l.refilter() + case "enter": + l.filterMode = false + case "backspace": + if len(l.filter) > 0 { + l.filter = l.filter[:len(l.filter)-1] + l.refilter() + } + default: + if len(key.Runes) == 1 { + l.filter += string(key.Runes) + l.refilter() + } + } + return l, nil + } + switch key.String() { + case "/": + l.filterMode = true + case "down", "j": + if l.cursor < len(l.filtered)-1 { + l.cursor++ + } + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "g", "home": + l.cursor = 0 + case "G", "end": + l.cursor = max(0, len(l.filtered)-1) + } + l.clampOffset() + return l, nil +} + +func (l FilterableList) View() string { + if len(l.filtered) == 0 { + if l.filterMode || l.filter != "" { + return l.palette.Dim.Render("no matches for /" + l.filter) + } + return l.palette.Dim.Render("(empty)") + } + var b strings.Builder + end := l.offset + l.height + if end > len(l.filtered) { + end = len(l.filtered) + } + for i := l.offset; i < end; i++ { + item := l.items[l.filtered[i]] + line := item.Title() + if i == l.cursor { + line = l.palette.Selected.Render("▌ " + line) + } else { + line = " " + l.palette.Item.Render(line) + } + b.WriteString(line) + b.WriteByte('\n') + } + if l.filterMode || l.filter != "" { + b.WriteString(l.palette.Accent.Render("/" + l.filter)) + } + return b.String() +} + +func (l *FilterableList) refilter() { + l.filtered = l.filtered[:0] + q := strings.ToLower(l.filter) + for i, it := range l.items { + if q == "" || strings.Contains(strings.ToLower(it.FilterValue()), q) { + l.filtered = append(l.filtered, i) + } + } + if l.cursor >= len(l.filtered) { + l.cursor = max(0, len(l.filtered)-1) + } + l.clampOffset() +} + +func (l *FilterableList) clampOffset() { + if l.cursor < l.offset { + l.offset = l.cursor + } + if l.cursor >= l.offset+l.height { + l.offset = l.cursor - l.height + 1 + } + if l.offset < 0 { + l.offset = 0 + } +} diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go new file mode 100644 index 0000000..c13d614 --- /dev/null +++ b/internal/tui/list_test.go @@ -0,0 +1,80 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type strItem string + +func (s strItem) Title() string { return string(s) } +func (s strItem) FilterValue() string { return string(s) } + +func key(s string) tea.KeyMsg { + if len(s) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } + switch s { + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func TestFilterableList_CursorMovement(t *testing.T) { + l := NewFilterableList(Amber, []ListItem{strItem("a"), strItem("b"), strItem("c")}) + if got := l.Cursor(); got != 0 { + t.Fatalf("initial cursor = %d, want 0", got) + } + m, _ := l.Update(key("j")) + l = m.(FilterableList) + if got := l.Cursor(); got != 1 { + t.Errorf("after j: cursor = %d, want 1", got) + } + m, _ = l.Update(key("G")) + l = m.(FilterableList) + if got := l.Cursor(); got != 2 { + t.Errorf("after G: cursor = %d, want 2", got) + } +} + +func TestFilterableList_FilterMode(t *testing.T) { + l := NewFilterableList(Amber, []ListItem{strItem("apple"), strItem("banana"), strItem("avocado")}) + m, _ := l.Update(key("/")) + l = m.(FilterableList) + if !l.FilterMode() { + t.Fatal("expected filter mode active after /") + } + for _, c := range "av" { + m, _ = l.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}}) + l = m.(FilterableList) + } + if l.Filter() != "av" { + t.Errorf("filter = %q, want %q", l.Filter(), "av") + } + sel, ok := l.Selected() + if !ok { + t.Fatal("expected selection after filter") + } + if !strings.Contains(sel.Title(), "avocado") { + t.Errorf("first match = %q, want avocado", sel.Title()) + } +} + +func TestFilterableList_EmptyView(t *testing.T) { + l := NewFilterableList(Amber, nil) + v := l.View() + if !strings.Contains(v, "empty") { + t.Errorf("empty list view = %q", v) + } +} diff --git a/internal/tui/palette.go b/internal/tui/palette.go new file mode 100644 index 0000000..d070e20 --- /dev/null +++ b/internal/tui/palette.go @@ -0,0 +1,45 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +type Palette struct { + Title Style + Header Style + Footer Style + Selected Style + Accent Style + Dim Style + Error Style + Check Style + Border Style + Group Style + Item Style +} + +var Amber = Palette{ + Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("215")), + Header: lipgloss.NewStyle().Foreground(lipgloss.Color("173")).Background(lipgloss.Color("235")), + Footer: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Background(lipgloss.Color("235")), + Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("254")).Background(lipgloss.Color("236")).Bold(true), + Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("215")).Bold(true), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), + Check: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), + Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("173")).Padding(0, 1), + Group: lipgloss.NewStyle().Foreground(lipgloss.Color("182")).Bold(true), + Item: lipgloss.NewStyle().Foreground(lipgloss.Color("254")), +} + +var Cyan = Palette{ + Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("6")).Padding(0, 1), + Header: lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true), + Footer: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Selected: lipgloss.NewStyle().Background(lipgloss.Color("237")), + Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), + Check: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), + Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("6")).Padding(0, 1), + Group: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true).Underline(true), + Item: lipgloss.NewStyle().Foreground(lipgloss.Color("15")), +} diff --git a/internal/tui/state.go b/internal/tui/state.go new file mode 100644 index 0000000..2b2c651 --- /dev/null +++ b/internal/tui/state.go @@ -0,0 +1,46 @@ +package tui + +type Steppable interface { + Model + IsDone() bool +} + +type Stepper struct { + steps []Steppable + idx int +} + +func NewStepper(steps ...Steppable) Stepper { + return Stepper{steps: steps} +} + +func (s Stepper) Init() Cmd { + if len(s.steps) == 0 { + return nil + } + return s.steps[0].Init() +} + +func (s Stepper) Update(msg Msg) (Model, Cmd) { + if s.idx >= len(s.steps) { + return s, nil + } + updated, cmd := s.steps[s.idx].Update(msg) + s.steps[s.idx] = updated.(Steppable) + if s.steps[s.idx].IsDone() && s.idx+1 < len(s.steps) { + s.idx++ + next := s.steps[s.idx].Init() + cmd = Batch(cmd, next) + } + return s, cmd +} + +func (s Stepper) View() string { + if s.idx >= len(s.steps) { + return "" + } + return s.steps[s.idx].View() +} + +func (s Stepper) Current() int { return s.idx } +func (s Stepper) Done() bool { return s.idx >= len(s.steps) || (s.idx == len(s.steps)-1 && s.steps[s.idx].IsDone()) } diff --git a/internal/tui/state_test.go b/internal/tui/state_test.go new file mode 100644 index 0000000..a492a73 --- /dev/null +++ b/internal/tui/state_test.go @@ -0,0 +1,40 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type stepStub struct { + done bool + view string +} + +func (s stepStub) Init() Cmd { return nil } +func (s stepStub) View() string { return s.view } +func (s stepStub) IsDone() bool { return s.done } +func (s stepStub) Update(msg Msg) (Model, Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + s.done = true + } + return s, nil +} + +func TestStepper_AdvancesOnDone(t *testing.T) { + s := NewStepper(stepStub{view: "step1"}, stepStub{view: "step2"}) + if s.Current() != 0 { + t.Fatalf("initial idx = %d", s.Current()) + } + if s.View() != "step1" { + t.Errorf("initial view = %q", s.View()) + } + m, _ := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + s = m.(Stepper) + if s.Current() != 1 { + t.Errorf("after key: idx = %d, want 1", s.Current()) + } + if s.View() != "step2" { + t.Errorf("after key: view = %q, want step2", s.View()) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..7a1bc8c --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,21 @@ +// Package tui consolidates TUI primitives shared across the workspace +// CLI's interactive flows (agent, add, aliasmgr, bootstrap, migrate, +// create, setup). The package is the eviction seam: callers import +// these aliases instead of bubbletea/lipgloss directly, so a future PR +// can swap the underlying implementation without touching consumers. +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ( + Msg = tea.Msg + Cmd = tea.Cmd + Model = tea.Model + Style = lipgloss.Style +) + +func Batch(cmds ...Cmd) Cmd { return tea.Batch(cmds...) } +func Quit() Msg { return tea.Quit() } From a5938bd86a02384428b760434c670849a402bfa3 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 13:09:51 +0300 Subject: [PATCH 4/9] =?UTF-8?q?feat(tui):=20make=20eviction=20seam=20stric?= =?UTF-8?q?t=20=E2=80=94=20nominal=20types,=20no=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit used type aliases (Msg = tea.Msg, Style = lipgloss.Style). Aliases allow consumers to still import bubbletea/lipgloss directly — the seam is structurally permeable. This commit replaces aliases with own types so that grep for bubbletea/lipgloss outside internal/tui becomes a regression signal. Type model: - Msg, Cmd, Model — own interfaces; bubbletea is invisible to consumers - Style — value-type wrapper around lipgloss.Style with explicit Render, Bold, Foreground, Padding, Border, etc. methods - Color, Border, Position — own types replacing lipgloss.Color, .Border, .Position - KeyMsg, KeyType (KeyEnter, KeyEsc, KeyTab, ...) — own enum-based key events; runtime adapter translates tea.KeyMsg in - WindowSizeMsg — own type - QuitMsg + Quit() — own quit signal; adapter translates to tea.Quit Runtime adapter (runtime.go): - NewProgram(Model, ...ProgramOption) wraps a Model in a tea.Program - teaWrapper.Update bridges tea.Msg -> Msg, returns lifted Cmd - WithAltScreen, WithMouseCellMotion options bridge to tea options - batchMsg + Batch() lift-and-flatten cmd batching Bubbletea eviction is now a pure internal/tui rewrite: redefine the types, reimplement runtime.go against raw termios/ANSI. Consumer code compiles unchanged. LOC: +186 vs aliases (worth it for the strict boundary). Build, race tests, golangci-lint all green. --- internal/tui/dialog.go | 6 +- internal/tui/dialog_test.go | 16 ++--- internal/tui/form.go | 8 +-- internal/tui/form_test.go | 10 ++- internal/tui/keys.go | 74 ++++++++++++++++++++ internal/tui/list.go | 50 ++++++------- internal/tui/list_test.go | 23 +++--- internal/tui/palette.go | 46 ++++++------ internal/tui/runtime.go | 135 ++++++++++++++++++++++++++++++++++++ internal/tui/state_test.go | 16 ++--- internal/tui/style.go | 50 +++++++++++++ internal/tui/tui.go | 53 +++++++++----- 12 files changed, 371 insertions(+), 116 deletions(-) create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/runtime.go create mode 100644 internal/tui/style.go diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go index b3a575c..c9f1da7 100644 --- a/internal/tui/dialog.go +++ b/internal/tui/dialog.go @@ -1,9 +1,5 @@ package tui -import ( - tea "github.com/charmbracelet/bubbletea" -) - type ConfirmedMsg struct{} type CancelledMsg struct{} @@ -19,7 +15,7 @@ func NewConfirmDialog(palette Palette, prompt string) ConfirmDialog { func (d ConfirmDialog) Init() Cmd { return nil } func (d ConfirmDialog) Update(msg Msg) (Model, Cmd) { - key, ok := msg.(tea.KeyMsg) + key, ok := msg.(KeyMsg) if !ok { return d, nil } diff --git a/internal/tui/dialog_test.go b/internal/tui/dialog_test.go index eaf8117..a056df1 100644 --- a/internal/tui/dialog_test.go +++ b/internal/tui/dialog_test.go @@ -1,21 +1,17 @@ package tui -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) +import "testing" func TestConfirmDialog_YesNoCancel(t *testing.T) { d := NewConfirmDialog(Amber, "Delete?") cases := []struct { - key tea.KeyMsg + key KeyMsg want Msg }{ - {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, ConfirmedMsg{}}, - {tea.KeyMsg{Type: tea.KeyEnter}, ConfirmedMsg{}}, - {tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, CancelledMsg{}}, - {tea.KeyMsg{Type: tea.KeyEsc}, CancelledMsg{}}, + {KeyMsg{Type: KeyRune, Runes: []rune{'y'}}, ConfirmedMsg{}}, + {KeyMsg{Type: KeyEnter}, ConfirmedMsg{}}, + {KeyMsg{Type: KeyRune, Runes: []rune{'n'}}, CancelledMsg{}}, + {KeyMsg{Type: KeyEsc}, CancelledMsg{}}, } for _, c := range cases { _, cmd := d.Update(c.key) diff --git a/internal/tui/form.go b/internal/tui/form.go index b73468c..5e58f58 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -1,10 +1,6 @@ package tui -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" -) +import "strings" type Field struct { Name string @@ -33,7 +29,7 @@ func NewModalForm(palette Palette, title string, fields []Field) ModalForm { func (f ModalForm) Init() Cmd { return nil } func (f ModalForm) Update(msg Msg) (Model, Cmd) { - key, ok := msg.(tea.KeyMsg) + key, ok := msg.(KeyMsg) if !ok { return f, nil } diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go index c97208a..d5a8b66 100644 --- a/internal/tui/form_test.go +++ b/internal/tui/form_test.go @@ -3,8 +3,6 @@ package tui import ( "errors" "testing" - - tea "github.com/charmbracelet/bubbletea" ) func TestModalForm_TypingAndSubmit(t *testing.T) { @@ -12,10 +10,10 @@ func TestModalForm_TypingAndSubmit(t *testing.T) { {Name: "branch", Value: ""}, }) for _, c := range "feat/x" { - m, _ := f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}}) + m, _ := f.Update(KeyMsg{Type: KeyRune, Runes: []rune{c}}) f = m.(ModalForm) } - _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _, cmd := f.Update(KeyMsg{Type: KeyEnter}) if cmd == nil { t.Fatal("expected submit cmd") } @@ -38,7 +36,7 @@ func TestModalForm_ValidatorBlocksSubmit(t *testing.T) { return nil }}, }) - _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + _, cmd := f.Update(KeyMsg{Type: KeyEnter}) if cmd != nil { t.Errorf("expected no cmd on validation failure, got %v", cmd()) } @@ -46,7 +44,7 @@ func TestModalForm_ValidatorBlocksSubmit(t *testing.T) { func TestModalForm_EscCancels(t *testing.T) { f := NewModalForm(Cyan, "", []Field{{Name: "n", Value: "x"}}) - _, cmd := f.Update(tea.KeyMsg{Type: tea.KeyEsc}) + _, cmd := f.Update(KeyMsg{Type: KeyEsc}) if cmd == nil { t.Fatal("expected cancel cmd") } diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..7d2267f --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,74 @@ +package tui + +import "strings" + +type KeyType int + +const ( + KeyRune KeyType = iota + KeyEnter + KeyEsc + KeyTab + KeyShiftTab + KeyBackspace + KeySpace + KeyUp + KeyDown + KeyLeft + KeyRight + KeyHome + KeyEnd + KeyPgUp + KeyPgDn + KeyDelete + KeyCtrlC + KeyCtrlD +) + +type KeyMsg struct { + Type KeyType + Runes []rune + Alt bool + Ctrl bool +} + +var keyNames = map[KeyType]string{ + KeyEnter: "enter", + KeyEsc: "esc", + KeyTab: "tab", + KeyShiftTab: "shift+tab", + KeyBackspace: "backspace", + KeySpace: "space", + KeyUp: "up", + KeyDown: "down", + KeyLeft: "left", + KeyRight: "right", + KeyHome: "home", + KeyEnd: "end", + KeyPgUp: "pgup", + KeyPgDn: "pgdn", + KeyDelete: "delete", + KeyCtrlC: "ctrl+c", + KeyCtrlD: "ctrl+d", +} + +func (k KeyMsg) String() string { + var b strings.Builder + if k.Alt { + b.WriteString("alt+") + } + if k.Ctrl && k.Type != KeyCtrlC && k.Type != KeyCtrlD { + b.WriteString("ctrl+") + } + if k.Type == KeyRune { + b.WriteString(string(k.Runes)) + } else if name, ok := keyNames[k.Type]; ok { + b.WriteString(name) + } + return b.String() +} + +type WindowSizeMsg struct { + Width int + Height int +} diff --git a/internal/tui/list.go b/internal/tui/list.go index 11409e4..e181c28 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -1,10 +1,6 @@ package tui -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" -) +import "strings" type ListItem interface { Title() string @@ -58,30 +54,12 @@ func (l FilterableList) Filter() string { return l.filter } func (l FilterableList) Init() Cmd { return nil } func (l FilterableList) Update(msg Msg) (Model, Cmd) { - key, ok := msg.(tea.KeyMsg) + key, ok := msg.(KeyMsg) if !ok { return l, nil } if l.filterMode { - switch key.String() { - case "esc": - l.filterMode = false - l.filter = "" - l.refilter() - case "enter": - l.filterMode = false - case "backspace": - if len(l.filter) > 0 { - l.filter = l.filter[:len(l.filter)-1] - l.refilter() - } - default: - if len(key.Runes) == 1 { - l.filter += string(key.Runes) - l.refilter() - } - } - return l, nil + return l.updateFilterMode(key), nil } switch key.String() { case "/": @@ -103,6 +81,28 @@ func (l FilterableList) Update(msg Msg) (Model, Cmd) { return l, nil } +func (l FilterableList) updateFilterMode(key KeyMsg) FilterableList { + switch key.String() { + case "esc": + l.filterMode = false + l.filter = "" + l.refilter() + case "enter": + l.filterMode = false + case "backspace": + if len(l.filter) > 0 { + l.filter = l.filter[:len(l.filter)-1] + l.refilter() + } + default: + if len(key.Runes) > 0 { + l.filter += string(key.Runes) + l.refilter() + } + } + return l +} + func (l FilterableList) View() string { if len(l.filtered) == 0 { if l.filterMode || l.filter != "" { diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go index c13d614..14b8341 100644 --- a/internal/tui/list_test.go +++ b/internal/tui/list_test.go @@ -3,8 +3,6 @@ package tui import ( "strings" "testing" - - tea "github.com/charmbracelet/bubbletea" ) type strItem string @@ -12,23 +10,22 @@ type strItem string func (s strItem) Title() string { return string(s) } func (s strItem) FilterValue() string { return string(s) } -func key(s string) tea.KeyMsg { - if len(s) == 1 { - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} - } +func key(s string) KeyMsg { switch s { case "esc": - return tea.KeyMsg{Type: tea.KeyEsc} + return KeyMsg{Type: KeyEsc} case "enter": - return tea.KeyMsg{Type: tea.KeyEnter} + return KeyMsg{Type: KeyEnter} case "backspace": - return tea.KeyMsg{Type: tea.KeyBackspace} + return KeyMsg{Type: KeyBackspace} case "down": - return tea.KeyMsg{Type: tea.KeyDown} + return KeyMsg{Type: KeyDown} case "up": - return tea.KeyMsg{Type: tea.KeyUp} + return KeyMsg{Type: KeyUp} + case "tab": + return KeyMsg{Type: KeyTab} } - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + return KeyMsg{Type: KeyRune, Runes: []rune(s)} } func TestFilterableList_CursorMovement(t *testing.T) { @@ -56,7 +53,7 @@ func TestFilterableList_FilterMode(t *testing.T) { t.Fatal("expected filter mode active after /") } for _, c := range "av" { - m, _ = l.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{c}}) + m, _ = l.Update(KeyMsg{Type: KeyRune, Runes: []rune{c}}) l = m.(FilterableList) } if l.Filter() != "av" { diff --git a/internal/tui/palette.go b/internal/tui/palette.go index d070e20..e1d54f8 100644 --- a/internal/tui/palette.go +++ b/internal/tui/palette.go @@ -1,7 +1,5 @@ package tui -import "github.com/charmbracelet/lipgloss" - type Palette struct { Title Style Header Style @@ -17,29 +15,29 @@ type Palette struct { } var Amber = Palette{ - Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("215")), - Header: lipgloss.NewStyle().Foreground(lipgloss.Color("173")).Background(lipgloss.Color("235")), - Footer: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Background(lipgloss.Color("235")), - Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("254")).Background(lipgloss.Color("236")).Bold(true), - Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("215")).Bold(true), - Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), - Check: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), - Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("173")).Padding(0, 1), - Group: lipgloss.NewStyle().Foreground(lipgloss.Color("182")).Bold(true), - Item: lipgloss.NewStyle().Foreground(lipgloss.Color("254")), + Title: NewStyle().Bold(true).Foreground("215"), + Header: NewStyle().Foreground("173").Background("235"), + Footer: NewStyle().Foreground("240").Background("235"), + Selected: NewStyle().Foreground("254").Background("236").Bold(true), + Accent: NewStyle().Foreground("215").Bold(true), + Dim: NewStyle().Foreground("240"), + Error: NewStyle().Foreground("1").Bold(true), + Check: NewStyle().Foreground("2"), + Border: NewStyle().Border(RoundedBorder()).BorderForeground("173").Padding(0, 1), + Group: NewStyle().Foreground("182").Bold(true), + Item: NewStyle().Foreground("254"), } var Cyan = Palette{ - Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("6")).Padding(0, 1), - Header: lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true), - Footer: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - Selected: lipgloss.NewStyle().Background(lipgloss.Color("237")), - Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true), - Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true), - Check: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), - Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("6")).Padding(0, 1), - Group: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true).Underline(true), - Item: lipgloss.NewStyle().Foreground(lipgloss.Color("15")), + Title: NewStyle().Bold(true).Foreground("15").Background("6").Padding(0, 1), + Header: NewStyle().Foreground("6").Bold(true), + Footer: NewStyle().Foreground("8"), + Selected: NewStyle().Background("237"), + Accent: NewStyle().Foreground("6").Bold(true), + Dim: NewStyle().Foreground("8"), + Error: NewStyle().Foreground("1").Bold(true), + Check: NewStyle().Foreground("2"), + Border: NewStyle().Border(RoundedBorder()).BorderForeground("6").Padding(0, 1), + Group: NewStyle().Foreground("5").Bold(true).Underline(true), + Item: NewStyle().Foreground("15"), } diff --git a/internal/tui/runtime.go b/internal/tui/runtime.go new file mode 100644 index 0000000..ba4e3a0 --- /dev/null +++ b/internal/tui/runtime.go @@ -0,0 +1,135 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +type ProgramOption func(*programConfig) + +type programConfig struct { + altScreen bool + mouse bool +} + +func WithAltScreen() ProgramOption { return func(c *programConfig) { c.altScreen = true } } +func WithMouseCellMotion() ProgramOption { return func(c *programConfig) { c.mouse = true } } + +type Program struct { + p *tea.Program + bt *teaWrapper + cfg programConfig +} + +func NewProgram(m Model, opts ...ProgramOption) *Program { + cfg := programConfig{} + for _, o := range opts { + o(&cfg) + } + w := &teaWrapper{m: m} + teaOpts := []tea.ProgramOption{} + if cfg.altScreen { + teaOpts = append(teaOpts, tea.WithAltScreen()) + } + if cfg.mouse { + teaOpts = append(teaOpts, tea.WithMouseCellMotion()) + } + return &Program{p: tea.NewProgram(w, teaOpts...), bt: w, cfg: cfg} +} + +func (p *Program) Run() (Model, error) { + result, err := p.p.Run() + if err != nil { + return nil, err + } + if w, ok := result.(*teaWrapper); ok { + return w.m, nil + } + return nil, nil +} + +func (p *Program) Send(msg Msg) { p.p.Send(msg) } +func (p *Program) Quit() { p.p.Quit() } + +type teaWrapper struct { + m Model +} + +func (w *teaWrapper) Init() tea.Cmd { + return liftCmd(w.m.Init()) +} + +func (w *teaWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + our := translateIn(msg) + updated, cmd := w.m.Update(our) + w.m = updated + return w, liftCmd(cmd) +} + +func (w *teaWrapper) View() string { + return w.m.View() +} + +func liftCmd(c Cmd) tea.Cmd { + if c == nil { + return nil + } + return func() tea.Msg { + return translateOut(c()) + } +} + +func translateIn(msg tea.Msg) Msg { + switch m := msg.(type) { + case tea.KeyMsg: + return keyMsgFromBubbletea(m) + case tea.WindowSizeMsg: + return WindowSizeMsg{Width: m.Width, Height: m.Height} + } + return msg +} + +func translateOut(msg Msg) tea.Msg { + switch m := msg.(type) { + case QuitMsg: + return tea.Quit() + case batchMsg: + teaCmds := make([]tea.Cmd, len(m)) + for i, c := range m { + teaCmds[i] = liftCmd(c) + } + return tea.Batch(teaCmds...)() + } + return msg +} + +var teaToOwnKeyType = map[tea.KeyType]KeyType{ + tea.KeyRunes: KeyRune, + tea.KeySpace: KeySpace, + tea.KeyEnter: KeyEnter, + tea.KeyEsc: KeyEsc, + tea.KeyTab: KeyTab, + tea.KeyShiftTab: KeyShiftTab, + tea.KeyBackspace: KeyBackspace, + tea.KeyUp: KeyUp, + tea.KeyDown: KeyDown, + tea.KeyLeft: KeyLeft, + tea.KeyRight: KeyRight, + tea.KeyHome: KeyHome, + tea.KeyEnd: KeyEnd, + tea.KeyPgUp: KeyPgUp, + tea.KeyPgDown: KeyPgDn, + tea.KeyDelete: KeyDelete, + tea.KeyCtrlC: KeyCtrlC, + tea.KeyCtrlD: KeyCtrlD, +} + +func keyMsgFromBubbletea(m tea.KeyMsg) KeyMsg { + out := KeyMsg{Runes: m.Runes, Alt: m.Alt} + if t, ok := teaToOwnKeyType[m.Type]; ok { + out.Type = t + } + if out.Type == KeyCtrlC || out.Type == KeyCtrlD { + out.Ctrl = true + } + return out +} diff --git a/internal/tui/state_test.go b/internal/tui/state_test.go index a492a73..e1b5636 100644 --- a/internal/tui/state_test.go +++ b/internal/tui/state_test.go @@ -1,21 +1,17 @@ package tui -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) +import "testing" type stepStub struct { done bool view string } -func (s stepStub) Init() Cmd { return nil } -func (s stepStub) View() string { return s.view } -func (s stepStub) IsDone() bool { return s.done } +func (s stepStub) Init() Cmd { return nil } +func (s stepStub) View() string { return s.view } +func (s stepStub) IsDone() bool { return s.done } func (s stepStub) Update(msg Msg) (Model, Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { + if _, ok := msg.(KeyMsg); ok { s.done = true } return s, nil @@ -29,7 +25,7 @@ func TestStepper_AdvancesOnDone(t *testing.T) { if s.View() != "step1" { t.Errorf("initial view = %q", s.View()) } - m, _ := s.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m, _ := s.Update(KeyMsg{Type: KeyEnter}) s = m.(Stepper) if s.Current() != 1 { t.Errorf("after key: idx = %d, want 1", s.Current()) diff --git a/internal/tui/style.go b/internal/tui/style.go new file mode 100644 index 0000000..161efea --- /dev/null +++ b/internal/tui/style.go @@ -0,0 +1,50 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +type Color string + +type Border struct{ inner lipgloss.Border } + +func RoundedBorder() Border { return Border{inner: lipgloss.RoundedBorder()} } +func NormalBorder() Border { return Border{inner: lipgloss.NormalBorder()} } +func ThickBorder() Border { return Border{inner: lipgloss.ThickBorder()} } + +type Style struct{ s lipgloss.Style } + +func NewStyle() Style { return Style{s: lipgloss.NewStyle()} } + +func (s Style) Render(text string) string { return s.s.Render(text) } +func (s Style) Bold(b bool) Style { return Style{s: s.s.Bold(b)} } +func (s Style) Italic(b bool) Style { return Style{s: s.s.Italic(b)} } +func (s Style) Underline(b bool) Style { return Style{s: s.s.Underline(b)} } +func (s Style) Foreground(c Color) Style { return Style{s: s.s.Foreground(lipgloss.Color(string(c)))} } +func (s Style) Background(c Color) Style { return Style{s: s.s.Background(lipgloss.Color(string(c)))} } +func (s Style) Padding(p ...int) Style { return Style{s: s.s.Padding(p...)} } +func (s Style) Margin(m ...int) Style { return Style{s: s.s.Margin(m...)} } +func (s Style) Width(w int) Style { return Style{s: s.s.Width(w)} } +func (s Style) Height(h int) Style { return Style{s: s.s.Height(h)} } +func (s Style) Align(p Position) Style { return Style{s: s.s.Align(lipgloss.Position(p))} } +func (s Style) Border(b Border) Style { return Style{s: s.s.Border(b.inner)} } +func (s Style) BorderForeground(c Color) Style { + return Style{s: s.s.BorderForeground(lipgloss.Color(string(c)))} +} + +type Position float64 + +const ( + Left Position = Position(lipgloss.Left) + Center Position = Position(lipgloss.Center) + Right Position = Position(lipgloss.Right) +) + +func Width(s string) int { return lipgloss.Width(s) } +func Height(s string) int { return lipgloss.Height(s) } + +func JoinHorizontal(pos Position, strs ...string) string { + return lipgloss.JoinHorizontal(lipgloss.Position(pos), strs...) +} + +func JoinVertical(pos Position, strs ...string) string { + return lipgloss.JoinVertical(lipgloss.Position(pos), strs...) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7a1bc8c..31355ec 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,21 +1,40 @@ -// Package tui consolidates TUI primitives shared across the workspace -// CLI's interactive flows (agent, add, aliasmgr, bootstrap, migrate, -// create, setup). The package is the eviction seam: callers import -// these aliases instead of bubbletea/lipgloss directly, so a future PR -// can swap the underlying implementation without touching consumers. +// Package tui owns every interactive primitive used by the workspace +// CLI. It is the only place in the codebase that imports a TUI +// framework. The Msg / Cmd / Model / Style types are nominally +// distinct from bubbletea's, so a future eviction is a pure +// internal/tui rewrite with no consumer changes. package tui -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) +type Msg interface{} -type ( - Msg = tea.Msg - Cmd = tea.Cmd - Model = tea.Model - Style = lipgloss.Style -) +type Cmd func() Msg -func Batch(cmds ...Cmd) Cmd { return tea.Batch(cmds...) } -func Quit() Msg { return tea.Quit() } +type Model interface { + Init() Cmd + Update(msg Msg) (Model, Cmd) + View() string +} + +type QuitMsg struct{} + +func Quit() Msg { return QuitMsg{} } + +func Batch(cmds ...Cmd) Cmd { + var live []Cmd + for _, c := range cmds { + if c != nil { + live = append(live, c) + } + } + if len(live) == 0 { + return nil + } + if len(live) == 1 { + return live[0] + } + return func() Msg { + return batchMsg(live) + } +} + +type batchMsg []Cmd From 3f8cc3a0b4a255e3eac17962ceea0f80e3ba6e05 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 13:20:18 +0300 Subject: [PATCH 5/9] =?UTF-8?q?refactor(agent):=20consume=20internal/tui?= =?UTF-8?q?=20=E2=80=94=20drop=20bubbletea/lipgloss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/agent now sees only internal/tui. The 10 production files in internal/agent that previously imported bubbletea or lipgloss now import internal/tui only: - All type signatures (Msg, Cmd, Model, KeyMsg, WindowSizeMsg) switched to tui.* equivalents - All lipgloss helpers (Place, Width, Height, Join*, Color, Style) now call tui.* equivalents - styles.go rewritten to use tui.NewStyle and tui.Amber palette for shared roles (header, footer, selected, accent, dim, group, item). Agent-specific styles (popup*, whichKey*, flash*, wt, session, chip) stay defined in agent/styles.go but as tui.Style values. - internal/cli/explorer.go: replaced tea.NewProgram with tui.NewProgram The internal/tui package gained Place, WithWhitespaceBackground, Top position to cover lipgloss helpers used by agent's centered popups. tui.Quit became a Cmd value (var, not func) to preserve the `return m, tui.Quit` idiom from bubbletea. Tooling: /tmp/teatotui/main.go — AST-based renamer that walks Go files, rewrites tea.X/lipgloss.X selector exprs to tui.X, removes tea/lipgloss imports, adds tui import. Single-pass, format-preserving. Build, race tests, golangci-lint all green. Binary builds; ws agent --help prints expected output. Net: -54 LOC across 13 files. Agent is the first consumer of internal/tui's strict seam — zero bubbletea/lipgloss in the package. --- internal/agent/chip_action.go | 14 ++-- internal/agent/edit_project.go | 11 ++- internal/agent/flash.go | 7 +- internal/agent/forms.go | 19 +++-- internal/agent/header.go | 5 +- internal/agent/list.go | 16 ++-- internal/agent/render.go | 21 +++-- internal/agent/styles.go | 140 +++++++++------------------------ internal/agent/tui.go | 14 ++-- internal/agent/whichkey.go | 17 ++-- internal/cli/explorer.go | 4 +- internal/tui/style.go | 24 ++++++ internal/tui/tui.go | 2 +- 13 files changed, 120 insertions(+), 174 deletions(-) diff --git a/internal/agent/chip_action.go b/internal/agent/chip_action.go index 1666c26..2814f97 100644 --- a/internal/agent/chip_action.go +++ b/internal/agent/chip_action.go @@ -2,13 +2,11 @@ package agent import ( "fmt" + "github.com/kuchmenko/workspace/internal/tui" "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) -func (m *Model) updateChipAction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateChipAction(msg tui.KeyMsg) (tui.Model, tui.Cmd) { if m.chipTarget == nil { m.mode = viewList return m, nil @@ -21,10 +19,10 @@ func (m *Model) updateChipAction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "c", "enter": m.Launch = &LaunchRequest{Cwd: target.Path} - return m, tea.Quit + return m, tui.Quit case "s", "l": m.Launch = &LaunchRequest{Cwd: target.Path, ShellOnly: true} - return m, tea.Quit + return m, tui.Quit case "p": m.pendingLaunch = &LaunchRequest{Cwd: target.Path} m.promptInput = "" @@ -78,6 +76,6 @@ func (m *Model) viewChipAction() string { 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"))) + return tui.Place(m.width, m.height, tui.Center, tui.Center, popup, + tui.WithWhitespaceBackground(tui.Color("234"))) } diff --git a/internal/agent/edit_project.go b/internal/agent/edit_project.go index c8c7fc8..0119304 100644 --- a/internal/agent/edit_project.go +++ b/internal/agent/edit_project.go @@ -5,9 +5,8 @@ import ( "sort" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) func EditProjectMetadata(wsRoot, projID, group string, category config.Category) error { @@ -56,7 +55,7 @@ func existingGroups(workspaces []WorkspaceData) []string { return out } -func (m *Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateEditProject(msg tui.KeyMsg) (tui.Model, tui.Cmd) { key := msg.String() switch key { case "esc": @@ -101,7 +100,7 @@ func (m *Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) executeEditProject() (tea.Model, tea.Cmd) { +func (m *Model) executeEditProject() (tui.Model, tui.Cmd) { proj := m.popupProj if proj == nil { m.mode = viewList @@ -238,8 +237,8 @@ func (m *Model) viewEditProject() string { 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"))) + return tui.Place(m.width, m.height, tui.Center, tui.Center, popup, + tui.WithWhitespaceBackground(tui.Color("234"))) } func groupHint(workspaces []WorkspaceData) string { diff --git a/internal/agent/flash.go b/internal/agent/flash.go index 7713148..28c8623 100644 --- a/internal/agent/flash.go +++ b/internal/agent/flash.go @@ -1,18 +1,17 @@ package agent import ( + "github.com/kuchmenko/workspace/internal/tui" "strings" - - tea "github.com/charmbracelet/bubbletea" ) const jumpLabels = "asdfghjklqwertyuiopzxcvbnm" -func (m *Model) updateFlash(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateFlash(msg tui.KeyMsg) (tui.Model, tui.Cmd) { key := msg.String() switch key { case "ctrl+c": - return m, tea.Quit + return m, tui.Quit case "esc": m.exitFlash(false) case "backspace": diff --git a/internal/agent/forms.go b/internal/agent/forms.go index 40a9401..cbc8554 100644 --- a/internal/agent/forms.go +++ b/internal/agent/forms.go @@ -4,12 +4,11 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/layout" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m *Model) updateNewWorktree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateNewWorktree(msg tui.KeyMsg) (tui.Model, tui.Cmd) { key := msg.String() switch key { @@ -41,7 +40,7 @@ func (m *Model) updateNewWorktree(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) executeNewWorktree() (tea.Model, tea.Cmd) { +func (m *Model) executeNewWorktree() (tui.Model, tui.Cmd) { branch := strings.TrimSpace(m.wtBranch) if branch == "" { return m, nil @@ -117,11 +116,11 @@ func (m *Model) viewNewWorktree() string { 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"))) + return tui.Place(m.width, m.height, tui.Center, tui.Center, popup, + tui.WithWhitespaceBackground(tui.Color("234"))) } -func (m *Model) updatePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updatePromptInput(msg tui.KeyMsg) (tui.Model, tui.Cmd) { key := msg.String() switch key { case "esc": @@ -132,7 +131,7 @@ func (m *Model) updatePromptInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.pendingLaunch.Prompt = strings.TrimSpace(m.promptInput) m.Launch = m.pendingLaunch m.pendingLaunch = nil - return m, tea.Quit + return m, tui.Quit case "backspace": if len(m.promptInput) > 0 { m.promptInput = m.promptInput[:len(m.promptInput)-1] @@ -172,6 +171,6 @@ func (m *Model) viewPromptInput() string { 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"))) + return tui.Place(m.width, m.height, tui.Center, tui.Center, popup, + tui.WithWhitespaceBackground(tui.Color("234"))) } diff --git a/internal/agent/header.go b/internal/agent/header.go index 0076314..688b028 100644 --- a/internal/agent/header.go +++ b/internal/agent/header.go @@ -2,11 +2,10 @@ package agent import ( "fmt" + "github.com/kuchmenko/workspace/internal/tui" "sort" "strings" "time" - - "github.com/charmbracelet/lipgloss" ) const HeaderCap = 9 @@ -128,7 +127,7 @@ func packChips(chips []string, w, maxLines int) []string { if cur != "" { next = cur + " " + c } - if lipgloss.Width(next) > w { + if tui.Width(next) > w { if cur != "" { lines = append(lines, cur) if len(lines) >= maxLines { diff --git a/internal/agent/list.go b/internal/agent/list.go index 2671745..e3b29e1 100644 --- a/internal/agent/list.go +++ b/internal/agent/list.go @@ -3,12 +3,12 @@ package agent import ( "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/git" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateList(msg tui.KeyMsg) (tui.Model, tui.Cmd) { if m.pendingDelete { m.pendingDelete = false if msg.String() == "y" && m.deleteItem != nil { @@ -50,7 +50,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": - return m, tea.Quit + return m, tui.Quit case "j", "down": if m.cursor+1 < len(m.items) { m.cursor++ @@ -69,17 +69,17 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch item.kind { case KindGroup: m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit + return m, tui.Quit case KindProject: m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit + return m, tui.Quit case KindWorktree: m.Launch = &LaunchRequest{Cwd: item.path} - return m, tea.Quit + return m, tui.Quit case KindPortal: if item.session != nil { m.Launch = &LaunchRequest{Cwd: item.session.Cwd, ResumeID: item.session.ID} - return m, tea.Quit + return m, tui.Quit } } @@ -128,7 +128,7 @@ func (m *Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "l", "right": if item != nil && item.path != "" { m.Launch = &LaunchRequest{Cwd: item.path, ShellOnly: true} - return m, tea.Quit + return m, tui.Quit } case "f": diff --git a/internal/agent/render.go b/internal/agent/render.go index 2a4d0bc..5464ea0 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -2,9 +2,8 @@ package agent import ( "fmt" + "github.com/kuchmenko/workspace/internal/tui" "strings" - - "github.com/charmbracelet/lipgloss" ) func (m *Model) renderListRows(listW int, dimAll bool) []string { @@ -129,7 +128,7 @@ func (m *Model) renderProject(item listItem, selected, inFlash, isMatch bool, fl if badges != "" { leftPart := fmt.Sprintf(" %s%s %s", indent, icon, name) - padding := w - lipgloss.Width(leftPart) - lipgloss.Width(badges) - 1 + padding := w - tui.Width(leftPart) - tui.Width(badges) - 1 if padding < 1 { padding = 1 } @@ -161,7 +160,7 @@ func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, prefix := fmt.Sprintf(" %s%s ", indent, iconWorktree) - maxName := w - lipgloss.Width(prefix) - lipgloss.Width(status) - 2 + maxName := w - tui.Width(prefix) - tui.Width(status) - 2 if maxName > 0 && !inFlash { name = truncateStr(name, maxName) } @@ -176,7 +175,7 @@ func (m *Model) renderWorktree(item listItem, selected bool, w int, dimAll bool, return m.renderSelected(line, wtStyle, w) } leftRendered := wtStyle.Render(left) - padding := w - lipgloss.Width(left) - lipgloss.Width(status) - 1 + padding := w - tui.Width(left) - tui.Width(status) - 1 if padding < 1 { padding = 1 } @@ -231,7 +230,7 @@ func truncateStr(s string, maxLen int) string { return string(runes[:maxLen-1]) + "…" } -func (m *Model) renderSelected(content string, base lipgloss.Style, w int) string { +func (m *Model) renderSelected(content string, base tui.Style, w int) string { bar := accentBarStyle.Render("▌") rest := selectedStyle.Width(w - 1).Render(content) @@ -239,8 +238,8 @@ func (m *Model) renderSelected(content string, base lipgloss.Style, w int) strin } func (m *Model) padRight(left, right string, w int) string { - lw := lipgloss.Width(left) - rw := lipgloss.Width(right) + lw := tui.Width(left) + rw := tui.Width(right) gap := w - lw - rw - 1 if gap < 1 { gap = 1 @@ -295,11 +294,11 @@ func (m *Model) viewList() string { rows = append(rows, footerStyle.Width(listW).Render(" "+nav)) } - panel := lipgloss.JoinVertical(lipgloss.Left, rows...) + panel := tui.JoinVertical(tui.Left, rows...) - return lipgloss.Place( + return tui.Place( m.width, m.height, - lipgloss.Center, lipgloss.Center, + tui.Center, tui.Center, panel, ) } diff --git a/internal/agent/styles.go b/internal/agent/styles.go index 831f485..311f0c3 100644 --- a/internal/agent/styles.go +++ b/internal/agent/styles.go @@ -1,110 +1,40 @@ package agent -import "github.com/charmbracelet/lipgloss" +import "github.com/kuchmenko/workspace/internal/tui" var ( - headerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")). - Background(lipgloss.Color("235")) - - footerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Background(lipgloss.Color("235")) - - accentBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")) - - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")). - Background(lipgloss.Color("236")). - Bold(true) - - groupStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("182")). - Bold(true) - - itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")) - - wtStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("108")) - - sessionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("110")) - - badgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - wtStatusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("173")) - - statusMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")). - Bold(true) - - dimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - favoriteStarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("215")) - - activityAgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - - chipNumberStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) - - chipNameStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("254")). - Bold(true) - - flashSearchStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("215")). - Background(lipgloss.Color("235")) - - flashLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("235")). - Background(lipgloss.Color("215")) - - flashMatchStyle = lipgloss.NewStyle(). - Underline(true). - Foreground(lipgloss.Color("215")) - - popupBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("173")). - Padding(1, 1) - - popupTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("215")) - - 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")) - - 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")). - Bold(true) - - whichKeyDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) + headerStyle = tui.Amber.Header + footerStyle = tui.Amber.Footer + accentBarStyle = tui.NewStyle().Foreground("215") + selectedStyle = tui.Amber.Selected + groupStyle = tui.Amber.Group + itemStyle = tui.Amber.Item + dimStyle = tui.Amber.Dim + + wtStyle = tui.NewStyle().Foreground("108") + sessionStyle = tui.NewStyle().Foreground("110") + badgeStyle = tui.NewStyle().Foreground("240") + wtStatusStyle = tui.NewStyle().Foreground("173") + + statusMsgStyle = tui.NewStyle().Foreground("215").Bold(true) + favoriteStarStyle = tui.NewStyle().Foreground("215") + activityAgeStyle = tui.NewStyle().Foreground("240") + + chipNumberStyle = tui.NewStyle().Foreground("245") + chipNameStyle = tui.NewStyle().Foreground("254").Bold(true) + + flashSearchStyle = tui.NewStyle().Bold(true).Foreground("215").Background("235") + flashLabelStyle = tui.NewStyle().Bold(true).Foreground("235").Background("215") + flashMatchStyle = tui.NewStyle().Underline(true).Foreground("215") + + popupBorderStyle = tui.NewStyle().Border(tui.RoundedBorder()).BorderForeground("173").Padding(1, 1) + popupTitleStyle = tui.NewStyle().Bold(true).Foreground("215") + popupSelectedStyle = tui.NewStyle().Bold(true).Foreground("215").Background("237") + popupItemStyle = tui.NewStyle().Foreground("254") + popupDimStyle = tui.NewStyle().Foreground("240") + + whichKeyBorderStyle = tui.NewStyle().Border(tui.RoundedBorder()).BorderForeground("173").Padding(0, 1) + whichKeyTitleStyle = tui.NewStyle().Foreground("215").Bold(true) + whichKeyKeyStyle = tui.NewStyle().Foreground("215").Bold(true) + whichKeyDescStyle = tui.NewStyle().Foreground("245") ) diff --git a/internal/agent/tui.go b/internal/agent/tui.go index bf1eda4..e43a9cf 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -1,8 +1,8 @@ package agent import ( - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type viewMode int @@ -110,26 +110,26 @@ func NewModel(workspaces []WorkspaceData, sessCache *SessionCache) *Model { return m } -func (m *Model) Init() tea.Cmd { return nil } +func (m *Model) Init() tui.Cmd { return nil } -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Model) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case tea.KeyMsg: + case tui.KeyMsg: if msg.String() == "ctrl+c" || msg.String() == "ctrl+q" { - return m, tea.Quit + return m, tui.Quit } if msg.String() == "ctrl+s" { item := m.currentItem() if item != nil && item.path != "" { m.Launch = &LaunchRequest{Cwd: item.path, ShellOnly: true} - return m, tea.Quit + return m, tui.Quit } } if m.mode == viewPromptInput { diff --git a/internal/agent/whichkey.go b/internal/agent/whichkey.go index 8cfeb5f..7e3e431 100644 --- a/internal/agent/whichkey.go +++ b/internal/agent/whichkey.go @@ -4,9 +4,8 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type whichKeyAction struct { @@ -90,7 +89,7 @@ func (m *Model) favoriteToggleLabelGroup(group string) string { return "favorite" } -func (m *Model) updateWhichKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) updateWhichKey(msg tui.KeyMsg) (tui.Model, tui.Cmd) { key := msg.String() item := m.currentItem() @@ -284,7 +283,7 @@ func (m *Model) viewWhichKey() string { 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...) + listPanel := tui.JoinVertical(tui.Left, rows...) actions := m.whichKeyActions() title := m.whichKeyTitle() @@ -307,19 +306,19 @@ func (m *Model) viewWhichKey() string { actionContent := strings.Join(actionLines, "\n") actionPanel := whichKeyBorderStyle.Width(panelW).Render(actionContent) - listH := lipgloss.Height(listPanel) - panelH := lipgloss.Height(actionPanel) + listH := tui.Height(listPanel) + panelH := tui.Height(actionPanel) topPad := (listH - panelH) / 2 if topPad < 0 { topPad = 0 } paddedPanel := strings.Repeat("\n", topPad) + actionPanel - combined := lipgloss.JoinHorizontal(lipgloss.Top, listPanel, " ", paddedPanel) + combined := tui.JoinHorizontal(tui.Top, listPanel, " ", paddedPanel) - return lipgloss.Place( + return tui.Place( m.width, m.height, - lipgloss.Center, lipgloss.Center, + tui.Center, tui.Center, combined, ) } diff --git a/internal/cli/explorer.go b/internal/cli/explorer.go index d736702..968671d 100644 --- a/internal/cli/explorer.go +++ b/internal/cli/explorer.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/agent" + "github.com/kuchmenko/workspace/internal/tui" "github.com/spf13/cobra" ) @@ -109,7 +109,7 @@ func runExplorerTUI() error { } m := agent.NewModel(workspaces, sessCache) - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tui.NewProgram(m, tui.WithAltScreen()) finalModel, err := p.Run() if err != nil { return err diff --git a/internal/tui/style.go b/internal/tui/style.go index 161efea..bd0355e 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -33,11 +33,35 @@ func (s Style) BorderForeground(c Color) Style { type Position float64 const ( + Top Position = Position(lipgloss.Top) Left Position = Position(lipgloss.Left) Center Position = Position(lipgloss.Center) Right Position = Position(lipgloss.Right) + Bottom Position = Position(lipgloss.Bottom) ) +type PlaceOption func(*placeConfig) + +type placeConfig struct { + whitespaceBg *Color +} + +func WithWhitespaceBackground(c Color) PlaceOption { + return func(p *placeConfig) { p.whitespaceBg = &c } +} + +func Place(width, height int, hPos, vPos Position, content string, opts ...PlaceOption) string { + cfg := placeConfig{} + for _, o := range opts { + o(&cfg) + } + teaOpts := []lipgloss.WhitespaceOption{} + if cfg.whitespaceBg != nil { + teaOpts = append(teaOpts, lipgloss.WithWhitespaceBackground(lipgloss.Color(string(*cfg.whitespaceBg)))) + } + return lipgloss.Place(width, height, lipgloss.Position(hPos), lipgloss.Position(vPos), content, teaOpts...) +} + func Width(s string) int { return lipgloss.Width(s) } func Height(s string) int { return lipgloss.Height(s) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 31355ec..2883958 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -17,7 +17,7 @@ type Model interface { type QuitMsg struct{} -func Quit() Msg { return QuitMsg{} } +var Quit Cmd = func() Msg { return QuitMsg{} } func Batch(cmds ...Cmd) Cmd { var live []Cmd From 7de0b11096c8e8072df7101a5f3758eda541f667 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 13:34:14 +0300 Subject: [PATCH 6/9] =?UTF-8?q?refactor(add,branchprompt):=20consume=20int?= =?UTF-8?q?ernal/tui=20=E2=80=94=20drop=20bubbletea/lipgloss/bubbles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/tui gained widgets wrappers + a few helpers needed by add's flow: - tui.TextInput / tui.Spinner wrap bubbles/textinput / bubbles/spinner; consumers never see bubbles. SpinnerTickMsg re-exported as tui.SpinnerTickMsg type alias (the only point where bubbles types leak through a name). - tui.Sequence wraps tea.Sequence; tui.WithContext threads a context into the program so callers can cancel without importing bubbletea. - KeyMsg.Type renamed KeyRune -> KeyRunes for parity with bubbletea conventions; added KeyEscape as alias for KeyEsc. - TextInput.Focus moved to pointer receiver so focus state mutation propagates from the wrapper into the embedded bubbles model. Migrations (internal/add 11 files, internal/branchprompt 2 files): - All tea.X / lipgloss.X selector exprs rewritten to tui.X via the AST renamer (/tmp/teatotui/main.go) - bubbles imports removed from every consumer file; struct fields and constructors switched to tui.TextInput / tui.Spinner - branchprompt.NewModel auto-focuses the input so the test flow can type without an intervening Focus() Cmd dispatch - bubbletea+lipgloss now compile-fenced out of both packages Build, race tests, golangci-lint all green. Note: internal/cli/bootstrap_model.go remains broken because it embeds branchprompt.Model and is still bubbletea-native. Fixed in next commit (cli modals migration). Net: +115 LOC (the bulk is the new tui/widgets.go; consumer files are net-neutral or smaller). --- internal/add/add.go | 8 +- internal/add/browse.go | 10 +- internal/add/bulk_test.go | 5 +- internal/add/clone.go | 29 +++--- internal/add/edit.go | 20 ++-- internal/add/format.go | 12 +-- internal/add/gather.go | 11 +- internal/add/manual.go | 8 +- internal/add/styles.go | 52 +++++----- internal/add/tui.go | 51 +++++----- internal/add/tui_test.go | 26 ++--- internal/branchprompt/branchprompt.go | 61 ++++++----- internal/branchprompt/branchprompt_test.go | 21 ++-- internal/tui/dialog_test.go | 4 +- internal/tui/form_test.go | 2 +- internal/tui/keys.go | 6 +- internal/tui/list_test.go | 4 +- internal/tui/runtime.go | 12 ++- internal/tui/widgets.go | 113 +++++++++++++++++++++ 19 files changed, 285 insertions(+), 170 deletions(-) create mode 100644 internal/tui/widgets.go diff --git a/internal/add/add.go b/internal/add/add.go index 2283485..7ef09a9 100644 --- a/internal/add/add.go +++ b/internal/add/add.go @@ -21,10 +21,10 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/clipboard" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/github" + "github.com/kuchmenko/workspace/internal/tui" ) var ErrEmbedNotSupported = errors.New("embedded mode not yet supported") @@ -84,10 +84,10 @@ func runTUI(ctx context.Context, opts Options) (*Result, error) { Standalone: true, }) - prog := tea.NewProgram( + prog := tui.NewProgram( model, - tea.WithAltScreen(), - tea.WithContext(ctx), + tui.WithAltScreen(), + tui.WithContext(ctx), ) finalModel, err := prog.Run() diff --git a/internal/add/browse.go b/internal/add/browse.go index 3958509..1be6157 100644 --- a/internal/add/browse.go +++ b/internal/add/browse.go @@ -4,12 +4,12 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) +func (m AddModel) updateBrowse(msg tui.Msg) (tui.Model, tui.Cmd) { + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -27,7 +27,7 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 return m, nil } - var cmd tea.Cmd + var cmd tui.Cmd m.filterInput, cmd = m.filterInput.Update(msg) m.cursor = 0 return m, cmd @@ -121,7 +121,7 @@ func (m AddModel) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { } done := m.toDone() if m.standalone { - return done, tea.Sequence(emit(m.doneMsg()), tea.Quit) + return done, tui.Sequence(emit(m.doneMsg()), tui.Quit) } return done, emit(m.doneMsg()) } diff --git a/internal/add/bulk_test.go b/internal/add/bulk_test.go index 4f42aaf..59a0d67 100644 --- a/internal/add/bulk_test.go +++ b/internal/add/bulk_test.go @@ -1,12 +1,11 @@ package add import ( + "github.com/kuchmenko/workspace/internal/tui" "testing" - - tea "github.com/charmbracelet/bubbletea" ) -func keySpace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} } +func keySpace() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyRunes, Runes: []rune{' '}} } // browseModelWith returns a model that has already transitioned to // browse with the given suggestions. diff --git a/internal/add/clone.go b/internal/add/clone.go index 2691e68..bf1ddf6 100644 --- a/internal/add/clone.go +++ b/internal/add/clone.go @@ -5,18 +5,17 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/branchprompt" "github.com/kuchmenko/workspace/internal/clone" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m AddModel) startCloneJob(idx int) tea.Cmd { +func (m AddModel) startCloneJob(idx int) tui.Cmd { if idx >= len(m.queue) { - return func() tea.Msg { return allClonesDoneMsg{} } + return func() tui.Msg { return allClonesDoneMsg{} } } job := m.queue[idx] - return func() tea.Msg { + return func() tui.Msg { opts := Options{ URLs: []string{job.URL}, Name: job.Name, @@ -46,10 +45,10 @@ func (m AddModel) startCloneJob(idx int) tea.Cmd { } } -func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m AddModel) updateCloning(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd + case tui.SpinnerTickMsg: + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case cloneDoneMsg: @@ -65,7 +64,7 @@ func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentIdx >= len(m.queue) { m.transitionTo(addStateDone) if m.standalone { - return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) + return m, tui.Sequence(emit(m.doneMsg()), tui.Quit) } return m, emit(m.doneMsg()) } @@ -79,7 +78,7 @@ func (m AddModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { case allClonesDoneMsg: m.transitionTo(addStateDone) if m.standalone { - return m, tea.Sequence(emit(m.doneMsg()), tea.Quit) + return m, tui.Sequence(emit(m.doneMsg()), tui.Quit) } return m, emit(m.doneMsg()) } @@ -105,7 +104,7 @@ func (m AddModel) viewCloning() string { return b.String() } -func (m AddModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m AddModel) updateBranchPrompt(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { case branchprompt.PickedMsg: m.resolveBranch(msg.Branch, nil) @@ -116,7 +115,7 @@ func (m AddModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { m.transitionTo(addStateCloning) return m, nil } - var cmd tea.Cmd + var cmd tui.Cmd m.branchPrompt, cmd = m.branchPrompt.Update(msg) return m, cmd } @@ -128,10 +127,10 @@ func (m *AddModel) resolveBranch(branch string, err error) { } } -func (m AddModel) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { +func (m AddModel) updateDone(msg tui.Msg) (tui.Model, tui.Cmd) { + if _, ok := msg.(tui.KeyMsg); ok { if m.standalone { - return m, tea.Quit + return m, tui.Quit } } return m, nil diff --git a/internal/add/edit.go b/internal/add/edit.go index 3b431fb..a6fd5da 100644 --- a/internal/add/edit.go +++ b/internal/add/edit.go @@ -5,12 +5,12 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) +func (m AddModel) updateEdit(msg tui.Msg) (tui.Model, tui.Cmd) { + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -35,7 +35,7 @@ func (m AddModel) updateEdit(msg tea.Msg) (tea.Model, tea.Cmd) { s := key.String() - if key.Type == tea.KeyRunes { + if key.Type == tui.KeyRunes { runes := key.Runes m.applyEditRunes(runes) return m, nil @@ -133,14 +133,14 @@ func (m AddModel) viewEdit() string { return b.String() } -func (m AddModel) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m AddModel) updateConfirm(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.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)) + return m, tui.Batch(m.spinner.Tick, m.startCloneJob(0)) case "n", "N", "esc": m.transitionTo(addStateBrowse) return m, nil @@ -167,8 +167,8 @@ func (m AddModel) viewConfirm() string { return b.String() } -func (m AddModel) updateBulkConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) +func (m AddModel) updateBulkConfirm(msg tui.Msg) (tui.Model, tui.Cmd) { + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -183,7 +183,7 @@ func (m AddModel) updateBulkConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentIdx = 0 m.selectedURLs = nil m.transitionTo(addStateCloning) - return m, tea.Batch(m.spinner.Tick, m.startCloneJob(0)) + return m, tui.Batch(m.spinner.Tick, m.startCloneJob(0)) case "n", "N", "esc": m.transitionTo(addStateBrowse) return m, nil diff --git a/internal/add/format.go b/internal/add/format.go index f198c94..e8bd268 100644 --- a/internal/add/format.go +++ b/internal/add/format.go @@ -4,11 +4,9 @@ import ( "context" "errors" "fmt" + "github.com/kuchmenko/workspace/internal/tui" "strings" "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) func truncate(s string, n int) string { @@ -41,8 +39,8 @@ func relativeTime(t time.Time) string { } } -func emit(msg tea.Msg) tea.Cmd { - return func() tea.Msg { return msg } +func emit(msg tui.Msg) tui.Cmd { + return func() tui.Msg { return msg } } func parseRepoNameFromURL(url string) string { @@ -99,8 +97,8 @@ func renderSourceChipsLive(outcomes []SourceOutcome) string { color = "2" label = fmt.Sprintf("%s:%d", o.Name, o.Count) } - chips = append(chips, lipgloss.NewStyle(). - Foreground(lipgloss.Color(color)).Render(label)) + chips = append(chips, tui.NewStyle(). + Foreground(tui.Color(color)).Render(label)) } return strings.Join(chips, " ") } diff --git a/internal/add/gather.go b/internal/add/gather.go index ef292b1..c1fd0f6 100644 --- a/internal/add/gather.go +++ b/internal/add/gather.go @@ -4,11 +4,10 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tea.Model, tea.Cmd) { +func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tui.Model, tui.Cmd) { m.sourcesDone++ m.sourceOutcomes = append(m.sourceOutcomes, SourceOutcome{ Name: msg.name, @@ -37,9 +36,9 @@ func (m AddModel) handleSourceDone(msg sourceDoneMsg) (tea.Model, tea.Cmd) { 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 +func (m AddModel) updateGathering(msg tui.Msg) (tui.Model, tui.Cmd) { + if _, ok := msg.(tui.SpinnerTickMsg); ok { + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } diff --git a/internal/add/manual.go b/internal/add/manual.go index 16e282b..ef780d7 100644 --- a/internal/add/manual.go +++ b/internal/add/manual.go @@ -3,12 +3,12 @@ package add import ( "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m AddModel) updateManual(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m AddModel) updateManual(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "enter": val := strings.TrimSpace(m.manualInput.Value()) @@ -35,7 +35,7 @@ func (m AddModel) updateManual(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - var cmd tea.Cmd + var cmd tui.Cmd m.manualInput, cmd = m.manualInput.Update(msg) return m, cmd } diff --git a/internal/add/styles.go b/internal/add/styles.go index b3f3394..dcd710a 100644 --- a/internal/add/styles.go +++ b/internal/add/styles.go @@ -1,53 +1,55 @@ package add -import "github.com/charmbracelet/lipgloss" +import ( + "github.com/kuchmenko/workspace/internal/tui" +) var ( - addTitle = lipgloss.NewStyle(). + addTitle = tui.NewStyle(). Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - addDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + addDim = tui.NewStyle().Foreground(tui.Color("8")) - addHelp = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + addHelp = tui.NewStyle().Foreground(tui.Color("8")) - addCursor = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + addCursor = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - addAccent = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + addAccent = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - addErr = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). + addErr = tui.NewStyle(). + Foreground(tui.Color("1")). Bold(true) - addCheck = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + addCheck = tui.NewStyle().Foreground(tui.Color("2")) - addChip = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) + addChip = tui.NewStyle().Foreground(tui.Color("4")) - addGroupHdr = lipgloss.NewStyle(). - Foreground(lipgloss.Color("5")). + addGroupHdr = tui.NewStyle(). + Foreground(tui.Color("5")). Bold(true). Underline(true) - addItemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + addItemName = tui.NewStyle().Foreground(tui.Color("15")) - addExists = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")). + addExists = tui.NewStyle(). + Foreground(tui.Color("3")). Bold(true) - addExistsTag = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")). + addExistsTag = tui.NewStyle(). + Foreground(tui.Color("3")). Italic(true) - addPreviewName = lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). + addPreviewName = tui.NewStyle(). + Foreground(tui.Color("14")). Bold(true) - addCursorRow = lipgloss.NewStyle(). - Background(lipgloss.Color("237")) + addCursorRow = tui.NewStyle(). + Background(tui.Color("237")) ) diff --git a/internal/add/tui.go b/internal/add/tui.go index 8efb179..0d30281 100644 --- a/internal/add/tui.go +++ b/internal/add/tui.go @@ -4,12 +4,9 @@ import ( "context" "time" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/branchprompt" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type AddModel struct { @@ -28,7 +25,7 @@ type AddModel struct { width, height int - spinner spinner.Model + spinner tui.Spinner sourceOutcomes []SourceOutcome sourcesDone int @@ -36,11 +33,11 @@ type AddModel struct { cursor int allSuggestions []Suggestion filterMode bool - filterInput textinput.Model + filterInput tui.TextInput selectedURLs map[string]bool - manualInput textinput.Model + manualInput tui.TextInput manualErr string editFields editFields @@ -88,19 +85,19 @@ type branchAnswer struct { } func NewAddModel(opts AddModelOptions) AddModel { - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + sp := tui.NewSpinner() + sp.SetStyle(tui.DotSpinner) + sp.SetTextStyle(tui.NewStyle().Foreground("6")) - manual := textinput.New() - manual.Placeholder = "git@github.com:owner/repo.git" - manual.CharLimit = 200 - manual.Width = 60 + manual := tui.NewTextInput() + manual.SetPlaceholder("git@github.com:owner/repo.git") + manual.SetCharLimit(200) + manual.SetWidth(60) - filter := textinput.New() - filter.Placeholder = "type to search name / url / description / org..." - filter.CharLimit = 60 - filter.Width = 50 + filter := tui.NewTextInput() + filter.SetPlaceholder("type to search name / url / description / org...") + filter.SetCharLimit(60) + filter.SetWidth(50) return AddModel{ state: addStateGathering, @@ -129,20 +126,20 @@ type AddModelOptions struct { PreURLs []string } -func (m AddModel) Init() tea.Cmd { - cmds := []tea.Cmd{m.spinner.Tick} +func (m AddModel) Init() tui.Cmd { + cmds := []tui.Cmd{m.spinner.Tick} for _, src := range m.sources { cmds = append(cmds, m.startSource(src)) } - return tea.Batch(cmds...) + return tui.Batch(cmds...) } -func (m AddModel) startSource(src Source) tea.Cmd { +func (m AddModel) startSource(src Source) tui.Cmd { timeout := m.gatherTO if timeout <= 0 { timeout = DefaultSourceTimeout } - return func() tea.Msg { + return func() tui.Msg { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() start := time.Now() @@ -156,13 +153,13 @@ func (m AddModel) startSource(src Source) tea.Cmd { } } -func (m AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m AddModel) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case tea.KeyMsg: + case tui.KeyMsg: if !m.stateChangedAt.IsZero() && time.Since(m.stateChangedAt) < 100*time.Millisecond { return m, nil @@ -170,7 +167,7 @@ func (m AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { done := m.toDone() if m.standalone { - return done, tea.Sequence(emit(AddDoneMsg{}), tea.Quit) + return done, tui.Sequence(emit(AddDoneMsg{}), tui.Quit) } return done, emit(AddDoneMsg{}) } diff --git a/internal/add/tui_test.go b/internal/add/tui_test.go index 51f1bf7..abd92c1 100644 --- a/internal/add/tui_test.go +++ b/internal/add/tui_test.go @@ -5,16 +5,16 @@ import ( "testing" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/testutil" + "github.com/kuchmenko/workspace/internal/tui" ) // driveModel feeds a sequence of tea.Msg through Update and returns // the final model + the list of tea.Cmd outputs. This is enough to // test state transitions without the full bubbletea runtime. -func driveModel(m AddModel, msgs ...tea.Msg) (AddModel, []tea.Cmd) { - var cmds []tea.Cmd +func driveModel(m AddModel, msgs ...tui.Msg) (AddModel, []tui.Cmd) { + var cmds []tui.Cmd for _, msg := range msgs { // Bypass debounce by clearing stateChangedAt — the // production debounce is real-time-based, tests want @@ -28,22 +28,22 @@ func driveModel(m AddModel, msgs ...tea.Msg) (AddModel, []tea.Cmd) { } // runCmd runs a tea.Cmd and returns the message it produces, or nil. -func runCmd(c tea.Cmd) tea.Msg { +func runCmd(c tui.Cmd) tui.Msg { if c == nil { return nil } return c() } -func keyRunes(s string) tea.KeyMsg { - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +func keyRunes(s string) tui.KeyMsg { + return tui.KeyMsg{Type: tui.KeyRunes, Runes: []rune(s)} } -func keyEnter() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} } -func keyEsc() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEscape} } -func keyDown() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDown} } -func keyTab() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyTab} } -func keyBackspace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyBackspace} } +func keyEnter() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyEnter} } +func keyEsc() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyEscape} } +func keyDown() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyDown} } +func keyTab() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyTab} } +func keyBackspace() tui.KeyMsg { return tui.KeyMsg{Type: tui.KeyBackspace} } func newTestModel(t *testing.T, sources []Source) AddModel { t.Helper() @@ -477,7 +477,7 @@ func TestAddModel_CtrlC_AlwaysGoesToDone(t *testing.T) { m := newTestModel(t, nil) m.state = st m.standalone = true - ctrlC := tea.KeyMsg{Type: tea.KeyCtrlC} + ctrlC := tui.KeyMsg{Type: tui.KeyCtrlC} mm, cmd := m.Update(ctrlC) m = mm.(AddModel) if m.state != addStateDone { @@ -536,7 +536,7 @@ func TestAddModel_FullHappyPath(t *testing.T) { // 4. Hit 'y' on confirm → cloning, queue has one entry, cmd // starts the clone. - var cmds []tea.Cmd + var cmds []tui.Cmd m, cmds = driveModel(m, keyRunes("y")) if m.state != addStateCloning { t.Fatalf("state after confirm: %d", m.state) diff --git a/internal/branchprompt/branchprompt.go b/internal/branchprompt/branchprompt.go index dc69650..a9233f6 100644 --- a/internal/branchprompt/branchprompt.go +++ b/internal/branchprompt/branchprompt.go @@ -17,9 +17,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/kuchmenko/workspace/internal/tui" ) type Model struct { @@ -27,13 +25,14 @@ type Model struct { candidates []string cursor int inputMode bool - input textinput.Model + input tui.TextInput } func NewModel(project string, candidates []string) Model { - ti := textinput.New() - ti.Placeholder = "branch name" - ti.CharLimit = 80 + ti := tui.NewTextInput() + ti.SetPlaceholder("branch name") + ti.SetCharLimit(80) + ti.Focus() return Model{ project: project, candidates: candidates, @@ -41,10 +40,10 @@ func NewModel(project string, candidates []string) Model { } } -func (m Model) Init() tea.Cmd { return nil } +func (m Model) Init() tui.Cmd { return nil } -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) +func (m Model) Update(msg tui.Msg) (Model, tui.Cmd) { + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -54,7 +53,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m.updateListMode(key) } -func (m Model) updateInputMode(msg tea.Msg, key tea.KeyMsg) (Model, tea.Cmd) { +func (m Model) updateInputMode(msg tui.Msg, key tui.KeyMsg) (Model, tui.Cmd) { switch key.String() { case "enter": val := strings.TrimSpace(m.input.Value()) @@ -66,12 +65,12 @@ func (m Model) updateInputMode(msg tea.Msg, key tea.KeyMsg) (Model, tea.Cmd) { m.inputMode = false return m, nil } - var cmd tea.Cmd + var cmd tui.Cmd m.input, cmd = m.input.Update(msg) return m, cmd } -func (m Model) updateListMode(key tea.KeyMsg) (Model, tea.Cmd) { +func (m Model) updateListMode(key tui.KeyMsg) (Model, tui.Cmd) { switch key.String() { case "up", "k": if m.cursor > 0 { @@ -92,7 +91,7 @@ func (m Model) updateListMode(key tea.KeyMsg) (Model, tea.Cmd) { return m, nil } -func (m Model) confirmListSelection() (Model, tea.Cmd) { +func (m Model) confirmListSelection() (Model, tui.Cmd) { if len(m.candidates) == 0 { m.inputMode = true return m, m.input.Focus() @@ -100,14 +99,14 @@ func (m Model) confirmListSelection() (Model, tea.Cmd) { return m, emitPickedCmd(m.project, m.candidates[m.cursor]) } -func emitPickedCmd(project, branch string) tea.Cmd { +func emitPickedCmd(project, branch string) tui.Cmd { picked := PickedMsg{Project: project, Branch: branch} - return func() tea.Msg { return picked } + return func() tui.Msg { return picked } } -func emitCancelledCmd(project string) tea.Cmd { +func emitCancelledCmd(project string) tui.Cmd { canceled := CancelledMsg{Project: project} - return func() tea.Msg { return canceled } + return func() tui.Msg { return canceled } } func (m Model) Project() string { return m.project } @@ -146,26 +145,26 @@ func (m Model) View() string { } var ( - titleStyle = lipgloss.NewStyle(). + titleStyle = tui.NewStyle(). Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - headerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + headerStyle = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - dimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + dimStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + helpStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - cursorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + cursorStyle = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) + selectedStyle = tui.NewStyle(). + Foreground(tui.Color("6")) ) diff --git a/internal/branchprompt/branchprompt_test.go b/internal/branchprompt/branchprompt_test.go index d06192b..ead38c3 100644 --- a/internal/branchprompt/branchprompt_test.go +++ b/internal/branchprompt/branchprompt_test.go @@ -1,26 +1,25 @@ package branchprompt import ( + "github.com/kuchmenko/workspace/internal/tui" "testing" - - tea "github.com/charmbracelet/bubbletea" ) // keyMsg constructs a tea.KeyMsg from a single-key string shortcut that // the model accepts ("up", "down", "enter", "esc", "i", "k", "j"). // Text input in free-text mode uses KeyMsg with Runes populated. -func keyMsg(s string) tea.KeyMsg { +func keyMsg(s string) tui.KeyMsg { switch s { case "up": - return tea.KeyMsg{Type: tea.KeyUp} + return tui.KeyMsg{Type: tui.KeyUp} case "down": - return tea.KeyMsg{Type: tea.KeyDown} + return tui.KeyMsg{Type: tui.KeyDown} case "enter": - return tea.KeyMsg{Type: tea.KeyEnter} + return tui.KeyMsg{Type: tui.KeyEnter} case "esc": - return tea.KeyMsg{Type: tea.KeyEscape} + return tui.KeyMsg{Type: tui.KeyEscape} default: - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + return tui.KeyMsg{Type: tui.KeyRunes, Runes: []rune(s)} } } @@ -28,7 +27,7 @@ func keyMsg(s string) tea.KeyMsg { // Mirrors how the bubbletea runtime dispatches the returned cmd back // through Update — good enough for asserting the "one-shot message" // emissions PickedMsg / CancelledMsg. -func collectMsg(cmd tea.Cmd) tea.Msg { +func collectMsg(cmd tui.Cmd) tui.Msg { if cmd == nil { return nil } @@ -97,7 +96,7 @@ func TestModel_InputMode_EnterEmpty_NoEmit(t *testing.T) { m := NewModel("x", nil) m, _ = m.Update(keyMsg("enter")) // Now in input mode, empty value. - var cmd tea.Cmd + var cmd tui.Cmd m, cmd = m.Update(keyMsg("enter")) if msg := collectMsg(cmd); msg != nil { t.Fatalf("expected no msg for empty enter, got %T", msg) @@ -137,7 +136,7 @@ func TestModel_InputMode_EnterEmitsPicked(t *testing.T) { func TestModel_Escape_EmitsCancelled(t *testing.T) { m := NewModel("proj", []string{"main", "master"}) - var cmd tea.Cmd + var cmd tui.Cmd _, cmd = m.Update(keyMsg("esc")) msg := collectMsg(cmd) diff --git a/internal/tui/dialog_test.go b/internal/tui/dialog_test.go index a056df1..5d57f40 100644 --- a/internal/tui/dialog_test.go +++ b/internal/tui/dialog_test.go @@ -8,9 +8,9 @@ func TestConfirmDialog_YesNoCancel(t *testing.T) { key KeyMsg want Msg }{ - {KeyMsg{Type: KeyRune, Runes: []rune{'y'}}, ConfirmedMsg{}}, + {KeyMsg{Type: KeyRunes, Runes: []rune{'y'}}, ConfirmedMsg{}}, {KeyMsg{Type: KeyEnter}, ConfirmedMsg{}}, - {KeyMsg{Type: KeyRune, Runes: []rune{'n'}}, CancelledMsg{}}, + {KeyMsg{Type: KeyRunes, Runes: []rune{'n'}}, CancelledMsg{}}, {KeyMsg{Type: KeyEsc}, CancelledMsg{}}, } for _, c := range cases { diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go index d5a8b66..cae0d58 100644 --- a/internal/tui/form_test.go +++ b/internal/tui/form_test.go @@ -10,7 +10,7 @@ func TestModalForm_TypingAndSubmit(t *testing.T) { {Name: "branch", Value: ""}, }) for _, c := range "feat/x" { - m, _ := f.Update(KeyMsg{Type: KeyRune, Runes: []rune{c}}) + m, _ := f.Update(KeyMsg{Type: KeyRunes, Runes: []rune{c}}) f = m.(ModalForm) } _, cmd := f.Update(KeyMsg{Type: KeyEnter}) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 7d2267f..654031f 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -5,7 +5,7 @@ import "strings" type KeyType int const ( - KeyRune KeyType = iota + KeyRunes KeyType = iota KeyEnter KeyEsc KeyTab @@ -25,6 +25,8 @@ const ( KeyCtrlD ) +const KeyEscape = KeyEsc + type KeyMsg struct { Type KeyType Runes []rune @@ -60,7 +62,7 @@ func (k KeyMsg) String() string { if k.Ctrl && k.Type != KeyCtrlC && k.Type != KeyCtrlD { b.WriteString("ctrl+") } - if k.Type == KeyRune { + if k.Type == KeyRunes { b.WriteString(string(k.Runes)) } else if name, ok := keyNames[k.Type]; ok { b.WriteString(name) diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go index 14b8341..650cff3 100644 --- a/internal/tui/list_test.go +++ b/internal/tui/list_test.go @@ -25,7 +25,7 @@ func key(s string) KeyMsg { case "tab": return KeyMsg{Type: KeyTab} } - return KeyMsg{Type: KeyRune, Runes: []rune(s)} + return KeyMsg{Type: KeyRunes, Runes: []rune(s)} } func TestFilterableList_CursorMovement(t *testing.T) { @@ -53,7 +53,7 @@ func TestFilterableList_FilterMode(t *testing.T) { t.Fatal("expected filter mode active after /") } for _, c := range "av" { - m, _ = l.Update(KeyMsg{Type: KeyRune, Runes: []rune{c}}) + m, _ = l.Update(KeyMsg{Type: KeyRunes, Runes: []rune{c}}) l = m.(FilterableList) } if l.Filter() != "av" { diff --git a/internal/tui/runtime.go b/internal/tui/runtime.go index ba4e3a0..d9476ce 100644 --- a/internal/tui/runtime.go +++ b/internal/tui/runtime.go @@ -1,6 +1,8 @@ package tui import ( + "context" + tea "github.com/charmbracelet/bubbletea" ) @@ -9,9 +11,10 @@ type ProgramOption func(*programConfig) type programConfig struct { altScreen bool mouse bool + ctx interface{} } -func WithAltScreen() ProgramOption { return func(c *programConfig) { c.altScreen = true } } +func WithAltScreen() ProgramOption { return func(c *programConfig) { c.altScreen = true } } func WithMouseCellMotion() ProgramOption { return func(c *programConfig) { c.mouse = true } } type Program struct { @@ -33,6 +36,11 @@ func NewProgram(m Model, opts ...ProgramOption) *Program { if cfg.mouse { teaOpts = append(teaOpts, tea.WithMouseCellMotion()) } + if cfg.ctx != nil { + if ctx, ok := cfg.ctx.(context.Context); ok { + teaOpts = append(teaOpts, tea.WithContext(ctx)) + } + } return &Program{p: tea.NewProgram(w, teaOpts...), bt: w, cfg: cfg} } @@ -103,7 +111,7 @@ func translateOut(msg Msg) tea.Msg { } var teaToOwnKeyType = map[tea.KeyType]KeyType{ - tea.KeyRunes: KeyRune, + tea.KeyRunes: KeyRunes, tea.KeySpace: KeySpace, tea.KeyEnter: KeyEnter, tea.KeyEsc: KeyEsc, diff --git a/internal/tui/widgets.go b/internal/tui/widgets.go new file mode 100644 index 0000000..b91b8fa --- /dev/null +++ b/internal/tui/widgets.go @@ -0,0 +1,113 @@ +package tui + +import ( + "context" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type TextInput struct{ ti textinput.Model } + +func NewTextInput() TextInput { + return TextInput{ti: textinput.New()} +} + +func (t TextInput) Update(msg Msg) (TextInput, Cmd) { + next, cmd := t.ti.Update(teaMsg(msg)) + return TextInput{ti: next}, ownCmd(cmd) +} + +func (t TextInput) View() string { return t.ti.View() } +func (t TextInput) Value() string { return t.ti.Value() } +func (t TextInput) Focused() bool { return t.ti.Focused() } +func (t *TextInput) Focus() Cmd { return ownCmd(t.ti.Focus()) } +func (t *TextInput) Blur() { t.ti.Blur() } +func (t *TextInput) SetValue(v string) { t.ti.SetValue(v) } +func (t *TextInput) SetPlaceholder(p string) { t.ti.Placeholder = p } +func (t *TextInput) SetPrompt(p string) { t.ti.Prompt = p } +func (t *TextInput) SetCharLimit(n int) { t.ti.CharLimit = n } +func (t *TextInput) SetWidth(w int) { t.ti.Width = w } + +type SpinnerStyle struct{ s spinner.Spinner } + +var DotSpinner = SpinnerStyle{s: spinner.Dot} +var LineSpinner = SpinnerStyle{s: spinner.Line} + +type Spinner struct{ sp spinner.Model } + +func NewSpinner() Spinner { + return Spinner{sp: spinner.New()} +} + +func (s Spinner) Update(msg Msg) (Spinner, Cmd) { + next, cmd := s.sp.Update(teaMsg(msg)) + return Spinner{sp: next}, ownCmd(cmd) +} + +func (s Spinner) View() string { return s.sp.View() } +func (s Spinner) Tick() Msg { return s.sp.Tick() } +func (s *Spinner) SetStyle(st SpinnerStyle) { s.sp.Spinner = st.s } +func (s *Spinner) SetTextStyle(st Style) { s.sp.Style = st.s } + +type SpinnerTickMsg = spinner.TickMsg + +func Sequence(cmds ...Cmd) Cmd { + teaCmds := make([]tea.Cmd, len(cmds)) + for i, c := range cmds { + teaCmds[i] = liftCmd(c) + } + c := tea.Sequence(teaCmds...) + return ownCmd(c) +} + +func WithContext(ctx context.Context) ProgramOption { + return func(c *programConfig) { c.ctx = ctx } +} + +func teaMsg(m Msg) tea.Msg { + switch v := m.(type) { + case KeyMsg: + return keyMsgToBubbletea(v) + case WindowSizeMsg: + return tea.WindowSizeMsg{Width: v.Width, Height: v.Height} + } + return m +} + +func ownCmd(c tea.Cmd) Cmd { + if c == nil { + return nil + } + return func() Msg { return translateIn(c()) } +} + +var ownToTeaKeyType = map[KeyType]tea.KeyType{ + KeyRunes: tea.KeyRunes, + KeySpace: tea.KeySpace, + KeyEnter: tea.KeyEnter, + KeyEsc: tea.KeyEsc, + KeyTab: tea.KeyTab, + KeyShiftTab: tea.KeyShiftTab, + KeyBackspace: tea.KeyBackspace, + KeyUp: tea.KeyUp, + KeyDown: tea.KeyDown, + KeyLeft: tea.KeyLeft, + KeyRight: tea.KeyRight, + KeyHome: tea.KeyHome, + KeyEnd: tea.KeyEnd, + KeyPgUp: tea.KeyPgUp, + KeyPgDn: tea.KeyPgDown, + KeyDelete: tea.KeyDelete, + KeyCtrlC: tea.KeyCtrlC, + KeyCtrlD: tea.KeyCtrlD, +} + +func keyMsgToBubbletea(m KeyMsg) tea.KeyMsg { + out := tea.KeyMsg{Runes: m.Runes, Alt: m.Alt} + if t, ok := ownToTeaKeyType[m.Type]; ok { + out.Type = t + } + return out +} From bd645096d2333048067c001ace2ea79ba921ffca Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 13:40:43 +0300 Subject: [PATCH 7/9] =?UTF-8?q?refactor(cli,create,aliasmgr,setup):=20cons?= =?UTF-8?q?ume=20internal/tui=20=E2=80=94=20seal=20the=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining 4 packages that still imported bubbletea/lipgloss/bubbles are now migrated. Pattern (same as agent + add): - All tea.X / lipgloss.X selector exprs rewritten to tui.X via the AST renamer (/tmp/teatotui/main.go). - Bubbles widget fields switched: spinner.Model -> tui.Spinner, textinput.Model -> tui.TextInput. - Constructors and setters use the tui wrappers: NewSpinner/NewTextInput with SetStyle/SetPlaceholder/SetCharLimit/SetWidth/CursorEnd. - spinner.TickMsg -> tui.SpinnerTickMsg in case branches. Packages migrated in this commit: - internal/cli (bootstrap_model.go, migrate_model.go, plus the renamer swept bootstrap_view.go, bootstrap.go, migrate_tui.go, setup.go, alias.go) - internal/create (tui.go, plus render.go, runner.go, cmd.go via renamer) - internal/aliasmgr (model.go, step_confirm.go, step_manage.go) - internal/setup (setup.go, step_select.go, step_group.go) tui additions: - TextInput.CursorEnd() — required by aliasmgr's editInput flow Verification: - grep "charmbracelet" --include="*.go" | grep -v internal/tui → zero - go build ./... ✓ - go test -timeout 5m ./... ✓ (all packages green) - golangci-lint run ✓ (0 issues) The strict eviction seam is fully sealed: bubbletea/lipgloss/bubbles are import-visible to internal/tui only. Bubbletea eviction is now a pure internal/tui rewrite. --- internal/aliasmgr/model.go | 54 +++++++-------- internal/aliasmgr/step_confirm.go | 10 +-- internal/aliasmgr/step_manage.go | 16 ++--- internal/cli/alias.go | 4 +- internal/cli/bootstrap.go | 4 +- internal/cli/bootstrap_model.go | 60 ++++++++-------- internal/cli/bootstrap_view.go | 48 ++++++------- internal/cli/migrate_model.go | 64 +++++++++-------- internal/cli/migrate_tui.go | 4 +- internal/cli/setup.go | 4 +- internal/create/cmd.go | 10 +-- internal/create/render.go | 29 ++++---- internal/create/runner.go | 9 ++- internal/create/tui.go | 86 ++++++++++++----------- internal/setup/setup.go | 110 +++++++++++++++--------------- internal/setup/step_group.go | 23 +++---- internal/setup/step_select.go | 17 +++-- internal/tui/widgets.go | 1 + 18 files changed, 270 insertions(+), 283 deletions(-) diff --git a/internal/aliasmgr/model.go b/internal/aliasmgr/model.go index b39f9de..8852112 100644 --- a/internal/aliasmgr/model.go +++ b/internal/aliasmgr/model.go @@ -4,11 +4,9 @@ import ( "sort" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/alias" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type step int @@ -48,9 +46,9 @@ type Model struct { items []item cursor int offset int - search textinput.Model + search tui.TextInput editing bool - editInput textinput.Model + editInput tui.TextInput editTarget int result Result stepChangedAt time.Time @@ -59,12 +57,12 @@ type Model struct { func New(ws *config.Workspace, root string) Model { items := buildItems(ws) - search := textinput.New() - search.Placeholder = "type to search..." - search.CharLimit = 60 + search := tui.NewTextInput() + search.SetPlaceholder("type to search...") + search.SetCharLimit(60) - edit := textinput.New() - edit.CharLimit = 32 + edit := tui.NewTextInput() + edit.SetCharLimit(32) return Model{ ws: ws, @@ -130,23 +128,23 @@ func buildItems(ws *config.Workspace) []item { return items } -func (m Model) Init() tea.Cmd { +func (m Model) Init() tui.Cmd { return m.search.Focus() } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case tea.KeyMsg: + case tui.KeyMsg: if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { return m, nil } if msg.String() == "ctrl+c" { m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit } } @@ -202,19 +200,19 @@ func (m Model) buildAliasMap() map[string]string { } var ( - titleStyle = lipgloss.NewStyle().Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + titleStyle = tui.NewStyle().Bold(true). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) - checkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - uncheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - groupNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Bold(true) - rootNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) + selectedStyle = tui.NewStyle().Foreground(tui.Color("6")) + dimStyle = tui.NewStyle().Foreground(tui.Color("8")) + cursorStyle = tui.NewStyle().Foreground(tui.Color("6")).Bold(true) + checkStyle = tui.NewStyle().Foreground(tui.Color("2")) + uncheckStyle = tui.NewStyle().Foreground(tui.Color("8")) + warnStyle = tui.NewStyle().Foreground(tui.Color("3")) + errStyle = tui.NewStyle().Foreground(tui.Color("1")) + helpStyle = tui.NewStyle().Foreground(tui.Color("8")) + groupNameStyle = tui.NewStyle().Foreground(tui.Color("4")).Bold(true) + rootNameStyle = tui.NewStyle().Foreground(tui.Color("5")).Bold(true) ) diff --git a/internal/aliasmgr/step_confirm.go b/internal/aliasmgr/step_confirm.go index b947c4c..f7ed1fb 100644 --- a/internal/aliasmgr/step_confirm.go +++ b/internal/aliasmgr/step_confirm.go @@ -6,23 +6,23 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/alias" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) -func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m Model) updateConfirm(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "y", "Y", "enter": m.result = Result{ Confirmed: true, Aliases: m.buildAliasMap(), } - return m, tea.Quit + return m, tui.Quit case "n", "N": m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit case "esc": m.step = stepManage m.stepChangedAt = time.Now() diff --git a/internal/aliasmgr/step_manage.go b/internal/aliasmgr/step_manage.go index 34e7121..1abbee1 100644 --- a/internal/aliasmgr/step_manage.go +++ b/internal/aliasmgr/step_manage.go @@ -6,8 +6,8 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/alias" + "github.com/kuchmenko/workspace/internal/tui" ) type treeRow struct { @@ -148,17 +148,17 @@ func (m Model) maxVisible() int { return h } -func (m Model) updateManage(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) updateManage(msg tui.Msg) (tui.Model, tui.Cmd) { if m.editing { return m.updateEditing(msg) } - if key, ok := msg.(tea.KeyMsg); ok { + if key, ok := msg.(tui.KeyMsg); ok { rows := m.buildTree() switch key.String() { case "esc": m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit case "enter": m.step = stepConfirm m.stepChangedAt = time.Now() @@ -206,7 +206,7 @@ func (m Model) updateManage(msg tea.Msg) (tea.Model, tea.Cmd) { } } - var cmd tea.Cmd + var cmd tui.Cmd prev := m.search.Value() m.search, cmd = m.search.Update(msg) if m.search.Value() != prev { @@ -216,8 +216,8 @@ func (m Model) updateManage(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m Model) updateEditing(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m Model) updateEditing(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "enter": name := strings.TrimSpace(m.editInput.Value()) @@ -232,7 +232,7 @@ func (m Model) updateEditing(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - var cmd tea.Cmd + var cmd tui.Cmd m.editInput, cmd = m.editInput.Update(msg) return m, cmd } diff --git a/internal/cli/alias.go b/internal/cli/alias.go index 157f338..b51e745 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -5,9 +5,9 @@ import ( "os" "text/tabwriter" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/alias" "github.com/kuchmenko/workspace/internal/aliasmgr" + "github.com/kuchmenko/workspace/internal/tui" "github.com/spf13/cobra" ) @@ -33,7 +33,7 @@ func newAliasCmd() *cobra.Command { func runAliasTUI(cmd *cobra.Command, args []string) error { m := aliasmgr.New(ws, wsRoot) - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tui.NewProgram(m, tui.WithAltScreen()) res, err := p.Run() if err != nil { return fmt.Errorf("TUI crashed: %w", err) diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index a3a4206..933e4e8 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -7,10 +7,10 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/bootstrap" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/conflict" + "github.com/kuchmenko/workspace/internal/tui" "github.com/spf13/cobra" ) @@ -107,7 +107,7 @@ func runBootstrap(args []string, dryRun bool) error { } model := newBootstrapModel(plan, toClone, resumeFrom) - p := tea.NewProgram(model, tea.WithAltScreen()) + p := tui.NewProgram(model, tui.WithAltScreen()) program = p defer func() { program = nil }() finalRaw, runErr := p.Run() diff --git a/internal/cli/bootstrap_model.go b/internal/cli/bootstrap_model.go index 996baa6..d6c9d72 100644 --- a/internal/cli/bootstrap_model.go +++ b/internal/cli/bootstrap_model.go @@ -5,13 +5,11 @@ import ( "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" + "github.com/kuchmenko/workspace/internal/tui" ) type bootstrapStep int @@ -41,7 +39,7 @@ type bootstrapModel struct { errors []bootstrapError canceled bool - spinner spinner.Model + spinner tui.Spinner sidecar *bootstrap.Sidecar branchPrompt branchprompt.Model @@ -67,12 +65,12 @@ type needsBranchMsg struct { type allDoneMsg struct{} -var program *tea.Program +var program *tui.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")) + sp := tui.NewSpinner() + sp.SetStyle(tui.DotSpinner) + sp.SetTextStyle(tui.NewStyle().Foreground("6")) sc := bootstrap.New(wsRoot) for k, v := range resume { @@ -88,25 +86,25 @@ func newBootstrapModel(plan *bootstrap.Plan, toClone []bootstrap.PlanItem, resum } } -func (m bootstrapModel) Init() tea.Cmd { +func (m bootstrapModel) Init() tui.Cmd { return m.spinner.Tick } -func (m bootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m bootstrapModel) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case tea.KeyMsg: + case tui.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 + return m, tui.Quit } } @@ -118,45 +116,45 @@ func (m bootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case bsStepBranchPrompt: return m.updateBranchPrompt(msg) case bsStepDone: - if _, ok := msg.(tea.KeyMsg); ok { - return m, tea.Quit + if _, ok := msg.(tui.KeyMsg); ok { + return m, tui.Quit } } return m, nil } -func (m bootstrapModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m bootstrapModel) updatePlan(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "y", "Y", "enter": if len(m.toClone) == 0 { m.step = bsStepDone - return m, tea.Quit + return m, tui.Quit } if err := bootstrap.Save(m.sidecar); err != nil { m.errors = append(m.errors, bootstrapError{project: "", err: err}) - return m, tea.Quit + return m, tui.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)) + return m, tui.Batch(m.spinner.Tick, m.startClone(0)) case "n", "N", "escape": m.canceled = true - return m, tea.Quit + return m, tui.Quit } } return m, nil } -func (m bootstrapModel) startClone(index int) tea.Cmd { +func (m bootstrapModel) startClone(index int) tui.Cmd { if index >= len(m.toClone) { - return func() tea.Msg { return allDoneMsg{} } + return func() tui.Msg { return allDoneMsg{} } } item := m.toClone[index] - return func() tea.Msg { + return func() tui.Msg { proj := item.Project ch := make(chan branchAnswer, 1) @@ -179,10 +177,10 @@ func (m bootstrapModel) startClone(index int) tea.Cmd { } } -func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m bootstrapModel) updateCloning(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd + case tui.SpinnerTickMsg: + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd @@ -213,18 +211,18 @@ func (m bootstrapModel) updateCloning(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.current >= len(m.toClone) { m.step = bsStepDone - return m, tea.Quit + return m, tui.Quit } return m, m.startClone(m.current) case allDoneMsg: m.step = bsStepDone - return m, tea.Quit + return m, tui.Quit } return m, nil } -func (m bootstrapModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m bootstrapModel) updateBranchPrompt(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { case branchprompt.PickedMsg: m.resolveBranch(msg.Branch, nil) @@ -239,7 +237,7 @@ func (m bootstrapModel) updateBranchPrompt(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - var cmd tea.Cmd + var cmd tui.Cmd m.branchPrompt, cmd = m.branchPrompt.Update(msg) return m, cmd } diff --git a/internal/cli/bootstrap_view.go b/internal/cli/bootstrap_view.go index 8446819..66de898 100644 --- a/internal/cli/bootstrap_view.go +++ b/internal/cli/bootstrap_view.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" "github.com/kuchmenko/workspace/internal/bootstrap" + "github.com/kuchmenko/workspace/internal/tui" ) func (m bootstrapModel) View() string { @@ -137,41 +137,41 @@ func indent(s, prefix string) string { } var ( - bsTitleStyle = lipgloss.NewStyle(). + bsTitleStyle = tui.NewStyle(). Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - bsHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + bsHeaderStyle = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - bsDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + bsDimStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - bsHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + bsHelpStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - bsCheckStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("2")) + bsCheckStyle = tui.NewStyle(). + Foreground(tui.Color("2")) - bsWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("3")) + bsWarnStyle = tui.NewStyle(). + Foreground(tui.Color("3")) - bsErrStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")) + bsErrStyle = tui.NewStyle(). + Foreground(tui.Color("1")) - bsArrowStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) + bsArrowStyle = tui.NewStyle(). + Foreground(tui.Color("6")) - bsBarFilledStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) + bsBarFilledStyle = tui.NewStyle(). + Foreground(tui.Color("6")) - bsBarEmptyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + bsBarEmptyStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - errorBannerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). + errorBannerStyle = tui.NewStyle(). + Foreground(tui.Color("1")). Bold(true) ) diff --git a/internal/cli/migrate_model.go b/internal/cli/migrate_model.go index 1eb2a50..a01ec1f 100644 --- a/internal/cli/migrate_model.go +++ b/internal/cli/migrate_model.go @@ -4,11 +4,9 @@ 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" + "github.com/kuchmenko/workspace/internal/tui" ) type migrateStep int @@ -42,7 +40,7 @@ type migrateModel struct { skipped int canceled bool - spinner spinner.Model + spinner tui.Spinner sidecar *migrate.Sidecar } @@ -63,9 +61,9 @@ type migrateDoneMsg struct { 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")) + sp := tui.NewSpinner() + sp.SetStyle(tui.DotSpinner) + sp.SetTextStyle(tui.NewStyle().Foreground("6")) sc := migrate.New(wsRoot) for k, v := range resume { @@ -82,19 +80,19 @@ func newMigrateModel(plan *migratePlan, machine string, resume map[string]migrat } } -func (m migrateModel) Init() tea.Cmd { +func (m migrateModel) Init() tui.Cmd { return m.spinner.Tick } -func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m migrateModel) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tui.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 + return m, tui.Quit } } @@ -106,15 +104,15 @@ func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case mStepMigrating: return m.updateMigrating(msg) case mStepDone: - if _, ok := msg.(tea.KeyMsg); ok { - return m, tea.Quit + if _, ok := msg.(tui.KeyMsg); ok { + return m, tui.Quit } } return m, nil } -func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m migrateModel) updatePlan(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "y", "Y", "enter": @@ -123,28 +121,28 @@ func (m migrateModel) updatePlan(msg tea.Msg) (tea.Model, tea.Cmd) { } if len(m.queue) == 0 { m.step = mStepDone - return m, tea.Quit + return m, tui.Quit } if err := migrate.Save(m.sidecar); err != nil { m.errors = append(m.errors, migrateError{project: "", err: err}) - return m, tea.Quit + return m, tui.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, tui.Quit } } return m, nil } -func (m migrateModel) advance() (tea.Model, tea.Cmd) { +func (m migrateModel) advance() (tui.Model, tui.Cmd) { if m.cursor >= len(m.queue) { m.step = mStepDone - return m, tea.Quit + return m, tui.Quit } m.current = m.queue[m.cursor] switch m.current.State { @@ -152,7 +150,7 @@ func (m migrateModel) advance() (tea.Model, tea.Cmd) { m.step = mStepMigrating m.stepChangedAt = time.Now() - return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) + return m, tui.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) case mstDirty, mstStash, mstDetached: m.step = mStepDecision m.stepChangedAt = time.Now() @@ -164,8 +162,8 @@ func (m migrateModel) advance() (tea.Model, tea.Cmd) { return m.advance() } -func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { - key, ok := msg.(tea.KeyMsg) +func (m migrateModel) updateDecision(msg tui.Msg) (tui.Model, tui.Cmd) { + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -182,7 +180,7 @@ func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { resolved = true case "a", "A": m.canceled = true - return m, tea.Quit + return m, tui.Quit } case mstStash: switch key.String() { @@ -194,7 +192,7 @@ func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { resolved = true case "a", "A": m.canceled = true - return m, tea.Quit + return m, tui.Quit } case mstDetached: switch key.String() { @@ -206,7 +204,7 @@ func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { resolved = true case "a", "A": m.canceled = true - return m, tea.Quit + return m, tui.Quit } } if !resolved { @@ -220,14 +218,14 @@ func (m migrateModel) updateDecision(msg tea.Msg) (tea.Model, tea.Cmd) { } m.step = mStepMigrating m.stepChangedAt = time.Now() - return m, tea.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) + return m, tui.Batch(m.spinner.Tick, m.startMigrate(m.cursor)) } -func (m migrateModel) startMigrate(index int) tea.Cmd { +func (m migrateModel) startMigrate(index int) tui.Cmd { item := m.queue[index] dec := m.decisions[item.Name] machine := m.machine - return func() tea.Msg { + return func() tui.Msg { proj := item.Project opts := migrate.Options{ WIP: dec.WIP, @@ -240,10 +238,10 @@ func (m migrateModel) startMigrate(index int) tea.Cmd { } } -func (m migrateModel) updateMigrating(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m migrateModel) updateMigrating(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case spinner.TickMsg: - var cmd tea.Cmd + case tui.SpinnerTickMsg: + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case migrateDoneMsg: @@ -260,7 +258,7 @@ func (m migrateModel) updateMigrating(msg tea.Msg) (tea.Model, tea.Cmd) { return m.advance() case migrateAllDoneMsg: m.step = mStepDone - return m, tea.Quit + return m, tui.Quit } return m, nil } diff --git a/internal/cli/migrate_tui.go b/internal/cli/migrate_tui.go index b003b92..8503d38 100644 --- a/internal/cli/migrate_tui.go +++ b/internal/cli/migrate_tui.go @@ -8,10 +8,10 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/conflict" "github.com/kuchmenko/workspace/internal/migrate" + "github.com/kuchmenko/workspace/internal/tui" ) func runMigrateTUI(args []string) error { @@ -60,7 +60,7 @@ func runMigrateTUI(args []string) error { } model := newMigrateModel(plan, machine, resumeFrom) - p := tea.NewProgram(model, tea.WithAltScreen()) + p := tui.NewProgram(model, tui.WithAltScreen()) finalRaw, runErr := p.Run() if runErr != nil { return fmt.Errorf("TUI crashed: %w", runErr) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 0da08dc..8ba2651 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -3,9 +3,9 @@ package cli import ( "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" "github.com/kuchmenko/workspace/internal/setup" + "github.com/kuchmenko/workspace/internal/tui" "github.com/spf13/cobra" ) @@ -20,7 +20,7 @@ func newSetupCmd() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { m := setup.NewModel() - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tui.NewProgram(m, tui.WithAltScreen()) result, err := p.Run() if err != nil { diff --git a/internal/create/cmd.go b/internal/create/cmd.go index c0e1a18..2e566b2 100644 --- a/internal/create/cmd.go +++ b/internal/create/cmd.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/add" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type ownersLoadedMsg struct{ owners []Owner } @@ -14,12 +14,12 @@ type ownersErrMsg struct{ err error } type createDoneMsg struct{ result *Result } type createErrMsg struct{ err error } -func (m CreateModel) fetchOwnersCmd() tea.Cmd { +func (m CreateModel) fetchOwnersCmd() tui.Cmd { runner := m.opts.GHRunner if runner == nil { runner = realGHRunner{} } - return func() tea.Msg { + return func() tui.Msg { owners, err := ListOwners(runner) if err != nil { return ownersErrMsg{err: err} @@ -28,7 +28,7 @@ func (m CreateModel) fetchOwnersCmd() tea.Cmd { } } -func (m CreateModel) createCmd() tea.Cmd { +func (m CreateModel) createCmd() tui.Cmd { runner := m.opts.GHRunner if runner == nil { runner = realGHRunner{} @@ -52,7 +52,7 @@ func (m CreateModel) createCmd() tea.Cmd { projectName = name } - return func() tea.Msg { + return func() tui.Msg { if _, err := CreateRepo(runner, CreateRepoOptions{ Owner: owner, Name: name, diff --git a/internal/create/render.go b/internal/create/render.go index 37b3abb..28d0baf 100644 --- a/internal/create/render.go +++ b/internal/create/render.go @@ -2,9 +2,8 @@ package create import ( "fmt" + "github.com/kuchmenko/workspace/internal/tui" "strings" - - "github.com/charmbracelet/lipgloss" ) func (m CreateModel) View() string { @@ -181,19 +180,19 @@ func (m CreateModel) viewDone() string { } var ( - createTitle = lipgloss.NewStyle(). + createTitle = tui.NewStyle(). Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + Foreground(tui.Color("15")). + Background(tui.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) + createDim = tui.NewStyle().Foreground(tui.Color("8")) + createLabel = tui.NewStyle().Foreground(tui.Color("7")).Bold(true) + createCursor = tui.NewStyle().Foreground(tui.Color("6")).Bold(true) + createAccent = tui.NewStyle().Foreground(tui.Color("6")).Bold(true) + createErr = tui.NewStyle().Foreground(tui.Color("1")).Bold(true) + createCheck = tui.NewStyle().Foreground(tui.Color("2")).Bold(true) + createChip = tui.NewStyle().Foreground(tui.Color("4")).Bold(true) + createItemName = tui.NewStyle().Foreground(tui.Color("15")) + createBtn = tui.NewStyle().Foreground(tui.Color("7")).Background(tui.Color("8")) + createBtnFocus = tui.NewStyle().Foreground(tui.Color("0")).Background(tui.Color("6")).Bold(true) ) diff --git a/internal/create/runner.go b/internal/create/runner.go index 54b08a7..96bb260 100644 --- a/internal/create/runner.go +++ b/internal/create/runner.go @@ -4,8 +4,7 @@ import ( "context" "errors" "fmt" - - tea "github.com/charmbracelet/bubbletea" + "github.com/kuchmenko/workspace/internal/tui" ) var ErrCancelled = errors.New("create canceled by user") @@ -26,10 +25,10 @@ func runTUI(ctx context.Context, opts Options) (*Result, error) { URLFor: opts.URLFor, }) - prog := tea.NewProgram( + prog := tui.NewProgram( model, - tea.WithAltScreen(), - tea.WithContext(ctx), + tui.WithAltScreen(), + tui.WithContext(ctx), ) finalModel, err := prog.Run() if err != nil { diff --git a/internal/create/tui.go b/internal/create/tui.go index c701a2a..3ea1c45 100644 --- a/internal/create/tui.go +++ b/internal/create/tui.go @@ -4,10 +4,8 @@ import ( "errors" "strings" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" "github.com/kuchmenko/workspace/internal/config" + "github.com/kuchmenko/workspace/internal/tui" ) type state int @@ -62,11 +60,11 @@ type CreateModel struct { catIdx int focus int - nameInput textinput.Model - descInput textinput.Model - groupInput textinput.Model + nameInput tui.TextInput + descInput tui.TextInput + groupInput tui.TextInput - spinner spinner.Model + spinner tui.Spinner width int height int @@ -85,27 +83,27 @@ func NewCreateModel(opts CreateModelOptions) CreateModel { vis = VisibilityPrivate } - name := textinput.New() - name.Placeholder = "my-new-repo" - name.CharLimit = 100 - name.Width = 40 + name := tui.NewTextInput() + name.SetPlaceholder("my-new-repo") + name.SetCharLimit(100) + name.SetWidth(40) name.SetValue(opts.Name) - desc := textinput.New() - desc.Placeholder = "(optional) one-line description" - desc.CharLimit = 200 - desc.Width = 60 + desc := tui.NewTextInput() + desc.SetPlaceholder("(optional) one-line description") + desc.SetCharLimit(200) + desc.SetWidth(60) desc.SetValue(opts.Description) - group := textinput.New() - group.Placeholder = "(optional) project group/dir" - group.CharLimit = 80 - group.Width = 40 + group := tui.NewTextInput() + group.SetPlaceholder("(optional) project group/dir") + group.SetCharLimit(80) + group.SetWidth(40) group.SetValue(opts.Group) - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = createAccent + sp := tui.NewSpinner() + sp.SetStyle(tui.DotSpinner) + sp.SetTextStyle(createAccent) visibilities := []Visibility{VisibilityPrivate, VisibilityPublic} visIdx := 0 @@ -141,19 +139,19 @@ func NewCreateModel(opts CreateModelOptions) CreateModel { return m } -func (m CreateModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.fetchOwnersCmd()) +func (m CreateModel) Init() tui.Cmd { + return tui.Batch(m.spinner.Tick, m.fetchOwnersCmd()) } -func (m CreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m CreateModel) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case spinner.TickMsg: - var cmd tea.Cmd + case tui.SpinnerTickMsg: + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd @@ -189,19 +187,19 @@ func (m CreateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.st = stateErrored return m, nil - case tea.KeyMsg: + case tui.KeyMsg: return m.handleKey(msg) } return m, nil } -func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m CreateModel) handleKey(msg tui.KeyMsg) (tui.Model, tui.Cmd) { switch m.st { case stateLoadingOwners: switch msg.String() { case "ctrl+c", "esc": m.canceled = true - return m, tea.Quit + return m, tui.Quit } return m, nil @@ -209,7 +207,7 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc", "q": m.canceled = true - return m, tea.Quit + return m, tui.Quit case "enter": if len(m.owners) == 0 { @@ -225,13 +223,13 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case stateDone: - return m, tea.Quit + return m, tui.Quit case stateCreating: if msg.String() == "ctrl+c" { m.canceled = true - return m, tea.Quit + return m, tui.Quit } return m, nil } @@ -239,15 +237,15 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": m.canceled = true - return m, tea.Quit + return m, tui.Quit case "esc": if m.focus == focusCreate { m.canceled = true - return m, tea.Quit + return m, tui.Quit } m.canceled = true - return m, tea.Quit + return m, tui.Quit case "tab": m.focus = (m.focus + 1) % focusCount return m, m.refocus() @@ -264,15 +262,15 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case focusCategory: return m.handleToggleKey(msg, &m.catIdx, len(m.categories)) case focusName: - var cmd tea.Cmd + var cmd tui.Cmd m.nameInput, cmd = m.nameInput.Update(msg) return m, cmd case focusDescription: - var cmd tea.Cmd + var cmd tui.Cmd m.descInput, cmd = m.descInput.Update(msg) return m, cmd case focusGroup: - var cmd tea.Cmd + var cmd tui.Cmd m.groupInput, cmd = m.groupInput.Update(msg) return m, cmd case focusCreate: @@ -283,13 +281,13 @@ func (m CreateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.st = stateCreating - return m, tea.Batch(m.spinner.Tick, m.createCmd()) + return m, tui.Batch(m.spinner.Tick, m.createCmd()) } } return m, nil } -func (m CreateModel) handleOwnerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m CreateModel) handleOwnerKey(msg tui.KeyMsg) (tui.Model, tui.Cmd) { if len(m.owners) == 0 { return m, nil } @@ -310,7 +308,7 @@ func (m CreateModel) handleOwnerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m CreateModel) handleToggleKey(msg tea.KeyMsg, idx *int, max int) (tea.Model, tea.Cmd) { +func (m CreateModel) handleToggleKey(msg tui.KeyMsg, idx *int, max int) (tui.Model, tui.Cmd) { switch msg.String() { case "left", "h": if *idx > 0 { @@ -330,7 +328,7 @@ func (m CreateModel) handleToggleKey(msg tea.KeyMsg, idx *int, max int) (tea.Mod return m, nil } -func (m *CreateModel) refocus() tea.Cmd { +func (m *CreateModel) refocus() tui.Cmd { m.nameInput.Blur() m.descInput.Blur() m.groupInput.Blur() diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 94a5c86..92aae65 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -3,10 +3,8 @@ package setup import ( "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" gh "github.com/kuchmenko/workspace/internal/github" + "github.com/kuchmenko/workspace/internal/tui" ) type step int @@ -41,7 +39,7 @@ type Model struct { step step width int height int - spinner spinner.Model + spinner tui.Spinner err error result Result username string @@ -53,9 +51,9 @@ type Model struct { } func NewModel() Model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + s := tui.NewSpinner() + s.SetStyle(tui.DotSpinner) + s.SetTextStyle(tui.NewStyle().Foreground("6")) return Model{ step: stepLoading, @@ -63,18 +61,18 @@ func NewModel() Model { } } -func (m Model) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, fetchRepos) +func (m Model) Init() tui.Cmd { + return tui.Batch(m.spinner.Tick, fetchRepos) } -func fetchRepos() tea.Msg { +func fetchRepos() tui.Msg { repos, username, err := gh.FetchAll() return fetchDoneMsg{repos: repos, username: username, err: err} } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: + case tui.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.selectModel.width = msg.Width @@ -85,7 +83,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.confirmModel.height = msg.Height return m, nil - case tea.KeyMsg: + case tui.KeyMsg: if !m.stepChangedAt.IsZero() && time.Since(m.stepChangedAt) < 100*time.Millisecond { return m, nil @@ -93,7 +91,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit } } @@ -111,28 +109,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) updateLoading(msg tui.Msg) (tui.Model, tui.Cmd) { switch msg := msg.(type) { case fetchDoneMsg: if msg.err != nil { m.result = Result{Err: msg.err} - return m, tea.Quit + return m, tui.Quit } m.username = msg.username m.selectModel = newSelectModel(msg.repos, msg.username, m.width, m.height) m.step = stepSelect m.stepChangedAt = time.Now() return m, m.selectModel.search.Focus() - case spinner.TickMsg: - var cmd tea.Cmd + case tui.SpinnerTickMsg: + var cmd tui.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } return m, nil } -func (m Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok && key.String() == "enter" { +func (m Model) updateSelect(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok && key.String() == "enter" { selected := m.selectModel.selected() if len(selected) == 0 { return m, nil @@ -142,18 +140,18 @@ func (m Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) { m.stepChangedAt = time.Now() return m, nil } - if key, ok := msg.(tea.KeyMsg); ok && key.String() == "escape" { + if key, ok := msg.(tui.KeyMsg); ok && key.String() == "escape" { m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit } - var cmd tea.Cmd + var cmd tui.Cmd m.selectModel, cmd = m.selectModel.update(msg) return m, cmd } -func (m Model) updateGroup(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m Model) updateGroup(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "enter": if !m.groupModel.editing { @@ -174,13 +172,13 @@ func (m Model) updateGroup(msg tea.Msg) (tea.Model, tea.Cmd) { } } - var cmd tea.Cmd + var cmd tui.Cmd m.groupModel, cmd = m.groupModel.update(msg) return m, cmd } -func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m Model) updateConfirm(msg tui.Msg) (tui.Model, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "y", "Y", "enter": m.result = Result{ @@ -188,10 +186,10 @@ func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { Groups: m.confirmModel.groups, Username: m.username, } - return m, tea.Quit + return m, tui.Quit case "n", "N": m.result = Result{Canceled: true} - return m, tea.Quit + return m, tui.Quit case "escape": m.step = stepGroup m.stepChangedAt = time.Now() @@ -223,48 +221,48 @@ func (m Model) GetResult() Result { } var ( - titleStyle = lipgloss.NewStyle(). + titleStyle = tui.NewStyle(). Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - subtitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + subtitleStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")) + selectedStyle = tui.NewStyle(). + Foreground(tui.Color("6")) - dimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + dimStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - cursorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + cursorStyle = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). + errorStyle = tui.NewStyle(). + Foreground(tui.Color("1")). Bold(true) - activeTabStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("6")). + activeTabStyle = tui.NewStyle(). + Foreground(tui.Color("15")). + Background(tui.Color("6")). Padding(0, 1) - inactiveTabStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("7")). + inactiveTabStyle = tui.NewStyle(). + Foreground(tui.Color("7")). Padding(0, 1) - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + helpStyle = tui.NewStyle(). + Foreground(tui.Color("8")) - groupHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("6")). + groupHeaderStyle = tui.NewStyle(). + Foreground(tui.Color("6")). Bold(true) - checkStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("2")) + checkStyle = tui.NewStyle(). + Foreground(tui.Color("2")) - uncheckStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) + uncheckStyle = tui.NewStyle(). + Foreground(tui.Color("8")) ) diff --git a/internal/setup/step_group.go b/internal/setup/step_group.go index b1f3589..bbf4b63 100644 --- a/internal/setup/step_group.go +++ b/internal/setup/step_group.go @@ -4,9 +4,8 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" gh "github.com/kuchmenko/workspace/internal/github" + "github.com/kuchmenko/workspace/internal/tui" ) type groupModel struct { @@ -14,7 +13,7 @@ type groupModel struct { cursor int repoCursor int editing bool - editInput textinput.Model + editInput tui.TextInput moving bool moveFrom int moveRepoIdx int @@ -46,8 +45,8 @@ func newGroupModel(repos []gh.Repo, username string, w, h int) groupModel { }) } - ti := textinput.New() - ti.CharLimit = 40 + ti := tui.NewTextInput() + ti.SetCharLimit(40) return groupModel{ groups: groups, @@ -123,7 +122,7 @@ func (m *groupModel) clampCursor() { } } -func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { +func (m groupModel) update(msg tui.Msg) (groupModel, tui.Cmd) { if m.editing { return m.updateEditing(msg) } @@ -131,7 +130,7 @@ func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { return m.updateMoving(msg) } - key, ok := msg.(tea.KeyMsg) + key, ok := msg.(tui.KeyMsg) if !ok { return m, nil } @@ -181,8 +180,8 @@ func (m groupModel) update(msg tea.Msg) (groupModel, tea.Cmd) { return m, nil } -func (m groupModel) updateEditing(msg tea.Msg) (groupModel, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m groupModel) updateEditing(msg tui.Msg) (groupModel, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "enter": newName := strings.TrimSpace(m.editInput.Value()) @@ -197,13 +196,13 @@ func (m groupModel) updateEditing(msg tea.Msg) (groupModel, tea.Cmd) { } } - var cmd tea.Cmd + var cmd tui.Cmd m.editInput, cmd = m.editInput.Update(msg) return m, cmd } -func (m groupModel) updateMoving(msg tea.Msg) (groupModel, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m groupModel) updateMoving(msg tui.Msg) (groupModel, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { switch key.String() { case "escape": m.moving = false diff --git a/internal/setup/step_select.go b/internal/setup/step_select.go index 0a9249a..1f21014 100644 --- a/internal/setup/step_select.go +++ b/internal/setup/step_select.go @@ -6,9 +6,8 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" gh "github.com/kuchmenko/workspace/internal/github" + "github.com/kuchmenko/workspace/internal/tui" ) type sortMode int @@ -43,7 +42,7 @@ type selectModel struct { sortBy sortMode cursor int offset int - search textinput.Model + search tui.TextInput width int height int username string @@ -55,9 +54,9 @@ func newSelectModel(repos []gh.Repo, username string, w, h int) selectModel { items[i] = repoItem{repo: r} } - ti := textinput.New() - ti.Placeholder = "type to search..." - ti.CharLimit = 60 + ti := tui.NewTextInput() + ti.SetPlaceholder("type to search...") + ti.SetCharLimit(60) orgs := gh.Orgs(repos) @@ -128,8 +127,8 @@ func (m selectModel) selectedCount() int { return n } -func (m selectModel) update(msg tea.Msg) (selectModel, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { +func (m selectModel) update(msg tui.Msg) (selectModel, tui.Cmd) { + if key, ok := msg.(tui.KeyMsg); ok { filtered := m.filtered() maxVisible := m.maxVisible() @@ -187,7 +186,7 @@ func (m selectModel) update(msg tea.Msg) (selectModel, tea.Cmd) { } } - var cmd tea.Cmd + var cmd tui.Cmd prevVal := m.search.Value() m.search, cmd = m.search.Update(msg) if m.search.Value() != prevVal { diff --git a/internal/tui/widgets.go b/internal/tui/widgets.go index b91b8fa..0e081ad 100644 --- a/internal/tui/widgets.go +++ b/internal/tui/widgets.go @@ -29,6 +29,7 @@ func (t *TextInput) SetPlaceholder(p string) { t.ti.Placeholder = p } func (t *TextInput) SetPrompt(p string) { t.ti.Prompt = p } func (t *TextInput) SetCharLimit(n int) { t.ti.CharLimit = n } func (t *TextInput) SetWidth(w int) { t.ti.Width = w } +func (t *TextInput) CursorEnd() { t.ti.CursorEnd() } type SpinnerStyle struct{ s spinner.Spinner } From 9c7ffbaa71f1a69d3f72503c36f94ceecbedb1de Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 13:42:03 +0300 Subject: [PATCH 8/9] docs(agents): codify no-comments-in-main-code and internal/tui seam rules Two new conventions documented in AGENTS.md, derived from this PR's work: - No comments in production Go code. Comments that can become code should become code. Permitted markers: //go:build, package doc (single line), // DECISION:, // TODO:, // FIXME:, // HACK:. Test files exempt. - Direct bubbletea/lipgloss/bubbles imports outside internal/tui are a regression. The strict eviction seam is enforced by convention; a single grep is the check. These rules formalize what the cleanup PR already established and verified end-to-end. --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dd23918..bb07397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -426,6 +426,20 @@ removed on the next `config.Save`. No manual edit is required. - 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. +- **No comments in production Go code.** Things explained by a comment + should become code (named constants, asserts, typed wrappers, explicit + state enums). Permitted: build constraints (`//go:build`), package doc + comments (one line, required by `go doc`), `// DECISION:` blocks for + non-obvious WHY anchored to a specific line, `// TODO:` / `// FIXME:` + / `// HACK:` markers. Test files (`*_test.go`) are exempt — explanatory + comments in tests are fine. +- **TUI consumers import `internal/tui` only.** Direct imports of + `github.com/charmbracelet/{bubbletea,lipgloss,bubbles/*}` outside + `internal/tui/` are a regression. The seam exists so a future PR can + swap the bubbletea implementation behind `internal/tui` without + touching consumers. Quick check: + `grep -rln "charmbracelet" --include='*.go' | grep -v internal/tui` + should return zero. - `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 From 235144e5361a6824754cba8236a1bc3d1e9c7054 Mon Sep 17 00:00:00 2001 From: Ivan Kuchmenko Date: Tue, 19 May 2026 15:32:55 +0300 Subject: [PATCH 9/9] style(tui): gofmt state.go and widgets.go CI Format check failed because two files were committed with non-gofmt formatting: - state.go: long one-liner Done() func body - widgets.go: alignment column off in Spinner.View/Tick and ownToTeaKeyType[KeyRunes] Pure formatting; no behavior change. Build, race tests, lint all green. --- internal/tui/state.go | 4 +++- internal/tui/widgets.go | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/tui/state.go b/internal/tui/state.go index 2b2c651..897859b 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -43,4 +43,6 @@ func (s Stepper) View() string { } func (s Stepper) Current() int { return s.idx } -func (s Stepper) Done() bool { return s.idx >= len(s.steps) || (s.idx == len(s.steps)-1 && s.steps[s.idx].IsDone()) } +func (s Stepper) Done() bool { + return s.idx >= len(s.steps) || (s.idx == len(s.steps)-1 && s.steps[s.idx].IsDone()) +} diff --git a/internal/tui/widgets.go b/internal/tui/widgets.go index 0e081ad..7209ad1 100644 --- a/internal/tui/widgets.go +++ b/internal/tui/widgets.go @@ -47,8 +47,8 @@ func (s Spinner) Update(msg Msg) (Spinner, Cmd) { return Spinner{sp: next}, ownCmd(cmd) } -func (s Spinner) View() string { return s.sp.View() } -func (s Spinner) Tick() Msg { return s.sp.Tick() } +func (s Spinner) View() string { return s.sp.View() } +func (s Spinner) Tick() Msg { return s.sp.Tick() } func (s *Spinner) SetStyle(st SpinnerStyle) { s.sp.Spinner = st.s } func (s *Spinner) SetTextStyle(st Style) { s.sp.Style = st.s } @@ -85,7 +85,7 @@ func ownCmd(c tea.Cmd) Cmd { } var ownToTeaKeyType = map[KeyType]tea.KeyType{ - KeyRunes: tea.KeyRunes, + KeyRunes: tea.KeyRunes, KeySpace: tea.KeySpace, KeyEnter: tea.KeyEnter, KeyEsc: tea.KeyEsc,