// Package tui implements the once-add wizard. // // Add path: menu → select app → (custom image) → name → deploying → done/error // Remove path: menu → pick app → confirm (+ data toggle) → removing → done/error package tui import ( "context" "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "once-add/internal/config" "once-add/internal/deploy" ) type state int const ( stateMenu state = iota stateSelectApp stateCustomImage stateName stateDeploying stateRemovePick stateRemoveConfirm stateRemoving stateDone stateError ) // op tracks which recipe produced a terminal Done event, so viewDone can word // itself correctly. type op int const ( opAdd op = iota opRemove ) // maxLogLines caps the streamed output kept on screen. const maxLogLines = 12 type model struct { state state op op menuCursor int // 0 = add, 1 = remove // add path apps []config.App cursor int // index into apps; len(apps) == the "Custom image…" row customInput textinput.Model nameInput textinput.Model fromCustom bool image string name string nameErr error // remove path removeHosts []string removeCursor int removeTarget string removeData bool // shared spinner spinner.Model logLines []string events <-chan deploy.Event cancel context.CancelFunc resultURL string // add: the reachable URL doneMsg string // remove: the done line err error } func newBase() model { custom := textinput.New() custom.Placeholder = "ghcr.io/org/image:tag" custom.CharLimit = 256 name := textinput.New() name.Placeholder = "book" name.CharLimit = 63 sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = okStyle return model{state: stateMenu, customInput: custom, nameInput: name, spinner: sp} } // New builds the wizard starting at the Add/Remove menu. func New() model { return newBase() } // NewRemove builds the wizard jumping straight to the remove picker (for // `once-add remove` with no name). func NewRemove() model { m := newBase() return m.loadRemove() } func (m model) Init() tea.Cmd { return nil } // --- streaming plumbing ----------------------------------------------------- type eventMsg deploy.Event func waitEvent(ch <-chan deploy.Event) tea.Cmd { return func() tea.Msg { ev, ok := <-ch if !ok { return nil } return eventMsg(ev) } } func (m *model) startDeploy() tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel m.events = deploy.Run(ctx, m.name, m.image) m.op = opAdd m.state = stateDeploying m.logLines = nil return tea.Batch(m.spinner.Tick, waitEvent(m.events)) } func (m *model) startRemove() tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel m.events = deploy.RunRemove(ctx, m.removeTarget, m.removeData) m.op = opRemove m.state = stateRemoving m.logLines = nil return tea.Batch(m.spinner.Tick, waitEvent(m.events)) } // loadRemove enumerates deployed .local hosts and enters the picker (or the // error state if docker can't be reached). func (m model) loadRemove() model { hosts, err := deploy.DeployedHosts() if err != nil { m.err = fmt.Errorf("listing deployed apps: %w", err) m.state = stateError return m } m.removeHosts = hosts m.removeCursor = 0 m.removeData = false m.state = stateRemovePick return m } func (m model) loadAdd() (model, tea.Cmd) { apps, err := config.Load() if err != nil { m.err = fmt.Errorf("loading %s: %w", config.Path, err) m.state = stateError return m, nil } m.apps = apps m.cursor = 0 m.state = stateSelectApp return m, nil } func (m *model) quit() (tea.Model, tea.Cmd) { if m.cancel != nil { m.cancel() } return m, tea.Quit } // --- update ----------------------------------------------------------------- func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case eventMsg: return m.handleEvent(deploy.Event(msg)) case spinner.TickMsg: if m.state != stateDeploying && m.state != stateRemoving { return m, nil } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { return m.quit() } switch m.state { case stateMenu: return m.updateMenu(msg) case stateSelectApp: return m.updateSelect(msg) case stateCustomImage: return m.updateCustom(msg) case stateName: return m.updateName(msg) case stateRemovePick: return m.updateRemovePick(msg) case stateRemoveConfirm: return m.updateRemoveConfirm(msg) case stateDeploying, stateRemoving: return m, nil // in progress; ctrl+c handled above case stateDone, stateError: return m.quit() } } return m, nil } func (m model) handleEvent(ev deploy.Event) (tea.Model, tea.Cmd) { switch { case ev.Err != nil: m.err = ev.Err m.state = stateError return m, nil case ev.Done: if m.op == opAdd { m.resultURL = ev.Line } else { m.doneMsg = ev.Line } m.state = stateDone return m, nil default: m.logLines = append(m.logLines, ev.Line) if len(m.logLines) > maxLogLines { m.logLines = m.logLines[len(m.logLines)-maxLogLines:] } return m, waitEvent(m.events) } } func (m model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m.quit() case "up", "k": m.menuCursor = 0 case "down", "j": m.menuCursor = 1 case "enter": if m.menuCursor == 0 { return m.loadAdd() } return m.loadRemove(), nil } return m, nil } func (m model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { last := len(m.apps) // index of the "Custom image…" row switch msg.String() { case "q": return m.quit() case "esc": m.state = stateMenu return m, nil case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < last { m.cursor++ } case "enter": if m.cursor == last { m.fromCustom = true m.customInput.SetValue("") m.customInput.Focus() m.state = stateCustomImage return m, textinput.Blink } app := m.apps[m.cursor] m.image = app.Image m.fromCustom = false return m.enterNameScreen(app.Suggested) } return m, nil } func (m model) updateCustom(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": m.state = stateSelectApp return m, nil case "enter": img := strings.TrimSpace(m.customInput.Value()) if img == "" { return m, nil } m.image = img return m.enterNameScreen("") } var cmd tea.Cmd m.customInput, cmd = m.customInput.Update(msg) return m, cmd } func (m model) enterNameScreen(suggested string) (tea.Model, tea.Cmd) { m.nameInput.SetValue(suggested) m.nameInput.CursorEnd() m.nameInput.Focus() m.nameErr = nil m.state = stateName return m, textinput.Blink } func (m model) updateName(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": if m.fromCustom { m.customInput.Focus() m.state = stateCustomImage return m, textinput.Blink } m.state = stateSelectApp return m, nil case "enter": name := strings.TrimSpace(m.nameInput.Value()) if err := deploy.ValidateName(name); err != nil { m.nameErr = err return m, nil } if hosts, err := deploy.InUseHosts(); err == nil && hosts[deploy.Host(name)] { m.nameErr = fmt.Errorf("%s is already in use — pick another", deploy.Host(name)) return m, nil } m.name = name return m, m.startDeploy() } var cmd tea.Cmd m.nameInput, cmd = m.nameInput.Update(msg) if v := strings.TrimSpace(m.nameInput.Value()); v != "" { m.nameErr = deploy.ValidateName(v) } else { m.nameErr = nil } return m, cmd } func (m model) updateRemovePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m.quit() case "esc": m.state = stateMenu return m, nil case "up", "k": if m.removeCursor > 0 { m.removeCursor-- } case "down", "j": if m.removeCursor < len(m.removeHosts)-1 { m.removeCursor++ } case "enter": if len(m.removeHosts) == 0 { return m, nil } m.removeTarget = m.removeHosts[m.removeCursor] m.removeData = false m.state = stateRemoveConfirm } return m, nil } func (m model) updateRemoveConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "n": m.state = stateRemovePick return m, nil case " ": m.removeData = !m.removeData return m, nil case "enter", "y": return m, m.startRemove() } return m, nil } // --- view ------------------------------------------------------------------- func (m model) View() string { var b strings.Builder b.WriteString(titleStyle.Render("once-add") + descStyle.Render(" — manage LAN Once sites (.local)") + "\n\n") switch m.state { case stateMenu: b.WriteString(m.viewMenu()) case stateSelectApp: b.WriteString(m.viewSelect()) case stateCustomImage: b.WriteString(m.viewCustom()) case stateName: b.WriteString(m.viewName()) case stateRemovePick: b.WriteString(m.viewRemovePick()) case stateRemoveConfirm: b.WriteString(m.viewRemoveConfirm()) case stateDeploying: b.WriteString(m.viewProgress("Deploying " + deploy.Host(m.name))) case stateRemoving: b.WriteString(m.viewProgress("Removing " + m.removeTarget)) case stateDone: b.WriteString(m.viewDone()) case stateError: b.WriteString(m.viewError()) } return b.String() } func (m model) viewMenu() string { var b strings.Builder b.WriteString("What do you want to do?\n\n") b.WriteString(m.menuRow(0, "Add a site", "deploy an app as .local")) b.WriteString(m.menuRow(1, "Remove a site", "tear down a deployed app + its hosts entry")) b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · q quit")) return b.String() } func (m model) menuRow(i int, name, desc string) string { cursor, label := " ", name if m.menuCursor == i { cursor = selectedStyle.Render("➜ ") label = selectedStyle.Render(name) } return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc)) } func (m model) viewSelect() string { var b strings.Builder b.WriteString("Which app do you want to add?\n\n") for i, app := range m.apps { b.WriteString(m.row(i, app.Name, app.Description)) } b.WriteString(m.row(len(m.apps), "Custom image…", "deploy any container image")) b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit")) return b.String() } func (m model) row(i int, name, desc string) string { cursor, label := " ", name if m.cursor == i { cursor = selectedStyle.Render("➜ ") label = selectedStyle.Render(name) } return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc)) } func (m model) viewCustom() string { var b strings.Builder b.WriteString("Container image to deploy:\n\n") b.WriteString(" " + m.customInput.View() + "\n\n") b.WriteString(helpStyle.Render("enter continue · esc back · ctrl+c quit")) return b.String() } func (m model) viewName() string { var b strings.Builder b.WriteString("Name for this site (single label):\n\n") b.WriteString(" " + m.nameInput.View() + "\n") if name := strings.TrimSpace(m.nameInput.Value()); name != "" && m.nameErr == nil { b.WriteString(" " + okStyle.Render("→ http://"+deploy.Host(name)) + "\n") } if m.nameErr != nil { b.WriteString(" " + errStyle.Render("✗ "+m.nameErr.Error()) + "\n") } b.WriteString("\n" + descStyle.Render("image: "+m.image) + "\n") b.WriteString(helpStyle.Render("enter deploy · esc back · ctrl+c quit")) return b.String() } func (m model) viewRemovePick() string { var b strings.Builder if len(m.removeHosts) == 0 { b.WriteString(descStyle.Render("No deployed .local apps to remove.") + "\n\n") b.WriteString(helpStyle.Render("esc back · q quit")) return b.String() } b.WriteString("Which site do you want to remove?\n\n") for i, h := range m.removeHosts { cursor, label := " ", h if m.removeCursor == i { cursor = selectedStyle.Render("➜ ") label = selectedStyle.Render(h) } b.WriteString(fmt.Sprintf("%s%s\n", cursor, label)) } b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit")) return b.String() } func (m model) viewRemoveConfirm() string { var b strings.Builder b.WriteString("Remove " + selectedStyle.Render(m.removeTarget) + "? This stops and deletes the app.\n\n") box := "[ ]" if m.removeData { box = okStyle.Render("[x]") } b.WriteString(" " + box + " also delete its data volume " + errStyle.Render("(irreversible)") + "\n\n") if m.removeData { b.WriteString(errStyle.Render(" Data will be permanently deleted.") + "\n\n") } else { b.WriteString(hintStyle.Render(" Data is kept; re-deploying the same name restores it.") + "\n\n") } b.WriteString(helpStyle.Render("space toggle data · enter/y remove · esc/n back · ctrl+c quit")) return b.String() } func (m model) viewProgress(title string) string { var b strings.Builder b.WriteString(m.spinner.View() + title + "\n\n") for _, line := range m.logLines { b.WriteString(descStyle.Render(" "+line) + "\n") } b.WriteString("\n" + helpStyle.Render("ctrl+c cancel")) return b.String() } func (m model) viewDone() string { var b strings.Builder if m.op == opAdd { b.WriteString(okStyle.Render("✓ Added.") + " Reachable at " + urlStyle.Render(m.resultURL) + "\n\n") b.WriteString(hintStyle.Render("Published on the LAN within ~20s (once-mdns-sync).") + "\n") b.WriteString(hintStyle.Render("Manage it in the Once web interface.") + "\n\n") } else { b.WriteString(okStyle.Render("✓ "+m.doneMsg) + "\n\n") b.WriteString(hintStyle.Render("once-mdns-sync stops publishing it on the LAN automatically.") + "\n\n") } b.WriteString(helpStyle.Render("enter/q quit")) return b.String() } func (m model) viewError() string { var b strings.Builder b.WriteString(errStyle.Render("✗ "+m.err.Error()) + "\n\n") b.WriteString(hintStyle.Render("Inspect: journalctl -u once-mdns-sync -f") + "\n") b.WriteString(hintStyle.Render(" docker logs once-proxy") + "\n\n") b.WriteString(helpStyle.Render("enter/q quit")) return b.String() }