ui.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync"
9	"time"
10	"unicode/utf8"
11
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14)
15
16const uiActionHistory = 64
17
18// UI owns attachment-local action and waiter bookkeeping. It never holds its
19// mutex across terminal access, transport I/O, or predicate evaluation.
20
21type UI struct {
22	state           ports.UIState
23	clock           ports.Clock
24	handle          string
25	mu              sync.Mutex
26	changed         chan struct{}
27	generation      uint64
28	input           *terminalInputPump
29	consumer        uint64
30	foreground      context.Context
31	records         map[uint64]ports.UIActionResult
32	order           []uint64
33	nextAction      uint64
34	pending         uint64
35	reservedContext ports.UIContext
36	waits           int
37	boundary        ports.UIActionResult
38	dispatched      map[uint64]bool
39	completion      map[uint64]chan struct{}
40	handoff         *uiActionHandoff
41	// suppressed is the newest attached context the foreground applied but
42	// did not write because the client picker overlay owned the terminal.
43	suppressed ports.UIContext
44	// overlayAction is the admitted action whose input the client picker
45	// overlay consumed; the supervisor settles it at its event-loop boundary.
46	overlayAction uint64
47}
48
49type uiActionHandoff struct {
50	actionID              uint64
51	sourceGeneration      uint64
52	destinationGeneration uint64
53	boundary              ports.UIActionResult
54}
55
56func NewUI(state ports.UIState, clock ports.Clock) *UI {
57	if clock == nil {
58		clock = systemClock{}
59	}
60	return &UI{state: state, clock: clock, handle: fmt.Sprintf("%x", newClientID()), changed: make(chan struct{}), records: make(map[uint64]ports.UIActionResult), dispatched: make(map[uint64]bool), completion: make(map[uint64]chan struct{})}
61}
62
63func (u *UI) Handle() string { return u.handle }
64
65// WaitForSnapshot waits on the UI owner's broadcast signal rather than
66// consuming the terminal's single coalesced state channel. The predicate is
67// evaluated against the latest owned snapshot and never under the UI mutex.
68func (u *UI) WaitForSnapshot(ctx context.Context, match func(ports.UISnapshot) bool) (ports.UISnapshot, error) {
69	if match == nil {
70		return ports.UISnapshot{}, ports.ErrUIUnavailable
71	}
72	var lastSnapshotErr error
73	for {
74		if err := ctx.Err(); err != nil {
75			if lastSnapshotErr != nil {
76				return ports.UISnapshot{}, errors.Join(err, lastSnapshotErr)
77			}
78			return ports.UISnapshot{}, err
79		}
80		snapshot, err := u.state.Snapshot()
81		if err == nil {
82			lastSnapshotErr = nil
83			if match(snapshot) {
84				return snapshot, nil
85			}
86		} else {
87			lastSnapshotErr = err
88		}
89		u.mu.Lock()
90		changed := u.changed
91		u.mu.Unlock()
92		select {
93		case <-ctx.Done():
94			if lastSnapshotErr != nil {
95				return ports.UISnapshot{}, errors.Join(ctx.Err(), lastSnapshotErr)
96			}
97			return ports.UISnapshot{}, ctx.Err()
98		case <-changed:
99		}
100	}
101}
102
103// ActionComplete returns a closed channel once an accepted action reaches a
104// terminal status. It is an optional lifecycle seam for adapters enforcing
105// attachment-wide action admission after a request timeout.
106func (u *UI) ActionComplete(actionID uint64) <-chan struct{} {
107	if actionID == 0 {
108		return nil
109	}
110	u.mu.Lock()
111	defer u.mu.Unlock()
112	if done, ok := u.completion[actionID]; ok {
113		return done
114	}
115	if record, ok := u.records[actionID]; ok && record.Status != ports.UIActionPending {
116		done := make(chan struct{})
117		close(done)
118		return done
119	}
120	return nil
121}
122
123// publishPresentation publishes one unattached presentation of this run. It
124// writes a cleaned context: the run's stable handle and the presentation only,
125// with no session identity and no committed output boundary, and it never
126// changes the internal generation counter. A caller therefore can never fence an
127// action against a fabricated generation, and a capture in Picker or Connecting
128// never leaks session metadata.
129//
130// Attached is deliberately not publishable here. Only the attachment foreground
131// may publish the attached presentation, and only once its first frame was
132// written, flushed, and committed, carrying the real action generation. A closed
133// UI observation channel reports ErrUIUnavailable instead of inventing state.
134func (u *UI) publishPresentation(status ports.UIPresentationStatus) error {
135	if status != ports.UIStatusPicker && status != ports.UIStatusConnecting {
136		return &ports.UIError{Code: ports.UIErrInvalidRequest}
137	}
138	publication, ok := u.state.(ports.UIOutputTransaction)
139	if !ok {
140		return ports.ErrUIUnavailable
141	}
142	return publication.PublishContext(presentationContext(u.handle, status, ports.UIContext{}))
143}
144
145// PublishPresentation publishes one unattached presentation of this run through
146// the UI owner. It is the composition seam for the presentations the supervisor
147// does not itself drive — the initial Picker a driver publishes before any
148// broker attempt — and it keeps the private shape rule in one place. Attached is
149// refused: only the admitted attachment foreground may publish it.
150func (u *UI) PublishPresentation(status ports.UIPresentationStatus) error {
151	if u == nil {
152		return ports.ErrUIUnavailable
153	}
154	return u.publishPresentation(status)
155}
156
157// presentationContext is the single shape rule for a published UI context. Only
158// Attached may carry the session identity, the committed output boundary, and
159// the real actionable generation; Picker and Connecting carry the run's stable
160// handle and their status alone, with every session field zeroed. It is applied
161// by every publisher, so a capture can never present session metadata as an
162// attachment and an unattached publication can never fence an action against a
163// generation the attachment never committed.
164func presentationContext(handle string, status ports.UIPresentationStatus, identity ports.UIContext) ports.UIContext {
165	if status == ports.UIStatusAttached {
166		identity.AttachmentHandle = handle
167		return identity
168	}
169	return ports.UIContext{AttachmentHandle: handle, Status: status}
170}
171
172// Observe relays the sink's single coalesced signal to bounded concurrent
173// waiters. Its caller owns this worker's lifetime.
174func (u *UI) Observe(ctx context.Context) {
175	defer func() {
176		u.mu.Lock()
177		if u.pending != 0 {
178			u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
179		}
180		u.mu.Unlock()
181	}()
182	for {
183		select {
184		case <-ctx.Done():
185			return
186		case <-u.state.Changes():
187			u.mu.Lock()
188			u.signalLocked()
189			u.mu.Unlock()
190		}
191	}
192}
193
194func (u *UI) signalLocked() { close(u.changed); u.changed = make(chan struct{}) }
195
196func (u *UI) Capture(attachment string) (ports.UISnapshot, error) {
197	if attachment != u.handle {
198		return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
199	}
200	return u.state.Snapshot()
201}
202
203func uiTimeout(timeout time.Duration) (time.Duration, error) {
204	if timeout == 0 {
205		return 5 * time.Second, nil
206	}
207	if timeout < time.Millisecond || timeout > 30*time.Second {
208		return 0, &ports.UIError{Code: ports.UIErrInvalidRequest}
209	}
210	return timeout, nil
211}
212
213func validateUIExpect(expect ports.UIExpect) bool {
214	if expect.TextContains == nil && expect.Session == nil && expect.Focus == nil && expect.Status == nil {
215		return false
216	}
217	if expect.TextContains != nil && (len(*expect.TextContains) > 4096 || !utf8.ValidString(*expect.TextContains)) {
218		return false
219	}
220	if expect.Session != nil && expect.Session.Validate() != nil {
221		return false
222	}
223	if expect.Focus != nil && (domain.ValidateTabStableID(expect.Focus.TabID) != nil || domain.ValidatePaneStableID(expect.Focus.PaneID) != nil) {
224		return false
225	}
226	if expect.Status != nil && !expect.Status.Valid() {
227		return false
228	}
229	return true
230}
231
232func uiMatches(snapshot ports.UISnapshot, expect ports.UIExpect) bool {
233	if expect.Session != nil && snapshot.Context.Route.Target != *expect.Session {
234		return false
235	}
236	if expect.Focus != nil && (snapshot.Context.TabID != expect.Focus.TabID || snapshot.Context.FocusedPaneID != expect.Focus.PaneID) {
237		return false
238	}
239	if expect.Status != nil && snapshot.Context.Status != *expect.Status {
240		return false
241	}
242	if expect.TextContains != nil && !strings.Contains(uiSnapshotText(snapshot), *expect.TextContains) {
243		return false
244	}
245	return true
246}
247
248func uiSnapshotText(snapshot ports.UISnapshot) string {
249	var text strings.Builder
250	for row := 0; row < snapshot.Rows; row++ {
251		if row > 0 {
252			text.WriteByte('\n')
253		}
254		for col := 0; col < snapshot.Columns; col++ {
255			index := row*snapshot.Columns + col
256			if index >= len(snapshot.Cells) {
257				text.WriteByte(' ')
258				continue
259			}
260			cell := snapshot.Cells[index]
261			if cell.Continuation {
262				continue
263			}
264			if cell.Text == "" {
265				text.WriteByte(' ')
266			} else {
267				text.WriteString(cell.Text)
268			}
269		}
270	}
271	return text.String()
272}
273
274func (u *UI) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) {
275	timeout, err := uiTimeout(request.Timeout)
276	if err != nil || !validateUIExpect(request.Expect) {
277		return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
278	}
279	if request.Attachment != u.handle {
280		return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
281	}
282	u.mu.Lock()
283	if u.waits >= 4 {
284		u.mu.Unlock()
285		return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrBusy}
286	}
287	u.waits++
288	u.mu.Unlock()
289	defer func() { u.mu.Lock(); u.waits--; u.mu.Unlock() }()
290	timer := u.clock.NewTimer(timeout)
291	defer timer.Stop()
292	for {
293		if err := ctx.Err(); err != nil {
294			return ports.UIWaitResult{}, err
295		}
296		u.mu.Lock()
297		changed := u.changed
298		action, exists := u.records[request.AfterAction]
299		generation := u.generation
300		u.mu.Unlock()
301		if request.AfterAction != 0 {
302			if !exists {
303				return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrActionExpired, ActionID: request.AfterAction}
304			}
305			if action.Status != ports.UIActionPending && action.Status != ports.UIActionProcessed {
306				return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrorCode(action.Status), Accepted: action.Accepted, ActionID: action.ActionID}
307			}
308			if action.Status == ports.UIActionProcessed && action.Context.Generation != generation {
309				return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrOutcomeUnknown, Accepted: true, ActionID: action.ActionID}
310			}
311		}
312		snapshot, snapshotErr := u.Capture(request.Attachment)
313		eligible := request.AfterAction == 0 || action.Status == ports.UIActionProcessed && snapshot.Revision >= action.Revision && snapshot.Context.Generation == action.Context.Generation
314		if snapshotErr == nil && eligible && uiMatches(snapshot, request.Expect) {
315			if request.AfterAction != 0 {
316				u.mu.Lock()
317				current := u.records[request.AfterAction]
318				unchanged := current == action && u.generation == generation
319				u.mu.Unlock()
320				if !unchanged {
321					continue
322				}
323			}
324			return ports.UIWaitResult{ActionID: request.AfterAction, ActionStatus: action.Status, Revision: snapshot.Revision, Context: snapshot.Context}, nil
325		}
326		select {
327		case <-ctx.Done():
328			return ports.UIWaitResult{}, ctx.Err()
329		case <-timer.C():
330			return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrTimeout, Accepted: action.Accepted, ActionID: request.AfterAction}
331		case <-changed:
332		}
333	}
334}

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