// 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 aiSugs []ai.Suggestion 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 } // aiResultMsg carries AI suggestions. type aiResultMsg struct{ suggestions []ai.Suggestion } // aiErrorMsg carries a failed AI request. type aiErrorMsg 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} } // aiCmd triggers an AI suggestion request. func (m Model) aiCmd() tea.Msg { if m.ai == nil { return aiErrorMsg{fmt.Errorf("no AI provider configured (use --ai)")} } sugs, err := m.ai.Suggest(context.Background(), m.result) if err != nil { return aiErrorMsg{err} } return aiResultMsg{sugs} } // ---- 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 "a": if m.aiPanel { m.aiPanel = false } else if !m.aiLoading { m.aiLoading = true return m, m.aiCmd } } 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} }) } case aiResultMsg: m.aiSugs = msg.suggestions m.aiLoading = false m.aiPanel = true case aiErrorMsg: m.err = "AI: " + msg.err.Error() m.aiLoading = false m.aiPanel = 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") } if m.aiPanel { b.WriteString(m.aiPanelView()) 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" if m.ai != nil { right += " a AI" } bar := left + strings.Repeat(" ", max(0, m.width-len(left)-len(right))) + right return m.styles.StatusBar.Render(bar) } // ---- AI panel ------------------------------------------------------------- func (m Model) aiPanelView() string { if m.aiLoading { return m.styles.Dim(" ⏳ asking AI...") } if len(m.aiSugs) == 0 { return m.styles.Dim(" AI: no suggestions") } 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") b.WriteString(" AI SUGGESTIONS\n") for _, s := range m.aiSugs { prio := "low" switch s.Priority { case 2: prio = "high" case 1: prio = "medium" } b.WriteString(fmt.Sprintf(" [%s] %-8s %s\n", prio, truncate(s.Action, 8), s.Message)) if s.Command != "" { b.WriteString(fmt.Sprintf(" $ %s\n", s.Command)) } } return b.String() } // ---- 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] + "…" }