Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkg/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""} }
}
Expand Down
82 changes: 82 additions & 0 deletions pkg/tui/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)}
}
}
17 changes: 17 additions & 0 deletions pkg/tui/mode.go
Original file line number Diff line number Diff line change
@@ -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
)
78 changes: 78 additions & 0 deletions pkg/tui/mode_agentselect.go
Original file line number Diff line number Diff line change
@@ -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())
}
77 changes: 77 additions & 0 deletions pkg/tui/mode_create.go
Original file line number Diff line number Diff line change
@@ -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"),
))
}
81 changes: 81 additions & 0 deletions pkg/tui/mode_create_remote.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading