1package modes
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/patriceckhart/zot/internal/core"
8 "github.com/patriceckhart/zot/internal/tui"
9)
10
11// sessionTreeDialog renders the branch topology for the current
12// cwd (parents, children, siblings) and lets the user pick another
13// session file to switch into. Reached via /session tree.
14//
15// Layout is a single flat list with indentation-based nesting so
16// we can reuse the simple up/down cursor model the other dialogs
17// use. The current session is tagged with a muted "[current]".
18type sessionTreeDialog struct {
19 active bool
20 items []treeItem
21 cursor int
22 current string // path of the session we're currently in (not selectable)
23}
24
25// treeItem is one visible row. Depth drives the indent; Path is the
26// on-disk session file to swap to when the user hits enter.
27type treeItem struct {
28 label string
29 path string
30 depth int
31 isCur bool
32}
33
34type sessionTreeAction struct {
35 Select bool
36 Path string
37 Close bool
38}
39
40func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
41
42// Open flattens the given forest into indented rows. currentPath
43// is highlighted and non-selectable (enter on it closes the dialog).
44func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool {
45 items := flattenTree(roots, currentPath)
46 if len(items) == 0 {
47 return false
48 }
49 d.items = items
50 d.current = currentPath
51 d.cursor = indexOfCurrent(items, currentPath)
52 d.active = true
53 return true
54}
55
56// Close hides the dialog.
57func (d *sessionTreeDialog) Close() { d.active = false }
58
59// Active reports whether the dialog consumes input.
60func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
61
62// HandleKey advances the cursor or resolves the selection.
63func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
64 switch k.Kind {
65 case tui.KeyUp:
66 if d.cursor > 0 {
67 d.cursor--
68 }
69 case tui.KeyDown:
70 if d.cursor < len(d.items)-1 {
71 d.cursor++
72 }
73 case tui.KeyEsc:
74 d.Close()
75 return sessionTreeAction{Close: true}
76 case tui.KeyEnter:
77 if len(d.items) == 0 || d.cursor < 0 || d.cursor >= len(d.items) {
78 d.Close()
79 return sessionTreeAction{Close: true}
80 }
81 it := d.items[d.cursor]
82 d.Close()
83 if it.isCur {
84 return sessionTreeAction{Close: true}
85 }
86 return sessionTreeAction{Select: true, Path: it.path}
87 }
88 return sessionTreeAction{}
89}
90
91// Render returns the dialog lines.
92func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
93 if !d.Active() {
94 return nil
95 }
96 var lines []string
97 lines = append(lines, frameHeader(th, "session tree", width))
98 lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):"))
99 for i, it := range d.items {
100 indent := strings.Repeat(" ", it.depth)
101 label := " " + indent + it.label
102 if it.isCur {
103 label += " " + th.FG256(th.Muted, "[current]")
104 }
105 if i == d.cursor {
106 lines = append(lines, th.PadHighlight(label, width))
107 } else {
108 lines = append(lines, th.FG256(th.Muted, label))
109 }
110 }
111 lines = append(lines, frameRule(th, width))
112 return lines
113}
114
115// flattenTree walks the forest depth-first and returns one treeItem
116// per node. Each label has the shape "<when> <first-prompt> (N msgs)".
117func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem {
118 var out []treeItem
119 var walk func(n *core.TreeNode, depth int)
120 walk = func(n *core.TreeNode, depth int) {
121 label := formatTreeRow(n)
122 out = append(out, treeItem{
123 label: label,
124 path: n.Summary.Path,
125 depth: depth,
126 isCur: n.Summary.Path == currentPath,
127 })
128 for _, c := range n.Children {
129 walk(c, depth+1)
130 }
131 }
132 for _, r := range roots {
133 walk(r, 0)
134 }
135 return out
136}
137
138// formatTreeRow renders one node's label line. Tries to fit a
139// compact "date preview (N msgs, $cost)" shape; falls back to
140// the meta id suffix when everything else is empty.
141func formatTreeRow(n *core.TreeNode) string {
142 when := formatRelative(n.Summary.Started)
143 preview := strings.TrimSpace(n.Summary.FirstUserText)
144 if preview == "" {
145 if n.Meta.ID != "" && len(n.Meta.ID) >= 8 {
146 preview = "(" + n.Meta.ID[:8] + ")"
147 } else {
148 preview = "(empty)"
149 }
150 }
151 if len(preview) > 50 {
152 preview = preview[:47] + "..."
153 }
154 return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
155}
156
157// indexOfCurrent returns the flat-list index of the row whose path
158// matches currentPath. -1 when not found; the caller defaults to 0.
159func indexOfCurrent(items []treeItem, currentPath string) int {
160 for i, it := range items {
161 if it.path == currentPath {
162 return i
163 }
164 }
165 return 0
166}