feat: M3 — TUI interaction (periodic scan, countdown, change markers)

Replace the static-scan boot in the TUI with a reactive scan loop anchored
on bubbletea.Tick and user-driven 'r' rescan.

Scan loop (tea.Tick instead of goroutine — no leak):
- Init returns a tick Cmd when --interval > 0; on each tick the model
  fires scanCmd and chains the next tick in the Update handler, producing
  a self-regulating interval loop that stops naturally on quit
- 'r' key triggers an immediate rescan when the model is not already
  loading, for on-demand refresh
- scanResultMsg handler records prevResult, computes status.Changed into a
  changed set (keyed by repo path), and updates lastScan
- scanErrorMsg stores the error text on the model (displayed in the status
  bar) without stopping the loop

Change detection in the view:
- Repos whose state changed since the previous scan get a ▲ prefix (yellow
  bold) in the repo list, distinguishing them from the > cursor

Status bar improvements:
- Countdown "next: 23s" when --interval is active, computed from
  time.Since(lastScan); "scanning…" shown during async scans
- Updated keybinding hints: r refresh, g/G home/end

Navigation additions:
- g/Home jumps to top, G/End jumps to bottom

Styles:
- Changed style (yellow bold foreground) for ▲ markers
- Styles struct now includes all five variants

Tests:
- Home/End keys navigate to first/last repo
- 'r' fires scanCmd when not loading
- ▲ marker appears for changed repos
- Interval tick fires scan when idle
- Countdown "next:" shown in status bar

Verified: go build, go vet, go test -race (12 packages), gofmt clean.
This commit is contained in:
dimitar 2026-08-02 09:10:56 +02:00
parent fad4d1c769
commit 3d389ac3e8
3 changed files with 166 additions and 36 deletions

View File

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

View File

