CLI + live dashboard wrapping aws/docker/git/awsmfa for local dev: AWS MFA auth status + expiry, dev compose stack control, container drift vs the dev repo, git repo status, prod-data and DB-access tools. Config-driven (~/.config/mydev/config.yaml). Dashboard runs commands via a command palette with captured/interactive modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
668 lines
19 KiB
Go
668 lines
19 KiB
Go
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"time"
|
||
|
||
"mydev/internal/checks"
|
||
|
||
"mydev/internal/config"
|
||
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
"github.com/charmbracelet/bubbles/viewport"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/muesli/reflow/wordwrap"
|
||
"github.com/muesli/reflow/wrap"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
var dashInterval int
|
||
|
||
var dashCmd = &cobra.Command{
|
||
Use: "dash",
|
||
Short: "Live refreshing TUI dashboard",
|
||
Run: func(cmd *cobra.Command, args []string) {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, err)
|
||
os.Exit(1)
|
||
}
|
||
awsmfa, _ := cfg.AWSMFA() // empty if dev_dir unset; panel shows "not configured"
|
||
composeFile, _ := cfg.ComposeFile() // empty if dev_dir unset; drift check skipped
|
||
clearTerminal() // start clean (no prior scrollback)
|
||
p := tea.NewProgram(newDash(cfg.ReposRoots, awsmfa, composeFile, time.Duration(dashInterval)*time.Second), tea.WithAltScreen())
|
||
_, err = p.Run()
|
||
clearTerminal() // leave clean on exit (hide any interactive-command output)
|
||
if err != nil {
|
||
fmt.Println(err)
|
||
}
|
||
},
|
||
}
|
||
|
||
func init() {
|
||
dashCmd.Flags().IntVar(&dashInterval, "interval", 10, "refresh interval in seconds")
|
||
rootCmd.AddCommand(dashCmd)
|
||
}
|
||
|
||
// ---- messages emitted by background fetches ----
|
||
|
||
type authMsg checks.AuthStatus
|
||
type dockerMsg struct {
|
||
list []checks.Container
|
||
err string
|
||
}
|
||
type driftMsg struct {
|
||
stale int
|
||
total int
|
||
err string
|
||
}
|
||
type repoMsg struct {
|
||
list []checks.Repo
|
||
err string
|
||
}
|
||
type tickMsg time.Time
|
||
type execDoneMsg struct{ err error }
|
||
|
||
// cmdResultMsg carries the captured output of a non-interactive command.
|
||
type cmdResultMsg struct {
|
||
title string
|
||
body string
|
||
err error
|
||
}
|
||
|
||
// pendingAction is a command awaiting y/n confirmation before it runs.
|
||
type pendingAction struct {
|
||
label string // shown in the confirm prompt
|
||
args []string // args passed to this binary (e.g. ["dev","restart"])
|
||
}
|
||
|
||
// ---- model ----
|
||
|
||
type dashModel struct {
|
||
reposRoots []string
|
||
awsmfa string
|
||
composeFile string
|
||
interval time.Duration
|
||
self string // path to this binary, for re-invoking subcommands
|
||
|
||
auth checks.AuthStatus
|
||
docker dockerMsg
|
||
drift driftMsg
|
||
repos repoMsg
|
||
|
||
pending *pendingAction // non-nil while awaiting y/n confirmation
|
||
cmdline textinput.Model
|
||
cmdActive bool // true while the command palette is open
|
||
paletteNote string // transient message shown in the palette (e.g. blocked cmd)
|
||
running string // label of a captured command currently executing
|
||
cancel context.CancelFunc // cancels the running captured command
|
||
cancelled bool // set when the user cancelled the running command
|
||
result *cmdResultMsg
|
||
viewport viewport.Model
|
||
lastRefresh time.Time
|
||
width int
|
||
height int
|
||
}
|
||
|
||
func newDash(reposRoots []string, awsmfa, composeFile string, interval time.Duration) dashModel {
|
||
if interval <= 0 {
|
||
interval = 10 * time.Second
|
||
}
|
||
self, err := os.Executable()
|
||
if err != nil {
|
||
self = os.Args[0]
|
||
}
|
||
ti := textinput.New()
|
||
ti.Prompt = "mydev ❯ "
|
||
ti.Placeholder = "data table <name> db grant <id> port 3306 …"
|
||
return dashModel{reposRoots: reposRoots, awsmfa: awsmfa, composeFile: composeFile, interval: interval, self: self, cmdline: ti}
|
||
}
|
||
|
||
// interactive commands need the live terminal (stdin prompts / long log streams)
|
||
// so they run via ExecProcess; everything else is captured into a result pane.
|
||
func interactive(args []string) bool {
|
||
switch {
|
||
case len(args) >= 2 && args[0] == "auth" && args[1] == "login":
|
||
return true
|
||
case len(args) >= 2 && args[0] == "dev" && (args[1] == "start" || args[1] == "restart"):
|
||
return true
|
||
case len(args) >= 1 && args[0] == "data":
|
||
// Prod-data fetches may prompt and stream progress — run them live.
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// launch picks the execution mode for a command and returns the updated model
|
||
// plus the command to run.
|
||
func (m dashModel) launch(args []string) (dashModel, tea.Cmd) {
|
||
if interactive(args) {
|
||
return m, m.runSelf(args...)
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
m.running = strings.Join(args, " ")
|
||
m.cancel = cancel
|
||
m.cancelled = false
|
||
return m, m.runCaptured(ctx, args...)
|
||
}
|
||
|
||
// clearTerminal wipes the screen and scrollback so the dashboard starts and
|
||
// exits without leftover command output showing in the normal buffer.
|
||
func clearTerminal() {
|
||
fmt.Print("\033[2J\033[3J\033[H")
|
||
}
|
||
|
||
// runSelf suspends the TUI, runs `this-binary <args...>` with full terminal
|
||
// control (so MFA prompts and compose logs work), then resumes and refreshes.
|
||
// It clears the revealed terminal (screen + scrollback) first so the user isn't
|
||
// looking at leftover output behind the interactive command. The args go
|
||
// through "$@" so spaces need no quoting.
|
||
func (m dashModel) runSelf(args ...string) tea.Cmd {
|
||
shArgs := append([]string{"-c", `printf '\033[2J\033[3J\033[H'; exec "$0" "$@"`, m.self}, args...)
|
||
c := exec.Command("sh", shArgs...)
|
||
return tea.ExecProcess(c, func(err error) tea.Msg { return execDoneMsg{err} })
|
||
}
|
||
|
||
// wrapText fits text to width w: word-wrap at spaces first, then hard-wrap any
|
||
// remaining over-long tokens (e.g. a generated password with no spaces) so
|
||
// nothing overflows the result pane.
|
||
func wrapText(s string, w int) string {
|
||
if w <= 0 {
|
||
return s
|
||
}
|
||
return wrap.String(wordwrap.String(s, w), w)
|
||
}
|
||
|
||
// showResult stores the captured output and loads it into the viewport, wrapped
|
||
// to the current width.
|
||
func (m *dashModel) showResult(r cmdResultMsg) {
|
||
m.result = &r
|
||
m.viewport.SetContent(wrapText(r.body, m.viewport.Width))
|
||
m.viewport.GotoTop()
|
||
}
|
||
|
||
// runCaptured runs the command in the background, capturing its output to show
|
||
// in the result pane. The dashboard stays visible (with a "running" note) until
|
||
// the result arrives.
|
||
func (m dashModel) runCaptured(ctx context.Context, args ...string) tea.Cmd {
|
||
self, title := m.self, strings.Join(args, " ")
|
||
return func() tea.Msg {
|
||
out, err := exec.CommandContext(ctx, self, args...).CombinedOutput()
|
||
return cmdResultMsg{title: title, body: string(out), err: err}
|
||
}
|
||
}
|
||
|
||
func (m dashModel) refresh() tea.Cmd {
|
||
return tea.Batch(fetchAuth(m.awsmfa), fetchDocker, fetchDrift(m.composeFile), fetchRepos(m.reposRoots))
|
||
}
|
||
|
||
func (m dashModel) Init() tea.Cmd {
|
||
return tea.Batch(m.refresh(), tick(m.interval))
|
||
}
|
||
|
||
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
k := msg.String()
|
||
|
||
// Result pane open: esc/q/enter closes it; other keys scroll.
|
||
if m.result != nil {
|
||
switch k {
|
||
case "esc", "q", "enter":
|
||
m.result = nil
|
||
return m, nil
|
||
case "ctrl+c":
|
||
return m, tea.Quit
|
||
}
|
||
var cmd tea.Cmd
|
||
m.viewport, cmd = m.viewport.Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
// Command palette open: route typing to the input; Enter runs it.
|
||
if m.cmdActive {
|
||
switch k {
|
||
case "enter":
|
||
args := strings.Fields(m.cmdline.Value())
|
||
if len(args) > 0 && dashboardCovered(args) {
|
||
// Already on the dashboard — refuse, keep palette open.
|
||
m.cmdline.Reset()
|
||
m.paletteNote = strings.Join(args, " ") + " is already shown on the dashboard"
|
||
return m, nil
|
||
}
|
||
m.cmdActive = false
|
||
m.cmdline.Blur()
|
||
m.cmdline.Reset()
|
||
m.paletteNote = ""
|
||
if len(args) > 0 {
|
||
return m.launch(args)
|
||
}
|
||
return m, nil
|
||
case "esc", "ctrl+c":
|
||
m.cmdActive = false
|
||
m.cmdline.Blur()
|
||
m.cmdline.Reset()
|
||
m.paletteNote = ""
|
||
return m, nil
|
||
case "tab":
|
||
if _, items, leaf := completions(m.cmdline.Value()); !leaf && len(items) > 0 {
|
||
toks := strings.Fields(m.cmdline.Value())
|
||
if !strings.HasSuffix(m.cmdline.Value(), " ") && len(toks) > 0 {
|
||
toks = toks[:len(toks)-1]
|
||
}
|
||
toks = append(toks, items[0].name)
|
||
m.cmdline.SetValue(strings.Join(toks, " ") + " ")
|
||
m.cmdline.CursorEnd()
|
||
}
|
||
return m, nil
|
||
}
|
||
m.paletteNote = "" // clear once the user edits the input
|
||
var cmd tea.Cmd
|
||
m.cmdline, cmd = m.cmdline.Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
// While a confirm prompt is up, only y/n/esc are meaningful.
|
||
if m.pending != nil {
|
||
switch k {
|
||
case "y", "Y":
|
||
p := m.pending
|
||
m.pending = nil
|
||
return m.launch(p.args)
|
||
case "n", "N", "esc", "q":
|
||
m.pending = nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// A captured command is running: block new launches; allow cancel/quit.
|
||
if m.running != "" {
|
||
switch k {
|
||
case "c", "C":
|
||
if m.cancel != nil {
|
||
m.cancelled = true
|
||
m.cancel()
|
||
}
|
||
case "ctrl+c", "q":
|
||
return m, tea.Quit
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
switch k {
|
||
case "q", "ctrl+c":
|
||
return m, tea.Quit
|
||
case "r": // manual refresh
|
||
return m, m.refresh()
|
||
case "a":
|
||
m.pending = &pendingAction{"auth login", []string{"auth", "login"}}
|
||
case "s":
|
||
m.pending = &pendingAction{"dev start", []string{"dev", "start"}}
|
||
case "x":
|
||
m.pending = &pendingAction{"dev stop", []string{"dev", "stop"}}
|
||
case "R":
|
||
m.pending = &pendingAction{"dev restart", []string{"dev", "restart"}}
|
||
case ":":
|
||
m.cmdActive = true
|
||
m.paletteNote = ""
|
||
return m, m.cmdline.Focus()
|
||
}
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
m.cmdline.Width = msg.Width - len(m.cmdline.Prompt) - 2
|
||
// Result pane leaves room for a title line and a help line.
|
||
m.viewport.Width = msg.Width
|
||
m.viewport.Height = msg.Height - 3
|
||
// Re-wrap any visible result to the new width.
|
||
if m.result != nil {
|
||
m.viewport.SetContent(wrapText(m.result.body, m.viewport.Width))
|
||
}
|
||
case authMsg:
|
||
m.auth = checks.AuthStatus(msg)
|
||
m.lastRefresh = time.Now()
|
||
case dockerMsg:
|
||
m.docker = msg
|
||
case driftMsg:
|
||
m.drift = msg
|
||
case repoMsg:
|
||
m.repos = msg
|
||
case tickMsg:
|
||
// periodic refresh + reschedule
|
||
return m, tea.Batch(m.refresh(), tick(m.interval))
|
||
case execDoneMsg:
|
||
// An interactive command finished and the TUI has resumed — refresh.
|
||
return m, m.refresh()
|
||
case cmdResultMsg:
|
||
m.running = ""
|
||
m.cancel = nil
|
||
// Cancelled by the user: just return to the menu, no result pane.
|
||
if m.cancelled {
|
||
m.cancelled = false
|
||
return m, m.refresh()
|
||
}
|
||
// Otherwise show the output in the result pane.
|
||
body := msg.body
|
||
if msg.err != nil {
|
||
body = strings.TrimRight(body, "\n") + "\n\n[exit: " + msg.err.Error() + "]"
|
||
}
|
||
if strings.TrimSpace(body) == "" {
|
||
body = "(no output)"
|
||
}
|
||
r := msg
|
||
r.body = body
|
||
m.showResult(r)
|
||
return m, m.refresh()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// ---- commands (run in background, return a msg) ----
|
||
|
||
func fetchAuth(awsmfa string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
if awsmfa == "" {
|
||
return authMsg{Err: "dev_dir not set in config"}
|
||
}
|
||
return authMsg(checks.AWSAuth(context.Background(), awsmfa))
|
||
}
|
||
}
|
||
|
||
func fetchDocker() tea.Msg {
|
||
list, err := checks.Containers(context.Background())
|
||
return dockerMsg{list: list, err: err}
|
||
}
|
||
|
||
func fetchDrift(composeFile string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
if composeFile == "" {
|
||
return driftMsg{}
|
||
}
|
||
drift, err := checks.ComposeDrift(context.Background(), composeFile)
|
||
if err != "" {
|
||
return driftMsg{err: err}
|
||
}
|
||
stale := 0
|
||
for _, d := range drift {
|
||
if !d.UpToDate() {
|
||
stale++
|
||
}
|
||
}
|
||
return driftMsg{stale: stale, total: len(drift)}
|
||
}
|
||
}
|
||
|
||
func fetchRepos(roots []string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
list, err := checks.ReposMulti(context.Background(), roots)
|
||
return repoMsg{list: list, err: err}
|
||
}
|
||
}
|
||
|
||
func tick(d time.Duration) tea.Cmd {
|
||
return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||
}
|
||
|
||
// ---- styles ----
|
||
|
||
var (
|
||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||
panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).MarginBottom(1)
|
||
headerStyle = lipgloss.NewStyle().Bold(true).Underline(true)
|
||
)
|
||
|
||
func (m dashModel) View() string {
|
||
// Result pane takes over the whole screen until dismissed.
|
||
if m.result != nil {
|
||
head := titleStyle.Render("$ mydev "+m.result.title) + "\n"
|
||
help := "\n" + dimStyle.Render("↑/↓ scroll · esc/enter close")
|
||
return head + m.viewport.View() + help
|
||
}
|
||
|
||
var b strings.Builder
|
||
|
||
status := fmt.Sprintf("refreshed %s · every %s", m.lastRefresh.Format("15:04:05"), m.interval)
|
||
if m.running != "" {
|
||
status += " · " + okStyle.Render("running "+m.running+"…")
|
||
}
|
||
b.WriteString(titleStyle.Render("MyDev") + " " + dimStyle.Render(status) + "\n\n")
|
||
|
||
b.WriteString(panelStyle.Render(m.authPanel()) + "\n")
|
||
b.WriteString(panelStyle.Render(m.dockerPanel()) + "\n")
|
||
b.WriteString(panelStyle.Render(m.repoPanel()) + "\n")
|
||
b.WriteString(m.footer())
|
||
return b.String()
|
||
}
|
||
|
||
type suggestion struct {
|
||
name string
|
||
short string
|
||
}
|
||
|
||
// completions introspects the cobra command tree for the current palette input.
|
||
// It descends through completed tokens, then returns the candidates for the
|
||
// next token. leaf is true when the matched command takes args rather than
|
||
// subcommands — then items holds a single usage hint.
|
||
func completions(input string) (label string, items []suggestion, leaf bool) {
|
||
tokens := strings.Fields(input)
|
||
// A trailing space (or empty input) means we're picking the *next* token,
|
||
// so there is no partial filter on the final token.
|
||
var partial string
|
||
completed := tokens
|
||
if !strings.HasSuffix(input, " ") && input != "" && len(tokens) > 0 {
|
||
partial = tokens[len(tokens)-1]
|
||
completed = tokens[:len(tokens)-1]
|
||
}
|
||
|
||
cur := rootCmd
|
||
for _, tok := range completed {
|
||
next := childNamed(cur, tok)
|
||
if next == nil {
|
||
return strings.Join(completed, " "), nil, false
|
||
}
|
||
cur = next
|
||
}
|
||
|
||
var children []suggestion
|
||
for _, c := range cur.Commands() {
|
||
switch c.Name() {
|
||
case "help", "completion", "dash": // not useful from inside the dash
|
||
continue
|
||
}
|
||
// Hide root commands already covered by the dashboard. `status` is shown
|
||
// across the panels; bare `auth` is just the AWS panel (and `auth login`
|
||
// has the `a` quick key + is reachable by typing `auth `).
|
||
// `dev status` is kept — more detail than the drift badge.
|
||
if cur == rootCmd && (c.Name() == "status" || c.Name() == "auth") {
|
||
continue
|
||
}
|
||
if c.Hidden {
|
||
continue
|
||
}
|
||
if partial == "" || strings.HasPrefix(c.Name(), partial) {
|
||
children = append(children, suggestion{c.Name(), c.Short})
|
||
}
|
||
}
|
||
|
||
if len(cur.Commands()) == 0 {
|
||
// Leaf command: show how to call it.
|
||
return cur.CommandPath(), []suggestion{{cur.Use, cur.Short}}, true
|
||
}
|
||
|
||
label = "commands"
|
||
if cur != rootCmd {
|
||
label = cur.CommandPath()
|
||
}
|
||
return label, children, false
|
||
}
|
||
|
||
// dashboardCovered reports commands whose output is already on the dashboard,
|
||
// so the palette hides them and refuses to run them. `auth login` is allowed;
|
||
// only the bare `auth` status is blocked.
|
||
func dashboardCovered(args []string) bool {
|
||
if len(args) == 0 {
|
||
return false
|
||
}
|
||
switch {
|
||
case args[0] == "status":
|
||
return true
|
||
case args[0] == "auth" && len(args) == 1:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func childNamed(parent *cobra.Command, name string) *cobra.Command {
|
||
for _, c := range parent.Commands() {
|
||
if c.Name() == name {
|
||
return c
|
||
}
|
||
for _, a := range c.Aliases {
|
||
if a == name {
|
||
return c
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// footer shows the command palette when open, the confirm prompt when an action
|
||
// is pending, otherwise the key legend.
|
||
func (m dashModel) footer() string {
|
||
if m.cmdActive {
|
||
var b strings.Builder
|
||
b.WriteString(m.cmdline.View() + "\n")
|
||
|
||
if m.paletteNote != "" {
|
||
b.WriteString(badStyle.Render(m.paletteNote) + "\n")
|
||
}
|
||
|
||
label, items, leaf := completions(m.cmdline.Value())
|
||
switch {
|
||
case leaf:
|
||
it := items[0]
|
||
line := "usage: " + it.name
|
||
if it.short != "" {
|
||
line += " — " + it.short
|
||
}
|
||
b.WriteString(dimStyle.Render(line) + "\n")
|
||
case len(items) == 0:
|
||
b.WriteString(dimStyle.Render("no matching commands") + "\n")
|
||
default:
|
||
b.WriteString(dimStyle.Render(label+":") + "\n")
|
||
const max = 8
|
||
for i, it := range items {
|
||
if i >= max {
|
||
b.WriteString(dimStyle.Render(fmt.Sprintf(" … +%d more", len(items)-max)) + "\n")
|
||
break
|
||
}
|
||
b.WriteString(dimStyle.Render(fmt.Sprintf(" %-10s %s", it.name, it.short)) + "\n")
|
||
}
|
||
}
|
||
b.WriteString(dimStyle.Render("tab complete · enter run · esc cancel"))
|
||
return b.String()
|
||
}
|
||
if m.running != "" {
|
||
return okStyle.Render("running "+m.running+"…") + dimStyle.Render(" [c]ancel")
|
||
}
|
||
if m.pending != nil {
|
||
return badStyle.Render("Run '"+m.pending.label+"'?") + dimStyle.Render(" [y] yes [n] no")
|
||
}
|
||
return dimStyle.Render("[r]efresh [a]uth [s]tart [x]stop [R]estart [:]command [q]uit")
|
||
}
|
||
|
||
func (m dashModel) authPanel() string {
|
||
var s strings.Builder
|
||
s.WriteString(headerStyle.Render("AWS AUTH") + "\n")
|
||
if m.auth.Authed {
|
||
mark := okStyle.Render("✓ authed")
|
||
// Warn when the session expires within 30 min.
|
||
if !m.auth.Expires.IsZero() && time.Until(m.auth.Expires) < 30*time.Minute {
|
||
mark = badStyle.Render("⚠ expiring")
|
||
}
|
||
s.WriteString(mark + dimStyle.Render(" "+m.auth.Detail))
|
||
} else if m.auth.Err != "" {
|
||
s.WriteString(badStyle.Render("✗ re-auth needed") + "\n" + dimStyle.Render(truncate(m.auth.Err, 70)))
|
||
} else {
|
||
s.WriteString(dimStyle.Render("checking…"))
|
||
}
|
||
return s.String()
|
||
}
|
||
|
||
func (m dashModel) dockerPanel() string {
|
||
var s strings.Builder
|
||
s.WriteString(headerStyle.Render("DOCKER") + m.driftBadge() + "\n")
|
||
if m.docker.err != "" {
|
||
s.WriteString(badStyle.Render("✗ " + truncate(m.docker.err, 70)))
|
||
return s.String()
|
||
}
|
||
if len(m.docker.list) == 0 {
|
||
s.WriteString(dimStyle.Render("no containers"))
|
||
return s.String()
|
||
}
|
||
for _, c := range m.docker.list {
|
||
mark := badStyle.Render("●")
|
||
if c.State == "running" {
|
||
mark = okStyle.Render("●")
|
||
}
|
||
s.WriteString(fmt.Sprintf("%s %-20s %s\n", mark, c.Name, dimStyle.Render(c.Status)))
|
||
}
|
||
return strings.TrimRight(s.String(), "\n")
|
||
}
|
||
|
||
// driftBadge summarizes dev-repo container freshness next to the DOCKER header.
|
||
func (m dashModel) driftBadge() string {
|
||
switch {
|
||
case m.composeFile == "":
|
||
return ""
|
||
case m.drift.err != "":
|
||
return " " + dimStyle.Render("(drift: "+truncate(m.drift.err, 30)+")")
|
||
case m.drift.total == 0:
|
||
return " " + dimStyle.Render("(checking…)")
|
||
case m.drift.stale > 0:
|
||
return " " + badStyle.Render(fmt.Sprintf("⚠ %d/%d stale — dev restart", m.drift.stale, m.drift.total))
|
||
default:
|
||
return " " + okStyle.Render(fmt.Sprintf("✓ all %d current", m.drift.total))
|
||
}
|
||
}
|
||
|
||
func (m dashModel) repoPanel() string {
|
||
var s strings.Builder
|
||
s.WriteString(headerStyle.Render("REPOS") + "\n")
|
||
if m.repos.err != "" {
|
||
s.WriteString(badStyle.Render("✗ " + truncate(m.repos.err, 70)))
|
||
return s.String()
|
||
}
|
||
if len(m.repos.list) == 0 {
|
||
s.WriteString(dimStyle.Render("none found"))
|
||
return s.String()
|
||
}
|
||
for _, r := range m.repos.list {
|
||
state := okStyle.Render("clean")
|
||
if r.Dirty {
|
||
state = badStyle.Render("dirty")
|
||
}
|
||
if r.Err != "" {
|
||
state = badStyle.Render("err")
|
||
}
|
||
s.WriteString(fmt.Sprintf("%-20s %s %s\n", r.Name, dimStyle.Render(r.Branch), state))
|
||
}
|
||
return strings.TrimRight(s.String(), "\n")
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
s = strings.ReplaceAll(s, "\n", " ")
|
||
if len(s) > n {
|
||
return s[:n] + "…"
|
||
}
|
||
return s
|
||
}
|