terminal_focus.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"sync"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/protocol"
9)
10
11// Terminal focus.
12//
13// The client turns on focus reporting (DEC mode 1004) with its other terminal
14// modes. The terminal then sends ESC [ I when its window gains focus and
15// ESC [ O when it loses it. terminalFocusScanner strips both reports from the
16// input stream and keeps the latest state, so any client feature can read
17// Focus() and the attached loop tells the daemon about each change.
18//
19// A terminal without focus reporting sends nothing: focus stays unknown and
20// every feature keeps its pre-focus behavior.
21
22var (
23	terminalFocusInReport  = []byte("\x1b[I")
24	terminalFocusOutReport = []byte("\x1b[O")
25)
26
27// TerminalFocusState is the client's one terminal focus. The terminal input
28// pump is its only writer; any client feature may read it or wait for a
29// change. It outlives attachments, so a new attachment starts from the focus
30// the terminal last reported.
31type TerminalFocusState struct {
32	mu      sync.Mutex
33	focus   domain.TerminalFocus
34	changed chan struct{}
35}
36
37func newTerminalFocusState() *TerminalFocusState {
38	return &TerminalFocusState{changed: make(chan struct{})}
39}
40
41// Focus returns the latest reported focus. A nil state is unknown.
42func (s *TerminalFocusState) Focus() domain.TerminalFocus {
43	if s == nil {
44		return domain.TerminalFocusUnknown
45	}
46	s.mu.Lock()
47	defer s.mu.Unlock()
48	return s.focus
49}
50
51// Watch returns the current focus and a channel closed on the next change.
52func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) {
53	if s == nil {
54		return domain.TerminalFocusUnknown, nil
55	}
56	s.mu.Lock()
57	defer s.mu.Unlock()
58	return s.focus, s.changed
59}
60
61func (s *TerminalFocusState) set(focus domain.TerminalFocus) {
62	s.mu.Lock()
63	defer s.mu.Unlock()
64	if focus == s.focus {
65		return
66	}
67	s.focus = focus
68	close(s.changed)
69	s.changed = make(chan struct{})
70}
71
72// attachmentFocusForeground is the optional seam through which an attached
73// loop reads the terminal focus.
74type attachmentFocusForeground interface {
75	terminalFocus() *TerminalFocusState
76}
77
78func (f *attachmentForeground) terminalFocus() *TerminalFocusState {
79	if f == nil || f.input == nil {
80		return nil
81	}
82	return f.input.focus
83}
84
85// attachmentTerminalFocus returns the focus state of fg, or nil.
86func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState {
87	if focus, ok := fg.(attachmentFocusForeground); ok {
88		return focus.terminalFocus()
89	}
90	return nil
91}
92
93// focusReporter sends the terminal focus to the daemon: the current state once
94// the attachment starts, then every change. Unknown focus is never sent.
95type focusReporter struct {
96	state   *TerminalFocusState
97	sent    domain.TerminalFocus
98	changed <-chan struct{}
99}
100
101// next returns the message to send now, if any, and re-arms the change wake.
102func (r *focusReporter) next() (protocol.TerminalFocus, bool) {
103	focus, changed := r.state.Watch()
104	r.changed = changed
105	if focus == domain.TerminalFocusUnknown || focus == r.sent {
106		return protocol.TerminalFocus{}, false
107	}
108	r.sent = focus
109	return protocol.TerminalFocus{Focus: focus}, true
110}
111
112// stripTerminalFocusReports removes complete focus reports from data and
113// records the last one in state. It never withholds a prefix: a terminal
114// writes each report whole, while a lone Escape or Alt+[ typed by the user
115// must reach the session without delay. Other bytes keep their order. A
116// pasted literal report, or one split across reads, is accepted as rare.
117func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte {
118	at, focus := nextTerminalFocusReport(data)
119	if at < 0 {
120		return data
121	}
122	out := make([]byte, 0, len(data))
123	for at >= 0 {
124		out = append(out, data[:at]...)
125		state.set(focus)
126		data = data[at+len(terminalFocusInReport):]
127		at, focus = nextTerminalFocusReport(data)
128	}
129	return append(out, data...)
130}
131
132// nextTerminalFocusReport returns the offset of the first complete report in
133// data, or -1.
134func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) {
135	in := bytes.Index(data, terminalFocusInReport)
136	out := bytes.Index(data, terminalFocusOutReport)
137	switch {
138	case in < 0 && out < 0:
139		return -1, domain.TerminalFocusUnknown
140	case out < 0 || (in >= 0 && in < out):
141		return in, domain.TerminalFocusFocused
142	default:
143		return out, domain.TerminalFocusUnfocused
144	}
145}

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