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:
parent
fad4d1c769
commit
3d389ac3e8
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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} {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user