merge: M3 TUI interaction into main
This commit is contained in:
commit
8ef13506da
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbletea"
|
"github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ type Model struct {
|
|||||||
opts presenter.Options
|
opts presenter.Options
|
||||||
|
|
||||||
result status.ScanResult
|
result status.ScanResult
|
||||||
|
prevResult status.ScanResult
|
||||||
cursor int
|
cursor int
|
||||||
detail bool
|
detail bool
|
||||||
help bool
|
help bool
|
||||||
@ -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,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
|
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,
|
||||||
@ -66,6 +66,9 @@ func newProgram(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts pre
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
detail: false,
|
detail: false,
|
||||||
first: true,
|
first: true,
|
||||||
|
interval: cfg.Interval,
|
||||||
|
lastScan: time.Now(),
|
||||||
|
changed: map[string]bool{},
|
||||||
styles: NewStyles(opts),
|
styles: NewStyles(opts),
|
||||||
}
|
}
|
||||||
if loading {
|
if loading {
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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} {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user