// Package tui provides an interactive terminal UI for browsing gitflow scan // results using bubbletea. package tui import ( "context" "fmt" "strings" "time" "github.com/charmbracelet/bubbletea" "gitea.oblak.solutions/dimitar/gitFlow/internal/ai" "gitea.oblak.solutions/dimitar/gitFlow/internal/app" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) // Model is the top-level bubbletea model for the TUI. type Model struct { app *app.App ai ai.Provider // nil when AI is disabled cfg *config.Config opts presenter.Options result status.ScanResult prevResult status.ScanResult cursor int detail bool help bool aiPanel bool width int height int loading bool aiLoading bool err string first bool interval time.Duration lastScan time.Time changed map[string]bool // repo paths whose state changed since previous scan styles Styles } // New builds a bubbletea Program for the TUI. It runs an initial scan // synchronously so the first frame shows results immediately. func New(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) (*tea.Program, error) { result, err := a.ScanOnce(context.Background()) if err != nil { return nil, fmt.Errorf("tui: initial scan: %w", err) } return newProgram(cfg, a, aiProvider, opts, result, false), nil } 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, interval: cfg.Interval, lastScan: time.Now(), changed: map[string]bool{}, styles: NewStyles(opts), } if loading { m.loading = true } return tea.NewProgram(m, tea.WithAltScreen()) } // Init is the initial command. 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()) if err != nil { return scanErrorMsg{err} } return scanResultMsg{result} } // ---- update ---------------------------------------------------------------- // Update handles incoming messages. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "?": m.help = !m.help case "down", "j": if m.cursor < len(m.result.Repos)-1 { m.cursor++ } case "up", "k": 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 } // ---- view ------------------------------------------------------------------ // View renders the full TUI. func (m Model) View() string { if m.help { return m.helpView() } var b strings.Builder b.WriteString(m.repoListView()) b.WriteString("\n") if m.detail && m.cursor < len(m.result.Repos) { b.WriteString(m.detailView(m.result.Repos[m.cursor])) b.WriteString("\n") } b.WriteString(m.statusBarView()) return b.String() } // ---- repo list ------------------------------------------------------------- func (m Model) repoListView() string { 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 { return fmt.Sprintf("\n %s\n", m.styles.Dim("no repositories found under "+m.cfg.Dir)) } var b strings.Builder header := m.styles.Header fmt.Fprintf(&b, "%s%-5s %-30s %-15s %-10s %5s %6s\n%s", header.Render(""), header.Render("STATUS"), header.Render("REPOSITORY"), header.Render("BRANCH"), header.Render("AHEAD/BEHIND"), header.Render("CHGS"), header.Render("STASH"), header.Render(""), ) for i, repo := range m.result.Repos { changed := m.changed[repo.Path] cursor := " " if i == m.cursor { cursor = ">" } else if changed { cursor = "▲" } ab := "-" if repo.AheadBy > 0 || repo.BehindBy > 0 { ab = fmt.Sprintf("%d/%d", repo.AheadBy, repo.BehindBy) } chs := "-" if n := repo.FileCount(); n > 0 { chs = fmt.Sprintf("%d", n) } stash := "-" if repo.StashCount > 0 { stash = fmt.Sprintf("%d", repo.StashCount) } line := fmt.Sprintf("%s %-5s %-30s %-15s %5s %6s %6s\n", cursor, presenter.StatusSymbolFor(repo.Status), truncate(shortPath(repo.Path), 29), truncate(repo.Branch, 14), ab, chs, 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)) } else { b.WriteString(m.styles.Row.Render(line)) } } return b.String() } func (m Model) colorLine(s status.RepoStatus, cursor string, line string) string { switch s { case status.StatusError: return m.styles.Dim(line) } return line } // ---- detail view ----------------------------------------------------------- func (m Model) detailView(repo status.RepoInfo) string { var b strings.Builder w := m.width if w < 40 { w = 80 } sep := m.styles.Dim(strings.Repeat("─", max(0, w-2))) b.WriteString(sep + "\n") line := fmt.Sprintf(" %s branch: %s | remote: %s | ahead: %d behind: %d", repo.Path, repo.Branch, truncate(repo.RemoteURL, 30), repo.AheadBy, repo.BehindBy) b.WriteString(m.styles.Dim(line) + "\n") d := fmt.Sprintf(" staged: %d modified: %d untracked: %d stash: %d", len(repo.StagedFiles), len(repo.ModifiedFiles), len(repo.UntrackedFiles), repo.StashCount) b.WriteString(d + "\n") if repo.Error != "" { b.WriteString(m.styles.Err(repo.Error) + "\n") } if len(repo.StagedFiles) > 0 { b.WriteString(" staged: " + strings.Join(repo.StagedFiles, ", ") + "\n") } if len(repo.ModifiedFiles) > 0 { b.WriteString(" modified: " + strings.Join(repo.ModifiedFiles, ", ") + "\n") } if len(repo.UntrackedFiles) > 0 { b.WriteString(" untracked: " + strings.Join(repo.UntrackedFiles, ", ") + "\n") } return b.String() } // ---- status bar ------------------------------------------------------------ func (m Model) statusBarView() string { s := m.result.Summary() left := fmt.Sprintf("%d repos | %d clean | %d need attention | %d errors", s.Total, s.Clean, s.Attention, s.Errored) if m.err != "" { left += " [error: " + m.err + "]" } 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) } // ---- help ------------------------------------------------------------------ func (m Model) helpView() string { return m.styles.Dim( "\n KEYBINDINGS\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", ) } // ---- helpers --------------------------------------------------------------- func shortPath(p string) string { return presenter.ShortPath(p) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n-1] + "…" }