v0.1.0 07cdc18

manager.go RawHistoryFolder

1package visualproc
2
3import (
4	"context"
5	"errors"
6	"sync/atomic"
7
8	"github.com/bnema/neferafk/internal/ports"
9	"github.com/bnema/zerowrap"
10)
11
12type live struct {
13	session ports.VisualSession
14	exited  atomic.Bool
15	lockGen atomic.Uint64 // generation the visual was last asked to lock; cleared on release
16}
17
18// Manager implements ports.Visual over a VisualLauncher. Fade/Lock/Close/Detach
19// are called from the policy owner goroutine only; forwarding goroutines touch
20// just the atomic exited flag and the event channel. The visual is spawned
21// lazily and never asked to unlock: there is no such command.
22type Manager struct {
23	ctx      context.Context
24	cancel   context.CancelFunc
25	launcher ports.VisualLauncher
26	out      chan<- ports.VisualEvent
27	log      zerowrap.Logger
28	cur      *live
29}
30
31func NewManager(ctx context.Context, launcher ports.VisualLauncher, out chan<- ports.VisualEvent, log zerowrap.Logger) *Manager {
32	ctx, cancel := context.WithCancel(ctx)
33	return &Manager{ctx: ctx, cancel: cancel, launcher: launcher, out: out, log: log.WithField("component", "visual")}
34}
35
36// current returns the running session or launches one when spawn is true.
37func (m *Manager) current(ctx context.Context, spawn bool, sp ports.VisualSpawn) (*live, error) {
38	if m.cur != nil && m.cur.exited.Load() {
39		if err := m.cur.session.Close(); err != nil {
40			m.log.Warn().Err(err).Msg("closing exited visual session")
41		}
42		m.cur = nil
43	}
44	if m.cur != nil || !spawn {
45		return m.cur, nil
46	}
47	s, err := m.launcher.Launch(ctx, sp)
48	if err != nil {
49		return nil, err
50	}
51	l := &live{session: s}
52	m.cur = l
53	go m.forward(l)
54	m.log.Info().Msg("visual process spawned")
55	return l, nil
56}
57
58// forward relays child events. The exited flag is set before the terminal
59// event is delivered, so a handler reacting to VisualExited already observes it.
60func (m *Manager) forward(l *live) {
61	for ev := range l.session.Events() {
62		switch v := ev.(type) {
63		case ports.VisualExited:
64			l.exited.Store(true)
65		case ports.LockReleased:
66			l.lockGen.CompareAndSwap(uint64(v.Generation), 0)
67		}
68		select {
69		case m.out <- ev:
70		case <-m.ctx.Done():
71			return
72		}
73	}
74	l.exited.Store(true)
75}
76
77// send retries once on a fresh process when the current one died between the
78// liveness check and the write.
79func (m *Manager) send(ctx context.Context, spawn bool, sp ports.VisualSpawn, cmd ports.VisualCommand, mark func(*live)) error {
80	for attempt := 0; attempt < 2; attempt++ {
81		l, err := m.current(ctx, spawn, sp)
82		if err != nil || l == nil {
83			return err
84		}
85		if err = l.session.Send(cmd); err == nil {
86			if mark != nil {
87				mark(l)
88			}
89			return nil
90		}
91		l.exited.Store(true)
92	}
93	return errors.New("visual unreachable")
94}
95
96func (m *Manager) Fade(ctx context.Context, req ports.VisualFade) error {
97	// Reveal never spawns a process.
98	return m.send(ctx, req.Black, req.Spawn, req, nil)
99}
100
101func (m *Manager) Lock(ctx context.Context, req ports.VisualLock) error {
102	defer clear(req.Auth.EnvPIN)
103	if m.cur != nil && !m.cur.exited.Load() && m.cur.lockGen.Load() == uint64(req.Generation) {
104		return nil
105	}
106	return m.send(ctx, true, req.Spawn, req, func(l *live) { l.lockGen.Store(uint64(req.Generation)) })
107}
108
109func (m *Manager) Close() error {
110	defer m.cancel()
111	if m.cur == nil {
112		return nil
113	}
114	err := m.cur.session.Close()
115	m.cur = nil
116	return err
117}
118
119func (m *Manager) Detach() error {
120	defer m.cancel()
121	if m.cur == nil {
122		return nil
123	}
124	err := m.cur.session.Detach()
125	m.cur = nil
126	return err
127}
128
129var _ ports.Visual = (*Manager)(nil)

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