// Package tui provides an interactive terminal UI for browsing gitflow scan // results using bubbletea. package tui import ( "context" "fmt" "strings" "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 cursor int detail bool help bool aiPanel bool width int height int loading bool aiLoading bool err string first bool 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 } // 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), } 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 } return nil } // 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} } // ---- 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. 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 "enter", " ": m.detail = !m.detail } case scanResultMsg: m.result = msg.result m.loading = false m.err = "" case scanErrorMsg: m.err = msg.err.Error() m.loading = false } 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 { 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 // Column widths 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 { cursor := " " if i == m.cursor { 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 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 { // 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 } // ---- detail view ----------------------------------------------------------- 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) } 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 + "]" } right := "? 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 navigate\n" + " Enter / Space toggle detail pane\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] + "…" }