okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: webhooks on listView: filter, pane, confirm only for destructive actions, add on ctrl+o82b9457

main
8.1 KiB226 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"errors"
6	"fmt"
7	"regexp"
8	"slices"
9	"strings"
10
11	tea "charm.land/bubbletea/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15// webhookForm adds a hook or edits one on a single screen: the session it
16// wakes, its name, signing secret, and the header and prefix its sender signs
17// with. The session and name are fixed once the hook exists.
18type webhookForm struct {
19	form
20	// Editing is the hook being edited; nil for a new hook.
21	Editing         *webhookEntry
22	Current, Chosen string
23	// Sessions are offered in order; Chosen starts at Current, the session
24	// the screen was opened from.
25	Sessions []daemon.Session
26}
27
28var hookFields = []string{hookFieldSession, hookFieldName, hookFieldSecret, hookFieldHeader, hookFieldPrefix}
29
30func newWebhookForm(editing *webhookEntry, sessions []daemon.Session, current string) *webhookForm {
31	f := &webhookForm{form: newForm(hookFields, hookFieldSecret), Sessions: sessions, Current: current, Chosen: current}
32	f.Inputs[hookFieldSession].Placeholder = "type to filter"
33	f.Inputs[hookFieldName].Placeholder = "github-deploys"
34	f.Inputs[hookFieldHeader].SetValue(defaultSignatureHeader)
35	f.Inputs[hookFieldPrefix].SetValue(defaultSignaturePrefix)
36	f.Inputs[hookFieldSecret].Placeholder = "leave blank to generate one"
37	f.Focus = 1 // the session already defaults to this one
38	if editing != nil {
39		hook := *editing
40		f.Editing, f.Chosen, f.Focus = &hook, hook.Session, 2
41		f.Inputs[hookFieldName].SetValue(hook.Name)
42		f.Inputs[hookFieldHeader].SetValue(hook.Header)
43		f.Inputs[hookFieldPrefix].SetValue(hook.Prefix)
44		f.Inputs[hookFieldSecret].Placeholder = "stored · leave blank to keep it"
45	}
46	f.focus(f.current())
47	return f
48}
49
50func (f *webhookForm) current() string { return hookFields[f.Focus] }
51
52// matches are the sessions the picker's filter leaves, in order.
53func (f *webhookForm) matches() []daemon.Session {
54	query := strings.ToLower(strings.TrimSpace(f.Inputs[hookFieldSession].Value()))
55	var out []daemon.Session
56	for _, s := range f.Sessions {
57		if query == "" || strings.Contains(strings.ToLower(sessionTitle(s)+" "+homePath(s.Workspace)+" "+s.ID), query) {
58			out = append(out, s)
59		}
60	}
61	return out
62}
63
64func (f *webhookForm) chosenIndex(matches []daemon.Session) int {
65	return slices.IndexFunc(matches, func(s daemon.Session) bool { return s.ID == f.Chosen })
66}
67
68// update handles one key or paste; submit reports that the form should be saved.
69func (f *webhookForm) update(msg tea.Msg) (submit bool, cmd tea.Cmd) {
70	firstField := 0
71	if f.Editing != nil {
72		firstField = 2
73	}
74	if submit, handled := f.key(msg, hookFields, firstField); handled {
75		return submit, nil
76	}
77	if key, ok := msg.(tea.KeyPressMsg); ok {
78		if f.current() == hookFieldSession && (key.String() == "left" || key.String() == "right") {
79			if matches := f.matches(); len(matches) > 0 {
80				i := f.chosenIndex(matches)
81				if i < 0 {
82					i = 0
83				} else {
84					step := 1
85					if key.String() == "left" {
86						step = -1
87					}
88					i = (i + step + len(matches)) % len(matches)
89				}
90				f.Chosen = matches[i].ID
91				return false, nil
92			}
93		}
94	}
95	input := f.Inputs[f.current()]
96	next, cmd := input.Update(msg)
97	*input = next
98	if f.current() == hookFieldSession {
99		if matches := f.matches(); len(matches) > 0 && f.chosenIndex(matches) < 0 {
100			f.Chosen = matches[0].ID
101		}
102	}
103	return false, cmd
104}
105
106// steps validates the form before preparing its webhook mutations.
107func (f *webhookForm) steps() ([]daemon.WebhookRequest, string, error) {
108	name, secret, header, prefix := f.value(hookFieldName), f.value(hookFieldSecret), f.value(hookFieldHeader), f.value(hookFieldPrefix)
109	switch {
110	case f.Editing == nil && f.Chosen == "":
111		return nil, "", errors.New("choose the session this webhook will wake")
112	case f.Editing == nil && !hookName.MatchString(name):
113		return nil, "", errors.New("name: 1–64 letters, digits, _ or -")
114	case secret != "" && (len(secret) < 16 || len(secret) > 4096):
115		if f.Editing != nil {
116			return nil, "", errors.New("secret: 16–4096 bytes, or blank to keep it")
117		}
118		return nil, "", errors.New("secret: 16–4096 bytes, or blank to generate")
119	case !hookHeader.MatchString(header):
120		return nil, "", errors.New("header: 1–64 letters, digits or -")
121	case len(prefix) > 32 || strings.ContainsAny(prefix, "\r\n"):
122		return nil, "", errors.New("prefix: at most 32 bytes, no newlines")
123	}
124	if f.Editing == nil {
125		steps := []daemon.WebhookRequest{{Action: daemon.WebhookCreate, SessionID: f.Chosen, Name: name, Secret: secret, Header: header, Prefix: prefix}}
126		return steps, "added " + name + " · wakes " + sessionLabel(f.Sessions, f.Chosen, f.Current), nil
127	}
128	var steps []daemon.WebhookRequest
129	if !strings.EqualFold(header, f.Editing.Header) || prefix != f.Editing.Prefix {
130		steps = append(steps, daemon.WebhookRequest{Action: daemon.WebhookSignature, HookID: f.Editing.ID, Header: header, Prefix: prefix})
131	}
132	if secret != "" {
133		steps = append(steps, daemon.WebhookRequest{Action: daemon.WebhookRotate, HookID: f.Editing.ID, Secret: secret})
134	}
135	return steps, "saved " + f.Editing.Name, nil
136}
137
138// view is the form's rows; its keys are the footer.
139func (f *webhookForm) view(width int) []string {
140	title := "add webhook"
141	if f.Editing != nil {
142		title = "edit " + f.Editing.Name
143	}
144	f.fit(width - 14)
145	rows := []string{DefaultStyles.Bold.Render(title)}
146	indent := strings.Repeat(" ", 13)
147	for i, key := range hookFields {
148		value := f.Inputs[key].View()
149		switch {
150		case key == hookFieldSession && f.Editing != nil:
151			value = DefaultStyles.Faint.Render(sessionLabel(f.Sessions, f.Chosen, f.Current))
152		case key == hookFieldSession && i != f.Focus:
153			value = sessionLabel(f.Sessions, f.Chosen, f.Current)
154		case key == hookFieldName && f.Editing != nil:
155			value = DefaultStyles.Faint.Render(f.Editing.Name)
156		}
157		rows = append(rows, formRow(i == f.Focus, key, 11, value, width))
158		if key == hookFieldSession && i == f.Focus {
159			rows = append(rows, f.pickerRows(indent, width)...)
160		}
161	}
162	return rows
163}
164
165// footer explains the focused field and offers the keys every form shares.
166func (f *webhookForm) footer(width int) string {
167	var own []hint
168	var note string
169	switch f.current() {
170	case hookFieldSession:
171		note, own = "which session deliveries wake", []hint{{"←→", "choose"}}
172	case hookFieldName:
173		note = "letters, digits, _ or -"
174	case hookFieldSecret:
175		note = "16+ bytes from the sender, or blank to generate one"
176		if f.Editing != nil {
177			note = "enter a new secret to replace the stored one"
178		}
179	case hookFieldHeader:
180		note = "the header the sender signs with · GitHub uses x-hub-signature-256"
181	case hookFieldPrefix:
182		note = "text before the hex digest · blank for none"
183	}
184	keys := append(own, hint{"esc", "cancel"}, hint{"ctrl+s", "save"}, hint{"enter", "next"}, hint{"tab/↑↓", "move"})
185	return footerLine(width, keys, DefaultStyles.Faint.Render(note), false)
186}
187
188// pickerRows lists the matching sessions around the chosen one.
189func (f *webhookForm) pickerRows(indent string, width int) []string {
190	matches := f.matches()
191	if len(matches) == 0 {
192		return []string{indent + DefaultStyles.Faint.Render("No sessions match. Try another search.")}
193	}
194	chosen := max(0, f.chosenIndex(matches))
195	start := max(0, min(chosen-sessionChoices/2, len(matches)-sessionChoices))
196	var rows []string
197	w := width - len(indent)
198	for i := start; i < min(len(matches), start+sessionChoices); i++ {
199		label := sessionLabel(f.Sessions, matches[i].ID, f.Current)
200		if i == chosen {
201			rows = append(rows, indent+selectedLine(ansi.Truncate(selectBar()+" "+label, w, "…"), w))
202		} else {
203			rows = append(rows, indent+DefaultStyles.Faint.Render(ansi.Truncate("  "+label, w, "…")))
204		}
205	}
206	if more := len(matches) - sessionChoices; more > 0 {
207		rows = append(rows, indent+DefaultStyles.Faint.Render(fmt.Sprintf("  %d more · type to filter", more)))
208	}
209	return rows
210}
211
212const (
213	hookFieldSession = "session"
214	hookFieldName    = "name"
215	hookFieldSecret  = "secret"
216	hookFieldHeader  = "header"
217	hookFieldPrefix  = "prefix"
218)
219
220// sessionChoices is how many matching sessions the picker shows at once.
221const sessionChoices = 5
222
223var (
224	hookName   = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
225	hookHeader = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`)
226)