From 7fa832fd1549c209817117400cba1dfc4981f99c Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 19 May 2026 16:44:09 +0200 Subject: [PATCH] refactor(tui): split god-package into per-mode handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI was a single Model with 50+ flat fields, one 677-line Update(), one 514-line View(), and 11 modes interleaved via boolean flag checks. Refactoring was risky and no mode had isolated tests. Stage 1a: introduce a Mode enum and dispatcher. Update() and View() now route through `switch m.mode` instead of overlapping if-chains. Stage 3: extract per-mode files (mode_.go) for list, create, create-from-remote, delete, diff, pr-creating, pr-generating, agent-select, and error-log. view.go shrinks to 27 lines; update.go keeps only non-key message handlers and shared helpers. Stage 2: replace flat fields with typed sub-structs (listState, createState, deleteState, diffState, prState, agentSelectState, errorLogState). Active-mode booleans (creating, deleting, …) are gone; m.mode is the single source of truth. Adds resetToList() for exhaustive recovery to the list view. Stage 4: extract test helpers to helpers_test.go. Add regression tests covering three latent bugs that the dispatcher made visible: - Bug A: errMsg used to clear only some mode flags. resetToList() now exhaustively zeroes every per-mode sub-struct. - Bug B: prGenerating did not gate keys, so pressing 'n' during PR generation opened the create dialog on top of the loading screen. ModePRGenerating now has its own handler that swallows non-esc keys. - Bug C: a late prContentGeneratedMsg arriving after esc would silently flip into ModePRCreating. esc now sets pr.cancelled and the message handler drops the late content. All 237 tests across 10 packages pass. go vet clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/tui/commands.go | 4 +- pkg/tui/helpers_test.go | 82 +++++ pkg/tui/mode.go | 17 + pkg/tui/mode_agentselect.go | 78 +++++ pkg/tui/mode_create.go | 77 +++++ pkg/tui/mode_create_remote.go | 81 +++++ pkg/tui/mode_delete.go | 66 ++++ pkg/tui/mode_diff.go | 79 +++++ pkg/tui/mode_errorlog.go | 28 ++ pkg/tui/mode_list.go | 446 ++++++++++++++++++++++++ pkg/tui/mode_pr_creating.go | 54 +++ pkg/tui/mode_pr_generating.go | 27 ++ pkg/tui/mode_pr_generating_test.go | 100 ++++++ pkg/tui/model.go | 157 +++++---- pkg/tui/tui_test.go | 454 ++++++++++-------------- pkg/tui/update.go | 483 ++++---------------------- pkg/tui/view.go | 533 ++--------------------------- 17 files changed, 1501 insertions(+), 1265 deletions(-) create mode 100644 pkg/tui/helpers_test.go create mode 100644 pkg/tui/mode.go create mode 100644 pkg/tui/mode_agentselect.go create mode 100644 pkg/tui/mode_create.go create mode 100644 pkg/tui/mode_create_remote.go create mode 100644 pkg/tui/mode_delete.go create mode 100644 pkg/tui/mode_diff.go create mode 100644 pkg/tui/mode_errorlog.go create mode 100644 pkg/tui/mode_list.go create mode 100644 pkg/tui/mode_pr_creating.go create mode 100644 pkg/tui/mode_pr_generating.go create mode 100644 pkg/tui/mode_pr_generating_test.go diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index cbd2567..de92f26 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -232,10 +232,10 @@ func (m Model) capturePreviewCmd() tea.Cmd { return nil } visible := m.visibleWorkspaces() - if len(visible) == 0 || m.cursor >= len(visible) { + if len(visible) == 0 || m.list.cursor >= len(visible) { return func() tea.Msg { return capturePreviewMsg{lines: ""} } } - ws := visible[m.cursor] + ws := visible[m.list.cursor] if ws.WindowID == "" { return func() tea.Msg { return capturePreviewMsg{lines: ""} } } diff --git a/pkg/tui/helpers_test.go b/pkg/tui/helpers_test.go new file mode 100644 index 0000000..2366f60 --- /dev/null +++ b/pkg/tui/helpers_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/axelgar/opentree/pkg/config" + "github.com/axelgar/opentree/pkg/state" +) + +// Shared test helpers used across all mode_*_test.go files in this package. + +// newTestModel builds a Model with no external dependencies. Tests that only +// exercise in-process logic (state transitions, View rendering, pure functions) +// should use this instead of NewModel, which requires a real git repo and tmux. +func newTestModel(workspaces ...WorkspaceItem) Model { + ti := textinput.New() + ti.Placeholder = "New branch name" + ti.CharLimit = 50 + ti.Width = 30 + return Model{ + cfg: config.Default(), + input: ti, + help: help.New(), + keys: keys, + workspaces: workspaces, + width: 120, + height: 40, + list: listState{selected: make(map[string]bool)}, + } +} + +func testWS(name string) WorkspaceItem { + return WorkspaceItem{ + Workspace: &state.Workspace{ + Name: name, + Branch: "feature/" + name, + BaseBranch: "main", + }, + DiffStat: "2 files changed", + } +} + +func testWSWithPR(name, prURL string) WorkspaceItem { + ws := testWS(name) + ws.PRURL = prURL + ws.PRStatus = "open" + return ws +} + +func testWSWithWindow(name string) WorkspaceItem { + ws := testWS(name) + ws.WindowID = "@1" + return ws +} + +func testWSWithIssue(name string, issueNumber int, issueTitle string) WorkspaceItem { + ws := testWS(name) + ws.IssueNumber = issueNumber + ws.IssueTitle = issueTitle + return ws +} + +// applyUpdate calls m.Update and casts the result back to Model. +func applyUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { + newM, cmd := m.Update(msg) + return newM.(Model), cmd +} + +func keyMsg(k string) tea.KeyMsg { + switch k { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} + } +} diff --git a/pkg/tui/mode.go b/pkg/tui/mode.go new file mode 100644 index 0000000..b056f05 --- /dev/null +++ b/pkg/tui/mode.go @@ -0,0 +1,17 @@ +package tui + +// Mode represents the active UI state for the TUI dispatcher. +type Mode int + +const ( + ModeList Mode = iota + ModeCreate + ModeCreateFromIssue + ModeCreateFromRemote + ModeDelete + ModeDiff + ModePRGenerating + ModePRCreating + ModeAgentSelect + ModeErrorLog +) diff --git a/pkg/tui/mode_agentselect.go b/pkg/tui/mode_agentselect.go new file mode 100644 index 0000000..346e272 --- /dev/null +++ b/pkg/tui/mode_agentselect.go @@ -0,0 +1,78 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/axelgar/opentree/pkg/config" +) + +func updateAgentSelect(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + agents := config.PredefinedAgents + switch msg.String() { + case "up", "k": + if m.agentSel.cursor > 0 { + m.agentSel.cursor-- + } + case "down", "j": + if m.agentSel.cursor < len(agents)-1 { + m.agentSel.cursor++ + } + case "enter": + agent := agents[m.agentSel.cursor] + m.cfg.Agent.Command = agent.Command + if agent.Args != nil { + m.cfg.Agent.Args = agent.Args + } else { + m.cfg.Agent.Args = []string{} + } + cfgPath := config.FindConfigFile() + _ = config.Save(m.cfg, cfgPath) + m.mode = ModeList + case "esc", "q": + m.mode = ModeList + } + return m, nil +} + +func viewAgentSelect(m Model) string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Select Agent")) + sb.WriteString("\n\n") + for i, agent := range config.PredefinedAgents { + cursor := " " + style := itemStyle + if i == m.agentSel.cursor { + cursor = "▶ " + style = selectedItemStyle + } + + name := agent.Name + if agent.IsActive(m.cfg) { + name += " (active)" + } + + status := "not found" + statusSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#666")) + if agent.IsInstalled() { + status = "installed" + statusSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#2A9D8F")) + } + + cmdStr := agent.Command + if len(agent.Args) > 0 { + cmdStr += " " + strings.Join(agent.Args, " ") + } + + line := fmt.Sprintf("%s%-18s %-14s %s %s", + cursor, name, cmdStr, statusSt.Render(status), agent.Description) + sb.WriteString(style.Render(line)) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(helpStyle.Render("↑/↓ navigate • Enter select • Esc cancel")) + return appStyle.Render(sb.String()) +} diff --git a/pkg/tui/mode_create.go b/pkg/tui/mode_create.go new file mode 100644 index 0000000..03ea289 --- /dev/null +++ b/pkg/tui/mode_create.go @@ -0,0 +1,77 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/axelgar/opentree/pkg/gitutil" +) + +// updateCreate handles both ModeCreate and ModeCreateFromIssue. Their key +// semantics diverge only inside the enter branch. +func updateCreate(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "enter": + val := m.input.Value() + if val == "" { + return m, nil + } + if m.mode == ModeCreateFromIssue { + m.resetCreateMode() + m.workspaceCreating = true + m.workspaceCreatingName = "issue " + val + return m, tea.Batch(m.createWorkspaceFromIssueCmd(val), spinnerTickCmd()) + } + if m.create.step == 0 { + if err := gitutil.ValidateBranchName(val); err != nil { + m.err = err + m.appendErrLog(err.Error()) + return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearErrorMsg{} + }) + } + m.create.newBranchName = val + m.create.step = 1 + m.focusInput("Base branch", m.cfg.Worktree.DefaultBase) + return m, textinput.Blink + } + branchName := m.create.newBranchName + baseBranch := val + m.resetCreateMode() + m.workspaceCreating = true + m.workspaceCreatingName = branchName + return m, tea.Batch(m.createWorkspaceCmd(branchName, baseBranch), spinnerTickCmd()) + case "esc": + m.resetCreateMode() + return m, nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func viewCreate(m Model) string { + var stepLabel string + if m.create.step == 0 { + stepLabel = "Step 1/2 — Branch name" + } else { + stepLabel = fmt.Sprintf("Step 2/2 — Base branch (branching from: %s)", m.create.newBranchName) + } + return appStyle.Render(fmt.Sprintf("%s\n\n%s\n%s\n\n%s", + titleStyle.Render("Create New Workspace"), + stepLabelStyle.Render(stepLabel), + m.input.View(), + helpStyle.Render("Enter to continue • Esc to cancel"), + )) +} + +func viewCreateFromIssue(m Model) string { + return appStyle.Render(fmt.Sprintf("%s\n\n%s\n\n%s", + titleStyle.Render("Create Workspace from GitHub Issue"), + m.input.View(), + helpStyle.Render("Enter issue number • Esc to cancel"), + )) +} diff --git a/pkg/tui/mode_create_remote.go b/pkg/tui/mode_create_remote.go new file mode 100644 index 0000000..3357d87 --- /dev/null +++ b/pkg/tui/mode_create_remote.go @@ -0,0 +1,81 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func updateCreateFromRemote(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "up": + if m.create.branchSuggestionCursor > 0 { + m.create.branchSuggestionCursor-- + } + return m, nil + case "down": + if m.create.branchSuggestionCursor < len(m.create.filteredBranches)-1 { + m.create.branchSuggestionCursor++ + } + return m, nil + case "tab": + if len(m.create.filteredBranches) > 0 { + m.input.SetValue(m.create.filteredBranches[m.create.branchSuggestionCursor]) + m.create.filteredBranches = filterBranches(m.create.remoteBranches, m.input.Value()) + m.create.branchSuggestionCursor = 0 + } + return m, nil + case "enter": + var branchName string + if m.create.branchSuggestionCursor < len(m.create.filteredBranches) { + branchName = m.create.filteredBranches[m.create.branchSuggestionCursor] + } else { + branchName = m.input.Value() + } + if branchName == "" { + return m, nil + } + m.resetCreateMode() + m.workspaceCreating = true + m.workspaceCreatingName = branchName + return m, tea.Batch(m.createWorkspaceFromRemoteCmd(branchName), spinnerTickCmd()) + case "esc": + m.resetCreateMode() + return m, nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.create.filteredBranches = filterBranches(m.create.remoteBranches, m.input.Value()) + m.create.branchSuggestionCursor = 0 + return m, cmd +} + +func viewCreateFromRemote(m Model) string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Create Workspace from Remote Branch")) + sb.WriteString("\n\n") + sb.WriteString(m.input.View()) + sb.WriteString("\n") + if len(m.create.filteredBranches) > 0 { + sb.WriteString("\n") + for i, b := range m.create.filteredBranches { + if i == m.create.branchSuggestionCursor { + sb.WriteString(selectedItemStyle.Render("▶ " + b)) + } else { + sb.WriteString(itemStyle.Render(" " + b)) + } + sb.WriteString("\n") + } + } else if len(m.create.remoteBranches) == 0 { + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" loading branches…")) + sb.WriteString("\n") + } else { + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" no branches match")) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(helpStyle.Render("↑/↓ navigate • Tab select • Enter confirm • Esc cancel")) + return appStyle.Render(sb.String()) +} diff --git a/pkg/tui/mode_delete.go b/pkg/tui/mode_delete.go new file mode 100644 index 0000000..be25bb8 --- /dev/null +++ b/pkg/tui/mode_delete.go @@ -0,0 +1,66 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func updateDelete(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + if m.del.target != "" { + target := m.del.target + m.mode = ModeList + m.del = deleteState{} + m.workspaceDeleting = true + m.workspaceDeletingName = target + return m, tea.Batch(m.deleteWorkspaceCmd(target), spinnerTickCmd()) + } + // batch delete + targets := make([]string, 0, len(m.list.selected)) + for name := range m.list.selected { + targets = append(targets, name) + } + m.mode = ModeList + m.del = deleteState{} + m.workspaceDeletingNames = make(map[string]bool) + for _, name := range targets { + m.workspaceDeletingNames[name] = true + } + m.list.selected = make(map[string]bool) + m.workspaceDeleting = true + m.workspaceDeletingName = fmt.Sprintf("%d workspaces", len(targets)) + return m, tea.Batch(m.batchDeleteWorkspaceCmd(targets), spinnerTickCmd()) + case "n", "esc": + m.mode = ModeList + m.del = deleteState{} + } + return m, nil +} + +func viewDelete(m Model) string { + var titleMsg string + if m.del.target != "" { + titleMsg = fmt.Sprintf("Delete workspace %q?", m.del.target) + } else { + names := make([]string, 0, len(m.list.selected)) + for name := range m.list.selected { + names = append(names, name) + } + sort.Strings(names) + titleMsg = fmt.Sprintf("Delete %d workspaces: %s?", len(names), strings.Join(names, ", ")) + } + footer := fmt.Sprintf("%s %s • %s %s", + confirmKeyStyle.Render("y"), confirmLabelStyle.Render("confirm"), + confirmKeyStyle.Render("esc/n"), confirmLabelStyle.Render("cancel"), + ) + content := fmt.Sprintf("%s\n\n%s\n\n%s", + dangerStyle.Render(titleMsg), + confirmLabelStyle.Render("The worktree, tmux window, and all local changes will be removed."), + footer, + ) + return appStyle.Render(deleteDialogStyle.Render(content)) +} diff --git a/pkg/tui/mode_diff.go b/pkg/tui/mode_diff.go new file mode 100644 index 0000000..4330d5f --- /dev/null +++ b/pkg/tui/mode_diff.go @@ -0,0 +1,79 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + headerFooterHeight = 8 + minDiffHeight = 5 +) + +func updateDiff(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.mode = ModeList + m.diff = diffState{} + case "up", "k": + if m.diff.scrollOffset > 0 { + m.diff.scrollOffset-- + } + case "down", "j": + availHeight := m.height - 8 + if availHeight < 5 { + availHeight = 5 + } + maxScroll := len(strings.Split(m.diff.content, "\n")) - availHeight + if maxScroll < 0 { + maxScroll = 0 + } + if m.diff.scrollOffset < maxScroll { + m.diff.scrollOffset++ + } + } + return m, nil +} + +func viewDiff(m Model) string { + lines := strings.Split(m.diff.content, "\n") + availHeight := m.height - headerFooterHeight + if availHeight < minDiffHeight { + availHeight = minDiffHeight + } + maxScroll := len(lines) - availHeight + if maxScroll < 0 { + maxScroll = 0 + } + // Clamp is authoritative in Update; this is a read-only safety for rendering. + offset := m.diff.scrollOffset + if offset > maxScroll { + offset = maxScroll + } + end := offset + availHeight + if end > len(lines) { + end = len(lines) + } + visible := lines[offset:end] + + var sb strings.Builder + for _, line := range visible { + sb.WriteString(renderDiffLine(line)) + sb.WriteString("\n") + } + + scrollInfo := fmt.Sprintf("line %d/%d", offset+1, len(lines)) + footer := fmt.Sprintf("%s • %s • %s", + helpStyle.Render("↑/k ↓/j scroll"), + helpStyle.Render("esc to close"), + helpStyle.Render(scrollInfo), + ) + content := fmt.Sprintf("%s\n\n%s\n%s", + titleStyle.Render("Diff: "+m.diff.wsName), + sb.String(), + footer, + ) + return appStyle.Render(content) +} diff --git a/pkg/tui/mode_errorlog.go b/pkg/tui/mode_errorlog.go new file mode 100644 index 0000000..9f7b2a5 --- /dev/null +++ b/pkg/tui/mode_errorlog.go @@ -0,0 +1,28 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func updateErrorLog(m Model, _ tea.KeyMsg) (Model, tea.Cmd) { + // Any key closes the overlay. + m.mode = ModeList + return m, nil +} + +func viewErrorLog(m Model) string { + var sb strings.Builder + sb.WriteString(errLogTitleStyle.Render("Error Log") + "\n\n") + if len(m.errorLog.entries) == 0 { + sb.WriteString(errLogLineStyle.Render("No errors recorded.")) + } else { + for _, entry := range m.errorLog.entries { + sb.WriteString(errLogLineStyle.Render(entry)) + sb.WriteString("\n") + } + } + sb.WriteString("\n" + helpStyle.Render("Any key to close")) + return appStyle.Render(sb.String()) +} diff --git a/pkg/tui/mode_list.go b/pkg/tui/mode_list.go new file mode 100644 index 0000000..6ffde30 --- /dev/null +++ b/pkg/tui/mode_list.go @@ -0,0 +1,446 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/axelgar/opentree/pkg/config" +) + +const ( + defaultPreviewWidth = 60 + minPreviewWidth = 20 +) + +// updateList handles both the filter sub-state and the normal list keys. +// Filter is a sub-state of ModeList — the list renders underneath the prompt. +func updateList(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + if m.list.filtering { + switch msg.String() { + case "esc", "enter": + m.list.filtering = false + m.list.cursor = 0 + return m, m.capturePreviewCmd() + case "backspace": + if len(m.list.filterQuery) > 0 { + m.list.filterQuery = m.list.filterQuery[:len(m.list.filterQuery)-1] + } + m.list.cursor = 0 + default: + if len(msg.String()) == 1 { + m.list.filterQuery += msg.String() + m.list.cursor = 0 + } + } + return m, nil + } + + visible := m.visibleWorkspaces() + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Up): + if m.list.cursor > 0 { + m.list.cursor-- + return m, m.capturePreviewCmd() + } + case key.Matches(msg, m.keys.Down): + if m.list.cursor < len(visible)-1 { + m.list.cursor++ + return m, m.capturePreviewCmd() + } + case key.Matches(msg, m.keys.New): + m.mode = ModeCreate + m.create.step = 0 + m.focusInput("New branch name", "") + return m, textinput.Blink + case key.Matches(msg, m.keys.Issue): + m.mode = ModeCreateFromIssue + m.focusInput("GitHub issue number", "") + return m, textinput.Blink + case key.Matches(msg, m.keys.Remote): + m.mode = ModeCreateFromRemote + m.create.remoteBranches = nil + m.create.filteredBranches = nil + m.create.branchSuggestionCursor = 0 + m.focusInput("Remote branch name", "") + return m, tea.Batch(textinput.Blink, m.loadRemoteBranchesCmd()) + case key.Matches(msg, m.keys.Enter): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + return m, m.attachWorkspaceCmd(ws.Name) + } + case key.Matches(msg, m.keys.Diff): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + return m, m.loadDiffCmd(ws) + } + case key.Matches(msg, m.keys.PR): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + m.mode = ModePRGenerating + m.pr.wsName = ws.Name + m.pr.branch = ws.Branch + m.pr.base = ws.BaseBranch + return m, m.generatePRContentCmd(ws) + } + case key.Matches(msg, m.keys.Open): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + if ws.PRURL != "" { + return m, openURLCmd(ws.PRURL) + } + return m, m.transientErrCmd(fmt.Sprintf("no PR for %q — create one with 'p'", ws.Name)) + } + case key.Matches(msg, m.keys.Review): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + if ws.PRURL != "" { + return m, m.sendReviewsCmd(ws.Name) + } + return m, m.transientErrCmd(fmt.Sprintf("no PR for %q — create one first with 'p'", ws.Name)) + } + case key.Matches(msg, m.keys.Select): + if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + if m.list.selected[ws.Name] { + delete(m.list.selected, ws.Name) + } else { + m.list.selected[ws.Name] = true + } + if m.list.cursor < len(visible)-1 { + m.list.cursor++ + } + } + case key.Matches(msg, m.keys.Delete): + if len(m.list.selected) > 0 { + m.mode = ModeDelete + m.del.target = "" + } else if len(visible) > 0 { + ws := visible[m.list.cursor] + if m.isWorkspaceInFlight(ws.Name) { + return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) + } + m.mode = ModeDelete + m.del.target = ws.Name + } + case key.Matches(msg, m.keys.Filter): + m.list.filtering = true + m.list.filterQuery = "" + m.list.cursor = 0 + case key.Matches(msg, m.keys.Sort): + m.list.sortMode = (m.list.sortMode + 1) % 4 + m.list.cursor = 0 + case key.Matches(msg, m.keys.Agent): + m.mode = ModeAgentSelect + m.agentSel.cursor = 0 + for i, a := range config.PredefinedAgents { + if a.IsActive(m.cfg) { + m.agentSel.cursor = i + break + } + } + return m, nil + case key.Matches(msg, m.keys.ErrLog): + m.mode = ModeErrorLog + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + } + return m, nil +} + +func viewList(m Model) string { + var s strings.Builder + + s.WriteString(renderLogo()) + s.WriteString("\n\n") + + s.WriteString(titleStyle.Render("Workspaces")) + s.WriteString("\n\n") + + if m.list.filtering { + prompt := filterPromptStyle.Render("/") + " " + m.list.filterQuery + "█" + s.WriteString(prompt + "\n\n") + } else if m.list.filterQuery != "" { + s.WriteString(filterPromptStyle.Render(fmt.Sprintf("filter: %q (/ to change, esc to clear)", m.list.filterQuery)) + "\n\n") + } + + if m.err != nil { + s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(fmt.Sprintf("Error: %v", m.err))) + s.WriteString("\n\n") + } + + visible := m.visibleWorkspaces() + + if len(visible) == 0 { + if m.list.filterQuery != "" { + s.WriteString(itemStyle.Render("No workspaces match the filter.")) + } else { + s.WriteString(itemStyle.Render("No workspaces found. Press 'n' to create one.")) + } + s.WriteString("\n") + } else { + for i, ws := range visible { + isDeleting := m.workspaceDeletingName == ws.Name || m.workspaceDeletingNames[ws.Name] + if isDeleting { + spinner := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + row := spinner + " " + ws.Name + " " + pendingLabelStyle.Render("deleting…") + s.WriteString(pendingItemStyle.Render(row)) + s.WriteString("\n") + continue + } + + style := itemStyle + if i == m.list.cursor { + style = selectedItemStyle + } + + status := "○" + statusColor := stoppedStyle + if ws.Active { + status = "●" + statusColor = activeStyle + } else if ws.WindowID != "" { + status = "◎" + statusColor = idleStyle + } + + selectMark := " " + if m.list.selected[ws.Name] { + selectMark = selectedMarkStyle.Render("✓ ") + } + + title := selectMark + fmt.Sprintf("%s %s", statusColor.Render(status), ws.Name) + + if ws.IssueNumber > 0 { + title += " " + issueBadgeStyle.Render(fmt.Sprintf("#%d", ws.IssueNumber)) + } + switch { + case ws.PRStatus == "merged": + title += " " + mergedBadgeStyle.Render("merged · ready to delete") + case ws.PRStatus == "closed": + title += " " + closedBadgeStyle.Render("PR closed") + case ws.RemoteDeleted: + title += " " + remoteDeletedBadgeStyle.Render("remote deleted") + case ws.PRStatus == "open" && ws.MergeConflicts: + title += " " + conflictsBadgeStyle.Render("PR open · conflicts") + if ci, ok := m.ciStatus[ws.Name]; ok { + title += renderCIBadge(ci) + } + case ws.PRStatus == "open": + title += " " + prOpenBadgeStyle.Render("PR open") + if ci, ok := m.ciStatus[ws.Name]; ok { + title += renderCIBadge(ci) + } + case ws.BranchPushed: + title += " " + pushedBadgeStyle.Render("pushed") + default: + title += " " + notPushedBadgeStyle.Render("not pushed") + } + + if ws.AgentStatus != nil { + switch ws.AgentStatus.Status { + case "success": + title += " " + agentSuccessStyle.Render("done") + case "failure": + title += " " + agentFailureStyle.Render("failed") + case "error": + title += " " + agentErrorStyle.Render("error") + case "in_progress": + title += " " + agentInProgressStyle.Render("working...") + } + } + + branchDisplay := ws.Branch + if ws.BaseBranch != "" { + branchDisplay += " ← " + ws.BaseBranch + } + descParts := []string{branchDisplay, ws.DiffStat, "created " + formatAge(ws.CreatedAt)} + + if ws.UncommittedCount > 0 { + descParts = append(descParts, uncommittedStyle.Render(fmt.Sprintf("~%d uncommitted", ws.UncommittedCount))) + } + + if !ws.LastActivity.IsZero() { + descParts = append(descParts, "active "+formatAge(ws.LastActivity)) + } + + if ws.AgentStatus != nil && ws.AgentStatus.Message != "" { + descParts = append(descParts, ws.AgentStatus.Message) + } + + desc := " " + strings.Join(descParts, " • ") + + s.WriteString(style.Render(fmt.Sprintf("%s\n%s", title, diffStyle.Render(desc)))) + s.WriteString("\n") + + if ws.PRStatus == "merged" && i == m.list.cursor { + s.WriteString(mergedHintStyle.Render(" → Press x to clean up this merged workspace")) + s.WriteString("\n") + } + } + + if m.list.cursor < len(visible) { + ws := visible[m.list.cursor] + if len(ws.FileChanges) > 0 { + previewWidth := m.width - 8 + if previewWidth < 20 { + previewWidth = 60 + } + content := m.renderFileChanges(ws.FileChanges, previewWidth) + s.WriteString(fileChangesBoxStyle.Width(previewWidth).Render(content)) + s.WriteString("\n") + } + } + + if m.agentPreview != "" && m.list.cursor < len(visible) { + wsName := visible[m.list.cursor].Name + previewWidth := m.width - 8 + if previewWidth < minPreviewWidth { + previewWidth = defaultPreviewWidth + } + content := previewTitleStyle.Render("Agent Output: "+wsName) + "\n" + + previewLineStyle.Render(m.agentPreview) + s.WriteString(previewBoxStyle.Width(previewWidth).Render(content)) + s.WriteString("\n") + } + } + + if m.workspaceCreating { + spinner := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + s.WriteString(pendingItemStyle.Render(fmt.Sprintf( + " %s %s %s", + spinner, + m.workspaceCreatingName, + pendingLabelStyle.Render("creating…"), + ))) + s.WriteString("\n") + } + + s.WriteString("\n") + s.WriteString(m.statusBar()) + s.WriteString("\n") + + s.WriteString(m.help.View(m.keys)) + + return appStyle.Render(s.String()) +} + +func (m Model) statusBar() string { + total := len(m.workspaces) + active := 0 + openPRs := 0 + doneCount := 0 + for _, ws := range m.workspaces { + if ws.Active { + active++ + } + if ws.PRStatus == "open" { + openPRs++ + } + if ws.AgentStatus != nil && (ws.AgentStatus.Status == "success" || ws.AgentStatus.Status == "failure" || ws.AgentStatus.Status == "error") { + doneCount++ + } + } + parts := []string{ + fmt.Sprintf("%d workspaces", total), + fmt.Sprintf("%d active", active), + fmt.Sprintf("%d open PRs", openPRs), + "sort: " + sortModeNames[m.list.sortMode], + } + if doneCount > 0 { + parts = append(parts, fmt.Sprintf("%d done", doneCount)) + } + if len(m.list.selected) > 0 { + parts = append(parts, fmt.Sprintf("%d selected", len(m.list.selected))) + } + if len(m.errorLog.entries) > 0 { + parts = append(parts, fmt.Sprintf("%d errors (E)", len(m.errorLog.entries))) + } + return statusBarStyle.Render(strings.Join(parts, " • ")) +} + +func (m Model) visibleWorkspaces() []WorkspaceItem { + sorted := m.sortedWorkspaces() + if m.list.filterQuery == "" { + return sorted + } + q := strings.ToLower(m.list.filterQuery) + var out []WorkspaceItem + for _, ws := range sorted { + if strings.Contains(strings.ToLower(ws.Name), q) { + out = append(out, ws) + } + } + return out +} + +func (m Model) sortedWorkspaces() []WorkspaceItem { + ws := make([]WorkspaceItem, len(m.workspaces)) + copy(ws, m.workspaces) + switch m.list.sortMode { + case sortByAge: + sort.Slice(ws, func(i, j int) bool { + return ws[i].CreatedAt.After(ws[j].CreatedAt) + }) + case sortByActivity: + sort.Slice(ws, func(i, j int) bool { + return ws[i].LastActivity.After(ws[j].LastActivity) + }) + case sortByPR: + prOrder := func(s string) int { + switch s { + case "open": + return 0 + case "merged": + return 1 + default: + return 2 + } + } + sort.Slice(ws, func(i, j int) bool { + return prOrder(ws[i].PRStatus) < prOrder(ws[j].PRStatus) + }) + default: // sortByName + sort.Slice(ws, func(i, j int) bool { + return ws[i].Name < ws[j].Name + }) + } + return ws +} + +func renderCIBadge(ci string) string { + switch ci { + case "success": + return " " + ciSuccessStyle.Render("✓ CI") + case "failure": + return " " + ciFailureStyle.Render("✗ CI") + case "pending": + return " " + ciPendingStyle.Render("⟳ CI") + } + return "" +} diff --git a/pkg/tui/mode_pr_creating.go b/pkg/tui/mode_pr_creating.go new file mode 100644 index 0000000..61bf31c --- /dev/null +++ b/pkg/tui/mode_pr_creating.go @@ -0,0 +1,54 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func updatePRCreating(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "enter": + val := m.input.Value() + if m.pr.step == 0 { + m.pr.title = val + m.pr.step = 1 + m.focusInput("PR body (optional)", m.pr.bodyPrefill) + return m, textinput.Blink + } + // step 1: body confirmed + wsName := m.pr.wsName + title := m.pr.title + body := val + m.mode = ModeList + m.pr = prState{} + m.input.SetValue("") + m.input.Placeholder = "New branch name" + return m, m.createPRCmd(wsName, title, body) + case "esc": + m.mode = ModeList + m.pr = prState{} + m.input.SetValue("") + m.input.Placeholder = "New branch name" + return m, nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func viewPRCreating(m Model) string { + var stepLabel string + if m.pr.step == 0 { + stepLabel = "Step 1/2 — PR title" + } else { + stepLabel = fmt.Sprintf("Step 2/2 — PR body (title: %s)", m.pr.title) + } + return appStyle.Render(fmt.Sprintf("%s\n\n%s\n%s\n\n%s", + titleStyle.Render(fmt.Sprintf("Create PR: %s → %s", m.pr.branch, m.pr.base)), + stepLabelStyle.Render(stepLabel), + m.input.View(), + helpStyle.Render("Enter to continue • Esc to cancel"), + )) +} diff --git a/pkg/tui/mode_pr_generating.go b/pkg/tui/mode_pr_generating.go new file mode 100644 index 0000000..c28b337 --- /dev/null +++ b/pkg/tui/mode_pr_generating.go @@ -0,0 +1,27 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +func updatePRGenerating(m Model, msg tea.KeyMsg) (Model, tea.Cmd) { + // Bug B fix: gate keys while generating; esc cancels (Bug C fix). + if msg.String() == "esc" { + m.mode = ModeList + m.pr.cancelled = true + m.pr.wsName = "" + m.pr.branch = "" + m.pr.base = "" + } + return m, nil +} + +func viewPRGenerating(m Model) string { + return appStyle.Render(fmt.Sprintf("%s\n\n%s\n\n%s", + titleStyle.Render(fmt.Sprintf("Create PR: %s → %s", m.pr.branch, m.pr.base)), + helpStyle.Render("Generating title and description from commits…"), + helpStyle.Render("Esc to cancel"), + )) +} diff --git a/pkg/tui/mode_pr_generating_test.go b/pkg/tui/mode_pr_generating_test.go new file mode 100644 index 0000000..303f95d --- /dev/null +++ b/pkg/tui/mode_pr_generating_test.go @@ -0,0 +1,100 @@ +package tui + +import "testing" + +// Bug C regression: when the user escapes out of ModePRGenerating before the +// async prContentGeneratedMsg arrives, the late message must be dropped +// rather than silently flipping the mode into ModePRCreating. +func TestPRGenerating_EscCancelsAndLateContentIsDropped(t *testing.T) { + m := newTestModel(testWS("ws")) + m.mode = ModePRGenerating + m.pr.wsName = "ws" + m.pr.branch = "feat/ws" + m.pr.base = "main" + + // User hits esc while waiting for generation. + m, _ = applyUpdate(m, keyMsg("esc")) + + if m.mode != ModeList { + t.Fatalf("after esc: mode = %v, want ModeList", m.mode) + } + if !m.pr.cancelled { + t.Fatal("after esc: pr.cancelled should be true") + } + + // Async message arrives after the user has already moved on. + m, _ = applyUpdate(m, prContentGeneratedMsg{title: "generated title", body: "generated body"}) + + if m.mode != ModeList { + t.Errorf("after late prContentGeneratedMsg: mode = %v, want ModeList (the message must be dropped)", m.mode) + } + if m.pr.cancelled { + t.Error("after late prContentGeneratedMsg: pr.cancelled should be reset to false") + } +} + +// Bug B regression: while in ModePRGenerating, non-esc keys must NOT fall +// through to the list-mode handlers (which would, e.g., open a create dialog +// on top of the generating screen). +func TestPRGenerating_NonEscKeysAreSwallowed(t *testing.T) { + m := newTestModel(testWS("ws")) + m.mode = ModePRGenerating + m.pr.wsName = "ws" + m.pr.branch = "feat/ws" + m.pr.base = "main" + + // 'n' would open the create dialog if the mode didn't gate keys. + m, _ = applyUpdate(m, keyMsg("n")) + + if m.mode != ModePRGenerating { + t.Errorf("after 'n' in ModePRGenerating: mode = %v, want ModePRGenerating (key must be swallowed)", m.mode) + } +} + +// Bug A regression: errMsg during a modal dialog must fully return to ModeList, +// zeroing per-mode sub-structs. +func TestErrMsg_ResetsAllModesAndSubStructs(t *testing.T) { + cases := []struct { + name string + mode Mode + set func(*Model) + }{ + {"delete", ModeDelete, func(m *Model) { m.del.target = "x" }}, + {"diff", ModeDiff, func(m *Model) { m.diff.content = "y"; m.diff.wsName = "x" }}, + {"agentselect", ModeAgentSelect, func(m *Model) { m.agentSel.cursor = 2 }}, + {"errorlog", ModeErrorLog, nil}, + {"pr-generating", ModePRGenerating, func(m *Model) { m.pr.wsName = "x" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTestModel() + m.mode = tc.mode + if tc.set != nil { + tc.set(&m) + } + + m, _ = applyUpdate(m, errMsg{err: fmtError("boom")}) + + if m.mode != ModeList { + t.Errorf("after errMsg from %s: mode = %v, want ModeList", tc.name, m.mode) + } + if m.del.target != "" { + t.Errorf("after errMsg from %s: del.target = %q, want empty", tc.name, m.del.target) + } + if m.diff.content != "" || m.diff.wsName != "" { + t.Errorf("after errMsg from %s: diff state not zeroed (content=%q wsName=%q)", tc.name, m.diff.content, m.diff.wsName) + } + if m.agentSel.cursor != 0 { + t.Errorf("after errMsg from %s: agentSel.cursor = %d, want 0", tc.name, m.agentSel.cursor) + } + if m.pr.wsName != "" { + t.Errorf("after errMsg from %s: pr.wsName = %q, want empty", tc.name, m.pr.wsName) + } + }) + } +} + +// fmtError is a tiny adapter so the regression test doesn't need to import fmt. +type fmtError string + +func (e fmtError) Error() string { return string(e) } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 9ac269c..19efb02 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -39,6 +39,53 @@ const ( var sortModeNames = []string{"name", "age", "activity", "PR"} +// Per-mode state sub-structs. Active-mode booleans live on m.mode instead; +// these structs hold only the data fields each mode reads or writes. + +type listState struct { + cursor int + selected map[string]bool + sortMode int + filtering bool // sub-state: filter prompt visible + filterQuery string +} + +type createState struct { + step int + newBranchName string + remoteBranches []string + filteredBranches []string + branchSuggestionCursor int +} + +type deleteState struct { + target string // empty = batch delete using m.list.selected +} + +type diffState struct { + content string + scrollOffset int + wsName string +} + +type prState struct { + step int // 0 = title, 1 = body + title string + bodyPrefill string + wsName string + branch string + base string + cancelled bool // set when esc'd out of ModePRGenerating; suppresses late content msg +} + +type agentSelectState struct { + cursor int +} + +type errorLogState struct { + entries []string +} + // Model is the main Bubble Tea model for the opentree TUI. type Model struct { svc *workspace.Service @@ -48,29 +95,32 @@ type Model struct { cfg *config.Config repoRoot string + // Active mode — single source of truth for the dispatcher. + mode Mode + + width int + height int + + // Workspaces and async-refreshed data (shared across modes). workspaces []WorkspaceItem - cursor int - width int - height int - - // two-step create dialog - input textinput.Model - creating bool - issueMode bool - remoteBranchMode bool - createStep int - newBranchName string - - // remote branch suggestion list (used in remoteBranchMode) - remoteBranches []string - filteredBranches []string - branchSuggestionCursor int + ciStatus map[string]string // wsName -> CI status + + // Shared textinput widget; each mode borrows it via m.focusInput. + input textinput.Model - // delete confirmation (single or batch) - deleting bool - deleteTarget string // single target; empty means batch (use m.selected) + // Per-mode state. + list listState + create createState + del deleteState + diff diffState + pr prState + agentSel agentSelectState + errorLog errorLogState - // in-flight operation feedback + // Agent output preview (periodically refreshed). + agentPreview string + + // In-flight tracking for workspace creation/deletion. workspaceCreating bool workspaceCreatingName string workspaceDeleting bool @@ -78,44 +128,6 @@ type Model struct { workspaceDeletingNames map[string]bool spinnerFrame int - // agent output preview - agentPreview string - - // PR creation dialog - prCreating bool - prGenerating bool - prStep int // 0 = title, 1 = body - prTitle string - prBodyPrefill string - prWsName string - prBranch string - prBase string - - // CI status per workspace - ciStatus map[string]string // wsName -> "success"/"failure"/"pending"/"" - - // multi-select - selected map[string]bool - - // sorting & filtering - sortMode int - filtering bool - filterQuery string - - // diff view - diffViewing bool - diffContent string - diffScrollOffset int - diffWsName string - - // agent selection overlay - agentSelecting bool - agentCursor int - - // error log - errLog []string - showErrLog bool - help help.Model keys keyMap @@ -208,15 +220,42 @@ func NewModel() (*Model, error) { prMgr: gh, cfg: cfg, repoRoot: repoRoot, + mode: ModeList, input: ti, help: help.New(), keys: keys, ciStatus: make(map[string]string), - selected: make(map[string]bool), + list: listState{selected: make(map[string]bool)}, workspaceDeletingNames: make(map[string]bool), }, nil } +// focusInput resets the shared textinput widget for a new mode/step. +// Consolidates the placeholder/value/Focus dance used by every modal that +// reuses m.input (Create, CreateFromIssue, CreateFromRemote, PRCreating). +func (m *Model) focusInput(placeholder, value string) { + m.input.Placeholder = placeholder + m.input.SetValue(value) + m.input.Focus() +} + +// resetToList exhaustively returns the model to ModeList, zeroing every +// per-mode sub-struct so a stale flag from an aborted dialog never leaks +// into the next session. Bug A fix: the errMsg handler used to clear only +// some flags; calling this from any error-recovery path is now sufficient. +// errorLog.entries are preserved so the user can still review past errors. +func (m *Model) resetToList() { + m.mode = ModeList + m.list.filtering = false + m.create = createState{} + m.del = deleteState{} + m.diff = diffState{} + m.pr = prState{} + m.agentSel = agentSelectState{} + m.input.SetValue("") + m.input.Placeholder = "New branch name" +} + // Init starts the initial commands: load workspaces, periodic tickers. func (m Model) Init() tea.Cmd { return tea.Batch( diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go index 177383b..096c6c7 100644 --- a/pkg/tui/tui_test.go +++ b/pkg/tui/tui_test.go @@ -6,80 +6,9 @@ import ( "path/filepath" "strings" "testing" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - - "github.com/axelgar/opentree/pkg/config" - "github.com/axelgar/opentree/pkg/state" ) -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// newTestModel builds a Model with no external dependencies. Tests that only -// exercise in-process logic (state transitions, View rendering, pure functions) -// should use this instead of NewModel, which requires a real git repo and tmux. -func newTestModel(workspaces ...WorkspaceItem) Model { - ti := textinput.New() - ti.Placeholder = "New branch name" - ti.CharLimit = 50 - ti.Width = 30 - return Model{ - cfg: config.Default(), - input: ti, - help: help.New(), - keys: keys, - workspaces: workspaces, - width: 120, - height: 40, - } -} - -func testWS(name string) WorkspaceItem { - return WorkspaceItem{ - Workspace: &state.Workspace{ - Name: name, - Branch: "feature/" + name, - BaseBranch: "main", - }, - DiffStat: "2 files changed", - } -} - -func testWSWithPR(name, prURL string) WorkspaceItem { - ws := testWS(name) - ws.PRURL = prURL - ws.PRStatus = "open" - return ws -} - -func testWSWithWindow(name string) WorkspaceItem { - ws := testWS(name) - ws.WindowID = "@1" - return ws -} - -// applyUpdate calls m.Update and casts the result back to Model. -func applyUpdate(m Model, msg tea.Msg) (Model, tea.Cmd) { - newM, cmd := m.Update(msg) - return newM.(Model), cmd -} - -func keyMsg(k string) tea.KeyMsg { - switch k { - case "enter": - return tea.KeyMsg{Type: tea.KeyEnter} - case "esc": - return tea.KeyMsg{Type: tea.KeyEsc} - case "ctrl+c": - return tea.KeyMsg{Type: tea.KeyCtrlC} - default: - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} - } -} +// Test helpers (newTestModel, testWS*, applyUpdate, keyMsg) live in helpers_test.go. // --------------------------------------------------------------------------- // 1. Delete confirmation dialog @@ -89,11 +18,11 @@ func TestDeleteConfirmation_XEntersConfirmMode(t *testing.T) { m := newTestModel(testWS("alpha")) m, _ = applyUpdate(m, keyMsg("x")) - if !m.deleting { + if m.mode != ModeDelete { t.Error("expected deleting=true after pressing x") } - if m.deleteTarget != "alpha" { - t.Errorf("deleteTarget = %q, want %q", m.deleteTarget, "alpha") + if m.del.target != "alpha" { + t.Errorf("deleteTarget = %q, want %q", m.del.target, "alpha") } } @@ -101,23 +30,23 @@ func TestDeleteConfirmation_XNoOpWhenNoWorkspaces(t *testing.T) { m := newTestModel() m, _ = applyUpdate(m, keyMsg("x")) - if m.deleting { + if m.mode == ModeDelete { t.Error("expected deleting=false when workspace list is empty") } } func TestDeleteConfirmation_YConfirmsAndResets(t *testing.T) { m := newTestModel(testWS("beta")) - m.deleting = true - m.deleteTarget = "beta" + m.mode = ModeDelete + m.del.target = "beta" m, cmd := applyUpdate(m, keyMsg("y")) - if m.deleting { + if m.mode == ModeDelete { t.Error("expected deleting=false after confirming with y") } - if m.deleteTarget != "" { - t.Errorf("deleteTarget = %q, want empty string", m.deleteTarget) + if m.del.target != "" { + t.Errorf("deleteTarget = %q, want empty string", m.del.target) } // cmd should be non-nil (deleteWorkspaceCmd returned) if cmd == nil { @@ -127,12 +56,12 @@ func TestDeleteConfirmation_YConfirmsAndResets(t *testing.T) { func TestDeleteConfirmation_UpperYConfirms(t *testing.T) { m := newTestModel(testWS("beta")) - m.deleting = true - m.deleteTarget = "beta" + m.mode = ModeDelete + m.del.target = "beta" m, cmd := applyUpdate(m, keyMsg("Y")) - if m.deleting { + if m.mode == ModeDelete { t.Error("expected deleting=false after Y") } if cmd == nil { @@ -142,16 +71,16 @@ func TestDeleteConfirmation_UpperYConfirms(t *testing.T) { func TestDeleteConfirmation_NAbortsDelete(t *testing.T) { m := newTestModel(testWS("gamma")) - m.deleting = true - m.deleteTarget = "gamma" + m.mode = ModeDelete + m.del.target = "gamma" m, cmd := applyUpdate(m, keyMsg("n")) - if m.deleting { + if m.mode == ModeDelete { t.Error("expected deleting=false after n") } - if m.deleteTarget != "" { - t.Errorf("deleteTarget = %q, want empty string after abort", m.deleteTarget) + if m.del.target != "" { + t.Errorf("deleteTarget = %q, want empty string after abort", m.del.target) } if cmd != nil { t.Error("expected nil cmd after aborting delete") @@ -160,16 +89,16 @@ func TestDeleteConfirmation_NAbortsDelete(t *testing.T) { func TestDeleteConfirmation_EscAbortsDelete(t *testing.T) { m := newTestModel(testWS("delta")) - m.deleting = true - m.deleteTarget = "delta" + m.mode = ModeDelete + m.del.target = "delta" m, cmd := applyUpdate(m, keyMsg("esc")) - if m.deleting { + if m.mode == ModeDelete { t.Error("expected deleting=false after esc") } - if m.deleteTarget != "" { - t.Errorf("deleteTarget = %q, want empty after esc", m.deleteTarget) + if m.del.target != "" { + t.Errorf("deleteTarget = %q, want empty after esc", m.del.target) } if cmd != nil { t.Error("expected nil cmd after esc") @@ -178,13 +107,13 @@ func TestDeleteConfirmation_EscAbortsDelete(t *testing.T) { func TestDeleteConfirmation_UnrelatedKeysIgnored(t *testing.T) { m := newTestModel(testWS("epsilon")) - m.deleting = true - m.deleteTarget = "epsilon" + m.mode = ModeDelete + m.del.target = "epsilon" m, cmd := applyUpdate(m, keyMsg("q")) // q should not quit while in confirm mode - if !m.deleting { + if m.mode != ModeDelete { t.Error("expected deleting to remain true when pressing q in confirm mode") } if cmd != nil { @@ -194,8 +123,8 @@ func TestDeleteConfirmation_UnrelatedKeysIgnored(t *testing.T) { func TestDeleteConfirmation_ViewContainsWorkspaceName(t *testing.T) { m := newTestModel(testWS("myfeature")) - m.deleting = true - m.deleteTarget = "myfeature" + m.mode = ModeDelete + m.del.target = "myfeature" view := m.View() @@ -209,8 +138,8 @@ func TestDeleteConfirmation_ViewContainsWorkspaceName(t *testing.T) { func TestDeleteConfirmation_ViewContainsConfirmHints(t *testing.T) { m := newTestModel(testWS("ws")) - m.deleting = true - m.deleteTarget = "ws" + m.mode = ModeDelete + m.del.target = "ws" view := m.View() @@ -366,7 +295,7 @@ func TestAgentPreview_ViewHidesPanelWhenEmpty(t *testing.T) { func TestAgentPreview_CursorUpTriggersCapture(t *testing.T) { m := newTestModel(testWS("ws1"), testWS("ws2")) - m.cursor = 1 // start at second item + m.list.cursor = 1 // start at second item _, cmd := applyUpdate(m, keyMsg("k")) @@ -377,7 +306,7 @@ func TestAgentPreview_CursorUpTriggersCapture(t *testing.T) { func TestAgentPreview_CursorDownTriggersCapture(t *testing.T) { m := newTestModel(testWS("ws1"), testWS("ws2")) - m.cursor = 0 // start at first item + m.list.cursor = 0 // start at first item _, cmd := applyUpdate(m, keyMsg("j")) @@ -423,13 +352,13 @@ func TestAutoRefresh_RefreshTickReturnsNonNilCmd(t *testing.T) { func TestAutoRefresh_RefreshTickDoesNotChangeModelState(t *testing.T) { m := newTestModel(testWS("a"), testWS("b")) - m.cursor = 1 + m.list.cursor = 1 newM, _ := applyUpdate(m, refreshTickMsg{}) // State should be unchanged; only cmds are issued - if newM.cursor != 1 { - t.Errorf("cursor changed after refreshTickMsg: got %d, want 1", newM.cursor) + if newM.list.cursor != 1 { + t.Errorf("cursor changed after refreshTickMsg: got %d, want 1", newM.list.cursor) } if len(newM.workspaces) != 2 { t.Errorf("workspaces changed after refreshTickMsg: got %d, want 2", len(newM.workspaces)) @@ -455,38 +384,38 @@ func TestCreateDialog_NKeyEntersStep1(t *testing.T) { m := newTestModel() m, _ = applyUpdate(m, keyMsg("n")) - if !m.creating { + if m.mode == ModeList { t.Error("expected creating=true after pressing n") } - if m.createStep != 0 { - t.Errorf("createStep = %d, want 0", m.createStep) + if m.create.step != 0 { + t.Errorf("createStep = %d, want 0", m.create.step) } } func TestCreateDialog_Step1EnterAdvancesToStep2(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 0 + m.mode = ModeCreate + m.create.step = 0 _ = m.input.Focus() m.input.SetValue("feat/new") m, _ = applyUpdate(m, keyMsg("enter")) - if !m.creating { + if m.mode == ModeList { t.Error("expected creating=true after step 1 enter (should move to step 2)") } - if m.createStep != 1 { - t.Errorf("createStep = %d, want 1 after step 1 enter", m.createStep) + if m.create.step != 1 { + t.Errorf("createStep = %d, want 1 after step 1 enter", m.create.step) } - if m.newBranchName != "feat/new" { - t.Errorf("newBranchName = %q, want %q", m.newBranchName, "feat/new") + if m.create.newBranchName != "feat/new" { + t.Errorf("newBranchName = %q, want %q", m.create.newBranchName, "feat/new") } } func TestCreateDialog_Step2PrefilledWithConfigDefault(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 0 + m.mode = ModeCreate + m.create.step = 0 _ = m.input.Focus() m.input.SetValue("my-feature") @@ -501,22 +430,22 @@ func TestCreateDialog_Step2PrefilledWithConfigDefault(t *testing.T) { func TestCreateDialog_Step2EnterCreatesWorkspace(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 1 - m.newBranchName = "feat/thing" + m.mode = ModeCreate + m.create.step = 1 + m.create.newBranchName = "feat/thing" _ = m.input.Focus() m.input.SetValue("develop") m, cmd := applyUpdate(m, keyMsg("enter")) - if m.creating { + if m.mode != ModeList { t.Error("expected creating=false after step 2 enter") } - if m.createStep != 0 { - t.Errorf("createStep = %d, want 0 after completion", m.createStep) + if m.create.step != 0 { + t.Errorf("createStep = %d, want 0 after completion", m.create.step) } - if m.newBranchName != "" { - t.Errorf("newBranchName = %q, want empty after completion", m.newBranchName) + if m.create.newBranchName != "" { + t.Errorf("newBranchName = %q, want empty after completion", m.create.newBranchName) } if m.input.Value() != "" { t.Errorf("input value = %q, want empty after completion", m.input.Value()) @@ -529,18 +458,18 @@ func TestCreateDialog_Step2EnterCreatesWorkspace(t *testing.T) { func TestCreateDialog_EmptyNameInStep1IsIgnored(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 0 + m.mode = ModeCreate + m.create.step = 0 m.input.SetValue("") m, cmd := applyUpdate(m, keyMsg("enter")) // Should stay in step 1 and not advance - if !m.creating { + if m.mode == ModeList { t.Error("expected creating=true when submitting empty name") } - if m.createStep != 0 { - t.Errorf("createStep = %d, want 0 when name is empty", m.createStep) + if m.create.step != 0 { + t.Errorf("createStep = %d, want 0 when name is empty", m.create.step) } if cmd != nil { t.Error("expected nil cmd when name is empty") @@ -549,17 +478,17 @@ func TestCreateDialog_EmptyNameInStep1IsIgnored(t *testing.T) { func TestCreateDialog_EscCancelsAtStep1(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 0 + m.mode = ModeCreate + m.create.step = 0 m.input.SetValue("partial-name") m, cmd := applyUpdate(m, keyMsg("esc")) - if m.creating { + if m.mode != ModeList { t.Error("expected creating=false after esc") } - if m.createStep != 0 { - t.Errorf("createStep = %d, want 0 after esc", m.createStep) + if m.create.step != 0 { + t.Errorf("createStep = %d, want 0 after esc", m.create.step) } if m.input.Value() != "" { t.Errorf("input value = %q, want empty after esc", m.input.Value()) @@ -571,21 +500,21 @@ func TestCreateDialog_EscCancelsAtStep1(t *testing.T) { func TestCreateDialog_EscCancelsAtStep2(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 1 - m.newBranchName = "feat/foo" + m.mode = ModeCreate + m.create.step = 1 + m.create.newBranchName = "feat/foo" m.input.SetValue("develop") m, cmd := applyUpdate(m, keyMsg("esc")) - if m.creating { + if m.mode != ModeList { t.Error("expected creating=false after esc at step 2") } - if m.createStep != 0 { - t.Errorf("createStep = %d, want 0 after esc at step 2", m.createStep) + if m.create.step != 0 { + t.Errorf("createStep = %d, want 0 after esc at step 2", m.create.step) } - if m.newBranchName != "" { - t.Errorf("newBranchName = %q, want empty after esc at step 2", m.newBranchName) + if m.create.newBranchName != "" { + t.Errorf("newBranchName = %q, want empty after esc at step 2", m.create.newBranchName) } if cmd != nil { t.Error("expected nil cmd after esc") @@ -594,8 +523,8 @@ func TestCreateDialog_EscCancelsAtStep2(t *testing.T) { func TestCreateDialog_ViewShowsStep1Label(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 0 + m.mode = ModeCreate + m.create.step = 0 view := m.View() @@ -609,9 +538,9 @@ func TestCreateDialog_ViewShowsStep1Label(t *testing.T) { func TestCreateDialog_ViewShowsStep2LabelWithBranchName(t *testing.T) { m := newTestModel() - m.creating = true - m.createStep = 1 - m.newBranchName = "feat/awesome" + m.mode = ModeCreate + m.create.step = 1 + m.create.newBranchName = "feat/awesome" view := m.View() @@ -626,7 +555,7 @@ func TestCreateDialog_ViewShowsStep2LabelWithBranchName(t *testing.T) { func TestCreateDialog_ViewShowsCreateHeader(t *testing.T) { m := newTestModel() - m.creating = true + m.mode = ModeCreate view := m.View() @@ -747,33 +676,33 @@ func TestCursorNavigation_WrapsAtBounds(t *testing.T) { // Up at top should stay at 0 m, _ = applyUpdate(m, keyMsg("k")) - if m.cursor != 0 { - t.Errorf("cursor = %d, want 0 (can't go above 0)", m.cursor) + if m.list.cursor != 0 { + t.Errorf("cursor = %d, want 0 (can't go above 0)", m.list.cursor) } // Move to bottom m, _ = applyUpdate(m, keyMsg("j")) m, _ = applyUpdate(m, keyMsg("j")) - if m.cursor != 2 { - t.Errorf("cursor = %d, want 2", m.cursor) + if m.list.cursor != 2 { + t.Errorf("cursor = %d, want 2", m.list.cursor) } // Down at bottom should stay at 2 m, _ = applyUpdate(m, keyMsg("j")) - if m.cursor != 2 { - t.Errorf("cursor = %d, want 2 (can't go past last item)", m.cursor) + if m.list.cursor != 2 { + t.Errorf("cursor = %d, want 2 (can't go past last item)", m.list.cursor) } } func TestCursorClamped_OnLoadedWorkspacesShrink(t *testing.T) { m := newTestModel(testWS("a"), testWS("b"), testWS("c")) - m.cursor = 2 + m.list.cursor = 2 // Simulate list shrinking to 1 item m, _ = applyUpdate(m, loadedWorkspacesMsg{workspaces: []WorkspaceItem{testWS("a")}}) - if m.cursor != 0 { - t.Errorf("cursor = %d, want 0 after list shrinks below cursor", m.cursor) + if m.list.cursor != 0 { + t.Errorf("cursor = %d, want 0 after list shrinks below cursor", m.list.cursor) } } @@ -800,13 +729,6 @@ func TestErrorMessage_DisplayedAndCleared(t *testing.T) { // Issue badge tests // --------------------------------------------------------------------------- -func testWSWithIssue(name string, issueNumber int, issueTitle string) WorkspaceItem { - ws := testWS(name) - ws.IssueNumber = issueNumber - ws.IssueTitle = issueTitle - return ws -} - func TestView_IssueBadge_Shown(t *testing.T) { m := newTestModel(testWSWithIssue("my-issue-branch", 42, "Add dark mode")) view := m.View() @@ -894,13 +816,13 @@ func TestDiffScrolling_JScrollsDown(t *testing.T) { for i := 0; i < 50; i++ { lines = append(lines, fmt.Sprintf("line %d", i)) } - m.diffViewing = true - m.diffContent = strings.Join(lines, "\n") - m.diffScrollOffset = 0 + m.mode = ModeDiff + m.diff.content = strings.Join(lines, "\n") + m.diff.scrollOffset = 0 m, _ = applyUpdate(m, keyMsg("j")) - if m.diffScrollOffset != 1 { - t.Errorf("diffScrollOffset = %d, want 1", m.diffScrollOffset) + if m.diff.scrollOffset != 1 { + t.Errorf("diffScrollOffset = %d, want 1", m.diff.scrollOffset) } } @@ -910,13 +832,13 @@ func TestDiffScrolling_KScrollsUp(t *testing.T) { for i := 0; i < 50; i++ { lines = append(lines, fmt.Sprintf("line %d", i)) } - m.diffViewing = true - m.diffContent = strings.Join(lines, "\n") - m.diffScrollOffset = 5 + m.mode = ModeDiff + m.diff.content = strings.Join(lines, "\n") + m.diff.scrollOffset = 5 m, _ = applyUpdate(m, keyMsg("k")) - if m.diffScrollOffset != 4 { - t.Errorf("diffScrollOffset = %d, want 4", m.diffScrollOffset) + if m.diff.scrollOffset != 4 { + t.Errorf("diffScrollOffset = %d, want 4", m.diff.scrollOffset) } } @@ -926,26 +848,26 @@ func TestDiffScrolling_JClampsAtMaxScroll(t *testing.T) { for i := 0; i < 50; i++ { lines = append(lines, fmt.Sprintf("line %d", i)) } - m.diffViewing = true - m.diffContent = strings.Join(lines, "\n") + m.mode = ModeDiff + m.diff.content = strings.Join(lines, "\n") // maxScroll = 50 - 32 = 18 - m.diffScrollOffset = 18 + m.diff.scrollOffset = 18 m, _ = applyUpdate(m, keyMsg("j")) - if m.diffScrollOffset != 18 { - t.Errorf("diffScrollOffset = %d after j at maxScroll, want 18 (clamped)", m.diffScrollOffset) + if m.diff.scrollOffset != 18 { + t.Errorf("diffScrollOffset = %d after j at maxScroll, want 18 (clamped)", m.diff.scrollOffset) } } func TestDiffScrolling_KClampsAtZero(t *testing.T) { m := newTestModel() - m.diffViewing = true - m.diffContent = "line 1\nline 2\nline 3" - m.diffScrollOffset = 0 + m.mode = ModeDiff + m.diff.content = "line 1\nline 2\nline 3" + m.diff.scrollOffset = 0 m, _ = applyUpdate(m, keyMsg("k")) - if m.diffScrollOffset != 0 { - t.Errorf("diffScrollOffset = %d after k at 0, want 0 (clamped)", m.diffScrollOffset) + if m.diff.scrollOffset != 0 { + t.Errorf("diffScrollOffset = %d after k at 0, want 0 (clamped)", m.diff.scrollOffset) } } @@ -1095,35 +1017,34 @@ func TestRemoteBranchMode_RKeyEntersMode(t *testing.T) { m := newTestModel() m, _ = applyUpdate(m, keyMsg("r")) - if !m.creating { + if m.mode == ModeList { t.Error("expected creating=true after pressing r") } - if !m.remoteBranchMode { + if m.mode != ModeCreateFromRemote { t.Error("expected remoteBranchMode=true after pressing r") } } func TestRemoteBranchMode_EscResets(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.remoteBranches = []string{"feat/a", "feat/b"} - m.filteredBranches = []string{"feat/a", "feat/b"} - m.branchSuggestionCursor = 1 + m.mode = ModeCreateFromRemote + m.create.remoteBranches = []string{"feat/a", "feat/b"} + m.create.filteredBranches = []string{"feat/a", "feat/b"} + m.create.branchSuggestionCursor = 1 m, cmd := applyUpdate(m, keyMsg("esc")) - if m.creating { + if m.mode != ModeList { t.Error("expected creating=false after esc") } - if m.remoteBranchMode { + if m.mode == ModeCreateFromRemote { t.Error("expected remoteBranchMode=false after esc") } - if len(m.remoteBranches) != 0 { - t.Errorf("remoteBranches should be cleared after esc, got %v", m.remoteBranches) + if len(m.create.remoteBranches) != 0 { + t.Errorf("remoteBranches should be cleared after esc, got %v", m.create.remoteBranches) } - if m.branchSuggestionCursor != 0 { - t.Errorf("branchSuggestionCursor = %d, want 0 after esc", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 0 { + t.Errorf("branchSuggestionCursor = %d, want 0 after esc", m.create.branchSuggestionCursor) } if cmd != nil { t.Error("expected nil cmd after esc") @@ -1132,91 +1053,85 @@ func TestRemoteBranchMode_EscResets(t *testing.T) { func TestRemoteBranchMode_DownMovescursor(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/a", "feat/b", "feat/c"} - m.branchSuggestionCursor = 0 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/a", "feat/b", "feat/c"} + m.create.branchSuggestionCursor = 0 m, _ = applyUpdate(m, keyMsg("down")) - if m.branchSuggestionCursor != 1 { - t.Errorf("branchSuggestionCursor = %d, want 1 after down", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 1 { + t.Errorf("branchSuggestionCursor = %d, want 1 after down", m.create.branchSuggestionCursor) } } func TestRemoteBranchMode_UpMovescursor(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/a", "feat/b"} - m.branchSuggestionCursor = 1 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/a", "feat/b"} + m.create.branchSuggestionCursor = 1 m, _ = applyUpdate(m, keyMsg("up")) - if m.branchSuggestionCursor != 0 { - t.Errorf("branchSuggestionCursor = %d, want 0 after up", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 0 { + t.Errorf("branchSuggestionCursor = %d, want 0 after up", m.create.branchSuggestionCursor) } } func TestRemoteBranchMode_UpClampsAtZero(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/a", "feat/b"} - m.branchSuggestionCursor = 0 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/a", "feat/b"} + m.create.branchSuggestionCursor = 0 m, _ = applyUpdate(m, keyMsg("up")) - if m.branchSuggestionCursor != 0 { - t.Errorf("branchSuggestionCursor = %d, want 0 (clamped)", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 0 { + t.Errorf("branchSuggestionCursor = %d, want 0 (clamped)", m.create.branchSuggestionCursor) } } func TestRemoteBranchMode_DownClampsAtEnd(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/a", "feat/b"} - m.branchSuggestionCursor = 1 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/a", "feat/b"} + m.create.branchSuggestionCursor = 1 m, _ = applyUpdate(m, keyMsg("down")) - if m.branchSuggestionCursor != 1 { - t.Errorf("branchSuggestionCursor = %d, want 1 (clamped at end)", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 1 { + t.Errorf("branchSuggestionCursor = %d, want 1 (clamped at end)", m.create.branchSuggestionCursor) } } func TestRemoteBranchMode_TabSelectsSuggestion(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.remoteBranches = []string{"feat/alpha", "feat/beta"} - m.filteredBranches = []string{"feat/alpha", "feat/beta"} - m.branchSuggestionCursor = 1 + m.mode = ModeCreateFromRemote + m.create.remoteBranches = []string{"feat/alpha", "feat/beta"} + m.create.filteredBranches = []string{"feat/alpha", "feat/beta"} + m.create.branchSuggestionCursor = 1 m, _ = applyUpdate(m, keyMsg("tab")) if m.input.Value() != "feat/beta" { t.Errorf("input value after tab = %q, want %q", m.input.Value(), "feat/beta") } - if m.branchSuggestionCursor != 0 { - t.Errorf("cursor should reset to 0 after tab, got %d", m.branchSuggestionCursor) + if m.create.branchSuggestionCursor != 0 { + t.Errorf("cursor should reset to 0 after tab, got %d", m.create.branchSuggestionCursor) } } func TestRemoteBranchMode_EnterWithHighlightedSuggestion(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/alpha", "feat/beta"} - m.branchSuggestionCursor = 1 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/alpha", "feat/beta"} + m.create.branchSuggestionCursor = 1 m, cmd := applyUpdate(m, keyMsg("enter")) - if m.creating { + if m.mode != ModeList { t.Error("expected creating=false after enter") } - if m.remoteBranchMode { + if m.mode == ModeCreateFromRemote { t.Error("expected remoteBranchMode=false after enter") } if cmd == nil { @@ -1226,23 +1141,21 @@ func TestRemoteBranchMode_EnterWithHighlightedSuggestion(t *testing.T) { func TestRemoteBranchMode_LoadedBranchesMsgSetsFields(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true + m.mode = ModeCreateFromRemote m, _ = applyUpdate(m, remoteBranchesLoadedMsg{branches: []string{"feat/a", "feat/b"}}) - if len(m.remoteBranches) != 2 { - t.Errorf("remoteBranches len = %d, want 2", len(m.remoteBranches)) + if len(m.create.remoteBranches) != 2 { + t.Errorf("remoteBranches len = %d, want 2", len(m.create.remoteBranches)) } - if len(m.filteredBranches) != 2 { - t.Errorf("filteredBranches len = %d, want 2", len(m.filteredBranches)) + if len(m.create.filteredBranches) != 2 { + t.Errorf("filteredBranches len = %d, want 2", len(m.create.filteredBranches)) } } func TestRemoteBranchMode_ViewShowsTitle(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true + m.mode = ModeCreateFromRemote view := m.View() @@ -1253,10 +1166,9 @@ func TestRemoteBranchMode_ViewShowsTitle(t *testing.T) { func TestRemoteBranchMode_ViewShowsSuggestions(t *testing.T) { m := newTestModel() - m.creating = true - m.remoteBranchMode = true - m.filteredBranches = []string{"feat/alpha", "feat/beta"} - m.branchSuggestionCursor = 0 + m.mode = ModeCreateFromRemote + m.create.filteredBranches = []string{"feat/alpha", "feat/beta"} + m.create.branchSuggestionCursor = 0 view := m.View() @@ -1315,7 +1227,7 @@ func TestAgentSelection_AKeyEntersMode(t *testing.T) { m := newTestModel(testWS("ws")) m, _ = applyUpdate(m, keyMsg("A")) - if !m.agentSelecting { + if m.mode != ModeAgentSelect { t.Error("expected agentSelecting=true after pressing A") } } @@ -1325,67 +1237,67 @@ func TestAgentSelection_CursorStartsOnActiveAgent(t *testing.T) { // Default agent is opencode (index 0) m, _ = applyUpdate(m, keyMsg("A")) - if m.agentCursor != 0 { - t.Errorf("agentCursor = %d, want 0 (OpenCode is default)", m.agentCursor) + if m.agentSel.cursor != 0 { + t.Errorf("agentCursor = %d, want 0 (OpenCode is default)", m.agentSel.cursor) } } func TestAgentSelection_DownMovescursor(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 0 + m.mode = ModeAgentSelect + m.agentSel.cursor = 0 m, _ = applyUpdate(m, keyMsg("j")) - if m.agentCursor != 1 { - t.Errorf("agentCursor = %d, want 1 after down", m.agentCursor) + if m.agentSel.cursor != 1 { + t.Errorf("agentCursor = %d, want 1 after down", m.agentSel.cursor) } } func TestAgentSelection_UpMovescursor(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 1 + m.mode = ModeAgentSelect + m.agentSel.cursor = 1 m, _ = applyUpdate(m, keyMsg("k")) - if m.agentCursor != 0 { - t.Errorf("agentCursor = %d, want 0 after up", m.agentCursor) + if m.agentSel.cursor != 0 { + t.Errorf("agentCursor = %d, want 0 after up", m.agentSel.cursor) } } func TestAgentSelection_UpClampsAtZero(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 0 + m.mode = ModeAgentSelect + m.agentSel.cursor = 0 m, _ = applyUpdate(m, keyMsg("k")) - if m.agentCursor != 0 { - t.Errorf("agentCursor = %d, want 0 (clamped)", m.agentCursor) + if m.agentSel.cursor != 0 { + t.Errorf("agentCursor = %d, want 0 (clamped)", m.agentSel.cursor) } } func TestAgentSelection_EscCancels(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 2 + m.mode = ModeAgentSelect + m.agentSel.cursor = 2 m, _ = applyUpdate(m, keyMsg("esc")) - if m.agentSelecting { + if m.mode == ModeAgentSelect { t.Error("expected agentSelecting=false after esc") } } func TestAgentSelection_EnterSelectsAgent(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 1 // Claude Code + m.mode = ModeAgentSelect + m.agentSel.cursor = 1 // Claude Code m, _ = applyUpdate(m, keyMsg("enter")) - if m.agentSelecting { + if m.mode == ModeAgentSelect { t.Error("expected agentSelecting=false after enter") } if m.cfg.Agent.Command != "claude" { @@ -1395,8 +1307,8 @@ func TestAgentSelection_EnterSelectsAgent(t *testing.T) { func TestAgentSelection_ViewShowsOverlay(t *testing.T) { m := newTestModel(testWS("ws")) - m.agentSelecting = true - m.agentCursor = 0 + m.mode = ModeAgentSelect + m.agentSel.cursor = 0 view := m.View() diff --git a/pkg/tui/update.go b/pkg/tui/update.go index f731052..99bb566 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -5,12 +5,8 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - - "github.com/axelgar/opentree/pkg/config" - "github.com/axelgar/opentree/pkg/gitutil" ) func spinnerTickCmd() tea.Cmd { @@ -23,401 +19,41 @@ func (m Model) isWorkspaceInFlight(name string) bool { return m.workspaceDeletingName == name || m.workspaceDeletingNames[name] } +// Update is the top-level Bubble Tea dispatcher. +// Key messages route through m.mode to per-mode handlers in mode_*.go. +// Non-key messages (ticks, async results) stay here because they don't +// belong to any single mode. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.help.Width = msg.Width // Clamp diff scroll offset when terminal resizes while diff is open. - if m.diffViewing { + if m.mode == ModeDiff { m.clampDiffScroll() } case tea.KeyMsg: - // Error log overlay swallows all keys - if m.showErrLog { - m.showErrLog = false - return m, nil - } - - // Agent selection mode - if m.agentSelecting { - agents := config.PredefinedAgents - switch msg.String() { - case "up", "k": - if m.agentCursor > 0 { - m.agentCursor-- - } - case "down", "j": - if m.agentCursor < len(agents)-1 { - m.agentCursor++ - } - case "enter": - agent := agents[m.agentCursor] - m.cfg.Agent.Command = agent.Command - if agent.Args != nil { - m.cfg.Agent.Args = agent.Args - } else { - m.cfg.Agent.Args = []string{} - } - cfgPath := config.FindConfigFile() - _ = config.Save(m.cfg, cfgPath) - m.agentSelecting = false - case "esc", "q": - m.agentSelecting = false - } - return m, nil - } - - // Diff view mode - if m.diffViewing { - switch msg.String() { - case "esc", "q": - m.diffViewing = false - m.diffContent = "" - m.diffScrollOffset = 0 - m.diffWsName = "" - case "up", "k": - if m.diffScrollOffset > 0 { - m.diffScrollOffset-- - } - case "down", "j": - availHeight := m.height - 8 - if availHeight < 5 { - availHeight = 5 - } - maxScroll := len(strings.Split(m.diffContent, "\n")) - availHeight - if maxScroll < 0 { - maxScroll = 0 - } - if m.diffScrollOffset < maxScroll { - m.diffScrollOffset++ - } - } - return m, nil - } - - // Delete confirmation mode - if m.deleting { - switch msg.String() { - case "y", "Y": - if m.deleteTarget != "" { - target := m.deleteTarget - m.deleting = false - m.deleteTarget = "" - m.workspaceDeleting = true - m.workspaceDeletingName = target - return m, tea.Batch(m.deleteWorkspaceCmd(target), spinnerTickCmd()) - } - // batch delete - targets := make([]string, 0, len(m.selected)) - for name := range m.selected { - targets = append(targets, name) - } - m.deleting = false - m.deleteTarget = "" - m.workspaceDeletingNames = make(map[string]bool) - for _, name := range targets { - m.workspaceDeletingNames[name] = true - } - m.selected = make(map[string]bool) - m.workspaceDeleting = true - m.workspaceDeletingName = fmt.Sprintf("%d workspaces", len(targets)) - return m, tea.Batch(m.batchDeleteWorkspaceCmd(targets), spinnerTickCmd()) - case "n", "esc": - m.deleting = false - m.deleteTarget = "" - } - return m, nil - } - - // PR creation dialog - if m.prCreating { - switch msg.String() { - case "enter": - val := m.input.Value() - if m.prStep == 0 { - m.prTitle = val - m.prStep = 1 - m.input.Placeholder = "PR body (optional)" - m.input.SetValue(m.prBodyPrefill) - return m, textinput.Blink - } - // step 1: body confirmed - wsName := m.prWsName - title := m.prTitle - body := val - m.prCreating = false - m.prStep = 0 - m.prTitle = "" - m.prBodyPrefill = "" - m.input.SetValue("") - m.input.Placeholder = "New branch name" - return m, m.createPRCmd(wsName, title, body) - case "esc": - m.prCreating = false - m.prStep = 0 - m.prTitle = "" - m.prBodyPrefill = "" - m.input.SetValue("") - m.input.Placeholder = "New branch name" - return m, nil - } - m.input, cmd = m.input.Update(msg) - return m, cmd - } - - // Filter mode - if m.filtering { - switch msg.String() { - case "esc", "enter": - m.filtering = false - m.cursor = 0 - return m, m.capturePreviewCmd() - case "backspace": - if len(m.filterQuery) > 0 { - m.filterQuery = m.filterQuery[:len(m.filterQuery)-1] - } - m.cursor = 0 - default: - if len(msg.String()) == 1 { - m.filterQuery += msg.String() - m.cursor = 0 - } - } - return m, nil - } - - // Two-step workspace create / issue / remote branch mode - if m.creating { - // Remote branch mode: handle suggestion navigation before input - if m.remoteBranchMode { - switch msg.String() { - case "up": - if m.branchSuggestionCursor > 0 { - m.branchSuggestionCursor-- - } - return m, nil - case "down": - if m.branchSuggestionCursor < len(m.filteredBranches)-1 { - m.branchSuggestionCursor++ - } - return m, nil - case "tab": - if len(m.filteredBranches) > 0 { - m.input.SetValue(m.filteredBranches[m.branchSuggestionCursor]) - m.filteredBranches = filterBranches(m.remoteBranches, m.input.Value()) - m.branchSuggestionCursor = 0 - } - return m, nil - case "enter": - var branchName string - if m.branchSuggestionCursor < len(m.filteredBranches) { - branchName = m.filteredBranches[m.branchSuggestionCursor] - } else { - branchName = m.input.Value() - } - if branchName == "" { - return m, nil - } - m.resetCreateMode() - m.workspaceCreating = true - m.workspaceCreatingName = branchName - return m, tea.Batch(m.createWorkspaceFromRemoteCmd(branchName), spinnerTickCmd()) - case "esc": - m.resetCreateMode() - return m, nil - default: - m.input, cmd = m.input.Update(msg) - m.filteredBranches = filterBranches(m.remoteBranches, m.input.Value()) - m.branchSuggestionCursor = 0 - return m, cmd - } - } - - switch msg.String() { - case "enter": - val := m.input.Value() - if val == "" { - return m, nil - } - if m.issueMode { - m.resetCreateMode() - m.workspaceCreating = true - m.workspaceCreatingName = "issue " + val - return m, tea.Batch(m.createWorkspaceFromIssueCmd(val), spinnerTickCmd()) - } - if m.createStep == 0 { - if err := gitutil.ValidateBranchName(val); err != nil { - m.err = err - m.appendErrLog(err.Error()) - return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { - return clearErrorMsg{} - }) - } - m.newBranchName = val - m.createStep = 1 - m.input.Placeholder = "Base branch" - m.input.SetValue(m.cfg.Worktree.DefaultBase) - return m, textinput.Blink - } - branchName := m.newBranchName - baseBranch := val - m.resetCreateMode() - m.workspaceCreating = true - m.workspaceCreatingName = branchName - return m, tea.Batch(m.createWorkspaceCmd(branchName, baseBranch), spinnerTickCmd()) - case "esc": - m.resetCreateMode() - return m, nil - } - m.input, cmd = m.input.Update(msg) - return m, cmd - } - - // Normal mode - visible := m.visibleWorkspaces() - switch { - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - case key.Matches(msg, m.keys.Up): - if m.cursor > 0 { - m.cursor-- - return m, m.capturePreviewCmd() - } - case key.Matches(msg, m.keys.Down): - if m.cursor < len(visible)-1 { - m.cursor++ - return m, m.capturePreviewCmd() - } - case key.Matches(msg, m.keys.New): - m.creating = true - m.createStep = 0 - m.input.Placeholder = "New branch name" - m.input.SetValue("") - m.input.Focus() - return m, textinput.Blink - case key.Matches(msg, m.keys.Issue): - m.creating = true - m.issueMode = true - m.input.Placeholder = "GitHub issue number" - m.input.SetValue("") - m.input.Focus() - return m, textinput.Blink - case key.Matches(msg, m.keys.Remote): - m.creating = true - m.remoteBranchMode = true - m.remoteBranches = nil - m.filteredBranches = nil - m.branchSuggestionCursor = 0 - m.input.Placeholder = "Remote branch name" - m.input.SetValue("") - m.input.Focus() - return m, tea.Batch(textinput.Blink, m.loadRemoteBranchesCmd()) - case key.Matches(msg, m.keys.Enter): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - return m, m.attachWorkspaceCmd(ws.Name) - } - case key.Matches(msg, m.keys.Diff): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - return m, m.loadDiffCmd(ws) - } - case key.Matches(msg, m.keys.PR): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - m.prGenerating = true - m.prWsName = ws.Name - m.prBranch = ws.Branch - m.prBase = ws.BaseBranch - return m, m.generatePRContentCmd(ws) - } - case key.Matches(msg, m.keys.Open): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - if ws.PRURL != "" { - return m, openURLCmd(ws.PRURL) - } - return m, m.transientErrCmd(fmt.Sprintf("no PR for %q — create one with 'p'", ws.Name)) - } - case key.Matches(msg, m.keys.Review): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - if ws.PRURL != "" { - return m, m.sendReviewsCmd(ws.Name) - } - return m, m.transientErrCmd(fmt.Sprintf("no PR for %q — create one first with 'p'", ws.Name)) - } - case key.Matches(msg, m.keys.Select): - if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - if m.selected[ws.Name] { - delete(m.selected, ws.Name) - } else { - m.selected[ws.Name] = true - } - // Advance cursor - if m.cursor < len(visible)-1 { - m.cursor++ - } - } - case key.Matches(msg, m.keys.Delete): - if len(m.selected) > 0 { - // batch delete confirmation - m.deleting = true - m.deleteTarget = "" - } else if len(visible) > 0 { - ws := visible[m.cursor] - if m.isWorkspaceInFlight(ws.Name) { - return m, m.transientErrCmd(fmt.Sprintf("workspace %q has a pending operation", ws.Name)) - } - m.deleting = true - m.deleteTarget = ws.Name - } - case key.Matches(msg, m.keys.Filter): - m.filtering = true - m.filterQuery = "" - m.cursor = 0 - case key.Matches(msg, m.keys.Sort): - m.sortMode = (m.sortMode + 1) % 4 - m.cursor = 0 - case key.Matches(msg, m.keys.Agent): - m.agentSelecting = true - m.agentCursor = 0 - // Position cursor on the currently active agent - for i, a := range config.PredefinedAgents { - if a.IsActive(m.cfg) { - m.agentCursor = i - break - } - } - return m, nil - case key.Matches(msg, m.keys.ErrLog): - m.showErrLog = !m.showErrLog - case key.Matches(msg, m.keys.Help): - m.help.ShowAll = !m.help.ShowAll + switch m.mode { + case ModeErrorLog: + return updateErrorLog(m, msg) + case ModeAgentSelect: + return updateAgentSelect(m, msg) + case ModeDiff: + return updateDiff(m, msg) + case ModeDelete: + return updateDelete(m, msg) + case ModePRCreating: + return updatePRCreating(m, msg) + case ModePRGenerating: + return updatePRGenerating(m, msg) + case ModeCreateFromRemote: + return updateCreateFromRemote(m, msg) + case ModeCreate, ModeCreateFromIssue: + return updateCreate(m, msg) + case ModeList: + return updateList(m, msg) } case spinnerTickMsg: @@ -436,15 +72,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return clearErrorMsg{} }) } - m.remoteBranches = msg.branches - m.filteredBranches = filterBranches(msg.branches, m.input.Value()) - m.branchSuggestionCursor = 0 + m.create.remoteBranches = msg.branches + m.create.filteredBranches = filterBranches(msg.branches, m.input.Value()) + m.create.branchSuggestionCursor = 0 case loadedWorkspacesMsg: m.workspaces = msg.workspaces visible := m.visibleWorkspaces() - if m.cursor >= len(visible) { - m.cursor = max(0, len(visible)-1) + if m.list.cursor >= len(visible) { + m.list.cursor = max(0, len(visible)-1) } return m, m.capturePreviewCmd() @@ -469,7 +105,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.workspaceDeleting = false m.workspaceDeletingName = "" m.workspaceDeletingNames = make(map[string]bool) - m.selected = make(map[string]bool) + m.list.selected = make(map[string]bool) return m, m.loadWorkspacesCmd case capturePreviewMsg: @@ -507,13 +143,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.loadWorkspacesCmd, m.checkBranchStatusCmd(msg.wsName, branch, worktreeDir, wasPushed)) case prContentGeneratedMsg: - m.prGenerating = false - m.prCreating = true - m.prStep = 0 - m.prBodyPrefill = msg.body - m.input.Placeholder = "PR title" - m.input.SetValue(msg.title) - m.input.Focus() + // Bug C fix: drop the message if the user escaped out of prGenerating + // before the async content arrived. + if m.pr.cancelled { + m.pr.cancelled = false + return m, nil + } + m.mode = ModePRCreating + m.pr.step = 0 + m.pr.bodyPrefill = msg.body + m.focusInput("PR title", msg.title) return m, textinput.Blink case prStatusTickMsg: @@ -600,12 +239,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadWorkspacesCmd case errMsg: + // Bug A fix: exhaustively return to the list and zero every per-mode + // sub-struct. The old handler reset only `creating`, `filtering`, + // `prCreating`, leaving deletion/diff/agent-select/error-log/PR-generating + // flags hanging if an error fired while those modes were active. m.workspaceCreating = false m.workspaceDeleting = false m.workspaceDeletingNames = make(map[string]bool) - m.creating = false - m.filtering = false - m.prCreating = false + m.resetToList() m.err = msg.err m.appendErrLog(msg.err.Error()) return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { @@ -613,10 +254,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) case diffLoadedMsg: - m.diffViewing = true - m.diffContent = msg.content - m.diffScrollOffset = 0 - m.diffWsName = msg.wsName + m.mode = ModeDiff + m.diff.content = msg.content + m.diff.scrollOffset = 0 + m.diff.wsName = msg.wsName case clearErrorMsg: m.err = nil @@ -628,11 +269,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - return m, cmd + return m, nil } func (m *Model) clampDiffScroll() { - lines := len(strings.Split(m.diffContent, "\n")) + lines := len(strings.Split(m.diff.content, "\n")) availHeight := m.height - 8 if availHeight < 5 { availHeight = 5 @@ -641,20 +282,16 @@ func (m *Model) clampDiffScroll() { if maxScroll < 0 { maxScroll = 0 } - if m.diffScrollOffset > maxScroll { - m.diffScrollOffset = maxScroll + if m.diff.scrollOffset > maxScroll { + m.diff.scrollOffset = maxScroll } } +// resetCreateMode zeroes the create sub-struct and returns to the list view. +// Used when a create dialog is cancelled or completes. func (m *Model) resetCreateMode() { - m.creating = false - m.remoteBranchMode = false - m.remoteBranches = nil - m.filteredBranches = nil - m.branchSuggestionCursor = 0 - m.issueMode = false - m.createStep = 0 - m.newBranchName = "" + m.mode = ModeList + m.create = createState{} m.input.SetValue("") m.input.Placeholder = "New branch name" } @@ -670,8 +307,8 @@ func (m *Model) transientErrCmd(msg string) tea.Cmd { func (m *Model) appendErrLog(msg string) { ts := time.Now().Format("15:04:05") entry := fmt.Sprintf("[%s] %s", ts, msg) - m.errLog = append(m.errLog, entry) - if len(m.errLog) > 20 { - m.errLog = m.errLog[len(m.errLog)-20:] + m.errorLog.entries = append(m.errorLog.entries, entry) + if len(m.errorLog.entries) > 20 { + m.errorLog.entries = m.errorLog.entries[len(m.errorLog.entries)-20:] } } diff --git a/pkg/tui/view.go b/pkg/tui/view.go index 8e48088..113266d 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -1,514 +1,27 @@ package tui -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/axelgar/opentree/pkg/config" -) - -const ( - headerFooterHeight = 8 - minDiffHeight = 5 - defaultPreviewWidth = 60 - minPreviewWidth = 20 -) - +// View is the top-level Bubble Tea renderer. It dispatches via m.mode +// to per-mode view functions in mode_*.go. func (m Model) View() string { - // Error log overlay - if m.showErrLog { - var sb strings.Builder - sb.WriteString(errLogTitleStyle.Render("Error Log") + "\n\n") - if len(m.errLog) == 0 { - sb.WriteString(errLogLineStyle.Render("No errors recorded.")) - } else { - for _, entry := range m.errLog { - sb.WriteString(errLogLineStyle.Render(entry)) - sb.WriteString("\n") - } - } - sb.WriteString("\n" + helpStyle.Render("Any key to close")) - return appStyle.Render(sb.String()) - } - - // Agent selection overlay - if m.agentSelecting { - var sb strings.Builder - sb.WriteString(titleStyle.Render("Select Agent")) - sb.WriteString("\n\n") - for i, agent := range config.PredefinedAgents { - cursor := " " - style := itemStyle - if i == m.agentCursor { - cursor = "▶ " - style = selectedItemStyle - } - - name := agent.Name - if agent.IsActive(m.cfg) { - name += " (active)" - } - - status := "not found" - statusSt := lipgloss.NewStyle().Foreground(lipgloss.Color("#666")) - if agent.IsInstalled() { - status = "installed" - statusSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#2A9D8F")) - } - - cmdStr := agent.Command - if len(agent.Args) > 0 { - cmdStr += " " + strings.Join(agent.Args, " ") - } - - line := fmt.Sprintf("%s%-18s %-14s %s %s", - cursor, name, cmdStr, statusSt.Render(status), agent.Description) - sb.WriteString(style.Render(line)) - sb.WriteString("\n") - } - sb.WriteString("\n") - sb.WriteString(helpStyle.Render("↑/↓ navigate • Enter select • Esc cancel")) - return appStyle.Render(sb.String()) - } - - // Diff view overlay - if m.diffViewing { - lines := strings.Split(m.diffContent, "\n") - availHeight := m.height - headerFooterHeight - if availHeight < minDiffHeight { - availHeight = minDiffHeight - } - maxScroll := len(lines) - availHeight - if maxScroll < 0 { - maxScroll = 0 - } - // Clamp is authoritative in Update; this is a read-only safety for rendering. - offset := m.diffScrollOffset - if offset > maxScroll { - offset = maxScroll - } - end := offset + availHeight - if end > len(lines) { - end = len(lines) - } - visible := lines[offset:end] - - var sb strings.Builder - for _, line := range visible { - sb.WriteString(renderDiffLine(line)) - sb.WriteString("\n") - } - - scrollInfo := fmt.Sprintf("line %d/%d", offset+1, len(lines)) - footer := fmt.Sprintf("%s • %s • %s", - helpStyle.Render("↑/k ↓/j scroll"), - helpStyle.Render("esc to close"), - helpStyle.Render(scrollInfo), - ) - content := fmt.Sprintf("%s\n\n%s\n%s", - titleStyle.Render("Diff: "+m.diffWsName), - sb.String(), - footer, - ) - return appStyle.Render(content) - } - - // Delete confirmation dialog - if m.deleting { - var titleMsg string - if m.deleteTarget != "" { - titleMsg = fmt.Sprintf("Delete workspace %q?", m.deleteTarget) - } else { - names := make([]string, 0, len(m.selected)) - for name := range m.selected { - names = append(names, name) - } - sort.Strings(names) - titleMsg = fmt.Sprintf("Delete %d workspaces: %s?", len(names), strings.Join(names, ", ")) - } - footer := fmt.Sprintf("%s %s • %s %s", - confirmKeyStyle.Render("y"), confirmLabelStyle.Render("confirm"), - confirmKeyStyle.Render("esc/n"), confirmLabelStyle.Render("cancel"), - ) - content := fmt.Sprintf("%s\n\n%s\n\n%s", - dangerStyle.Render(titleMsg), - confirmLabelStyle.Render("The worktree, tmux window, and all local changes will be removed."), - footer, - ) - return appStyle.Render(deleteDialogStyle.Render(content)) - } - - // Issue creation dialog - if m.creating && m.issueMode { - return appStyle.Render(fmt.Sprintf("%s\n\n%s\n\n%s", - titleStyle.Render("Create Workspace from GitHub Issue"), - m.input.View(), - helpStyle.Render("Enter issue number • Esc to cancel"), - )) - } - - // Remote branch creation dialog with suggestion list - if m.creating && m.remoteBranchMode { - var sb strings.Builder - sb.WriteString(titleStyle.Render("Create Workspace from Remote Branch")) - sb.WriteString("\n\n") - sb.WriteString(m.input.View()) - sb.WriteString("\n") - if len(m.filteredBranches) > 0 { - sb.WriteString("\n") - for i, b := range m.filteredBranches { - if i == m.branchSuggestionCursor { - sb.WriteString(selectedItemStyle.Render("▶ " + b)) - } else { - sb.WriteString(itemStyle.Render(" " + b)) - } - sb.WriteString("\n") - } - } else if len(m.remoteBranches) == 0 { - sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" loading branches…")) - sb.WriteString("\n") - } else { - sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" no branches match")) - sb.WriteString("\n") - } - sb.WriteString("\n") - sb.WriteString(helpStyle.Render("↑/↓ navigate • Tab select • Enter confirm • Esc cancel")) - return appStyle.Render(sb.String()) - } - - // Two-step create dialog - if m.creating { - var stepLabel string - if m.createStep == 0 { - stepLabel = "Step 1/2 — Branch name" - } else { - stepLabel = fmt.Sprintf("Step 2/2 — Base branch (branching from: %s)", m.newBranchName) - } - return appStyle.Render(fmt.Sprintf("%s\n\n%s\n%s\n\n%s", - titleStyle.Render("Create New Workspace"), - stepLabelStyle.Render(stepLabel), - m.input.View(), - helpStyle.Render("Enter to continue • Esc to cancel"), - )) - } - - // PR content generation in progress - if m.prGenerating { - return appStyle.Render(fmt.Sprintf("%s\n\n%s", - titleStyle.Render(fmt.Sprintf("Create PR: %s → %s", m.prBranch, m.prBase)), - helpStyle.Render("Generating title and description from commits…"), - )) - } - - // PR creation dialog - if m.prCreating { - var stepLabel string - if m.prStep == 0 { - stepLabel = "Step 1/2 — PR title" - } else { - stepLabel = fmt.Sprintf("Step 2/2 — PR body (title: %s)", m.prTitle) - } - return appStyle.Render(fmt.Sprintf("%s\n\n%s\n%s\n\n%s", - titleStyle.Render(fmt.Sprintf("Create PR: %s → %s", m.prBranch, m.prBase)), - stepLabelStyle.Render(stepLabel), - m.input.View(), - helpStyle.Render("Enter to continue • Esc to cancel"), - )) - } - - var s strings.Builder - - // Logo - s.WriteString(renderLogo()) - s.WriteString("\n\n") - - // Header with sort/filter info - header := "Workspaces" - s.WriteString(titleStyle.Render(header)) - s.WriteString("\n\n") - - // Filter prompt - if m.filtering { - prompt := filterPromptStyle.Render("/") + " " + m.filterQuery + "█" - s.WriteString(prompt + "\n\n") - } else if m.filterQuery != "" { - s.WriteString(filterPromptStyle.Render(fmt.Sprintf("filter: %q (/ to change, esc to clear)", m.filterQuery)) + "\n\n") - } - - // Error message (transient) - if m.err != nil { - s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(fmt.Sprintf("Error: %v", m.err))) - s.WriteString("\n\n") - } - - visible := m.visibleWorkspaces() - - // Workspace list - if len(visible) == 0 { - if m.filterQuery != "" { - s.WriteString(itemStyle.Render("No workspaces match the filter.")) - } else { - s.WriteString(itemStyle.Render("No workspaces found. Press 'n' to create one.")) - } - s.WriteString("\n") - } else { - for i, ws := range visible { - // Inline deleting state - isDeleting := m.workspaceDeletingName == ws.Name || m.workspaceDeletingNames[ws.Name] - if isDeleting { - spinner := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] - row := spinner + " " + ws.Name + " " + pendingLabelStyle.Render("deleting…") - s.WriteString(pendingItemStyle.Render(row)) - s.WriteString("\n") - continue - } - - style := itemStyle - if i == m.cursor { - style = selectedItemStyle - } - - // Activity dot - status := "○" - statusColor := stoppedStyle - if ws.Active { - status = "●" - statusColor = activeStyle - } else if ws.WindowID != "" { - status = "◎" - statusColor = idleStyle - } - - // Multi-select mark - selectMark := " " - if m.selected[ws.Name] { - selectMark = selectedMarkStyle.Render("✓ ") - } - - title := selectMark + fmt.Sprintf("%s %s", statusColor.Render(status), ws.Name) - - // Badges - if ws.IssueNumber > 0 { - title += " " + issueBadgeStyle.Render(fmt.Sprintf("#%d", ws.IssueNumber)) - } - switch { - case ws.PRStatus == "merged": - title += " " + mergedBadgeStyle.Render("merged · ready to delete") - case ws.PRStatus == "closed": - title += " " + closedBadgeStyle.Render("PR closed") - case ws.RemoteDeleted: - title += " " + remoteDeletedBadgeStyle.Render("remote deleted") - case ws.PRStatus == "open" && ws.MergeConflicts: - title += " " + conflictsBadgeStyle.Render("PR open · conflicts") - if ci, ok := m.ciStatus[ws.Name]; ok { - title += renderCIBadge(ci) - } - case ws.PRStatus == "open": - title += " " + prOpenBadgeStyle.Render("PR open") - if ci, ok := m.ciStatus[ws.Name]; ok { - title += renderCIBadge(ci) - } - case ws.BranchPushed: - title += " " + pushedBadgeStyle.Render("pushed") - default: - title += " " + notPushedBadgeStyle.Render("not pushed") - } - - // Agent completion badge - if ws.AgentStatus != nil { - switch ws.AgentStatus.Status { - case "success": - title += " " + agentSuccessStyle.Render("done") - case "failure": - title += " " + agentFailureStyle.Render("failed") - case "error": - title += " " + agentErrorStyle.Render("error") - case "in_progress": - title += " " + agentInProgressStyle.Render("working...") - } - } - - // Description line - branchDisplay := ws.Branch - if ws.BaseBranch != "" { - branchDisplay += " ← " + ws.BaseBranch - } - descParts := []string{branchDisplay, ws.DiffStat, "created " + formatAge(ws.CreatedAt)} - - if ws.UncommittedCount > 0 { - descParts = append(descParts, uncommittedStyle.Render(fmt.Sprintf("~%d uncommitted", ws.UncommittedCount))) - } - - if !ws.LastActivity.IsZero() { - descParts = append(descParts, "active "+formatAge(ws.LastActivity)) - } - - if ws.AgentStatus != nil && ws.AgentStatus.Message != "" { - descParts = append(descParts, ws.AgentStatus.Message) - } - - desc := " " + strings.Join(descParts, " • ") - - s.WriteString(style.Render(fmt.Sprintf("%s\n%s", title, diffStyle.Render(desc)))) - s.WriteString("\n") - - // Merged cleanup hint - if ws.PRStatus == "merged" && i == m.cursor { - s.WriteString(mergedHintStyle.Render(" → Press x to clean up this merged workspace")) - s.WriteString("\n") - } - } - - // Per-file changes panel for selected workspace - if m.cursor < len(visible) { - ws := visible[m.cursor] - if len(ws.FileChanges) > 0 { - previewWidth := m.width - 8 - if previewWidth < 20 { - previewWidth = 60 - } - content := m.renderFileChanges(ws.FileChanges, previewWidth) - s.WriteString(fileChangesBoxStyle.Width(previewWidth).Render(content)) - s.WriteString("\n") - } - } - - // Agent output preview for selected workspace - if m.agentPreview != "" && m.cursor < len(visible) { - wsName := visible[m.cursor].Name - previewWidth := m.width - 8 - if previewWidth < minPreviewWidth { - previewWidth = defaultPreviewWidth - } - content := previewTitleStyle.Render("Agent Output: "+wsName) + "\n" + - previewLineStyle.Render(m.agentPreview) - s.WriteString(previewBoxStyle.Width(previewWidth).Render(content)) - s.WriteString("\n") - } - } - - // Creating ghost entry (non-selectable, rendered outside the list) - if m.workspaceCreating { - spinner := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] - s.WriteString(pendingItemStyle.Render(fmt.Sprintf( - " %s %s %s", - spinner, - m.workspaceCreatingName, - pendingLabelStyle.Render("creating…"), - ))) - s.WriteString("\n") - } - - // Status bar - s.WriteString("\n") - s.WriteString(m.statusBar()) - s.WriteString("\n") - - // Help - s.WriteString(m.help.View(m.keys)) - - return appStyle.Render(s.String()) -} - -// statusBar renders the bottom stats line. -func (m Model) statusBar() string { - total := len(m.workspaces) - active := 0 - openPRs := 0 - doneCount := 0 - for _, ws := range m.workspaces { - if ws.Active { - active++ - } - if ws.PRStatus == "open" { - openPRs++ - } - if ws.AgentStatus != nil && (ws.AgentStatus.Status == "success" || ws.AgentStatus.Status == "failure" || ws.AgentStatus.Status == "error") { - doneCount++ - } - } - parts := []string{ - fmt.Sprintf("%d workspaces", total), - fmt.Sprintf("%d active", active), - fmt.Sprintf("%d open PRs", openPRs), - "sort: " + sortModeNames[m.sortMode], - } - if doneCount > 0 { - parts = append(parts, fmt.Sprintf("%d done", doneCount)) - } - if len(m.selected) > 0 { - parts = append(parts, fmt.Sprintf("%d selected", len(m.selected))) - } - if len(m.errLog) > 0 { - parts = append(parts, fmt.Sprintf("%d errors (E)", len(m.errLog))) - } - return statusBarStyle.Render(strings.Join(parts, " • ")) -} - -// visibleWorkspaces returns the sorted and filtered workspace list. -func (m Model) visibleWorkspaces() []WorkspaceItem { - sorted := m.sortedWorkspaces() - if m.filterQuery == "" { - return sorted - } - q := strings.ToLower(m.filterQuery) - var out []WorkspaceItem - for _, ws := range sorted { - if strings.Contains(strings.ToLower(ws.Name), q) { - out = append(out, ws) - } - } - return out -} - -// sortedWorkspaces returns a copy of m.workspaces sorted by m.sortMode. -func (m Model) sortedWorkspaces() []WorkspaceItem { - ws := make([]WorkspaceItem, len(m.workspaces)) - copy(ws, m.workspaces) - switch m.sortMode { - case sortByAge: - sort.Slice(ws, func(i, j int) bool { - return ws[i].CreatedAt.After(ws[j].CreatedAt) - }) - case sortByActivity: - sort.Slice(ws, func(i, j int) bool { - return ws[i].LastActivity.After(ws[j].LastActivity) - }) - case sortByPR: - prOrder := func(s string) int { - switch s { - case "open": - return 0 - case "merged": - return 1 - default: - return 2 - } - } - sort.Slice(ws, func(i, j int) bool { - return prOrder(ws[i].PRStatus) < prOrder(ws[j].PRStatus) - }) - default: // sortByName - sort.Slice(ws, func(i, j int) bool { - return ws[i].Name < ws[j].Name - }) - } - return ws -} - -func renderCIBadge(ci string) string { - switch ci { - case "success": - return " " + ciSuccessStyle.Render("✓ CI") - case "failure": - return " " + ciFailureStyle.Render("✗ CI") - case "pending": - return " " + ciPendingStyle.Render("⟳ CI") - } - return "" + switch m.mode { + case ModeErrorLog: + return viewErrorLog(m) + case ModeAgentSelect: + return viewAgentSelect(m) + case ModeDiff: + return viewDiff(m) + case ModeDelete: + return viewDelete(m) + case ModeCreateFromIssue: + return viewCreateFromIssue(m) + case ModeCreateFromRemote: + return viewCreateFromRemote(m) + case ModeCreate: + return viewCreate(m) + case ModePRGenerating: + return viewPRGenerating(m) + case ModePRCreating: + return viewPRCreating(m) + } + return viewList(m) }