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