okami.mom/albedo

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

dawntui: page_view docs on listFrame and listView, always-live filter, shared confirm, ctrl+r retry, gallery of every page state2ecf203

main
12.4 KiB492 linesraw
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 (
20	TonePlain   = daemon.TonePlain
21	ToneActive  = daemon.ToneActive
22	ToneWarning = daemon.ToneWarning
23	ToneMuted   = 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 {
35	Doc *PageDocument
36	Err error
37	Gen int
38}
39
40type pageShortcutPreparedMsg struct {
41	Prepared *daemon.PreparedPageAction
42	Err      error
43	Gen      int
44}
45
46type pageActionExecutedMsg struct {
47	Prepared *PageDocument
48	Err      error
49	Message  string
50	Action   PageAction
51	Gen      int
52}
53
54type pageModeKind int
55
56const (
57	modeBrowse pageModeKind = iota
58	modeText
59	modeChoice
60	modeConfirm
61	modeActions
62)
63
64type PageViewModel struct {
65	Conn          *daemon.Connection
66	Doc           *PageDocument
67	CurrentAction *PageAction
68	SessionID     string
69	Command       string
70	TextInput     textinput.Model
71	pageStatus
72	listView
73	confirm
74	Mode                    pageModeKind
75	ChoiceIndex             int
76	FieldIndex, ActionIndex int
77	FormValues              map[string]json.RawMessage
78	Busy                    bool
79}
80
81func NewPageViewModel(conn *daemon.Connection, sessionID, command string) PageViewModel {
82	return PageViewModel{
83		Conn:       conn,
84		SessionID:  sessionID,
85		Command:    command,
86		TextInput:  newField(),
87		pageStatus: newPageStatus(false),
88		listView:   newListView("Search " + strings.TrimPrefix(command, "/")),
89		Busy:       true,
90	}
91}
92
93func (m *PageViewModel) SetSize(width, height int) {
94	m.pageStatus.SetSize(width, height)
95	m.listView.setSize(width, height)
96	m.TextInput.SetWidth(max(10, width-20))
97}
98
99func (m PageViewModel) Init() tea.Cmd {
100	return 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 {
107	previous := m.Doc
108	return func() tea.Msg {
109		if m.Conn == nil {
110			return pageLoadedMsg{Err: errors.New("daemon connection unavailable"), Gen: gen}
111		}
112		doc, err := daemon.RefreshPage(context.Background(), m.Conn, m.SessionID, m.Command, previous)
113		if err != nil {
114			return pageLoadedMsg{Err: err, Gen: gen}
115		}
116
117		return pageLoadedMsg{Doc: doc, Gen: gen}
118	}
119}
120
121func (m PageViewModel) executeActionCmd(act PageAction, row *PageRow, gen int) tea.Cmd {
122	form := maps.Clone(m.FormValues)
123	var session *daemon.Session
124	if m.Doc != nil {
125		session = m.Doc.Session
126	}
127	return func() tea.Msg {
128		if m.Conn == nil {
129			return pageActionExecutedMsg{Action: act, Err: errors.New("daemon connection unavailable"), Gen: gen}
130		}
131
132		if act.Operation.OperationID == "mergeLinkGroups" {
133			if raw, ok := form["other_workspace"]; ok {
134				var target string
135				if json.Unmarshal(raw, &target) == nil && session != nil {
136					if _, hasValidator := form["other_etag"]; !hasValidator {
137						prepared, err := daemon.PrepareLinkMerge(context.Background(), m.Conn, *session, target)
138						return pageActionExecutedMsg{Action: act, Gen: gen, Prepared: prepared, Err: err}
139					}
140				}
141			}
142		}
143		res, err := daemon.ExecutePageAction(context.Background(), m.Conn, daemon.PageActionRequest{Action: act, Row: row, Form: form, Session: session})
144		return 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 {
150	row, ok := m.highlighted()
151	if !ok || m.Doc == nil {
152		return nil
153	}
154	i := slices.IndexFunc(m.Doc.Rows, func(r PageRow) bool { return r.ID == row.key })
155	if i < 0 {
156		return nil
157	}
158	return &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 {
163	row := m.currentRow()
164	if !act.Row || row == nil {
165		return ""
166	}
167	target := " "
168	if id := rowID(*row); id != "" {
169		target += id + " "
170	}
171	return 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) {
176	m.Notice, m.Error = "", ""
177	m.CurrentAction, m.Mode = &act, modeConfirm
178	target := m.target(act)
179	m.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() {
184	m.Mode, m.CurrentAction, m.FormValues, m.FieldIndex = modeBrowse, nil, nil, 0
185	m.TextInput.Reset()
186	m.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 {
191	if len(act.Fields) > 0 && m.FieldIndex < len(act.Fields) {
192		field := act.Fields[m.FieldIndex]
193		if entered != "" || field.Required || field.Default != nil {
194			value, err := daemon.ParseActionField(field, entered)
195			if err != nil {
196				m.Error = err.Error()
197				return nil
198			}
199			if m.FormValues == nil {
200				m.FormValues = map[string]json.RawMessage{}
201			}
202			if value != nil {
203				m.FormValues[field.Name] = value
204			}
205		}
206		m.TextInput.Reset()
207		m.FieldIndex++
208		if m.FieldIndex < len(act.Fields) {
209			return m.promptField(act)
210		}
211	}
212	m.Mode, m.CurrentAction = modeBrowse, nil
213	m.Busy, m.Error, m.Notice = true, "", ""
214	m.Generation = nextPageGeneration()
215	cmd := m.executeActionCmd(act, m.currentRow(), m.Generation)
216	m.TextInput.Reset()
217	m.FormValues = nil
218	m.FieldIndex = 0
219	return 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 {
227	if fresh {
228		m.Notice, m.Error = "", ""
229	}
230	m.CurrentAction = &act
231	if len(act.Fields) > 0 {
232		m.FieldIndex = 0
233		m.FormValues = map[string]json.RawMessage{}
234		return m.promptField(act)
235	}
236	return m.runAction(act, "")
237}
238
239func (m PageViewModel) Update(msg tea.Msg) (PageViewModel, tea.Cmd) {
240	switch msg := msg.(type) {
241	case pageLoadedMsg:
242		if !m.settle(msg.Gen, msg.Err, &m.Busy) {
243			return m, nil
244		}
245		m.setDoc(msg.Doc)
246		m.Error = ""
247		return m, nil
248
249	case pageShortcutPreparedMsg:
250		if !m.settle(msg.Gen, msg.Err, &m.Busy) {
251			return m, nil
252		}
253		prepared := msg.Prepared
254		m.setDoc(prepared.Page)
255		m.Error = ""
256		if prepared.Request.Row != nil {
257			m.focusRow(prepared.Request.Row.ID)
258		}
259		act := prepared.Request.Action
260		if act.Confirm {
261			m.ask(act)
262			return m, nil
263		}
264		m.FormValues = prepared.Request.Form
265		m.Generation = nextPageGeneration()
266		m.Busy = true
267		cmd := m.executeActionCmd(act, prepared.Request.Row, m.Generation)
268		m.FormValues = nil
269		return m, cmd
270
271	case pageActionExecutedMsg:
272		if !m.settle(msg.Gen, msg.Err, &m.Busy) {
273			return m, nil
274		}
275		if msg.Prepared != nil {
276			m.setDoc(msg.Prepared)
277			m.Cursor = 0
278			m.Mode, m.Busy, m.CurrentAction = modeBrowse, false, nil
279			return m, nil
280		}
281		m.Notice = pageNotice(msg)
282		m.Error = ""
283		m.Busy = true
284		m.Generation = nextPageGeneration()
285		return m, tea.Batch(
286			func() tea.Msg { return PageViewChangedMsg{} },
287			m.loadPageCmd(m.Generation),
288		)
289
290	case tea.KeyPressMsg:
291		return m.keyPress(msg)
292	}
293	if m.Mode == modeText {
294		var cmd tea.Cmd
295		m.TextInput, cmd = m.TextInput.Update(msg)
296		return m, cmd
297	}
298	cmd := m.listView.update(msg)
299	return 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) {
305	key := msg.String()
306	if m.Mode == modeConfirm {
307		if m.confirm.key(msg) {
308			act := *m.CurrentAction
309			m.CurrentAction, m.Mode = nil, modeBrowse
310			m.confirm.dismiss()
311			cmd := m.beginAction(act, false)
312			return m, cmd
313		}
314		if !m.confirm.asking() {
315			m.CurrentAction, m.Mode = nil, modeBrowse
316		}
317		return m, nil
318	}
319	if key == "esc" || key == "ctrl+c" {
320		if m.Mode == modeBrowse {
321			return m, func() tea.Msg { return PageCancelMsg{} }
322		}
323		m.cancelPrompt()
324		return m, nil
325	}
326	if m.Busy {
327		return m, nil
328	}
329	switch m.Mode {
330	case modeText:
331		if key != "enter" {
332			var cmd tea.Cmd
333			m.TextInput, cmd = m.TextInput.Update(msg)
334			return m, cmd
335		}
336		if m.CurrentAction == nil {
337			return m, nil
338		}
339		act, val := *m.CurrentAction, m.TextInput.Value()
340		m.TextInput.Reset()
341		cmd := m.runAction(act, val)
342		return m, cmd
343	case modeChoice:
344		return m.choiceKey(key)
345	case modeActions:
346		return m.actionsKey(key)
347	}
348	return 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) {
354	key := msg.String()
355	if key == "ctrl+r" {
356		m.Busy, m.Error = true, ""
357		m.Generation = nextPageGeneration()
358		return m, m.loadPageCmd(m.Generation)
359	}
360	if m.Doc == nil {
361		return m, nil
362	}
363	if key == "ctrl+a" {
364		if len(m.Doc.Actions) > 0 {
365			m.Mode, m.ActionIndex = modeActions, 0
366		}
367		return m, nil
368	}
369	if act, ok := m.chord(key); ok {
370		if act.Confirm {
371			m.ask(act)
372			return m, nil
373		}
374		cmd := m.beginAction(act, true)
375		return m, cmd
376	}
377	cmd := m.listView.update(msg)
378	return 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) {
383	for _, act := range m.Doc.Actions {
384		if act.Key == key && (!act.Row || m.currentRow() != nil) {
385			return act, true
386		}
387	}
388	return 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) {
393	switch key {
394	case "up", "ctrl+p":
395		m.ActionIndex = max(0, m.ActionIndex-1)
396	case "down", "ctrl+n":
397		m.ActionIndex = min(len(m.Doc.Actions)-1, m.ActionIndex+1)
398	case "enter":
399		act := m.Doc.Actions[m.ActionIndex]
400		if act.Row && m.currentRow() == nil {
401			m.Error = "Select a row for this action."
402			return m, nil
403		}
404		m.Mode = modeBrowse
405		if act.Confirm {
406			m.ask(act)
407			return m, nil
408		}
409		cmd := m.beginAction(act, true)
410		return m, cmd
411	}
412	return 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) {
417	opts := m.fieldChoices()
418	if len(opts) == 0 {
419		return m, nil
420	}
421	switch key {
422	case "left", "up":
423		m.ChoiceIndex = (m.ChoiceIndex - 1 + len(opts)) % len(opts)
424	case "right", "down":
425		m.ChoiceIndex = (m.ChoiceIndex + 1) % len(opts)
426	case "enter":
427		cmd := m.runAction(*m.CurrentAction, opts[m.ChoiceIndex])
428		return m, cmd
429	}
430	return m, nil
431}
432
433func (m *PageViewModel) promptField(act PageAction) tea.Cmd {
434	field := act.Fields[m.FieldIndex]
435	var session *daemon.Session
436	if m.Doc != nil {
437		session = m.Doc.Session
438	}
439	resolved, err := daemon.ResolveFormField(field, m.currentRow(), session)
440	if err != nil {
441		m.Error = err.Error()
442		m.Mode = modeBrowse
443		return nil
444	}
445	field = resolved
446	act.Fields = slices.Clone(act.Fields)
447	act.Fields[m.FieldIndex] = field
448	m.CurrentAction = &act
449	if field.Type == "hidden" {
450		return m.runAction(act, "")
451	}
452	if field.Type == "choice" || field.Type == "boolean" {
453		m.Mode = modeChoice
454		m.ChoiceIndex = daemon.FormChoiceDefault(field)
455		return nil
456	}
457	m.Mode = modeText
458	initial := ""
459	if field.Default != nil {
460		if json.Unmarshal(field.Default, &initial) != nil && string(field.Default) != "null" {
461			initial = string(field.Default)
462		}
463	}
464	return ask(&m.TextInput, initial, field.Type == "secret")
465}
466
467func (m PageViewModel) currentField() daemon.FormField {
468	if m.CurrentAction != nil && m.FieldIndex < len(m.CurrentAction.Fields) {
469		return m.CurrentAction.Fields[m.FieldIndex]
470	}
471	return daemon.FormField{}
472}
473
474func (m PageViewModel) fieldChoices() []string {
475	field := m.currentField()
476	if field.Type == "boolean" {
477		return []string{"false", "true"}
478	}
479	labels := make([]string, len(field.Choices))
480	for i, choice := range field.Choices {
481		labels[i] = choice.Label
482	}
483	return labels
484}
485
486func (m PageViewModel) shortcutCmd(arguments string) tea.Cmd {
487	conn, session, command, generation := m.Conn, m.SessionID, m.Command, m.Generation
488	return func() tea.Msg {
489		prepared, err := daemon.PreparePageShortcut(context.Background(), conn, session, command, arguments)
490		return pageShortcutPreparedMsg{Prepared: prepared, Err: err, Gen: generation}
491	}
492}