v0.11.0 e1eefc2

ext_notes_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"testing"
8
9	"github.com/bnema/zut/packages/tui"
10)
11
12func newNotesTestInteractive() *Interactive {
13	i := &Interactive{dirty: make(chan struct{}, 1)}
14	i.cfg.Theme = tui.Theme{
15		Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1),
16		Tool: tui.Color256(2), Accent: tui.Color256(4),
17	}
18	return i
19}
20
21func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
22	i := newNotesTestInteractive()
23
24	i.Notify("kagi", "info", "pending")
25	i.Notify("kagi", "success", "approved")
26	i.Notify("other", "info", "keep me")
27
28	if len(i.extNotes) != 3 {
29		t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
30	}
31
32	i.ClearNotes("kagi")
33
34	if len(i.extNotes) != 1 {
35		t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
36	}
37	if !strings.Contains(i.extNotes[0], "[other] ") {
38		t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
39	}
40}
41
42func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
43	i := newNotesTestInteractive()
44	i.Notify("kagi", "info", "pending")
45
46	i.ClearNotes("nope")
47
48	if len(i.extNotes) != 1 {
49		t.Fatalf("expected note to survive, got %d", len(i.extNotes))
50	}
51}
52
53func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
54	i := newNotesTestInteractive()
55	lines := make([]string, 20)
56	for idx := range lines {
57		lines[idx] = "line"
58	}
59	i.SetWidget("extension", "widget", "above_input", "Title", lines)
60
61	i.mu.Lock()
62	got := i.extensionChromeLinesLocked(80)
63	i.mu.Unlock()
64	if len(got) != maxExtensionWidgetRows {
65		t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
66	}
67	if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
68		t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
69	}
70}
71
72func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
73	i := newNotesTestInteractive()
74	for n := 0; n < maxExtensionStatusRows+4; n++ {
75		i.SetStatus("extension", fmt.Sprintf("status-%d", n), "info", "line")
76	}
77
78	i.mu.Lock()
79	got := i.extensionChromeLinesLocked(80)
80	i.mu.Unlock()
81	if len(got) != maxExtensionStatusRows {
82		t.Fatalf("status rows = %d, want %d: %v", len(got), maxExtensionStatusRows, got)
83	}
84	if !strings.Contains(strings.Join(got, "\n"), "extension statuses truncated") {
85		t.Fatalf("bounded status rows omitted truncation marker: %v", got)
86	}
87}
88
89func TestNarrowRightBarFallbackCapsCombinedExtensionChrome(t *testing.T) {
90	i := newNotesTestInteractive()
91	for n := 0; n < 3; n++ {
92		lines := make([]string, 4)
93		for row := range lines {
94			lines[row] = fmt.Sprintf("extension-%d-line-%d", n, row)
95		}
96		i.SetWidget(fmt.Sprintf("extension-%d", n), "plan", "right_bar", "Plan", lines)
97	}
98	i.SetStatus("status-extension", "progress", "info", "still visible")
99
100	i.mu.Lock()
101	got := i.extensionChromeLinesForLayoutLocked(72, false, true)
102	i.mu.Unlock()
103	if len(got) != maxNarrowExtensionChromeRows {
104		t.Fatalf("narrow fallback rows = %d, want %d: %v", len(got), maxNarrowExtensionChromeRows, got)
105	}
106	if !strings.Contains(strings.Join(got, "\n"), "extension chrome truncated") {
107		t.Fatalf("narrow fallback omitted truncation marker: %v", got)
108	}
109}
110
111func TestPersistentExtensionStatusAndWidget(t *testing.T) {
112	i := newNotesTestInteractive()
113	i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
114	i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
115
116	i.mu.Lock()
117	lines := i.extensionChromeLinesLocked(80)
118	statusKey := i.extensionStatusesKeyLocked()
119	widgetKey := i.extensionWidgetsKeyLocked()
120	i.mu.Unlock()
121	joined := strings.Join(lines, "\n")
122	if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
123		t.Fatalf("persistent extension chrome = %q", joined)
124	}
125	if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
126		t.Fatalf("status cache key = %q", statusKey)
127	}
128	if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
129		t.Fatalf("widget cache key = %q", widgetKey)
130	}
131
132	i.SetStatus("tasked-phases", "progress", "", "")
133	i.ClearWidget("tasked-phases", "plan")
134	if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
135		t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
136	}
137}
138
139func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) {
140	i := newNotesTestInteractive()
141	i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"})
142	i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"})
143	i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"})
144	i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"})
145
146	i.mu.Lock()
147	widgets := i.rightBarWidgetsLocked()
148	wideAbove := i.extensionChromeLinesAtLocked(80, true)
149	narrowFallback := i.extensionChromeLinesAtLocked(72, false)
150	i.mu.Unlock()
151
152	if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" {
153		t.Fatalf("right-bar ordering = %+v", widgets)
154	}
155	wideText := strings.Join(wideAbove, "\n")
156	if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") {
157		t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText)
158	}
159	fallbackText := strings.Join(narrowFallback, "\n")
160	if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") {
161		t.Fatalf("narrow fallback omitted widgets: %q", fallbackText)
162	}
163}
164
165func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
166	i := newNotesTestInteractive()
167	i.SetStatus("gone", "progress", "success", "done")
168	i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"})
169	i.SetStatus("keep", "progress", "info", "still here")
170	i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"})
171	i.extNotes = []string{"[gone] old note", "[keep] keep note"}
172
173	i.ClearExtensionChrome("gone")
174
175	if _, ok := i.extStatuses["gone"]; ok {
176		t.Fatal("exited extension status survived")
177	}
178	if _, ok := i.extWidgets["gone"]; ok {
179		t.Fatal("exited extension widget survived")
180	}
181	if _, ok := i.extStatuses["keep"]; !ok {
182		t.Fatal("unrelated extension status was removed")
183	}
184	if _, ok := i.extWidgets["keep"]; !ok {
185		t.Fatal("unrelated extension widget was removed")
186	}
187	if got := strings.Join(i.extNotes, "\n"); got != "[keep] keep note" {
188		t.Fatalf("extension notes after cleanup = %q", got)
189	}
190}
191
192func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
193	term := &alertTestTerminal{}
194	i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
195	i.rend.Resize(80, 24)
196	i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
197	i.redraw()
198
199	out := stripANSIBytes(term.String())
200	if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
201		t.Fatalf("wide redraw omitted right-bar content: %q", out)
202	}
203	if !strings.Contains(out, "│") {
204		t.Fatal("wide redraw omitted the main-pane divider")
205	}
206}
207
208func TestCtrlBTogglesRightBarAndFallsBackAboveInput(t *testing.T) {
209	term := &alertTestTerminal{}
210	i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
211	i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
212
213	if i.rightBarHidden {
214		t.Fatal("right bar starts hidden")
215	}
216	if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}); done {
217		t.Fatal("ctrl+b unexpectedly exited interactive mode")
218	}
219	if !i.rightBarHidden {
220		t.Fatal("ctrl+b did not hide the right bar")
221	}
222
223	i.mu.Lock()
224	fallback := strings.Join(i.extensionChromeLinesForLayoutLocked(80, false, true), "\n")
225	i.mu.Unlock()
226	if !strings.Contains(fallback, "Plan") {
227		t.Fatalf("hidden right-bar widget did not fall back above input: %q", fallback)
228	}
229
230	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB})
231	if i.rightBarHidden {
232		t.Fatal("second ctrl+b did not show the right bar")
233	}
234}

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 477

↑↓ moveenter openesc close