@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
@ -23,11 +24,12 @@ type Model struct {
cfg *config.Config cfg *config.Config
opts presenter.Options opts presenter.Options
result status.ScanResult result status.ScanResult
cursor int prevResult status.ScanResult
detail bool cursor int
help bool detail bool
aiPanel bool help bool
aiPanel bool
width int width int
height int height int
@ -37,6 +39,10 @@ type Model struct {
err string err string
first bool first bool
interval time.Duration
lastScan time.Time
changed map[string]bool // repo paths whose state changed since previous scan
styles Styles styles Styles
} }
@ -50,23 +56,20 @@ func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.
return newProgram(cfg, a, aiProvider, opts, result, false), nil 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 { func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options, result status.ScanResult, loading bool) *tea.Program {
m := Model{ m := Model{
app: a, app: a,
ai: aiProvider, ai: aiProvider,
cfg: cfg, cfg: cfg,
opts: opts, opts: opts,
result: result, result: result,
cursor: 0, cursor: 0,
detail: false, detail: false,
first: true, first: true,
styles: NewStyles(opts), interval: cfg.Interval,
lastScan: time.Now(),
changed: map[string]bool{},
styles: NewStyles(opts),
} }
if loading { if loading {
m.loading = true m.loading = true
@ -79,9 +82,23 @@ func (m Model) Init() tea.Cmd {
if m.loading { if m.loading {
return m.scanCmd return m.scanCmd
} }
if m.interval > 0 {
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
return scanTickMsg{t}
})
}
return nil 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. // scanCmd triggers a scan in the background.
func (m Model) scanCmd() tea.Msg { func (m Model) scanCmd() tea.Msg {
result, err := m.app.ScanOnce(context.Background()) result, err := m.app.ScanOnce(context.Background())
@ -91,13 +108,6 @@ func (m Model) scanCmd() tea.Msg {
return scanResultMsg{result} 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 ----------------------------------------------------------------
// Update handles incoming messages. // Update handles incoming messages.
@ -121,18 +131,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- 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", " ": case "enter", " ":
m.detail = !m.detail 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: 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.result = msg.result
m.loading = false m.loading = false
m.err = "" 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: case scanErrorMsg:
m.err = msg.err.Error() m.err = msg.err.Error()
m.loading = false 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 return m, nil
} }
@ -146,7 +200,6 @@ func (m Model) View() string {
} }
var b strings.Builder var b strings.Builder
b.WriteString(m.repoListView()) b.WriteString(m.repoListView())
b.WriteString("\n") b.WriteString("\n")
if m.detail && m.cursor < len(m.result.Repos) { if m.detail && m.cursor < len(m.result.Repos) {
@ -160,7 +213,7 @@ func (m Model) View() string {
// ---- repo list ------------------------------------------------------------- // ---- repo list -------------------------------------------------------------
func (m Model) repoListView() string { 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) return fmt.Sprintf("\n %s scanning %s...\n", m.styles.Dim("⏳"), m.cfg.Dir)
} }
if len(m.result.Repos) == 0 { if len(m.result.Repos) == 0 {
@ -169,7 +222,6 @@ func (m Model) repoListView() string {
var b strings.Builder var b strings.Builder
header := m.styles.Header header := m.styles.Header
// Column widths
fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s", fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s",
header.Render(""), header.Render(""),
header.Render("STATUS"), header.Render("STATUS"),
@ -182,9 +234,12 @@ func (m Model) repoListView() string {
) )
for i, repo := range m.result.Repos { for i, repo := range m.result.Repos {
changed := m.changed[repo.Path]
cursor := " " cursor := " "
if i == m.cursor { if i == m.cursor {
cursor = ">" cursor = ">"
} else if changed {
cursor = "▲"
} }
ab := "-" ab := "-"
@ -212,6 +267,9 @@ func (m Model) repoListView() string {
stash, stash,
) )
line = m.colorLine(repo.Status, cursor, line) line = m.colorLine(repo.Status, cursor, line)
if changed && cursor != ">" {
line = m.styles.Changed.Render(line)
}
if i == m.cursor { if i == m.cursor {
b.WriteString(m.styles.CursorRow.Render(line)) 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 { 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 { switch s {
case status.StatusError: case status.StatusError:
return m.styles.Dim(line) return m.styles.Dim(line)
case status.StatusClean:
// keep default
} }
return line return line
} }
@ -239,7 +294,7 @@ func (m Model) detailView(repo status.RepoInfo) string {
var b strings.Builder var b strings.Builder
w := m.width w := m.width
if w < 40 { 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))) sep := m.styles.Dim(strings.Repeat("─", max(0, w-2)))
b.WriteString(sep + "\n") b.WriteString(sep + "\n")
@ -273,7 +328,18 @@ func (m Model) statusBarView() string {
if m.err != "" { if m.err != "" {
left += " [error: " + 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 bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right
return m.styles.StatusBar.Render(bar) return m.styles.StatusBar.Render(bar)
} }
@ -283,8 +349,9 @@ func (m Model) statusBarView() string {
func (m Model) helpView() string { func (m Model) helpView() string {
return m.styles.Dim( return m.styles.Dim(
"\n KEYBINDINGS\n" + "\n KEYBINDINGS\n" +
" ↑/↓ or j/k navigate\n" + " ↑/↓ or j/k g/G navigate\n" +
" Enter / Space toggle detail pane\n" + " Enter / Space toggle detail pane\n" +
" r refresh now\n" +
" a AI suggestions (requires --ai)\n" + " a AI suggestions (requires --ai)\n" +
" ? toggle this help\n" + " ? toggle this help\n" +
" q / Ctrl-C quit\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) { func TestModelHelpToggle(t *testing.T) {
m := newTestModel() m := newTestModel()
if m.help { 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) { func TestModelQuit(t *testing.T) {
m := newTestModel() m := newTestModel()
for _, key := range []tea.KeyType{tea.KeyCtrlC} { for _, key := range []tea.KeyType{tea.KeyCtrlC} {