v0.3.0 ea9abc7

session.go RawHistoryFolder

1package selection
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sync"
8	"sync/atomic"
9
10	"github.com/bnema/nefercap/internal/adapters/uierrors"
11	"github.com/bnema/nefercap/internal/core"
12	"github.com/bnema/nefercap/internal/ports"
13)
14
15var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
16
17// session is the only state the per-output overlays share. Each overlay owns
18// its model and window on its own goroutine; they talk to each other only
19// through this first-decision-wins record and the shared cancel, never through
20// window or picker state.
21type session struct {
22	cancel context.CancelFunc
23
24	// toggles counts Tab presses on any overlay; wakes redraw every overlay
25	// after one. wakes is set before the overlays start and never changes.
26	toggles atomic.Uint32
27	wakes   []chan struct{}
28
29	mu       sync.Mutex
30	decided  bool
31	accepted bool
32	result   core.PickResult
33	mode     ports.Mode // the mode the confirming overlay showed
34	errs     []error
35}
36
37func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
38
39// toggle flips the capture mode for every overlay and asks each to redraw.
40// After the first decision it does nothing, so a late Tab on another overlay
41// cannot change what was confirmed.
42func (s *session) toggle() {
43	s.mu.Lock()
44	decided := s.decided
45	if !decided {
46		s.toggles.Add(1)
47	}
48	s.mu.Unlock()
49	if decided {
50		return
51	}
52	for _, w := range s.wakes {
53		select {
54		case w <- struct{}{}:
55		default:
56		}
57	}
58}
59
60// accept records the first decision, with the mode its overlay showed, and
61// ends every overlay.
62func (s *session) accept(r core.PickResult, mode ports.Mode) {
63	s.mu.Lock()
64	if !s.decided {
65		s.decided, s.accepted, s.result, s.mode = true, true, r, mode
66	}
67	s.mu.Unlock()
68	s.cancel()
69}
70
71// abort records the first decision as "no selection" and ends every overlay.
72func (s *session) abort() {
73	s.mu.Lock()
74	s.decided = true
75	s.mu.Unlock()
76	s.cancel()
77}
78
79// fail records a real error, which rejects the selection, and ends every overlay.
80func (s *session) fail(err error) {
81	s.mu.Lock()
82	s.decided = true
83	s.errs = append(s.errs, err)
84	s.mu.Unlock()
85	s.cancel()
86}
87
88func (s *session) isDecided() bool {
89	s.mu.Lock()
90	defer s.mu.Unlock()
91	return s.decided
92}
93
94// runEnded classifies one overlay's Run result with uierrors.Ended. The state
95// it depends on (decided, parent error) is read once, so a context that ends
96// meanwhile cannot flip the answer. An overlay that ended with no decision and
97// a live parent was removed by the compositor, which is a failure, and any real
98// error reported with it is kept. Failures name the output.
99func (s *session) runEnded(parent context.Context, output string, runErr error) {
100	perr := parent.Err()
101	explained := perr != nil || s.isDecided()
102	if err := uierrors.Ended(runErr, explained, perr, errOverlayClosed); err != nil {
103		s.fail(fmt.Errorf("selection: output %s: %w", output, err))
104	}
105}
106
107// outcome is read after every overlay has returned. The caller's context wins;
108// then real errors; then the decision.
109func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) {
110	s.mu.Lock()
111	defer s.mu.Unlock()
112	if perr := parent.Err(); perr != nil {
113		return core.PickResult{}, false, errors.Join(append([]error{perr}, s.errs...)...)
114	}
115	if len(s.errs) > 0 {
116		return core.PickResult{}, false, errors.Join(s.errs...)
117	}
118	return s.result, s.accepted, nil
119}
120
121// acceptedMode is the mode recorded by accept.
122func (s *session) acceptedMode() ports.Mode {
123	s.mu.Lock()
124	defer s.mu.Unlock()
125	return s.mode
126}

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 2

Tags 9

↑↓ moveenter openesc close