v0.6.0 7d47882

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	// freezes counts F presses on any overlay: each flips the frozen screen
29	// on or off. canFreeze is set before the overlays start.
30	freezes   atomic.Uint32
31	canFreeze bool
32
33	mu       sync.Mutex
34	decided  bool
35	accepted bool
36	result   core.PickResult
37	mode     ports.Mode // the mode the confirming overlay showed
38	frozen   bool       // whether the confirming overlay showed the frozen screen
39	errs     []error
40}
41
42func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
43
44// toggle flips the capture mode for every overlay and asks each to redraw.
45// After the first decision it does nothing, so a late Tab on another overlay
46// cannot change what was confirmed.
47func (s *session) toggle() { s.flip(&s.toggles) }
48
49// toggleFreeze flips the frozen screen for every overlay, like toggle.
50func (s *session) toggleFreeze() {
51	if s.canFreeze {
52		s.flip(&s.freezes)
53	}
54}
55
56// flip counts one press before the first decision and wakes every overlay.
57func (s *session) flip(n *atomic.Uint32) {
58	s.mu.Lock()
59	decided := s.decided
60	if !decided {
61		n.Add(1)
62	}
63	s.mu.Unlock()
64	if decided {
65		return
66	}
67	for _, w := range s.wakes {
68		select {
69		case w <- struct{}{}:
70		default:
71		}
72	}
73}
74
75// accept records the first decision, with the mode and frozen state its
76// overlay showed, and ends every overlay.
77func (s *session) accept(r core.PickResult, mode ports.Mode, frozen bool) {
78	s.mu.Lock()
79	if !s.decided {
80		s.decided, s.accepted, s.result, s.mode, s.frozen = true, true, r, mode, frozen
81	}
82	s.mu.Unlock()
83	s.cancel()
84}
85
86// abort records the first decision as "no selection" and ends every overlay.
87func (s *session) abort() {
88	s.mu.Lock()
89	s.decided = true
90	s.mu.Unlock()
91	s.cancel()
92}
93
94// fail records a real error, which rejects the selection, and ends every overlay.
95func (s *session) fail(err error) {
96	s.mu.Lock()
97	s.decided = true
98	s.errs = append(s.errs, err)
99	s.mu.Unlock()
100	s.cancel()
101}
102
103func (s *session) isDecided() bool {
104	s.mu.Lock()
105	defer s.mu.Unlock()
106	return s.decided
107}
108
109// runEnded classifies one overlay's Run result with uierrors.Ended. The state
110// it depends on (decided, parent error) is read once, so a context that ends
111// meanwhile cannot flip the answer. An overlay that ended with no decision and
112// a live parent was removed by the compositor, which is a failure, and any real
113// error reported with it is kept. Failures name the output.
114func (s *session) runEnded(parent context.Context, output string, runErr error) {
115	perr := parent.Err()
116	explained := perr != nil || s.isDecided()
117	if err := uierrors.Ended(runErr, explained, perr, errOverlayClosed); err != nil {
118		s.fail(fmt.Errorf("selection: output %s: %w", output, err))
119	}
120}
121
122// outcome is read after every overlay has returned. The caller's context wins;
123// then real errors; then the decision.
124func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) {
125	s.mu.Lock()
126	defer s.mu.Unlock()
127	if perr := parent.Err(); perr != nil {
128		return core.PickResult{}, false, errors.Join(append([]error{perr}, s.errs...)...)
129	}
130	if len(s.errs) > 0 {
131		return core.PickResult{}, false, errors.Join(s.errs...)
132	}
133	return s.result, s.accepted, nil
134}
135
136// acceptedMode is the mode recorded by accept.
137func (s *session) acceptedMode() ports.Mode {
138	s.mu.Lock()
139	defer s.mu.Unlock()
140	return s.mode
141}
142
143// acceptedFrozen is the frozen state recorded by accept.
144func (s *session) acceptedFrozen() bool {
145	s.mu.Lock()
146	defer s.mu.Unlock()
147	return s.frozen
148}

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