okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
2ecf203
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"context" 7"encoding/json" 8"errors" 9"maps" 10"slices" 11"strings" 12 13"charm.land/bubbles/v2/textinput" 14 tea"charm.land/bubbletea/v2" 15) 16 17type PageTone = daemon.PageTone 18 19const ( 20TonePlain = daemon .TonePlain 21ToneActive = daemon .ToneActive 22ToneWarning = daemon .ToneWarning 23ToneMuted = daemon .ToneMuted 24) 25 26type PageRow = daemon.PageRow 27type PageAction = daemon.PageAction 28type PageGlance = daemon.PageGlance 29type PageDocument = daemon.PageDocument 30 31type PageCancelMsg struct {} 32type PageViewChangedMsg struct {} 33 34type pageLoadedMsg struct { 35Doc * PageDocument 36Err error 37Gen int 38} 39 40type pageShortcutPreparedMsg struct { 41Prepared * daemon.PreparedPageAction 42Err error 43Gen int 44} 45 46type pageActionExecutedMsg struct { 47Prepared * PageDocument 48Err error 49Message string 50Action PageAction 51Gen int 52} 53 54type pageModeKind int 55 56const ( 57modeBrowse pageModeKind = iota 58modeText 59modeChoice 60modeConfirm 61modeActions 62) 63 64type PageViewModel struct { 65Conn * daemon.Connection 66Doc * PageDocument 67CurrentAction * PageAction 68SessionID string 69Command string 70TextInput textinput.Model 71pageStatus 72listView 73confirm 74Mode pageModeKind 75ChoiceIndex int 76FieldIndex ,ActionIndex int 77FormValues map [string ]json.RawMessage 78Busy bool 79} 80 81func NewPageViewModel (conn * daemon.Connection ,sessionID ,command string )PageViewModel { 82return PageViewModel { 83Conn :conn , 84SessionID :sessionID , 85Command :command , 86TextInput :newField (), 87pageStatus :newPageStatus (false ), 88listView :newListView ("Search " + strings .TrimPrefix (command ,"/" )), 89Busy :true , 90 } 91} 92 93func (m * PageViewModel )SetSize (width ,height int ) { 94m .pageStatus .SetSize (width ,height ) 95m .listView .setSize (width ,height ) 96m .TextInput .SetWidth (max (10 ,width - 20 )) 97} 98 99func (m PageViewModel )Init () tea.Cmd { 100return m .loadPageCmd (m .Generation ) 101} 102 103// Confirming reports a question waiting for enter or esc. 104func (m PageViewModel )Confirming ()bool {return m .confirm .asking () } 105 106func (m PageViewModel )loadPageCmd (gen int ) tea.Cmd { 107previous := m .Doc 108return func () tea.Msg { 109if m .Conn == nil { 110return pageLoadedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 111 } 112doc ,err := daemon .RefreshPage (context .Background (),m .Conn ,m .SessionID ,m .Command ,previous ) 113if err != nil { 114return pageLoadedMsg {Err :err ,Gen :gen } 115 } 116 117return pageLoadedMsg {Doc :doc ,Gen :gen } 118 } 119} 120 121func (m PageViewModel )executeActionCmd (act PageAction ,row * PageRow ,gen int ) tea.Cmd { 122form := maps .Clone (m .FormValues ) 123var session * daemon.Session 124if m .Doc != nil { 125session = m .Doc .Session 126 } 127return func () tea.Msg { 128if m .Conn == nil { 129return pageActionExecutedMsg {Action :act ,Err :errors .New ("daemon connection unavailable" ),Gen :gen } 130 } 131 132if act .Operation .OperationID == "mergeLinkGroups" { 133if raw ,ok := form ["other_workspace" ];ok { 134var target string 135if json .Unmarshal (raw ,& target )== nil && session != nil { 136if _ ,hasValidator := form ["other_etag" ];! hasValidator { 137prepared ,err := daemon .PrepareLinkMerge (context .Background (),m .Conn ,* session ,target ) 138return pageActionExecutedMsg {Action :act ,Gen :gen ,Prepared :prepared ,Err :err } 139 } 140 } 141 } 142 } 143res ,err := daemon .ExecutePageAction (context .Background (),m .Conn , daemon.PageActionRequest {Action :act ,Row :row ,Form :form ,Session :session }) 144return pageActionExecutedMsg {Message :res .Message ,Prepared :res .Page ,Action :act ,Err :err ,Gen :gen } 145 } 146} 147 148// currentRow is the row under the cursor, nil when none is shown. 149func (m PageViewModel )currentRow ()* PageRow { 150row ,ok := m .highlighted () 151if ! ok || m .Doc == nil { 152return nil 153 } 154i := slices .IndexFunc (m .Doc .Rows ,func (r PageRow )bool {return r .ID == row .key }) 155if i < 0 { 156return nil 157 } 158return & m .Doc .Rows [i ] 159} 160 161// target names the row an action applies to, for the question and prompt. 162func (m PageViewModel )target (act PageAction )string { 163row := m .currentRow () 164if ! act .Row || row == nil { 165return "" 166 } 167target := " " 168if id := rowID (* row );id != "" { 169target += id + " " 170 } 171return target + row .Text 172} 173 174// ask opens act's question; enter in it runs the action and esc drops it. 175func (m * PageViewModel )ask (act PageAction ) { 176m .Notice ,m .Error = "" ,"" 177m .CurrentAction ,m .Mode = & act ,modeConfirm 178target := m .target (act ) 179m .confirm .ask (act .ID ,target ,act .Label ,cmp .Or (act .Confirmation ,act .Label + target + "?" )) 180} 181 182// cancelPrompt drops whatever a prompt or question was collecting. 183func (m * PageViewModel )cancelPrompt () { 184m .Mode ,m .CurrentAction ,m .FormValues ,m .FieldIndex = modeBrowse ,nil ,nil ,0 185m .TextInput .Reset () 186m .confirm .dismiss () 187} 188 189// runAction marks the page busy and asks the daemon to run act. 190func (m * PageViewModel )runAction (act PageAction ,entered string ) tea.Cmd { 191if len (act .Fields )> 0 && m .FieldIndex < len (act .Fields ) { 192field := act .Fields [m .FieldIndex ] 193if entered != "" || field .Required || field .Default != nil { 194value ,err := daemon .ParseActionField (field ,entered ) 195if err != nil { 196m .Error = err .Error () 197return nil 198 } 199if m .FormValues == nil { 200m .FormValues = map [string ]json.RawMessage {} 201 } 202if value != nil { 203m .FormValues [field .Name ]= value 204 } 205 } 206m .TextInput .Reset () 207m .FieldIndex ++ 208if m .FieldIndex < len (act .Fields ) { 209return m .promptField (act ) 210 } 211 } 212m .Mode ,m .CurrentAction = modeBrowse ,nil 213m .Busy ,m .Error ,m .Notice = true ,"" ,"" 214m .Generation = nextPageGeneration () 215cmd := m .executeActionCmd (act ,m .currentRow (),m .Generation ) 216m .TextInput .Reset () 217m .FormValues = nil 218m .FieldIndex = 0 219return cmd 220} 221 222// beginAction opens the interaction act needs: Update asks for a 223// confirmation first, then come text entry or a choice, or the action runs 224// at once. fresh clears a stale notice and error, as acting from the row 225// list does. 226func (m * PageViewModel )beginAction (act PageAction ,fresh bool ) tea.Cmd { 227if fresh { 228m .Notice ,m .Error = "" ,"" 229 } 230m .CurrentAction = & act 231if len (act .Fields )> 0 { 232m .FieldIndex = 0 233m .FormValues = map [string ]json.RawMessage {} 234return m .promptField (act ) 235 } 236return m .runAction (act ,"" ) 237} 238 239func (m PageViewModel )Update (msg tea.Msg ) (PageViewModel , tea.Cmd ) { 240switch msg := msg .(type ) { 241case pageLoadedMsg : 242if ! m .settle (msg .Gen ,msg .Err ,& m .Busy ) { 243return m ,nil 244 } 245m .setDoc (msg .Doc ) 246m .Error = "" 247return m ,nil 248 249case pageShortcutPreparedMsg : 250if ! m .settle (msg .Gen ,msg .Err ,& m .Busy ) { 251return m ,nil 252 } 253prepared := msg .Prepared 254m .setDoc (prepared .Page ) 255m .Error = "" 256if prepared .Request .Row != nil { 257m .focusRow (prepared .Request .Row .ID ) 258 } 259act := prepared .Request .Action 260if act .Confirm { 261m .ask (act ) 262return m ,nil 263 } 264m .FormValues = prepared .Request .Form 265m .Generation = nextPageGeneration () 266m .Busy = true 267cmd := m .executeActionCmd (act ,prepared .Request .Row ,m .Generation ) 268m .FormValues = nil 269return m ,cmd 270 271case pageActionExecutedMsg : 272if ! m .settle (msg .Gen ,msg .Err ,& m .Busy ) { 273return m ,nil 274 } 275if msg .Prepared != nil { 276m .setDoc (msg .Prepared ) 277m .Cursor = 0 278m .Mode ,m .Busy ,m .CurrentAction = modeBrowse ,false ,nil 279return m ,nil 280 } 281m .Notice = pageNotice (msg ) 282m .Error = "" 283m .Busy = true 284m .Generation = nextPageGeneration () 285return m ,tea .Batch ( 286func () tea.Msg {return PageViewChangedMsg {} }, 287m .loadPageCmd (m .Generation ), 288 ) 289 290case tea.KeyPressMsg : 291return m .keyPress (msg ) 292 } 293if m .Mode == modeText { 294var cmd tea.Cmd 295m .TextInput ,cmd = m .TextInput .Update (msg ) 296return m ,cmd 297 } 298cmd := m .listView .update (msg ) 299return m ,cmd 300} 301 302// keyPress routes a key by the mode the page is in. A bare letter only ever 303// reaches the filter or a prompt; actions run from chords. 304func (m PageViewModel )keyPress (msg tea.KeyPressMsg ) (PageViewModel , tea.Cmd ) { 305key := msg .String () 306if m .Mode == modeConfirm { 307if m .confirm .key (msg ) { 308act := * m .CurrentAction 309m .CurrentAction ,m .Mode = nil ,modeBrowse 310m .confirm .dismiss () 311cmd := m .beginAction (act ,false ) 312return m ,cmd 313 } 314if ! m .confirm .asking () { 315m .CurrentAction ,m .Mode = nil ,modeBrowse 316 } 317return m ,nil 318 } 319if key == "esc" || key == "ctrl+c" { 320if m .Mode == modeBrowse { 321return m ,func () tea.Msg {return PageCancelMsg {} } 322 } 323m .cancelPrompt () 324return m ,nil 325 } 326if m .Busy { 327return m ,nil 328 } 329switch m .Mode { 330case modeText : 331if key != "enter" { 332var cmd tea.Cmd 333m .TextInput ,cmd = m .TextInput .Update (msg ) 334return m ,cmd 335 } 336if m .CurrentAction == nil { 337return m ,nil 338 } 339act ,val := * m .CurrentAction ,m .TextInput .Value () 340m .TextInput .Reset () 341cmd := m .runAction (act ,val ) 342return m ,cmd 343case modeChoice : 344return m .choiceKey (key ) 345case modeActions : 346return m .actionsKey (key ) 347 } 348return m .browseKey (msg ) 349} 350 351// browseKey runs a chord, opens the menu, reloads, or moves and types in the 352// filter. 353func (m PageViewModel )browseKey (msg tea.KeyPressMsg ) (PageViewModel , tea.Cmd ) { 354key := msg .String () 355if key == "ctrl+r" { 356m .Busy ,m .Error = true ,"" 357m .Generation = nextPageGeneration () 358return m ,m .loadPageCmd (m .Generation ) 359 } 360if m .Doc == nil { 361return m ,nil 362 } 363if key == "ctrl+a" { 364if len (m .Doc .Actions )> 0 { 365m .Mode ,m .ActionIndex = modeActions ,0 366 } 367return m ,nil 368 } 369if act ,ok := m .chord (key );ok { 370if act .Confirm { 371m .ask (act ) 372return m ,nil 373 } 374cmd := m .beginAction (act ,true ) 375return m ,cmd 376 } 377cmd := m .listView .update (msg ) 378return m ,cmd 379} 380 381// chord is the action bound to key that applies to the row under the cursor. 382func (m PageViewModel )chord (key string ) (PageAction ,bool ) { 383for _ ,act := range m .Doc .Actions { 384if act .Key == key && (! act .Row || m .currentRow ()!= nil ) { 385return act ,true 386 } 387 } 388return PageAction {},false 389} 390 391// actionsKey moves through the actions menu and runs the one entered on. 392func (m PageViewModel )actionsKey (key string ) (PageViewModel , tea.Cmd ) { 393switch key { 394case "up" ,"ctrl+p" : 395m .ActionIndex = max (0 ,m .ActionIndex - 1 ) 396case "down" ,"ctrl+n" : 397m .ActionIndex = min (len (m .Doc .Actions )- 1 ,m .ActionIndex + 1 ) 398case "enter" : 399act := m .Doc .Actions [m .ActionIndex ] 400if act .Row && m .currentRow ()== nil { 401m .Error = "Select a row for this action." 402return m ,nil 403 } 404m .Mode = modeBrowse 405if act .Confirm { 406m .ask (act ) 407return m ,nil 408 } 409cmd := m .beginAction (act ,true ) 410return m ,cmd 411 } 412return m ,nil 413} 414 415// choiceKey moves through a choice field's options and applies the one chosen. 416func (m PageViewModel )choiceKey (key string ) (PageViewModel , tea.Cmd ) { 417opts := m .fieldChoices () 418if len (opts )== 0 { 419return m ,nil 420 } 421switch key { 422case "left" ,"up" : 423m .ChoiceIndex = (m .ChoiceIndex - 1 + len (opts ))% len (opts ) 424case "right" ,"down" : 425m .ChoiceIndex = (m .ChoiceIndex + 1 )% len (opts ) 426case "enter" : 427cmd := m .runAction (* m .CurrentAction ,opts [m .ChoiceIndex ]) 428return m ,cmd 429 } 430return m ,nil 431} 432 433func (m * PageViewModel )promptField (act PageAction ) tea.Cmd { 434field := act .Fields [m .FieldIndex ] 435var session * daemon.Session 436if m .Doc != nil { 437session = m .Doc .Session 438 } 439resolved ,err := daemon .ResolveFormField (field ,m .currentRow (),session ) 440if err != nil { 441m .Error = err .Error () 442m .Mode = modeBrowse 443return nil 444 } 445field = resolved 446act .Fields = slices .Clone (act .Fields ) 447act .Fields [m .FieldIndex ]= field 448m .CurrentAction = & act 449if field .Type == "hidden" { 450return m .runAction (act ,"" ) 451 } 452if field .Type == "choice" || field .Type == "boolean" { 453m .Mode = modeChoice 454m .ChoiceIndex = daemon .FormChoiceDefault (field ) 455return nil 456 } 457m .Mode = modeText 458initial := "" 459if field .Default != nil { 460if json .Unmarshal (field .Default ,& initial )!= nil && string (field .Default )!= "null" { 461initial = string (field .Default ) 462 } 463 } 464return ask (& m .TextInput ,initial ,field .Type == "secret" ) 465} 466 467func (m PageViewModel )currentField () daemon.FormField { 468if m .CurrentAction != nil && m .FieldIndex < len (m .CurrentAction .Fields ) { 469return m .CurrentAction .Fields [m .FieldIndex ] 470 } 471return daemon.FormField {} 472} 473 474func (m PageViewModel )fieldChoices () []string { 475field := m .currentField () 476if field .Type == "boolean" { 477return []string {"false" ,"true" } 478 } 479labels := make ([]string ,len (field .Choices )) 480for i ,choice := range field .Choices { 481labels [i ]= choice .Label 482 } 483return labels 484} 485 486func (m PageViewModel )shortcutCmd (arguments string ) tea.Cmd { 487conn ,session ,command ,generation := m .Conn ,m .SessionID ,m .Command ,m .Generation 488return func () tea.Msg { 489prepared ,err := daemon .PreparePageShortcut (context .Background (),conn ,session ,command ,arguments ) 490return pageShortcutPreparedMsg {Prepared :prepared ,Err :err ,Gen :generation } 491 } 492}