mydev/cmd/dash.go
Victor Abrell 47bb2d3a69 Use Gitea module path, add MIT license
Set module to git.abrell.se/victor/mydev so it installs from the
private Gitea host, and add an MIT LICENSE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:41:20 +02:00

668 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"git.abrell.se/victor/mydev/internal/checks"
"git.abrell.se/victor/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
}