v0.2.0 80c61e3

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// Escape sequences for alt-screen and cursor visibility control. All
21// emissions go through the batched writer and are explicitly flushed.
22const (
23	altScreenEnter        = "\x1b[?1049h"
24	altScreenExit         = "\x1b[?1049l"
25	cursorHide            = "\x1b[?25l"
26	cursorShow            = "\x1b[?25h"
27	cursorStyleDefault    = "\x1b[0 q"
28	mouseEnable           = "\x1b[?1002h\x1b[?1006h"
29	mouseDisable          = "\x1b[?1002l\x1b[?1006l"
30	bracketedPasteEnable  = "\x1b[?2004h"
31	bracketedPasteDisable = "\x1b[?2004l"
32	colorSchemeEnable     = "\x1b[?2031h"
33	colorSchemeDisable    = "\x1b[?2031l"
34	// DECAWM. vev addresses every row explicitly and never relies on the
35	// terminal wrapping its output, so autowrap stays off while it owns the
36	// alt screen: a frame the daemon composed for a wider terminal is then
37	// clipped at the right margin instead of bleeding a styled run onto the
38	// next row. Restored alongside the other modes, since DECAWM is global
39	// and is not saved by the alt-screen switch.
40	autowrapDisable = "\x1b[?7l"
41	autowrapEnable  = "\x1b[?7h"
42)
43
44// visualRestore turns off every mode EnterRaw turns on, in reverse order, and
45// hands the normal screen back. Shared by the ordinary restore and the direct
46// fallback so the two can never drift: a partial emission may have left any
47// prefix of the enter sequence in effect, so the reset has to cover all of it.
48// Every mode reset here is idempotent.
49const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
50	bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
51
52// bufSize is the batched writer's buffer capacity.
53const bufSize = 64 * 1024
54
55// Terminal implements ports.Terminal for the client-side controlling
56// terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a
57// batched stdout writer.
58//
59// Zero value is not usable; use New or NewWithFiles.
60type Terminal struct {
61	in  *os.File
62	out *os.File
63	fd  int
64	bw  *batchWriter
65
66	mu         sync.Mutex
67	orig       *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
68	entered    bool           // true between a successful EnterRaw and its restore
69	rawSkipped bool           // true if fd wasn't a tty, so MakeRaw/Restore were skipped
70	restoreFn  func() error   // the single idempotent restore closure for the current session
71
72	// Resize watcher state, also guarded by mu so watcher start
73	// (ResizeEvents) and watcher stop (restore) are strictly ordered:
74	// a restore that runs before the first ResizeEvents call must
75	// prevent any watcher (and its signal.Notify) from ever starting.
76	resizeCh   chan domain.Size
77	resizeQuit chan struct{}
78	sigCh      chan os.Signal
79	resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
80	resizeWG   sync.WaitGroup
81}
82
83// New creates a Terminal backed by os.Stdin and os.Stdout.
84func New() *Terminal {
85	return NewWithFiles(os.Stdin, os.Stdout)
86}
87
88// NewWithFiles creates a Terminal backed by the given tty files. It
89// exists so tests can inject a PTY pair (or a plain pipe, for the
90// non-tty paths) in place of the process's real stdin/stdout.
91func NewWithFiles(in, out *os.File) *Terminal {
92	return &Terminal{
93		in:  in,
94		out: out,
95		fd:  int(in.Fd()),
96		bw:  newBatchWriter(out, bufSize),
97	}
98}
99
100// EnterRaw puts the terminal into raw mode (when the input fd is
101// actually a tty; otherwise the raw-mode syscalls are skipped and
102// recorded, not treated as failure), enters the alt screen, and hides
103// the cursor. The returned restore func idempotently reverses all of
104// this; it is safe to call multiple times.
105func (t *Terminal) EnterRaw() (func() error, error) {
106	t.mu.Lock()
107	defer t.mu.Unlock()
108
109	if t.entered {
110		return t.restoreFn, nil
111	}
112
113	var old *rawterm.State
114	if rawterm.IsTerminal(t.fd) {
115		s, err := rawterm.MakeRaw(t.fd)
116		if err != nil {
117			return nil, fmt.Errorf("term: make raw: %w", err)
118		}
119		old = s
120		t.rawSkipped = false
121	} else {
122		t.rawSkipped = true
123	}
124	t.orig = old
125
126	if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
127		t.resetVisualModesDirect()
128		_ = t.restoreRawLocked()
129		return nil, fmt.Errorf("term: enter alt screen: %w", err)
130	}
131	if err := t.bw.Flush(); err != nil {
132		t.resetVisualModesDirect()
133		_ = t.restoreRawLocked()
134		return nil, fmt.Errorf("term: enter alt screen: %w", err)
135	}
136
137	t.entered = true
138	t.restoreFn = t.makeRestoreLocked()
139	return t.restoreFn, nil
140}
141
142// resetVisualModesDirect writes visualRestore straight to the descriptor
143// instead of through the batched writer. It is the fallback for a writer that
144// has already failed: any prefix of the enter sequence may have reached the
145// terminal, and a hidden cursor, live mouse reporting or the alt screen would
146// outlive the process. Replaying the resets when nothing was emitted is
147// harmless. Best effort by construction — the descriptor is usually broken
148// too. Must be called with t.mu held.
149func (t *Terminal) resetVisualModesDirect() {
150	_, _ = t.out.WriteString(visualRestore)
151}
152
153// makeRestoreLocked returns an idempotent restore closure for the
154// current EnterRaw "session". Must be called with t.mu held.
155func (t *Terminal) makeRestoreLocked() func() error {
156	var once sync.Once
157	var err error
158	return func() error {
159		once.Do(func() {
160			err = t.restore()
161		})
162		return err
163	}
164}
165
166// restore reverses EnterRaw: it shows the cursor, exits the alt screen,
167// restores the original terminal mode (if raw mode was actually
168// entered), and stops the resize watcher, closing its channel.
169func (t *Terminal) restore() error {
170	t.mu.Lock()
171	defer t.mu.Unlock()
172
173	_, werr := t.bw.WriteString(visualRestore)
174	ferr := t.bw.Flush()
175	if werr != nil || ferr != nil {
176		t.resetVisualModesDirect()
177	}
178
179	rerr := t.restoreRawLocked()
180
181	t.entered = false
182	t.restoreFn = nil
183
184	t.stopResizeLocked()
185
186	switch {
187	case werr != nil:
188		return fmt.Errorf("term: restore: write: %w", werr)
189	case ferr != nil:
190		return fmt.Errorf("term: restore: flush: %w", ferr)
191	case rerr != nil:
192		return fmt.Errorf("term: restore: %w", rerr)
193	default:
194		return nil
195	}
196}
197
198// restoreRawLocked restores the original terminal mode if it was saved.
199// Must be called with t.mu held.
200func (t *Terminal) restoreRawLocked() error {
201	if t.orig == nil {
202		return nil
203	}
204	err := rawterm.Restore(t.fd, t.orig)
205	t.orig = nil
206	return err
207}
208
209// Size returns the current terminal dimensions.
210func (t *Terminal) Size() (domain.Size, error) {
211	cols, rows, err := rawterm.GetSize(t.fd)
212	if err != nil {
213		return domain.Size{}, fmt.Errorf("term: get size: %w", err)
214	}
215	return domain.Size{Cols: cols, Rows: rows}, nil
216}
217
218// ResizeEvents returns a channel of coalesced terminal sizes, one per
219// SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call;
220// subsequent calls return the same channel. The channel is closed once
221// restore (returned by EnterRaw) runs. If restore has already run when
222// ResizeEvents is first called, no watcher is started and the returned
223// channel is already closed.
224func (t *Terminal) ResizeEvents() <-chan domain.Size {
225	t.mu.Lock()
226	defer t.mu.Unlock()
227
228	if t.resizeCh != nil {
229		return t.resizeCh
230	}
231	t.resizeCh = make(chan domain.Size)
232
233	if t.resizeDone {
234		// restore ran before the first ResizeEvents call: never start a
235		// watcher (or a signal handler) after stop; hand back a closed
236		// channel so a consumer selecting on it doesn't hang.
237		close(t.resizeCh)
238		return t.resizeCh
239	}
240
241	t.resizeQuit = make(chan struct{})
242	t.sigCh = make(chan os.Signal, 1)
243	signal.Notify(t.sigCh, syscall.SIGWINCH)
244
245	t.resizeWG.Go(func() {
246		defer signal.Stop(t.sigCh)
247		resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)
248	})
249
250	return t.resizeCh
251}
252
253// stopResizeLocked stops the SIGWINCH watcher goroutine (if one was
254// started) and waits for it to exit, so its channel is guaranteed
255// closed before stopResizeLocked returns. It marks the watcher as done
256// so no watcher can start afterwards. Must be called with t.mu held;
257// the watcher goroutine never takes t.mu, so waiting under the lock
258// cannot deadlock.
259func (t *Terminal) stopResizeLocked() {
260	if !t.resizeDone {
261		t.resizeDone = true
262		if t.resizeQuit != nil {
263			close(t.resizeQuit)
264		}
265	}
266	t.resizeWG.Wait()
267}
268
269// In returns the reader for the controlling terminal's input.
270func (t *Terminal) In() io.Reader {
271	return t.in
272}
273
274// Out returns the batched writer for the controlling terminal's output.
275// Call Flush to push buffered bytes to the underlying tty.
276func (t *Terminal) Out() io.Writer {
277	return t.bw
278}
279
280// Flush writes any buffered output to the terminal device.
281func (t *Terminal) Flush() error {
282	return t.bw.Flush()
283}

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