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) }