main 4509ec7

main.go RawHistoryFolder

1package main
2
3import (
4	"encoding/json"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"sync"
10
11	"github.com/bnema/zut/packages/agent/ext"
12)
13
14type todoItem struct {
15	Text string `json:"text"`
16	Done bool   `json:"done"`
17}
18
19type store struct {
20	Items  []todoItem `json:"items"`
21	Cursor int        `json:"cursor"`
22}
23
24type toolArgs struct {
25	Action   string `json:"action"`
26	Title    string `json:"title"`
27	NewTitle string `json:"new_title"`
28}
29
30type app struct {
31	e    *ext.Extension
32	mu   sync.Mutex
33	path string
34	st   store
35	mode string
36	edit string
37}
38
39const panelID = "todos-main"
40
41func main() {
42	e := ext.New("todo-panel", "0.3.0")
43	a := &app{e: e}
44	e.Command("todo", "open a persistent todo panel", func(args string) ext.Response {
45		if err := a.ensureLoaded(); err != nil {
46			return ext.Errorf("load todos: %v", err)
47		}
48		a.mu.Lock()
49		defer a.mu.Unlock()
50		return ext.OpenPanel(panelID, a.title(), a.renderLines(), a.footer())
51	})
52	schema, _ := json.Marshal(map[string]any{
53		"type": "object",
54		"properties": map[string]any{
55			"action":    map[string]any{"type": "string", "enum": []string{"list", "add", "complete", "edit", "remove"}},
56			"title":     map[string]any{"type": "string"},
57			"new_title": map[string]any{"type": "string"},
58		},
59		"required": []string{"action"},
60	})
61	e.Tool("todo_manage", "List, add, complete, edit, or remove todos by title.", schema, a.handleTool)
62	e.OnPanelKey(panelID, a.handleKey, nil)
63	if err := e.Run(); err != nil {
64		fmt.Fprintln(os.Stderr, err)
65		os.Exit(1)
66	}
67}
68
69func (a *app) ensureLoaded() error {
70	a.mu.Lock()
71	defer a.mu.Unlock()
72	if a.path != "" {
73		return nil
74	}
75	dir := a.e.Host().DataDir
76	if dir == "" {
77		dir = a.e.Host().ExtensionDir
78	}
79	if dir == "" {
80		return fmt.Errorf("host did not provide extension_dir/data_dir")
81	}
82	if err := os.MkdirAll(dir, 0o755); err != nil {
83		return err
84	}
85	a.path = filepath.Join(dir, "todos.json")
86	b, err := os.ReadFile(a.path)
87	if err != nil {
88		if os.IsNotExist(err) {
89			a.st = store{Items: []todoItem{{Text: "Build something fun", Done: false}}}
90			return a.saveLocked()
91		}
92		return err
93	}
94	if len(b) == 0 {
95		a.st = store{}
96		return nil
97	}
98	if err := json.Unmarshal(b, &a.st); err != nil {
99		return err
100	}
101	a.clampLocked()
102	return nil
103}
104
105func (a *app) saveLocked() error {
106	a.clampLocked()
107	b, err := json.MarshalIndent(a.st, "", "  ")
108	if err != nil {
109		return err
110	}
111	return os.WriteFile(a.path, b, 0o644)
112}
113
114func (a *app) clampLocked() {
115	if len(a.st.Items) == 0 {
116		a.st.Cursor = 0
117		return
118	}
119	if a.st.Cursor < 0 {
120		a.st.Cursor = 0
121	}
122	if a.st.Cursor >= len(a.st.Items) {
123		a.st.Cursor = len(a.st.Items) - 1
124	}
125}
126
127func (a *app) title() string {
128	done := 0
129	for _, it := range a.st.Items {
130		if it.Done {
131			done++
132		}
133	}
134	return fmt.Sprintf("Todos (%d/%d done)", done, len(a.st.Items))
135}
136
137func (a *app) renderLines() []string {
138	if a.mode == "add" || a.mode == "edit" {
139		label := "Add todo"
140		if a.mode == "edit" {
141			label = "Edit todo"
142		}
143		return []string{
144			"  " + label,
145			"",
146			"  " + a.edit + "▌",
147			"",
148			"  Enter saves, Esc cancels",
149		}
150	}
151	lines := []string{}
152	if len(a.st.Items) == 0 {
153		return []string{"  No todos yet.", "", "  Press a to add one."}
154	}
155	for i, it := range a.st.Items {
156		cursor := "  "
157		if i == a.st.Cursor {
158			cursor = "› "
159		}
160		box := "□"
161		if it.Done {
162			box = "✓"
163		}
164		text := it.Text
165		if it.Done {
166			text += "  (done)"
167		}
168		lines = append(lines, cursor+box+" "+text)
169	}
170	return lines
171}
172
173func (a *app) footer() string {
174	if a.mode == "add" || a.mode == "edit" {
175		return "type text - enter save - esc cancel - backspace delete"
176	}
177	return "↑/↓ navigate - x toggle - a add - e edit - d delete - r refresh - esc close"
178}
179
180func (a *app) rerenderLocked() {
181	a.e.RenderPanel(panelID, a.title(), a.renderLines(), a.footer())
182}
183
184func (a *app) handleKey(key, text string) {
185	if err := a.ensureLoaded(); err != nil {
186		a.e.Notify("error", fmt.Sprintf("todo: %v", err))
187		return
188	}
189	a.mu.Lock()
190	defer a.mu.Unlock()
191	if a.mode == "add" || a.mode == "edit" {
192		a.handleEditModeLocked(key, text)
193		return
194	}
195	switch key {
196	case "up":
197		a.st.Cursor--
198	case "down":
199		a.st.Cursor++
200	case "rune":
201		switch strings.ToLower(text) {
202		case "x":
203			if len(a.st.Items) > 0 {
204				a.st.Items[a.st.Cursor].Done = !a.st.Items[a.st.Cursor].Done
205			}
206		case "d":
207			if len(a.st.Items) > 0 {
208				a.st.Items = append(a.st.Items[:a.st.Cursor], a.st.Items[a.st.Cursor+1:]...)
209			}
210		case "a":
211			a.mode = "add"
212			a.edit = ""
213		case "e":
214			if len(a.st.Items) > 0 {
215				a.mode = "edit"
216				a.edit = a.st.Items[a.st.Cursor].Text
217			}
218		case "r":
219		}
220	}
221	a.clampLocked()
222	if err := a.saveLocked(); err != nil {
223		a.e.Notify("error", fmt.Sprintf("save todos: %v", err))
224	}
225	a.rerenderLocked()
226}
227
228func (a *app) handleEditModeLocked(key, text string) {
229	switch key {
230	case "enter":
231		trimmed := strings.TrimSpace(a.edit)
232		if trimmed != "" {
233			if a.mode == "add" {
234				a.st.Items = append(a.st.Items, todoItem{Text: trimmed})
235				a.st.Cursor = len(a.st.Items) - 1
236			} else if a.mode == "edit" && len(a.st.Items) > 0 {
237				a.st.Items[a.st.Cursor].Text = trimmed
238			}
239		}
240		a.mode = ""
241		a.edit = ""
242	case "backspace":
243		if len(a.edit) > 0 {
244			r := []rune(a.edit)
245			a.edit = string(r[:len(r)-1])
246		}
247	case "esc":
248		a.mode = ""
249		a.edit = ""
250	case "rune":
251		if text != "" {
252			a.edit += text
253		}
254	}
255	if err := a.saveLocked(); err != nil {
256		a.e.Notify("error", fmt.Sprintf("save todos: %v", err))
257	}
258	a.rerenderLocked()
259}
260
261func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
262	if err := a.ensureLoaded(); err != nil {
263		return ext.TextErrorResult("load todos: " + err.Error())
264	}
265	var in toolArgs
266	if err := json.Unmarshal(args, &in); err != nil {
267		return ext.TextErrorResult("invalid args: " + err.Error())
268	}
269	a.mu.Lock()
270	defer a.mu.Unlock()
271	switch in.Action {
272	case "list":
273		if len(a.st.Items) == 0 {
274			return ext.TextResult("No todos.")
275		}
276		var lines []string
277		for _, it := range a.st.Items {
278			mark := "[ ]"
279			if it.Done {
280				mark = "[x]"
281			}
282			lines = append(lines, mark+" "+it.Text)
283		}
284		return ext.TextResult(strings.Join(lines, "\n"))
285	case "add":
286		if strings.TrimSpace(in.Title) == "" {
287			return ext.TextErrorResult("title is required for add")
288		}
289		a.st.Items = append(a.st.Items, todoItem{Text: strings.TrimSpace(in.Title)})
290		a.st.Cursor = len(a.st.Items) - 1
291	case "complete":
292		idx := a.findByTitleLocked(in.Title)
293		if idx < 0 {
294			return ext.TextErrorResult("todo not found: " + in.Title)
295		}
296		a.st.Items[idx].Done = true
297		a.st.Cursor = idx
298	case "edit":
299		idx := a.findByTitleLocked(in.Title)
300		if idx < 0 {
301			return ext.TextErrorResult("todo not found: " + in.Title)
302		}
303		if strings.TrimSpace(in.NewTitle) == "" {
304			return ext.TextErrorResult("new_title is required for edit")
305		}
306		a.st.Items[idx].Text = strings.TrimSpace(in.NewTitle)
307		a.st.Cursor = idx
308	case "remove":
309		idx := a.findByTitleLocked(in.Title)
310		if idx < 0 {
311			return ext.TextErrorResult("todo not found: " + in.Title)
312		}
313		a.st.Items = append(a.st.Items[:idx], a.st.Items[idx+1:]...)
314	case "":
315		return ext.TextErrorResult("action is required")
316	default:
317		return ext.TextErrorResult("unsupported action: " + in.Action)
318	}
319	if err := a.saveLocked(); err != nil {
320		return ext.TextErrorResult("save todos: " + err.Error())
321	}
322	if a.mode == "" {
323		a.rerenderLocked()
324	}
325	return ext.TextResult("ok")
326}
327
328func (a *app) findByTitleLocked(title string) int {
329	needle := strings.TrimSpace(strings.ToLower(title))
330	for i, it := range a.st.Items {
331		if strings.TrimSpace(strings.ToLower(it.Text)) == needle {
332			return i
333		}
334	}
335	return -1
336}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close