merge: M3 TUI interaction into main

This commit is contained in:
dimitar 2026-08-02 09:11:02 +02:00
commit 8ef13506da
3 changed files with 166 additions and 36 deletions

View File

@ -12,6 +12,7 @@ type Styles struct {
Row lipgloss.Style
CursorRow lipgloss.Style
StatusBar lipgloss.Style
Changed lipgloss.Style
}
// Dim applies a dimmed, lower-contrast style (shared helper).
@ -43,6 +44,9 @@ func NewStyles(opts presenter.Options) Styles {
StatusBar: lipgloss.NewStyle().
Foreground(lipgloss.Color(barFg)).
Background(lipgloss.Color("236")),
Changed: lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).
Bold(true),
}
}

View File

@ -6,6 +6,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbletea"
@ -24,6 +25,7 @@ type Model struct {
opts presenter.Options
result status.ScanResult
prevResult status.ScanResult
cursor int
detail bool
help bool
@ -37,6 +39,10 @@ type Model struct {
err string
first bool
interval time.Duration
lastScan time.Time
changed map[string]bool // repo paths whose state changed since previous scan
styles Styles
}
@ -50,12 +56,6 @@ func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.
return newProgram(cfg, a, aiProvider, opts, result, false), nil
}
// NewEmpty returns a Program that shows an empty state and runs a scan on
// Init (for testing the async flow).
func NewEmpty(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) *tea.Program {
return newProgram(cfg, a, aiProvider, opts, status.ScanResult{}, true)
}
func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options, result status.ScanResult, loading bool) *tea.Program {
m := Model{
app: a,
@ -66,6 +66,9 @@ func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts pre
cursor: 0,
detail: false,
first: true,
interval: cfg.Interval,
lastScan: time.Now(),
changed: map[string]bool{},
styles: NewStyles(opts),
}
if loading {
@ -79,9 +82,23 @@ func (m Model) Init() tea.Cmd {
if m.loading {
return m.scanCmd
}
if m.interval > 0 {
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
return nil
}
// scanTickMsg is sent by the ticker every interval.
type scanTickMsg struct{ at time.Time }
// scanResultMsg carries a completed scan.
type scanResultMsg struct{ result status.ScanResult }
// scanErrorMsg carries a failed scan.
type scanErrorMsg struct{ err error }
// scanCmd triggers a scan in the background.
func (m Model) scanCmd() tea.Msg {
result, err := m.app.ScanOnce(context.Background())
@ -91,13 +108,6 @@ func (m Model) scanCmd() tea.Msg {
return scanResultMsg{result}
}
// ---- messages --------------------------------------------------------------
type scanTickMsg struct{}
type scanResultMsg struct{ result status.ScanResult }
type scanErrorMsg struct{ err error }
type terminalResizeMsg struct{ width, height int }
// ---- update ----------------------------------------------------------------
// Update handles incoming messages.
@ -121,18 +131,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor > 0 {
m.cursor--
}
case "home", "g":
m.cursor = 0
case "end", "G":
if n := len(m.result.Repos); n > 0 {
m.cursor = n - 1
}
case "enter", " ":
m.detail = !m.detail
case "r":
if !m.loading {
m.loading = true
return m, m.scanCmd
}
}
case scanTickMsg:
if !m.loading {
m.loading = true
return m, m.scanCmd
}
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
case scanResultMsg:
if !m.first {
m.prevResult = m.result
m.changed = make(map[string]bool, len(msg.result.Repos))
for _, r := range status.Changed(m.prevResult, msg.result) {
m.changed[r.Path] = true
}
}
m.result = msg.result
m.loading = false
m.err = ""
m.lastScan = time.Now()
if m.first {
m.first = false
}
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
case scanErrorMsg:
m.err = msg.err.Error()
m.loading = false
m.lastScan = time.Now()
if m.interval > 0 {
return m, tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
}
return m, nil
}
@ -146,7 +200,6 @@ func (m Model) View() string {
}
var b strings.Builder
b.WriteString(m.repoListView())
b.WriteString("\n")
if m.detail && m.cursor < len(m.result.Repos) {
@ -160,7 +213,7 @@ func (m Model) View() string {
// ---- repo list -------------------------------------------------------------
func (m Model) repoListView() string {
if m.loading {
if m.loading && m.first {
return fmt.Sprintf("\n %s scanning %s...\n", m.styles.Dim("⏳"), m.cfg.Dir)
}
if len(m.result.Repos) == 0 {
@ -169,7 +222,6 @@ func (m Model) repoListView() string {
var b strings.Builder
header := m.styles.Header
// Column widths
fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s",
header.Render(""),
header.Render("STATUS"),
@ -182,9 +234,12 @@ func (m Model) repoListView() string {
)
for i, repo := range m.result.Repos {
changed := m.changed[repo.Path]
cursor := " "
if i == m.cursor {
cursor = ">"
} else if changed {
cursor = "▲"
}
ab := "-"
@ -212,6 +267,9 @@ func (m Model) repoListView() string {
stash,
)
line = m.colorLine(repo.Status, cursor, line)
if changed && cursor != ">" {
line = m.styles.Changed.Render(line)
}
if i == m.cursor {
b.WriteString(m.styles.CursorRow.Render(line))
@ -223,12 +281,9 @@ func (m Model) repoListView() string {
}
func (m Model) colorLine(s status.RepoStatus, cursor string, line string) string {
// Dim errored repos; use status color for the status symbol column.
switch s {
case status.StatusError:
return m.styles.Dim(line)
case status.StatusClean:
// keep default
}
return line
}
@ -239,7 +294,7 @@ func (m Model) detailView(repo status.RepoInfo) string {
var b strings.Builder
w := m.width
if w < 40 {
w = 80 // default when no resize event yet (e.g. tests)
w = 80
}
sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
b.WriteString(sep + "\n")
@ -273,7 +328,18 @@ func (m Model) statusBarView() string {
if m.err != "" {
left += " [error: " + m.err + "]"
}
right := "? help q quit ↑↓ nav Enter detail"
if m.interval > 0 {
elapsed := time.Since(m.lastScan).Round(time.Second)
next := m.interval - elapsed
if next < 0 {
next = 0
}
left += fmt.Sprintf(" | next: %v", next)
}
if m.loading {
left += " | scanning…"
}
right := "r refresh ? help q quit ↑↓ nav Enter detail"
bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
return m.styles.StatusBar.Render(bar)
}
@ -283,8 +349,9 @@ func (m Model) statusBarView() string {
func (m Model) helpView() string {
return m.styles.Dim(
"\n KEYBINDINGS\n" +
" ↑/↓ or j/k navigate\n" +
" ↑/↓ or j/k g/G navigate\n" +
" Enter / Space toggle detail pane\n" +
" r refresh now\n" +
" a AI suggestions (requires --ai)\n" +
" ? toggle this help\n" +
" q / Ctrl-C quit\n",

View File

@ -94,6 +94,20 @@ func TestModelDetailToggle(t *testing.T) {
}
}
func TestModelHomeEndKeys(t *testing.T) {
m := newTestModel()
// Start at 0, go to end with G
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
if m2.(Model).cursor != 2 {
t.Errorf("cursor = %d after G, want 2 (last)", m2.(Model).cursor)
}
// Go to home with g
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
if m3.(Model).cursor != 0 {
t.Errorf("cursor = %d after g, want 0 (home)", m3.(Model).cursor)
}
}
func TestModelHelpToggle(t *testing.T) {
m := newTestModel()
if m.help {
@ -105,6 +119,51 @@ func TestModelHelpToggle(t *testing.T) {
}
}
func TestModelRescanR(t *testing.T) {
m := newTestModel()
if m.loading {
t.Error("loading = true initially, want false")
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
if cmd == nil {
t.Error("'r' produced nil cmd, want scanCmd")
}
}
func TestModelChangedMarkers(t *testing.T) {
m := newTestModel()
m.first = false
m.changed = map[string]bool{"/tmp/test/b": true}
view := m.View()
if !strings.Contains(view, "▲") {
t.Errorf("view missing ▲ marker for changed repo:\n%s", view)
}
}
func TestModelIntervalTickInitiatesScan(t *testing.T) {
m := newTestModel()
m.interval = 10 * time.Second
m.first = false
m.loading = false
// A tick fires a scan when not already loading.
_ = m
_, cmd := m.Update(scanTickMsg{time.Now()})
if cmd == nil {
t.Error("scanTickMsg produced nil cmd, want scanCmd")
}
}
func TestModelIntervalShownInStatusBar(t *testing.T) {
m := newTestModel()
m.interval = 5 * time.Second
m.lastScan = time.Now()
view := m.View()
if !strings.Contains(view, "next:") {
t.Errorf("status bar missing countdown:\n%s", view)
}
}
func TestModelQuit(t *testing.T) {
m := newTestModel()
for _, key := range []tea.KeyType{tea.KeyCtrlC} {