v0.27.0 01c3d31

session_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"testing"
8	"time"
9	"unicode"
10
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16const sessionDialogTestTimeout = 10 * time.Second
17
18func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
19	row := formatSessionRowPlain(core.SessionSummary{
20		Provider:      "test\x1b]0;bad\a",
21		Model:         "model\x1b[31m",
22		MessageCount:  1,
23		FirstUserText: "hello\x1b[2J\nworld",
24	}, 120)
25	if strings.IndexFunc(row, unicode.IsControl) >= 0 {
26		t.Fatalf("session row contains control characters: %q", row)
27	}
28	if !strings.Contains(row, "hello world") {
29		t.Fatalf("sanitized session text missing: %q", row)
30	}
31}
32
33func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
34	root := t.TempDir()
35	cwd := t.TempDir()
36	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
37	if err != nil {
38		t.Fatal(err)
39	}
40	if err := session.AppendMessage(provider.Message{
41		Role:    provider.RoleUser,
42		Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
43	}); err != nil {
44		t.Fatal(err)
45	}
46	if err := session.Close(); err != nil {
47		t.Fatal(err)
48	}
49
50	d := newSessionDialog()
51	events := d.Open(context.Background(), root, cwd)
52	t.Cleanup(d.Close)
53	if !d.Active() || !d.Loading() {
54		t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
55	}
56	loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
57	if !strings.Contains(loadingText, "Loading sessions") {
58		t.Fatalf("loading render = %q, want spinner status", loadingText)
59	}
60	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
61	if act.Select || !d.Active() {
62		t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
63	}
64
65	for event := range events {
66		d.ApplyLoad(event)
67	}
68
69	if d.Loading() {
70		t.Fatal("dialog still loading after final result")
71	}
72	if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
73		t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
74	}
75	loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
76	if strings.Contains(loadedText, "Loading sessions") {
77		t.Fatalf("completed render still shows loading spinner: %q", loadedText)
78	}
79	if !strings.Contains(loadedText, "load this session") {
80		t.Fatalf("completed render missing session text: %q", loadedText)
81	}
82}
83
84func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
85	ctx, cancel := context.WithCancel(context.Background())
86	cancel()
87
88	d := newSessionDialog()
89	events := d.Open(ctx, t.TempDir(), t.TempDir())
90	if d.Loading() {
91		t.Fatal("dialog remained loading with an already-canceled parent")
92	}
93	if d.Active() {
94		t.Fatal("dialog remained active with an already-canceled parent")
95	}
96	if _, ok := <-events; ok {
97		t.Fatal("canceled dialog emitted a load event")
98	}
99}
100
101func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102	ctx, cancel := context.WithCancel(context.Background())
103	d := newSessionDialog()
104	events := d.Open(ctx, t.TempDir(), t.TempDir())
105	cancel()
106	for range events {
107	}
108	d.ApplyLoadClosed()
109	if d.Loading() {
110		t.Fatal("dialog remained loading after parent cancellation")
111	}
112	if d.Active() {
113		t.Fatal("dialog remained active after parent cancellation")
114	}
115}
116
117func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
118	root := t.TempDir()
119	cwd := t.TempDir()
120	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
121	if err != nil {
122		t.Fatal(err)
123	}
124	if err := session.AppendMessage(provider.Message{
125		Role:    provider.RoleUser,
126		Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
127	}); err != nil {
128		t.Fatal(err)
129	}
130	if err := session.Close(); err != nil {
131		t.Fatal(err)
132	}
133
134	entered := make(chan struct{})
135	d := newSessionDialog()
136	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
137		close(entered)
138		<-ctx.Done()
139		return core.SessionSummary{Path: path}
140	}
141	ctx, cancel := context.WithCancel(context.Background())
142	events := d.Open(ctx, root, cwd)
143	t.Cleanup(func() {
144		cancel()
145		d.Close()
146	})
147
148	select {
149	case event, ok := <-events:
150		if !ok || event.kind != sessionLoadStarted {
151			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
152		}
153		d.ApplyLoad(event)
154	case <-time.After(sessionDialogTestTimeout):
155		t.Fatal("timed out waiting for sessionLoadStarted")
156	}
157	select {
158	case <-entered:
159	case <-time.After(sessionDialogTestTimeout):
160		t.Fatal("timed out waiting for the session worker")
161	}
162
163	cancel()
164	for {
165		select {
166		case event, ok := <-events:
167			if !ok {
168				d.ApplyLoadClosed()
169				if d.Loading() || d.Active() {
170					t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
171				}
172				return
173			}
174			if event.kind == sessionLoadFinished {
175				t.Fatal("canceled load emitted sessionLoadFinished")
176			}
177			d.ApplyLoad(event)
178		case <-time.After(sessionDialogTestTimeout):
179			t.Fatal("timed out waiting for canceled session load to close")
180		}
181	}
182}
183
184func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
185	root := t.TempDir()
186	cwd := t.TempDir()
187	session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
188	if err != nil {
189		t.Fatal(err)
190	}
191	if err := session.AppendMessage(provider.Message{
192		Role:    provider.RoleUser,
193		Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
194	}); err != nil {
195		t.Fatal(err)
196	}
197	if err := session.Close(); err != nil {
198		t.Fatal(err)
199	}
200
201	entered := make(chan struct{})
202	release := make(chan struct{})
203	var enteredOnce sync.Once
204	var releaseOnce sync.Once
205	releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
206	d := newSessionDialog()
207	d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
208		enteredOnce.Do(func() { close(entered) })
209		<-release
210		return core.SessionSummary{Path: path, MessageCount: 1}
211	}
212	firstCtx, firstCancel := context.WithCancel(context.Background())
213	firstEvents := d.Open(firstCtx, root, cwd)
214	t.Cleanup(func() {
215		firstCancel()
216		releaseLoad()
217		d.Close()
218	})
219
220	select {
221	case event, ok := <-firstEvents:
222		if !ok || event.kind != sessionLoadStarted {
223			t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
224		}
225		d.ApplyLoad(event)
226	case <-time.After(sessionDialogTestTimeout):
227		t.Fatal("timed out waiting for the first load to start")
228	}
229	select {
230	case <-entered:
231	case <-time.After(sessionDialogTestTimeout):
232		t.Fatal("timed out waiting for the first worker")
233	}
234
235	preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
236	cancelPreCanceled()
237	preCanceledEvents := d.Open(preCanceled, root, cwd)
238	if _, ok := <-preCanceledEvents; ok {
239		t.Fatal("pre-canceled open emitted a load event")
240	}
241
242	thirdCtx, cancelThird := context.WithCancel(context.Background())
243	thirdEvents := d.Open(thirdCtx, root, cwd)
244	select {
245	case event, ok := <-thirdEvents:
246		t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
247	case <-time.After(100 * time.Millisecond):
248	}
249
250	releaseLoad()
251	firstCancel()
252	select {
253	case event, ok := <-thirdEvents:
254		if !ok || event.kind != sessionLoadStarted {
255			t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
256		}
257		d.ApplyLoad(event)
258	case <-time.After(sessionDialogTestTimeout):
259		t.Fatal("timed out waiting for third load after releasing the first")
260	}
261	cancelThird()
262	for event := range thirdEvents {
263		d.ApplyLoad(event)
264	}
265}
266
267func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
268	d := newSessionDialog()
269	events := d.Open(context.Background(), t.TempDir(), t.TempDir())
270	act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
271	if !act.Close || d.Active() || d.Loading() {
272		t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
273	}
274	for range events {
275	}
276}
277
278func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
279	d := newSessionDialog()
280	d.active = true
281	d.loading = true
282	d.loadGeneration = 7
283
284	d.ApplyLoad(sessionLoadEvent{
285		kind:       sessionLoadStarted,
286		generation: 7,
287		total:      4,
288	})
289	d.ApplyLoad(sessionLoadEvent{
290		kind:       sessionLoadEntry,
291		generation: 7,
292		index:      2,
293		summary:    core.SessionSummary{Path: "third", MessageCount: 1},
294	})
295	if len(d.sessions) != 0 {
296		t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
297	}
298	d.ApplyLoad(sessionLoadEvent{
299		kind:       sessionLoadEntry,
300		generation: 7,
301		index:      1,
302		summary:    core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
303	})
304	d.ApplyLoad(sessionLoadEvent{
305		kind:       sessionLoadEntry,
306		generation: 7,
307		index:      3,
308		summary:    core.SessionSummary{Path: "empty"},
309	})
310	d.ApplyLoad(sessionLoadEvent{
311		kind:       sessionLoadEntry,
312		generation: 7,
313		index:      0,
314		summary:    core.SessionSummary{Path: "first", MessageCount: 1},
315	})
316	if !d.Loading() {
317		t.Fatal("dialog stopped loading before the terminal event")
318	}
319	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
320		t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
321	}
322
323	d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
324
325	if d.Loading() {
326		t.Fatal("dialog still loading after final result")
327	}
328	if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
329		t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
330	}
331}
332
333func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
334	d := newSessionDialog()
335	d.active = true
336	d.loading = true
337	d.loadGeneration = 9
338
339	d.ApplyLoad(sessionLoadEvent{
340		kind:       sessionLoadStarted,
341		generation: 9,
342		total:      2,
343	})
344	d.ApplyLoad(sessionLoadEvent{
345		kind:       sessionLoadEntry,
346		generation: 9,
347		index:      0,
348		summary:    core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
349	})
350
351	if !d.Loading() {
352		t.Fatal("dialog stopped loading after first entry")
353	}
354	rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
355	if !strings.Contains(rendered, "recent work") {
356		t.Fatalf("incremental render missing newest entry: %q", rendered)
357	}
358
359	act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360	if !act.Select || act.Path != "newest" || d.Active() {
361		t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
362	}
363}
364
365func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) {
366	root := t.TempDir()
367	cwdA := t.TempDir()
368	cwdB := t.TempDir()
369	for _, cwd := range []string{cwdA, cwdB} {
370		session, err := core.NewSession(root, cwd, "test", "model", "test")
371		if err != nil {
372			t.Fatal(err)
373		}
374		if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil {
375			t.Fatal(err)
376		}
377		if err := session.Close(); err != nil {
378			t.Fatal(err)
379		}
380	}
381	d := newSessionDialog()
382	events := d.Open(context.Background(), root, cwdA, true)
383	defer d.Close()
384	for event := range events {
385		d.ApplyLoad(event)
386	}
387	if !d.allScope || len(d.sessions) != 2 {
388		t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions)
389	}
390	if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope {
391		t.Fatalf("tab action = %#v, want scope toggle", action)
392	}
393}
394
395func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
396	d := newSessionDialog()
397	d.active = true
398	d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}}
399	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
400	d.query = "alpha"
401	d.searchReady = true
402	d.searchGeneration = 1
403	d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{
404		{Path: "one", Text: "alpha implementation details"},
405		{Path: "one", Text: "alpha follow-up"},
406		{Path: "two", Text: "unrelated text"},
407	})
408	d.applySearchFilter()
409	if len(d.sessions) != 1 || d.sessions[0].Path != "one" {
410		t.Fatalf("search sessions = %#v", d.sessions)
411	}
412	match := d.searchMatches["one"]
413	if match.count != 2 || match.excerpt == "" {
414		t.Fatalf("search match = %#v", match)
415	}
416	rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
417	if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") {
418		t.Fatalf("search render = %q", rendered)
419	}
420}
421
422func TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) {
423	d := newSessionDialog()
424	d.active = true
425	d.loading = true // Keep StartSearch pending; no background read is needed.
426	d.query = "go"
427
428	if events := d.StartSearch(context.Background()); events == nil {
429		t.Fatal("StartSearch returned nil")
430	}
431	defer d.Close()
432	if !d.SearchLoading() {
433		t.Fatal("search is not reported as loading while indexing")
434	}
435	if d.searchSpinner == nil {
436		t.Fatal("search did not initialize the shared spinner")
437	}
438	indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
439	if !strings.Contains(indexed, "Indexing session text") {
440		t.Fatalf("indexing render = %q", indexed)
441	}
442
443	d.searching = false
444	d.matching = true
445	matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
446	if !strings.Contains(matching, "Searching session text") {
447		t.Fatalf("matching render = %q", matching)
448	}
449
450	d.matching = false
451	if d.SearchLoading() {
452		t.Fatal("search remains reported as loading after matching completes")
453	}
454}
455
456func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) {
457	d := newSessionDialog()
458	d.active = true
459	d.query = "abc"
460	d.searchGeneration = 1
461	d.matchGeneration = 2
462	d.matching = true
463
464	d.ApplySearch(sessionSearchEvent{
465		kind:            sessionSearchMatchesReady,
466		generation:      1,
467		matchGeneration: 1,
468		query:           "abc",
469		matches:         map[string]sessionSearchMatch{"stale": {count: 1}},
470	})
471	if !d.matching {
472		t.Fatal("stale match result cleared the active search state")
473	}
474	if d.searchMatches != nil {
475		t.Fatalf("stale match result was applied: %#v", d.searchMatches)
476	}
477
478	d.ApplySearch(sessionSearchEvent{
479		kind:            sessionSearchMatchesReady,
480		generation:      1,
481		matchGeneration: 2,
482		query:           "abc",
483		matches:         map[string]sessionSearchMatch{"current": {count: 1}},
484	})
485	if d.matching {
486		t.Fatal("current match result did not finish the active search")
487	}
488	if _, ok := d.searchMatches["current"]; !ok {
489		t.Fatalf("current match result was not applied: %#v", d.searchMatches)
490	}
491}
492
493func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
494	segments := []core.SessionSearchSegment{
495		{Path: "scattered", Text: "sizzle then harp, again remote"},
496		{Path: "exact", Text: "resume the sharm project"},
497	}
498
499	if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
500		t.Fatalf("one-character search = %#v, want no search", matches)
501	}
502	matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
503	if len(matches) != 1 {
504		t.Fatalf("matches = %#v, want only exact session", matches)
505	}
506	match, ok := matches["exact"]
507	if !ok {
508		t.Fatalf("matches = %#v, missing exact session", matches)
509	}
510	if match.count != 1 || match.excerpt != "resume the sharm project" {
511		t.Fatalf("exact match = %#v", match)
512	}
513	if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
514		t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
515	}
516}
517
518func TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
519	matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
520		{Path: "repeated", Text: "sharm then sharm"},
521	})
522	match, ok := matches["repeated"]
523	if !ok {
524		t.Fatalf("matches = %#v, missing repeated session", matches)
525	}
526	if match.count != 2 {
527		t.Fatalf("match count = %d, want 2", match.count)
528	}
529	if match.excerpt != "sharm then sharm" {
530		t.Fatalf("match excerpt = %q", match.excerpt)
531	}
532	if want := []int{0, 1, 2, 3, 4}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
533		t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
534	}
535}
536
537func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
538	d := newSessionDialog()
539	d.active = true
540	d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
541	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
542
543	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
544		t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
545	}
546	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
547		t.Fatalf("one-character action = %#v, query = %q", action, d.query)
548	}
549	if len(d.sessions) != len(d.baseSessions) {
550		t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
551	}
552	if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
553		t.Fatal("one-character search started the corpus reader")
554	}
555	if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
556		t.Fatalf("two-character action = %#v, query = %q", action, d.query)
557	}
558}
559
560func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
561	d := newSessionDialog()
562	d.active = true
563	d.searchRequested = true
564	d.searchReady = true
565	d.query = "sh"
566	d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
567	d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
568	d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
569
570	d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
571	if d.searchMatches != nil {
572		t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
573	}
574	rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
575	if strings.Contains(rendered, "1 match") {
576		t.Fatalf("one-character search rendered stale match count: %q", rendered)
577	}
578}
579
580func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
581	first := newSessionDialog()
582	firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
583	firstGeneration := first.loadGeneration
584
585	secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
586	secondGeneration := first.loadGeneration
587	if secondGeneration == firstGeneration {
588		t.Fatal("reopening picker did not advance load generation")
589	}
590	first.ApplyLoad(sessionLoadEvent{
591		kind:       sessionLoadStarted,
592		generation: secondGeneration,
593		total:      1,
594	})
595
596	first.ApplyLoad(sessionLoadEvent{
597		kind:       sessionLoadEntry,
598		generation: firstGeneration,
599		index:      0,
600		summary: core.SessionSummary{
601			Path:         "stale",
602			MessageCount: 1,
603		},
604	})
605	if len(first.sessions) != 0 {
606		t.Fatalf("stale results repopulated picker: %+v", first.sessions)
607	}
608
609	first.Close()
610	for range firstEvents {
611	}
612	for range secondEvents {
613	}
614}

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