diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 5a6aff8..889f462 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -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), } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 94dff39..acf5ffb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbletea" @@ -23,11 +24,12 @@ type Model struct { cfg *config.Config opts presenter.Options - result status.ScanResult - cursor int - detail bool - help bool - aiPanel bool + result status.ScanResult + prevResult status.ScanResult + cursor int + detail bool + help bool + aiPanel bool width int height int @@ -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,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 } -// 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, - ai: aiProvider, - cfg: cfg, - opts: opts, - result: result, - cursor: 0, - detail: false, - first: true, - styles: NewStyles(opts), + app: a, + ai: aiProvider, + cfg: cfg, + opts: opts, + result: result, + cursor: 0, + detail: false, + first: true, + interval: cfg.Interval, + lastScan: time.Now(), + changed: map[string]bool{}, + styles: NewStyles(opts), } if loading { m.loading = true @@ -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", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index d2f3ece..cc43aa2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -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} {