package tui import ( "cmp" "fmt" "slices" "strings" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) // View draws the list beside the highlighted hook's detail. The form and the // secret shown once take the list's place, and a question takes the footer. func (m WebhooksPageModel) View() string { frame := m.listView.frame(brand("albedo")+" "+DefaultStyles.Muted.Render("/webhooks"), DefaultStyles.Faint.Render("all sessions · agent access "+onOff(m.AgentManagement)), m.footer(max(1, m.Width))) switch { case m.Reveal != nil: frame.filter, frame.pane, frame.list = "", nil, m.revealRows case m.Form != nil: frame.filter, frame.pane, frame.list = "", nil, m.formRows case !m.Loaded || len(m.Hooks) == 0: frame.filter, frame.pane = "", nil frame.list = func(width, _ int) []string { return m.guide(width) } } return frame.view(m.Width, m.Height) } // guide stands in for the list while there is nothing to list: loading, a // failed load, the extension off, or no hooks yet. func (m WebhooksPageModel) guide(width int) []string { faint := DefaultStyles.Faint.Render switch { case m.Loading && !m.Loaded: return []string{faint(" loading webhooks…")} case !m.Loaded: return []string{faint(" webhooks did not load · ctrl+r retries")} case !m.Mounted: return slices.Concat( guideText(width, DefaultStyles.Bold, "Webhooks are off."), []string{""}, guideText(width, lipgloss.NewStyle(), "Open /extensions, select webhooks and press enter to turn it on for every session."), guideText(width, DefaultStyles.Faint, "The daemon listens for deliveries as soon as it is on; come back here to add a hook."), ) } rows := slices.Concat( guideText(width, DefaultStyles.Bold, "Wake a session with a signed request"), guideText(width, DefaultStyles.Faint, "Each hook has its own URL and secret; a delivery it accepts wakes the session it belongs to."), []string{""}, ) rows = append(rows, guideStep(width, "1", "Add a hook with ctrl+o", "Name it and pick the session it wakes. A generated secret is shown once, right after you add it.")...) rows = append(rows, guideStep(width, "2", "Copy its URL with ctrl+l", "The intake listens on 127.0.0.1; put a tunnel or proxy in front of it for a sender elsewhere.")...) rows = append(rows, guideStep(width, "3", "Sign every POST", "X-Albedo-Signature: sha256=, keyed with the secret.")...) return rows } // footer is the keys for what is open and one status: a failure, a notice, // work in flight, or what the hooks are doing. func (m WebhooksPageModel) footer(width int) string { switch { case m.Reveal != nil: status, urgent := m.status() return footerLine(width, []hint{{"ctrl+l", "copy"}, {"enter", "done"}}, status, urgent) case m.Form != nil: return m.formFooter(width) case m.confirm.asking(): return m.confirm.footer(width, m.Error) } status, urgent := m.status() return footerLine(width, m.browseHints(), status, urgent) } // status is the footer's state beside the keys. An urgent status takes a row // of its own when the keys do not leave room for it. func (m WebhooksPageModel) status() (string, bool) { switch { case m.Error != "": return DefaultStyles.Error.Render(m.Error), true case m.Saving: return DefaultStyles.Busy.Render("saving…"), true case m.Loading: return DefaultStyles.Faint.Render("loading…"), false case m.Notice != "": return DefaultStyles.Success.Render(m.Notice), true case !m.Mounted && len(m.Hooks) > 0: return DefaultStyles.Warning.Render("Webhooks are not listening · enable them in /extensions"), true } return "", false } // browseHints keeps the main hook action and session agent access visible // first, then the hook's own actions. func (m WebhooksPageModel) browseHints() []hint { if !m.Loaded { return []hint{{"ctrl+r", "retry"}, {"esc", "back"}} } _, selected := m.current() var hints []hint if selected { hints = append(hints, hint{"enter", "on/off"}) } hints = append(hints, hint{"ctrl+t", "agent access"}, hint{"ctrl+o", "add hook"}) if selected { hints = append(hints, hint{"ctrl+e", "edit"}, hint{"ctrl+l", "copy url"}, hint{"ctrl+d", "delete"}, hint{"ctrl+g", "new secret"}) } return append(hints, hint{"ctrl+r", "refresh"}, hint{"esc", "back"}) } // formFooter is the form's keys, with a failure or the save in flight above them. func (m WebhooksPageModel) formFooter(width int) string { line := m.Form.footer(width) switch { case m.Error != "": var rows []string for _, l := range svWrap(m.Error, max(1, width-2), 2) { rows = append(rows, " "+DefaultStyles.Error.Render(l)) } return strings.Join(append(rows, line), "\n") case m.Saving: return footerLine(width, nil, DefaultStyles.Busy.Render("saving…"), true) + "\n" + line } return line } // formRows is the form in the list's place, scrolled to keep its focused field in view. func (m WebhooksPageModel) formRows(width, height int) []string { return inset(scrolled(m.Form.view(width-1), 1+m.Form.Focus, height)) } // revealRows is the generated secret, shown once, in the list's place. Its // session and warning sit on rows of their own so a narrow terminal keeps them. func (m WebhooksPageModel) revealRows(_, _ int) []string { r := m.Reveal return inset([]string{ DefaultStyles.Bold.Render("signing secret for " + r.Hook), DefaultStyles.Faint.Render("→ " + sessionLabel(m.Sessions, r.Session, m.SessionID)), DefaultStyles.Warning.Render("shown only once; copy it now"), "", " " + DefaultStyles.Prompt.Render(r.Secret), }) } // intakeStopped stands in for a hook's URL once the daemon's webhook intake // listener gave up; senders reach nothing until the daemon restarts. const intakeStopped = "intake stopped · restart the daemon" // entry is how a hook reads in the list: its state in front, its URL under // its name, and the deliveries waiting for its session at the edge. func (m WebhooksPageModel) entry(hook webhookEntry) listEntry { lead, tag := DefaultStyles.Faint.Render("off"), "" if hook.Enabled { lead = DefaultStyles.Success.Render("on") } if hook.Queued > 0 { tag = DefaultStyles.Warning.Render(fmt.Sprintf("%d queued", hook.Queued)) } section := sessionLabel(m.Sessions, hook.Session, m.SessionID) return listEntry{ key: hook.ID, section: section, lead: lead, name: hook.Name, desc: cmp.Or(hook.URL, intakeStopped), tag: tag, search: []string{section}, detail: func(width int) []string { return m.details(hook, width) }, } } // details is the pane: the url first, so a short stacked pane keeps it, then // the session it wakes, how deliveries are signed, and what is waiting. func (m WebhooksPageModel) details(hook webhookEntry, width int) []string { state := "off · new deliveries answer 404" if hook.Enabled { state = "on · accepting signed deliveries" } lines := paneTitle(hook.Name, state, width) url := intakeStopped if address := cmp.Or(hook.Address, hook.URL); address != "" { url = "POST " + address } lines = append(lines, factRows("url", ansi.Truncate(url, max(1, width-factLabelW), "…"), width)...) lines = append(lines, factRows("wakes", sessionLabel(m.Sessions, hook.Session, m.SessionID), width)...) lines = append(lines, factRows("signature", hook.Header+": "+hook.Prefix+"", width)...) lines = append(lines, factRows("secret", "stored · ctrl+g makes a new one", width)...) inbox := "empty" if hook.Queued > 0 { inbox = fmt.Sprintf("%d waiting for the session", hook.Queued) if hook.Deferred != "" { inbox += " · last attempt: " + hook.Deferred } } return append(lines, factRows("inbox", inbox, width)...) } // scrolled is the window of rows lines that keeps line at in view. func scrolled(lines []string, at, rows int) []string { start := max(0, at-rows+1) return lines[start:min(len(lines), start+rows)] }