okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
78011da
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"context" 6"errors" 7"fmt" 8"regexp" 9"slices" 10"strings" 11 12 tea"charm.land/bubbletea/v2" 13) 14 15var whitespaceRegex = regexp .MustCompile (`[\p{Cc}\p{Cf}\p{Z}]+` ) 16 17// previewText is a checkpoint's preview on one line, whole. 18func previewText (value string )string { 19clean := strings .TrimSpace (whitespaceRegex .ReplaceAllString (value ," " )) 20if clean == "" { 21return "(empty)" 22 } 23return clean 24} 25 26// readablePreview is previewText cut short for a question. 27func readablePreview (value string )string { 28runes := []rune (previewText (value )) 29if len (runes )<= 96 { 30return string (runes ) 31 } 32return string (runes [:95 ])+ "…" 33} 34 35type TreeCancelMsg struct {} 36 37type TreeForkSuccessMsg struct { 38Session daemon.Session 39Generation int 40} 41 42type treeLoadedMsg struct { 43Err error 44NextCursor * int 45Items []daemon.TreeCheckpoint 46Gen int 47HasMore bool 48} 49 50type treeForkedMsg struct { 51Err error 52Session daemon.Session 53Gen int 54} 55 56// TreePickerModel lists a session's checkpoints. Enter asks, and a second 57// enter branches the session from the checkpoint under the cursor. 58type TreePickerModel struct { 59Conn * daemon.Connection 60NextCursor * int 61SessionID string 62ForkError string 63Cursors []int 64Checkpoints []daemon.TreeCheckpoint 65pageStatus 66listView 67confirm 68PageIndex int 69HasMore bool 70Forking bool 71} 72 73func NewTreePickerModel (conn * daemon.Connection ,sessionID string )TreePickerModel { 74return TreePickerModel { 75Conn :conn , 76SessionID :sessionID , 77Cursors : []int {0 }, 78pageStatus :newPageStatus (true ), 79listView :newListView ("Search checkpoints" ), 80 } 81} 82 83func (m * TreePickerModel )SetSize (width ,height int ) { 84m .pageStatus .SetSize (width ,height ) 85m .listView .setSize (width ,height ) 86} 87 88// Confirming reports a question waiting for enter or esc. 89func (m TreePickerModel )Confirming ()bool {return m .confirm .asking () } 90 91func (m TreePickerModel )Init () tea.Cmd { 92return m .loadTreeCmd (0 ,m .Generation ) 93} 94 95func (m TreePickerModel )loadPage (after int ) (TreePickerModel , tea.Cmd ) { 96m .Loading ,m .Error = true ,"" 97m .Generation = nextPageGeneration () 98return m ,m .loadTreeCmd (after ,m .Generation ) 99} 100 101func (m TreePickerModel )loadTreeCmd (after int ,gen int ) tea.Cmd { 102return func () tea.Msg { 103if m .Conn == nil { 104return treeLoadedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 105 } 106 107resp ,err := daemon .GetSessionTree (context .Background (),m .Conn ,m .SessionID ,after ,50 ) 108if err != nil { 109return treeLoadedMsg {Err :err ,Gen :gen } 110 } 111return treeLoadedMsg { 112Items :resp .Items , 113NextCursor :resp .NextCursor , 114HasMore :resp .HasMore , 115Gen :gen , 116 } 117 } 118} 119 120func (m TreePickerModel )forkCmd (checkpointID string ,gen int ) tea.Cmd { 121return func () tea.Msg { 122if m .Conn == nil { 123return treeForkedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 124 } 125branch ,err := daemon .ForkSession (context .Background (),m .Conn ,m .SessionID , daemon.ForkRequest {Checkpoint :checkpointID }) 126return treeForkedMsg {Session :branch ,Err :err ,Gen :gen } 127 } 128} 129 130// current is the checkpoint under the cursor. 131func (m TreePickerModel )current () (daemon.TreeCheckpoint ,bool ) { 132row ,ok := m .highlighted () 133if ! ok { 134return daemon.TreeCheckpoint {},false 135 } 136i := slices .IndexFunc (m .Checkpoints ,func (cp daemon.TreeCheckpoint )bool {return cp .ID == row .key }) 137if i < 0 { 138return daemon.TreeCheckpoint {},false 139 } 140return m .Checkpoints [i ],true 141} 142 143func (m * TreePickerModel )setCheckpoints (items []daemon.TreeCheckpoint ) { 144m .Checkpoints = items 145rows := make ([]listEntry ,len (items )) 146for i ,cp := range items { 147rows [i ]= listEntry { 148key :cp .ID , 149lead :DefaultStyles .Faint .Render (cp .Type ), 150name :previewText (cp .Preview ), 151search : []string {cp .Type }, 152detail :func (width int ) []string {return checkpointDetails (cp ,width ) }, 153 } 154 } 155m .setRows (rows ) 156} 157 158// checkpointDetails is the pane: the checkpoint's type and whole preview, 159// and what branching from it does. 160func checkpointDetails (cp daemon.TreeCheckpoint ,width int ) []string { 161lines := paneTitle (previewText (cp .Preview ),"" ,width ) 162lines = append (lines ,factRows ("type" ,cp .Type ,width )... ) 163lines = append (lines ,"" ) 164return append (lines ,paneNote ("This creates a new session with a fresh Python namespace. Workspace files stay unchanged." ,width )... ) 165} 166 167// fork branches the session from the checkpoint the question named. 168func (m * TreePickerModel )fork () tea.Cmd { 169cp ,ok := m .current () 170if ! ok || cp .ID != m .confirm .target { 171m .confirm .dismiss () 172return nil 173 } 174m .Forking ,m .ForkError ,m .Error = true ,"" ,"" 175m .Generation = nextPageGeneration () 176return m .forkCmd (cp .ID ,m .Generation ) 177} 178 179func (m TreePickerModel )Update (msg tea.Msg ) (TreePickerModel , tea.Cmd ) { 180switch msg := msg .(type ) { 181case treeLoadedMsg : 182if ! m .settle (msg .Gen ,msg .Err ,& m .Loading ) { 183return m ,nil 184 } 185m .setCheckpoints (msg .Items ) 186m .NextCursor ,m .HasMore = msg .NextCursor ,msg .HasMore 187m .confirm .dismiss () 188m .ForkError ,m .Error = "" ,"" 189return m ,nil 190 191case treeForkedMsg : 192if msg .Gen != m .Generation { 193return m ,nil 194 } 195m .Forking = false 196if msg .Err != nil { 197// The question stays open, so enter retries. 198m .ForkError = operationError (msg .Err ,"" ,"Branch may have been created; check the session list before branching again." ) 199return m ,nil 200 } 201return m ,func () tea.Msg {return TreeForkSuccessMsg {Session :msg .Session ,Generation :msg .Gen } } 202 203case tea.KeyPressMsg : 204return m .key (msg ) 205 } 206cmd := m .listView .update (msg ) 207return m ,cmd 208} 209 210func (m TreePickerModel )key (msg tea.KeyPressMsg ) (TreePickerModel , tea.Cmd ) { 211key := msg .String () 212switch { 213case m .confirm .asking ()&& ! m .Forking : 214if m .confirm .key (msg ) { 215cmd := m .fork () 216return m ,cmd 217 } 218if ! m .confirm .asking () { 219m .ForkError = "" 220 } 221return m ,nil 222case key == "esc" || key == "ctrl+c" || key == "ctrl+d" : 223return m ,func () tea.Msg {return TreeCancelMsg {} } 224case m .Forking || m .Loading : 225return m ,nil 226 } 227 228switch key { 229case "enter" : 230if cp ,ok := m .current ();ok { 231m .ForkError = "" 232m .confirm .ask ("branch" ,cp .ID ,"branch" ,fmt .Sprintf ("Branch after %s · %s?" ,cp .Type ,readablePreview (cp .Preview ))) 233 } 234return m ,nil 235case "ctrl+r" : 236return m .loadPage (m .Cursors [m .PageIndex ]) 237case "pgdown" : 238// Past the last row, paging moves on to the next server page. 239if m .HasMore && m .NextCursor != nil && m .Cursor >= len (m .shown )- 1 { 240next := * m .NextCursor 241m .Cursors = append (m .Cursors [:m .PageIndex + 1 ],next ) 242m .PageIndex ++ 243return m .loadPage (next ) 244 } 245case "pgup" : 246if m .PageIndex > 0 && m .Cursor == 0 { 247m .PageIndex -- 248return m .loadPage (m .Cursors [m .PageIndex ]) 249 } 250 } 251cmd := m .listView .update (msg ) 252return m ,cmd 253} 254 255func (m TreePickerModel )footer (width int )string { 256if m .confirm .asking ()&& ! m .Forking { 257return m .confirm .footer (width ,m .ForkError ) 258 } 259hints := []hint {{"↑↓" ,"move" }, {"enter" ,"branch" }, {"esc" ,"back" }, {"pgup/pgdn" ,"page" }, {"ctrl+r" ,"refresh" }} 260if m .Error != "" { 261hints = []hint {{"ctrl+r" ,"retry" }, {"esc" ,"back" }} 262 } 263var status string 264urgent := true 265switch { 266case m .Forking : 267status = DefaultStyles .Busy .Render ("branching…" ) 268case m .Loading : 269status ,urgent = DefaultStyles .Faint .Render ("loading…" ),false 270case m .Error != "" : 271status = DefaultStyles .Error .Render (m .Error ) 272 } 273return footerLine (width ,hints ,status ,urgent ) 274} 275 276func (m TreePickerModel )View ()string { 277lv := m .listView 278lv .Empty = "No history checkpoints available to branch from." 279if m .Loading { 280lv .Empty = "loading history…" 281 } 282title := brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/tree" ) 283f := lv .frame (title ,DefaultStyles .Faint .Render ("branch history" ),m .footer (max (1 ,m .Width ))) 284// Without the pane's room, the branch note still reads as one line. 285f .summary = "this creates a new session with a fresh Python namespace" 286return f .view (m .Width ,m .Height ) 287}