main ece7682

terminal.go RawHistoryFolder

1// Package term adapts vev's ports to terminal I/O implementations.
2package term
3
4import (
5	"fmt"
6	"io"
7	"os"
8	"os/signal"
9	"sync"
10	"syscall"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14	"github.com/bnema/vev/pkg/rawterm"
15)
16
17// Compile-time check that Terminal satisfies ports.Terminal.
18var _ ports.Terminal = (*Terminal)(nil)
19
20// bufSize is the batched writer's buffer capacity.
21const bufSize = 64 * 1024
22
23// Terminal implements ports.Terminal for the client-side controlling
24// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
25// batched stdout writer.
26//
27// Zero value is not usable; use New or NewWithFiles.
28type Terminal struct {
29	in          *os.File
30	out         *os.File
31	fd          int
32	bw          *batchWriter
33	observation ports.UIObservationSink
34
35	mu         sync.Mutex
36	geometryMu sync.Mutex     // serializes winsize reads with observation publication
37	orig       *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
38	entered    bool           // true between a successful EnterRaw and its restore
39	rawSkipped bool           // true if fd wasn't a tty, so MakeRaw/Restore were skipped
40	restoreFn  func() error   // the single idempotent restore closure for the current session
41	kittyKeys  bool           // kitty keyboard flags were pushed and must be popped on restore
42
43	// Resize watcher state, also guarded by mu so watcher start
44	// (ResizeEvents) and watcher stop (restore) are strictly ordered:
45	// a restore that runs before the first ResizeEvents call must
46	// prevent any watcher (and its signal.Notify) from ever starting.
47	resizeCh   chan domain.Geometry
48	resizeQuit chan struct{}
49	sigCh      chan os.Signal
50	resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
51	resizeWG   sync.WaitGroup
52}
53
54// New creates a Terminal backed by os.Stdin and os.Stdout.
55func New() *Terminal {
56	return NewWithFiles(os.Stdin, os.Stdout)
57}
58
59// NewWithFiles creates a Terminal backed by the given tty files. It
60// exists so tests can inject a PTY pair (or a plain pipe, for the
61// non-tty paths) in place of the process's real stdin/stdout.
62func NewWithFiles(in, out *os.File) *Terminal {
63	return NewWithFilesAndObservation(in, out, nil)
64}
65
66// NewWithFilesAndObservation creates a terminal that mirrors successfully
67// emitted byte prefixes and flush boundaries to observation.
68func NewWithFilesAndObservation(in, out *os.File, observation ports.UIObservationSink) *Terminal {
69	return &Terminal{
70		in:          in,
71		out:         out,
72		fd:          int(in.Fd()),
73		bw:          newBatchWriterWithObservation(out, bufSize, observation),
74		observation: observation,
75	}
76}
77
78// EnterRaw puts the terminal into raw mode (when the input fd is
79// actually a tty; otherwise the raw-mode syscalls are skipped and
80// recorded, not treated as failure), enters the alt screen, and hides
81// the cursor. The returned restore func idempotently reverses all of
82// this; it is safe to call multiple times.
83func (t *Terminal) EnterRaw() (func() error, error) {
84	t.mu.Lock()
85	defer t.mu.Unlock()
86
87	if t.entered {
88		return t.restoreFn, nil
89	}
90
91	var old *rawterm.State
92	if rawterm.IsTerminal(t.fd) {
93		s, err := rawterm.MakeRaw(t.fd)
94		if err != nil {
95			return nil, fmt.Errorf("term: make raw: %w", err)
96		}
97		old = s
98		t.rawSkipped = false
99	} else {
100		t.rawSkipped = true
101	}
102	t.orig = old
103
104	if _, err := t.bw.Write(VisualEnterSequence()); err != nil {
105		t.resetVisualModesDirect()
106		_ = t.restoreRawLocked()
107		return nil, fmt.Errorf("term: enter alt screen: %w", err)
108	}
109	if err := t.bw.Flush(); err != nil {
110		t.resetVisualModesDirect()
111		_ = t.restoreRawLocked()
112		return nil, fmt.Errorf("term: enter alt screen: %w", err)
113	}
114
115	t.entered = true
116	t.restoreFn = t.makeRestoreLocked()
117	return t.restoreFn, nil
118}
119
120// EnableKittyKeyboard pushes kitty keyboard protocol flags on the alternate
121// screen entered by EnterRaw. The session's restore pops them.
122func (t *Terminal) EnableKittyKeyboard(flags int) error {
123	t.mu.Lock()
124	defer t.mu.Unlock()
125	if !t.entered || t.kittyKeys {
126		return nil
127	}
128	// Mark first: if any prefix reached the terminal, restore must pop.
129	t.kittyKeys = true
130	if _, err := t.bw.Write(KittyKeyboardPushSequence(flags)); err != nil {
131		return fmt.Errorf("term: enable kitty keyboard: %w", err)
132	}
133	if err := t.bw.Flush(); err != nil {
134		return fmt.Errorf("term: enable kitty keyboard: %w", err)
135	}
136	return nil
137}
138
139// restoreSequenceLocked returns the visual cleanup for the current session.
140// Must be called with t.mu held.
141func (t *Terminal) restoreSequenceLocked() []byte {
142	if t.kittyKeys {
143		return VisualRestoreSequenceWithKittyKeyboard()
144	}
145	return VisualRestoreSequence()
146}
147
148// resetVisualModesDirect writes visualRestore straight to the descriptor
149// instead of through the batched writer. It is the fallback for a writer that
150// has already failed: any prefix of the enter sequence may have reached the
151// terminal, and a hidden cursor, live mouse reporting or the alt screen would
152// outlive the process. Replaying the resets when nothing was emitted is
153// harmless. Best effort by construction — the descriptor is usually broken
154// too. Must be called with t.mu held.
155func (t *Terminal) resetVisualModesDirect() {
156	if t.observation != nil {
157		t.observation.InvalidateTerminalObservation()
158	}
159	_, _ = t.out.Write(t.restoreSequenceLocked())
160}
161
162// makeRestoreLocked returns an idempotent restore closure for the
163// current EnterRaw "session". Must be called with t.mu held.
164func (t *Terminal) makeRestoreLocked() func() error {
165	var once sync.Once
166	var err error
167	return func() error {
168		once.Do(func() {
169			err = t.restore()
170		})
171		return err
172	}
173}
174
175// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
176// restores the original terminal mode (if raw mode was actually
177// entered), and stops the resize watcher, closing its channel.
178func (t *Terminal) restore() error {
179	t.mu.Lock()
180	defer t.mu.Unlock()
181
182	_, werr := t.bw.Write(t.restoreSequenceLocked())
183	ferr := t.bw.Flush()
184	if werr != nil || ferr != nil {
185		t.resetVisualModesDirect()
186	}
187
188	rerr := t.restoreRawLocked()
189
190	t.entered = false
191	t.kittyKeys = false
192	t.restoreFn = nil
193
194	t.stopResizeLocked()
195
196	switch {
197	case werr != nil:
198		return fmt.Errorf("term: restore: write: %w", werr)
199	case ferr != nil:
200		return fmt.Errorf("term: restore: flush: %w", ferr)
201	case rerr != nil:
202		return fmt.Errorf("term: restore: %w", rerr)
203	default:
204		return nil
205	}
206}
207
208// restoreRawLocked restores the original terminal mode if it was saved.
209// Must be called with t.mu held.
210func (t *Terminal) restoreRawLocked() error {
211	if t.orig == nil {
212		return nil
213	}
214	err := rawterm.Restore(t.fd, t.orig)
215	t.orig = nil
216	return err
217}
218
219// Geometry returns the current terminal cell dimensions and optional pixel
220// dimensions reported by the controlling terminal.
221func (t *Terminal) Geometry() (domain.Geometry, error) {
222	t.geometryMu.Lock()
223	defer t.geometryMu.Unlock()
224	ws, err := rawterm.GetWinsize(t.fd)
225	if err != nil {
226		return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
227	}
228	geometry := (domain.Geometry{
229		Size:        domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
230		PixelWidth:  int(ws.Xpixel),
231		PixelHeight: int(ws.Ypixel),
232	}).NormalizePixels()
233	if t.observation != nil {
234		t.observation.ObserveTerminalResize(geometry)
235	}
236	return geometry, nil
237}
238
239// ResizeEvents returns a channel of coalesced terminal sizes, one per
240// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
241// subsequent calls return the same channel. The channel is closed once
242// restore (returned by EnterRaw) runs. If restore has already run when
243// ResizeEvents is first called, no watcher is started and the returned
244// channel is already closed.
245func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
246	t.mu.Lock()
247	defer t.mu.Unlock()
248
249	if t.resizeCh != nil {
250		return t.resizeCh
251	}
252	t.resizeCh = make(chan domain.Geometry)
253
254	if t.resizeDone {
255		// restore ran before the first ResizeEvents call: never start a
256		// watcher (or a signal handler) after stop; hand back a closed
257		// channel so a consumer selecting on it doesn't hang.
258		close(t.resizeCh)
259		return t.resizeCh
260	}
261
262	t.resizeQuit = make(chan struct{})
263	t.sigCh = make(chan os.Signal, 1)
264	signal.Notify(t.sigCh, syscall.SIGWINCH)
265
266	t.resizeWG.Go(func() {
267		defer signal.Stop(t.sigCh)
268		resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Geometry)
269	})
270
271	return t.resizeCh
272}
273
274// stopResizeLocked stops the SIGWINCH watcher goroutine (if one was
275// started) and waits for it to exit, so its channel is guaranteed
276// closed before stopResizeLocked returns. It marks the watcher as done
277// so no watcher can start afterwards. Must be called with t.mu held;
278// the watcher goroutine never takes t.mu, so waiting under the lock
279// cannot deadlock.
280func (t *Terminal) stopResizeLocked() {
281	if !t.resizeDone {
282		t.resizeDone = true
283		if t.resizeQuit != nil {
284			close(t.resizeQuit)
285		}
286	}
287	t.resizeWG.Wait()
288}
289
290// In returns the reader for the controlling terminal's input.
291func (t *Terminal) In() io.Reader {
292	return t.in
293}
294
295// Out returns the batched writer for the controlling terminal's output.
296// Call Flush to push buffered bytes to the underlying tty.
297func (t *Terminal) Out() io.Writer {
298	return t.bw
299}
300
301// Flush writes any buffered output to the terminal device.
302func (t *Terminal) Flush() error {
303	err := t.bw.Flush()
304	if err != nil && t.observation != nil {
305		t.observation.InvalidateTerminalObservation()
306	}
307	return err
308}

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 12

Tags 11

↑↓ moveenter openesc close