detached 2295403

feat(sessions): show search loading feedback

commit
2295403a945c9ddc701a6d71d816b9a2379fe360
author
brice · · 2026-08-20 14:09 UTC
parent
fae6c20
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +122 -11
M packages/agent/modes/interactive.go +2 -1
@@ -1423,8 +1423,9 @@ func (i *Interactive) Run(ctx context.Context) error {
14231423 i.mu.Lock()
14241424 busy := i.busy
14251425 sessionLoading := i.sessionDialog.Loading()
1426+ sessionSearching := i.sessionDialog.SearchLoading()
14261427 i.mu.Unlock()
1427- if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() {
1428+ if busy || sessionLoading || sessionSearching || i.btwDialog.Loading() || i.residentAnimating.Load() {
14281429 requestRedraw()
14291430 }
14301431 }
@@ -1423,8 +1423,9 @@ func (i *Interactive) Run(ctx context.Context) error {
1423 i.mu.Lock()1423 i.mu.Lock()
1424 busy := i.busy1424 busy := i.busy
1425 sessionLoading := i.sessionDialog.Loading()1425 sessionLoading := i.sessionDialog.Loading()
1426+ sessionSearching := i.sessionDialog.SearchLoading()
1426 i.mu.Unlock()1427 i.mu.Unlock()
1427- if busy || sessionLoading || i.btwDialog.Loading() || i.residentAnimating.Load() {1428+ if busy || sessionLoading || sessionSearching || i.btwDialog.Loading() || i.residentAnimating.Load() {
1428 requestRedraw()1429 requestRedraw()
1429 }1430 }
1430 }1431 }
M packages/agent/modes/session_dialog.go +49 -10
@@ -28,12 +28,13 @@ const (
2828 )
2929
3030 type sessionSearchEvent struct {
31- kind sessionSearchEventKind
32- generation uint64
33- query string
34- segments []core.SessionSearchSegment
35- matches map[string]sessionSearchMatch
36- skipped int
31+ kind sessionSearchEventKind
32+ generation uint64
33+ matchGeneration uint64
34+ query string
35+ segments []core.SessionSearchSegment
36+ matches map[string]sessionSearchMatch
37+ skipped int
3738 }
3839
3940 type sessionSearchMatch struct {
@@ -82,9 +83,12 @@ type sessionDialog struct {
8283 query string
8384 searchRequested bool
8485 searching bool
86+ matching bool
8587 searchPending bool
8688 searchReady bool
89+ searchSpinner *spinner
8790 searchGeneration uint64
91+ matchGeneration uint64
8892 searchContext context.Context
8993 searchCancel context.CancelFunc
9094 matchCancel context.CancelFunc
@@ -177,8 +181,11 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
177181 d.query = ""
178182 d.searchRequested = false
179183 d.searching = false
184+ d.matching = false
180185 d.searchPending = false
186+ d.searchSpinner = nil
181187 d.searchReady = false
188+ d.matchGeneration = 0
182189 d.searchSegments = nil
183190 d.searchMatches = nil
184191 d.searchErr = ""
@@ -433,6 +440,8 @@ func (d *sessionDialog) Close() {
433440 d.loading = false
434441 d.searchRequested = false
435442 d.searching = false
443+ d.matching = false
444+ d.searchSpinner = nil
436445 d.loadSlots = nil
437446 d.loadReadyThrough = 0
438447 d.searchEvents = nil
@@ -458,6 +467,8 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
458467 d.searchContext = ctx
459468 d.searchCancel = cancel
460469 d.searching = true
470+ d.matching = false
471+ d.searchSpinner = &spinner{startedAt: time.Now()}
461472 d.searchGeneration++
462473 events := make(chan sessionSearchEvent, 1)
463474 d.searchEvents = events
@@ -516,9 +527,10 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
516527 d.searchSegments = event.segments
517528 d.scheduleSearchMatch()
518529 case sessionSearchMatchesReady:
519- if event.query != d.query {
530+ if event.query != d.query || event.matchGeneration != d.matchGeneration {
520531 return
521532 }
533+ d.matching = false
522534 d.searchMatches = event.matches
523535 d.applySearchFilter()
524536 }
@@ -531,9 +543,12 @@ func (d *sessionDialog) scheduleSearchMatch() {
531543 if d.matchCancel != nil {
532544 d.matchCancel()
533545 }
546+ d.matching = true
547+ d.matchGeneration++
534548 query := d.query
535549 segments := d.searchSegments
536550 generation := d.searchGeneration
551+ matchGeneration := d.matchGeneration
537552 events := d.searchEvents
538553 ctx, cancel := context.WithCancel(d.searchContext)
539554 d.matchCancel = cancel
@@ -543,7 +558,7 @@ func (d *sessionDialog) scheduleSearchMatch() {
543558 return
544559 }
545560 select {
546- case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
561+ case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, matchGeneration: matchGeneration, query: query, matches: matches}:
547562 case <-ctx.Done():
548563 }
549564 }()
@@ -628,6 +643,11 @@ func (d *sessionDialog) Active() bool { return d != nil && d.active }
628643 // Loading reports whether the dialog still has session entries in flight.
629644 func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
630645
646+// SearchLoading reports whether the dialog is reading or matching session text.
647+func (d *sessionDialog) SearchLoading() bool {
648+ return d != nil && d.active && (d.searching || d.matching)
649+}
650+
631651 // Render returns the dialog lines.
632652 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
633653 if !d.Active() {
@@ -639,6 +659,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
639659 title = "sessions · all"
640660 }
641661 lines = append(lines, frameHeader(th, title, width))
662+ if d.SearchLoading() {
663+ lines = append(lines, d.searchLoadingMessage(th))
664+ }
642665 if d.loading {
643666 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
644667 if len(d.sessions) == 0 {
@@ -676,8 +699,6 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
676699 hint = "search: " + d.query + " (esc clear · tab scope)"
677700 if !sessionSearchQuery(d.query) {
678701 hint += " · type 2+ characters"
679- } else if d.searching {
680- hint += " · indexing"
681702 }
682703 }
683704 if d.loading {
@@ -901,6 +922,18 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
901922 return row
902923 }
903924
925+func (d *sessionDialog) searchLoadingMessage(th tui.Theme) string {
926+ if d.searchSpinner == nil {
927+ d.searchSpinner = &spinner{startedAt: time.Now()}
928+ }
929+ d.searchSpinner.Configure(th)
930+ message := "Searching session text"
931+ if d.searching {
932+ message = "Indexing session text"
933+ }
934+ return th.FGColor(th.Spinner, d.searchSpinner.Frame()) + " " + th.FGColor(th.Muted, message+"…")
935+}
936+
904937 func (d *sessionDialog) loadingMessage(th tui.Theme) string {
905938 frames := th.SpinnerFrames
906939 if len(frames) == 0 {
@@ -1009,6 +1042,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
10091042 return sessionDialogAction{Close: true}
10101043 }
10111044 d.query = ""
1045+ if d.matchCancel != nil {
1046+ d.matchCancel()
1047+ d.matchCancel = nil
1048+ }
1049+ d.matching = false
10121050 d.searchMatches = nil
10131051 d.applySearchFilter()
10141052 return sessionDialogAction{}
@@ -1022,6 +1060,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
10221060 d.matchCancel()
10231061 d.matchCancel = nil
10241062 }
1063+ d.matching = false
10251064 d.searchMatches = nil
10261065 }
10271066 d.applySearchFilter()
@@ -28,12 +28,13 @@ const (
28 )28 )
29 29
30 type sessionSearchEvent struct {30 type sessionSearchEvent struct {
31- kind sessionSearchEventKind31+ kind sessionSearchEventKind
32- generation uint6432+ generation uint64
33- query string33+ matchGeneration uint64
34- segments []core.SessionSearchSegment34+ query string
35- matches map[string]sessionSearchMatch35+ segments []core.SessionSearchSegment
36- skipped int36+ matches map[string]sessionSearchMatch
37+ skipped int
37 }38 }
38 39
39 type sessionSearchMatch struct {40 type sessionSearchMatch struct {
@@ -82,9 +83,12 @@ type sessionDialog struct {
82 query string83 query string
83 searchRequested bool84 searchRequested bool
84 searching bool85 searching bool
86+ matching bool
85 searchPending bool87 searchPending bool
86 searchReady bool88 searchReady bool
89+ searchSpinner *spinner
87 searchGeneration uint6490 searchGeneration uint64
91+ matchGeneration uint64
88 searchContext context.Context92 searchContext context.Context
89 searchCancel context.CancelFunc93 searchCancel context.CancelFunc
90 matchCancel context.CancelFunc94 matchCancel context.CancelFunc
@@ -177,8 +181,11 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
177 d.query = ""181 d.query = ""
178 d.searchRequested = false182 d.searchRequested = false
179 d.searching = false183 d.searching = false
184+ d.matching = false
180 d.searchPending = false185 d.searchPending = false
186+ d.searchSpinner = nil
181 d.searchReady = false187 d.searchReady = false
188+ d.matchGeneration = 0
182 d.searchSegments = nil189 d.searchSegments = nil
183 d.searchMatches = nil190 d.searchMatches = nil
184 d.searchErr = ""191 d.searchErr = ""
@@ -433,6 +440,8 @@ func (d *sessionDialog) Close() {
433 d.loading = false440 d.loading = false
434 d.searchRequested = false441 d.searchRequested = false
435 d.searching = false442 d.searching = false
443+ d.matching = false
444+ d.searchSpinner = nil
436 d.loadSlots = nil445 d.loadSlots = nil
437 d.loadReadyThrough = 0446 d.loadReadyThrough = 0
438 d.searchEvents = nil447 d.searchEvents = nil
@@ -458,6 +467,8 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
458 d.searchContext = ctx467 d.searchContext = ctx
459 d.searchCancel = cancel468 d.searchCancel = cancel
460 d.searching = true469 d.searching = true
470+ d.matching = false
471+ d.searchSpinner = &spinner{startedAt: time.Now()}
461 d.searchGeneration++472 d.searchGeneration++
462 events := make(chan sessionSearchEvent, 1)473 events := make(chan sessionSearchEvent, 1)
463 d.searchEvents = events474 d.searchEvents = events
@@ -516,9 +527,10 @@ func (d *sessionDialog) ApplySearch(event sessionSearchEvent) {
516 d.searchSegments = event.segments527 d.searchSegments = event.segments
517 d.scheduleSearchMatch()528 d.scheduleSearchMatch()
518 case sessionSearchMatchesReady:529 case sessionSearchMatchesReady:
519- if event.query != d.query {530+ if event.query != d.query || event.matchGeneration != d.matchGeneration {
520 return531 return
521 }532 }
533+ d.matching = false
522 d.searchMatches = event.matches534 d.searchMatches = event.matches
523 d.applySearchFilter()535 d.applySearchFilter()
524 }536 }
@@ -531,9 +543,12 @@ func (d *sessionDialog) scheduleSearchMatch() {
531 if d.matchCancel != nil {543 if d.matchCancel != nil {
532 d.matchCancel()544 d.matchCancel()
533 }545 }
546+ d.matching = true
547+ d.matchGeneration++
534 query := d.query548 query := d.query
535 segments := d.searchSegments549 segments := d.searchSegments
536 generation := d.searchGeneration550 generation := d.searchGeneration
551+ matchGeneration := d.matchGeneration
537 events := d.searchEvents552 events := d.searchEvents
538 ctx, cancel := context.WithCancel(d.searchContext)553 ctx, cancel := context.WithCancel(d.searchContext)
539 d.matchCancel = cancel554 d.matchCancel = cancel
@@ -543,7 +558,7 @@ func (d *sessionDialog) scheduleSearchMatch() {
543 return558 return
544 }559 }
545 select {560 select {
546- case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:561+ case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, matchGeneration: matchGeneration, query: query, matches: matches}:
547 case <-ctx.Done():562 case <-ctx.Done():
548 }563 }
549 }()564 }()
@@ -628,6 +643,11 @@ func (d *sessionDialog) Active() bool { return d != nil && d.active }
628 // Loading reports whether the dialog still has session entries in flight.643 // Loading reports whether the dialog still has session entries in flight.
629 func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }644 func (d *sessionDialog) Loading() bool { return d != nil && d.active && d.loading }
630 645
646+// SearchLoading reports whether the dialog is reading or matching session text.
647+func (d *sessionDialog) SearchLoading() bool {
648+ return d != nil && d.active && (d.searching || d.matching)
649+}
650+
631 // Render returns the dialog lines.651 // Render returns the dialog lines.
632 func (d *sessionDialog) Render(th tui.Theme, width int) []string {652 func (d *sessionDialog) Render(th tui.Theme, width int) []string {
633 if !d.Active() {653 if !d.Active() {
@@ -639,6 +659,9 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
639 title = "sessions · all"659 title = "sessions · all"
640 }660 }
641 lines = append(lines, frameHeader(th, title, width))661 lines = append(lines, frameHeader(th, title, width))
662+ if d.SearchLoading() {
663+ lines = append(lines, d.searchLoadingMessage(th))
664+ }
642 if d.loading {665 if d.loading {
643 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))666 lines = append(lines, th.FGColor(th.Muted, d.loadingMessage(th)))
644 if len(d.sessions) == 0 {667 if len(d.sessions) == 0 {
@@ -676,8 +699,6 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
676 hint = "search: " + d.query + " (esc clear · tab scope)"699 hint = "search: " + d.query + " (esc clear · tab scope)"
677 if !sessionSearchQuery(d.query) {700 if !sessionSearchQuery(d.query) {
678 hint += " · type 2+ characters"701 hint += " · type 2+ characters"
679- } else if d.searching {
680- hint += " · indexing"
681 }702 }
682 }703 }
683 if d.loading {704 if d.loading {
@@ -901,6 +922,18 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string {
901 return row922 return row
902 }923 }
903 924
925+func (d *sessionDialog) searchLoadingMessage(th tui.Theme) string {
926+ if d.searchSpinner == nil {
927+ d.searchSpinner = &spinner{startedAt: time.Now()}
928+ }
929+ d.searchSpinner.Configure(th)
930+ message := "Searching session text"
931+ if d.searching {
932+ message = "Indexing session text"
933+ }
934+ return th.FGColor(th.Spinner, d.searchSpinner.Frame()) + " " + th.FGColor(th.Muted, message+"…")
935+}
936+
904 func (d *sessionDialog) loadingMessage(th tui.Theme) string {937 func (d *sessionDialog) loadingMessage(th tui.Theme) string {
905 frames := th.SpinnerFrames938 frames := th.SpinnerFrames
906 if len(frames) == 0 {939 if len(frames) == 0 {
@@ -1009,6 +1042,11 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1009 return sessionDialogAction{Close: true}1042 return sessionDialogAction{Close: true}
1010 }1043 }
1011 d.query = ""1044 d.query = ""
1045+ if d.matchCancel != nil {
1046+ d.matchCancel()
1047+ d.matchCancel = nil
1048+ }
1049+ d.matching = false
1012 d.searchMatches = nil1050 d.searchMatches = nil
1013 d.applySearchFilter()1051 d.applySearchFilter()
1014 return sessionDialogAction{}1052 return sessionDialogAction{}
@@ -1022,6 +1060,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1022 d.matchCancel()1060 d.matchCancel()
1023 d.matchCancel = nil1061 d.matchCancel = nil
1024 }1062 }
1063+ d.matching = false
1025 d.searchMatches = nil1064 d.searchMatches = nil
1026 }1065 }
1027 d.applySearchFilter()1066 d.applySearchFilter()
M packages/agent/modes/session_dialog_test.go +71 -0
@@ -419,6 +419,77 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
419419 }
420420 }
421421
422+func 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+
456+func 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+
422493 func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
423494 segments := []core.SessionSearchSegment{
424495 {Path: "scattered", Text: "sizzle then harp, again remote"},
@@ -419,6 +419,77 @@ func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
419 }419 }
420 }420 }
421 421
422+func 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+
456+func 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+
422 func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {493 func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
423 segments := []core.SessionSearchSegment{494 segments := []core.SessionSearchSegment{
424 {Path: "scattered", Text: "sizzle then harp, again remote"},495 {Path: "scattered", Text: "sizzle then harp, again remote"},

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