ui_driver.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"path/filepath"
10	"strconv"
11	"strings"
12	"sync"
13	"time"
14
15	"github.com/bnema/vev/internal/adapters/clock"
16	"github.com/bnema/vev/internal/adapters/ipc"
17	"github.com/bnema/vev/internal/adapters/term"
18	"github.com/bnema/vev/internal/adapters/uidriver"
19	"github.com/bnema/vev/internal/adapters/uiterm"
20	"github.com/bnema/vev/internal/domain"
21	"github.com/bnema/vev/internal/logging"
22	"github.com/bnema/vev/internal/ports"
23	"github.com/bnema/vev/internal/protocol"
24	"github.com/bnema/vev/internal/usecase/client"
25)
26
27const (
28	uiDriverDefaultColumns = 80
29	uiDriverDefaultRows    = 24
30)
31
32// uiDriverOptions is the parsed `--ui-driver` invocation. It carries no launch
33// configuration, no provisioned endpoint, and no daemon ownership: the driver
34// uses the production broker of the process context and never creates,
35// reconfigures, or destroys a daemon or a session-scoped root.
36type uiDriverOptions struct {
37	socket  string
38	session string
39	cols    int
40	rows    int
41	remote  string
42	// picker starts with no initial navigation at all: the driver presents the
43	// picker and opens nothing. It is exclusive of --session and --remote.
44	picker bool
45}
46
47type interactiveUIOptions struct {
48	observe bool
49	control bool
50	socket  string
51}
52
53func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) {
54	options := interactiveUIOptions{observe: observe, control: control, socket: socket}
55	for len(args) > 0 {
56		switch args[0] {
57		case "--ui-observe":
58			options.observe = true
59			args = args[1:]
60		case "--ui-control":
61			options.control = true
62			args = args[1:]
63		case "--ui-socket":
64			if len(args) < 2 || args[1] == "" {
65				return interactiveUIOptions{}, usagef("`--ui-socket` requires a path")
66			}
67			options.socket = args[1]
68			args = args[2:]
69		default:
70			return interactiveUIOptions{}, usagef("unknown attach option %q", args[0])
71		}
72	}
73	if options.socket != "" && !options.observe && !options.control {
74		return interactiveUIOptions{}, usagef("`--ui-socket` requires `--ui-observe` or `--ui-control`")
75	}
76	if options.socket != "" && (!filepath.IsAbs(options.socket) || strings.IndexByte(options.socket, 0) >= 0) {
77		return interactiveUIOptions{}, usagef("`--ui-socket` requires an absolute path")
78	}
79	return options, nil
80}
81
82// parseUIDriverArgs parses the closed `--ui-driver` option set. `--socket` is
83// the JSONL bridge to an already running client and is exclusive of every
84// headless option; `--picker` is exclusive of `--session` and `--remote`. Any
85// other option, including the removed `--launch-config`, is an unknown option
86// and is refused before any I/O.
87func parseUIDriverArgs(args []string) (uiDriverOptions, error) {
88	options := uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}
89	var socketSet, sessionSet, colsSet, rowsSet, remoteSet, pickerSet bool
90	for len(args) > 0 {
91		name := args[0]
92		if name == "--help" || name == "-h" {
93			return uiDriverOptions{}, usagef("`--ui-driver` options: --session NAME --cols N --rows N --remote ENDPOINT --picker, or --socket PATH")
94		}
95		if !strings.HasPrefix(name, "--") {
96			return uiDriverOptions{}, usagef("`--ui-driver` does not accept positional arguments")
97		}
98		args = args[1:]
99		value := func(flag string) (string, error) {
100			if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "--") {
101				return "", usagef("`--ui-driver %s` requires a value", flag)
102			}
103			value := args[0]
104			args = args[1:]
105			return value, nil
106		}
107		switch name {
108		case "--socket":
109			if socketSet {
110				return uiDriverOptions{}, usagef("duplicate `--socket`")
111			}
112			value, err := value("--socket")
113			if err != nil {
114				return uiDriverOptions{}, err
115			}
116			if !filepath.IsAbs(value) || strings.IndexByte(value, 0) >= 0 {
117				return uiDriverOptions{}, usagef("`--socket` requires an absolute path")
118			}
119			options.socket, socketSet = value, true
120		case "--session":
121			if sessionSet {
122				return uiDriverOptions{}, usagef("duplicate `--session`")
123			}
124			value, err := value("--session")
125			if err != nil {
126				return uiDriverOptions{}, err
127			}
128			if err := domain.ValidateSessionName(value); err != nil {
129				return uiDriverOptions{}, err
130			}
131			options.session, sessionSet = value, true
132		case "--cols", "--rows":
133			value, err := value(name)
134			if err != nil {
135				return uiDriverOptions{}, err
136			}
137			number, err := strconv.Atoi(value)
138			if err != nil || number <= 0 {
139				return uiDriverOptions{}, usagef("`%s` requires a positive integer", name)
140			}
141			if name == "--cols" {
142				if colsSet {
143					return uiDriverOptions{}, usagef("duplicate `--cols`")
144				}
145				options.cols, colsSet = number, true
146			} else {
147				if rowsSet {
148					return uiDriverOptions{}, usagef("duplicate `--rows`")
149				}
150				options.rows, rowsSet = number, true
151			}
152		case "--remote":
153			if remoteSet {
154				return uiDriverOptions{}, usagef("duplicate `--remote`")
155			}
156			value, err := value("--remote")
157			if err != nil {
158				return uiDriverOptions{}, err
159			}
160			if err := domain.ValidateRemoteHostTarget(value); err != nil {
161				return uiDriverOptions{}, err
162			}
163			options.remote, remoteSet = value, true
164		case "--picker":
165			if pickerSet {
166				return uiDriverOptions{}, usagef("duplicate `--picker`")
167			}
168			options.picker, pickerSet = true, true
169		default:
170			return uiDriverOptions{}, usagef("unknown `--ui-driver` option %q", name)
171		}
172	}
173	if socketSet && (sessionSet || colsSet || rowsSet || remoteSet || pickerSet) {
174		return uiDriverOptions{}, usagef("`--socket` cannot be combined with headless options")
175	}
176	if pickerSet && (sessionSet || remoteSet) {
177		return uiDriverOptions{}, usagef("`--picker` cannot be combined with `--session` or `--remote`")
178	}
179	return options, nil
180}
181
182func runUIDriver(ctx context.Context, options uiDriverOptions) error {
183	if options.socket != "" {
184		return uidriver.Bridge(ctx, options.socket, os.Stdin, os.Stdout)
185	}
186	return runHeadlessUIDriver(ctx, options)
187}
188
189// uiDriverNavigation translates the parsed `--ui-driver` intent into the closed
190// initial-navigation union. Picker is the zero value and opens nothing. Every
191// other intent reuses the shared terminal CLI translation, so the driver cannot
192// invent a destination, an epoch, or a session identity of its own: a local
193// name creates, a bare local invocation creates ephemerally, and a remote
194// target is only ever the registration and lifecycle a broker publication
195// carries.
196func uiDriverNavigation(options uiDriverOptions, requestedAt time.Time) (client.InitialNavigation, client.InitialNavigationResolver, error) {
197	if options.picker {
198		return client.InitialNavigation{}, nil, nil
199	}
200	if options.remote != "" && options.session != "" {
201		// `--remote HOST --session NAME` is attach-or-create: an existing
202		// remote session is attached by its exact lifecycle, an absent one is
203		// created. Absence is only trusted from an observation newer than
204		// this invocation; creating on an older inventory would collide with
205		// a live remote name and be refused.
206		if err := domain.ValidateRemoteHostTarget(options.remote); err != nil {
207			return client.InitialNavigation{}, nil, err
208		}
209		if err := domain.ValidateSessionName(options.session); err != nil {
210			return client.InitialNavigation{}, nil, err
211		}
212		return client.InitialNavigation{}, remoteAttachOrCreateResolver(options.remote, options.session, requestedAt), nil
213	}
214	intent := protocol.IntentEphemeral
215	if options.session != "" {
216		intent = protocol.IntentNew
217	}
218	return terminalBrokerNavigation(intent, options.session, options.remote)
219}
220
221// runHeadlessUIDriver composes one headless UI-driver run: a virtual terminal,
222// the UI service over it, the production broker connector of the process'
223// context, and the one-shot initial navigation the CLI intent names. It never
224// provisions, reconfigures, or destroys an endpoint, and it owns no daemon.
225func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
226	clk := clock.New()
227	navigation, resolver, err := uiDriverNavigation(options, clk.Now())
228	if err != nil {
229		return err
230	}
231	log, logCloser, err := configureLogging(logging.Client, false)
232	if err != nil {
233		return err
234	}
235	defer func() { _ = logCloser.Close() }()
236	terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "")
237	if err != nil {
238		return fmt.Errorf("vev: create headless terminal: %w", err)
239	}
240	defer terminal.Close()
241	ui := client.NewUI(terminal, clk)
242	sessionEnv := terminalSessionEnvironment()
243	attachmentEnv := terminalAttachmentEnvironment()
244	attachmentEnv.Cwd = sessionEnv.Cwd
245	return runUIDriverClient(ctx, brokerClientConfig{
246		Logger:                   log,
247		Connector:                newProductionBrokerConnector(),
248		Terminal:                 terminal,
249		Clock:                    clk,
250		UI:                       ui,
251		InitialNavigation:        navigation,
252		ResolveInitialNavigation: resolver,
253		AttachmentEnvironment:    attachmentEnv,
254		SessionEnvironment:       sessionEnv,
255	}, &stdioStream{reader: os.Stdin, writer: os.Stdout})
256}
257
258// runUIDriverClient is the single JSON Lines UI-driver composition shared by the
259// production `--ui-driver` entry point and the sandbox `_broker-client
260// --harness ui-driver` harness. It launches runBrokerClient exactly once, waits
261// for the run's valid initial UI publication, serves the JSONL protocol over
262// stream, and on EOF or cancellation cancels and joins both workers.
263//
264// It builds no picker, supervisor, dialer, attachment, or navigation of its
265// own: the connector it is given is the only transport authority, so the sandbox
266// injects its own broker IPC connector with no production fallback. A failure of
267// the broker or of a destination is not fatal to the JSONL service: the run
268// stays in the picker with a bounded notice and the driver keeps answering
269// capture and wait. Closing the stream ends only this run: the broker, its
270// daemon, and every session stay alive for the other clients.
271func runUIDriverClient(ctx context.Context, cfg brokerClientConfig, stream io.ReadWriteCloser) error {
272	if cfg.UI == nil || cfg.Terminal == nil || cfg.Clock == nil {
273		return errors.New("vev: UI driver requires a UI, a terminal, and a clock")
274	}
275	if stream == nil {
276		return errors.New("vev: UI driver requires a stream")
277	}
278	runCtx, cancel := context.WithCancel(ctx)
279	defer cancel()
280	// The composition-owned identity and presentation of this run are published
281	// before the broker is reached, so the UI service is usable and describable
282	// even when no broker and no daemon can be reached. It publishes no
283	// actionable generation: an unattached publication reports the honest "no
284	// session owns input" status and leaves the generation to the committed
285	// attachment itself.
286	publishUIDriverInitialContext(cfg.UI)
287	runnerDone := make(chan error, 1)
288	go func() { runnerDone <- runBrokerClient(runCtx, cfg) }()
289
290	snapshot, err := waitForUIDriverPublication(runCtx, cfg.UI)
291	if err != nil {
292		cancel()
293		return errors.Join(err, ignoreContextCancellation(<-runnerDone))
294	}
295	ready := uidriverReady(cfg.UI, snapshot, true)
296	server := uidriver.New(cfg.UI, cfg.Clock)
297	serveDone := make(chan error, 1)
298	go func() { serveDone <- server.Serve(runCtx, stream, ready) }()
299
300	select {
301	case serveErr := <-serveDone:
302		cancel()
303		return errors.Join(serveErr, ignoreContextCancellation(<-runnerDone))
304	case runErr := <-runnerDone:
305		cancel()
306		return errors.Join(runErr, ignoreContextCancellation(<-serveDone))
307	}
308}
309
310// waitForUIDriverPublication waits for the run's first valid UI publication. A
311// valid publication is a committed owned snapshot that carries this run's UI
312// handle and a context that conforms to the presentation table: Picker and
313// Connecting present neither a session identity nor an actionable generation,
314// while Attached presents a real nonzero generation with a committed session
315// boundary. It deliberately requires neither a broker nor a daemon nor a
316// committed view, so a driver whose broker is absent still becomes usable and
317// describable. The startup budget is the existing handshake timeout: a driver
318// that never publishes anything is a real local error, while an unavailable
319// destination is not.
320func waitForUIDriverPublication(ctx context.Context, ui *client.UI) (ports.UISnapshot, error) {
321	readyCtx, cancel := context.WithTimeout(ctx, protocol.HandshakeTimeout)
322	defer cancel()
323	snapshot, err := ui.WaitForSnapshot(readyCtx, func(snapshot ports.UISnapshot) bool {
324		return snapshot.Revision != 0 && snapshot.Context.AttachmentHandle == ui.Handle() && uiDriverContextConforms(snapshot.Context)
325	})
326	if err != nil {
327		if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
328			return ports.UISnapshot{}, fmt.Errorf("vev: UI driver published no initial state: %w", err)
329		}
330		return ports.UISnapshot{}, err
331	}
332	return snapshot, nil
333}
334
335// uiDriverContextConforms reports whether a published context matches the
336// presentation table. An unattached presentation owns no session identity, no
337// output boundary, and no actionable generation; a committed attachment is the
338// only presentation that may carry them, and it must carry a real nonzero
339// generation. A publication that fails this rule is never adopted as the
340// driver's ready state, so a caller can never read a fabricated identity from
341// the discovery response.
342func uiDriverContextConforms(context ports.UIContext) bool {
343	if !context.Status.Valid() {
344		return false
345	}
346	if context.Status == ports.UIStatusAttached {
347		return context.Generation != 0
348	}
349	return context.Generation == 0 && context.Route == (protocol.CommittedRouteIdentity{}) && context.TabID == "" && context.FocusedPaneID == "" &&
350		context.OutputEpoch == 0 && context.OutputState == 0 && context.ViewRevision == 0 && context.ViewPublication == 0
351}
352
353// publishUIDriverInitialContext publishes the composition-owned identity and
354// presentation of one driver run before any broker interaction, so the UI
355// service is usable and describable — capture, wait, and the discovery response
356// — even when no broker and no daemon can be reached.
357//
358// It publishes the Picker presentation, the honest state of a run that owns no
359// session yet. It publishes no actionable generation and no session metadata:
360// the publication carries the run's stable handle and its status only, so a
361// caller can never fence an action against an invented generation.
362func publishUIDriverInitialContext(ui *client.UI) {
363	if ui == nil {
364		return
365	}
366	_ = ui.PublishPresentation(ports.UIStatusPicker)
367}
368
369// uidriverReady projects one published UI state into the driver discovery
370// response. The attachment handle is the run's stable UI service handle, never a
371// session identity; the generation is the real attachment generation and stays
372// zero while no committed attachment has published one.
373func uidriverReady(ui *client.UI, snapshot ports.UISnapshot, control bool) uidriver.Ready {
374	ready := uidriver.Ready{Attachment: ui.Handle(), Control: control, Status: snapshot.Context.Status}
375	if snapshot.Context.Status == ports.UIStatusAttached {
376		ready.Generation = snapshot.Context.Generation
377	}
378	return ready
379}
380
381func ignoreContextCancellation(err error) error {
382	if errors.Is(err, context.Canceled) {
383		return nil
384	}
385	return err
386}
387
388type observedTerminal struct {
389	ports.Terminal
390	ports.UIOutputTransaction
391}
392
393type stdioStream struct {
394	reader io.Reader
395	writer io.Writer
396	once   sync.Once
397}
398
399func (s *stdioStream) Read(data []byte) (int, error)  { return s.reader.Read(data) }
400func (s *stdioStream) Write(data []byte) (int, error) { return s.writer.Write(data) }
401func (s *stdioStream) Close() error {
402	var closeErr error
403	s.once.Do(func() {
404		for _, value := range []io.Writer{s.writer} {
405			if closer, ok := value.(io.Closer); ok {
406				closeErr = closer.Close()
407			}
408		}
409		if closer, ok := s.reader.(io.Closer); ok {
410			if err := closer.Close(); closeErr == nil {
411				closeErr = err
412			}
413		}
414	})
415	return closeErr
416}
417
418// runAttachWithOptions is the composition path for ordinary interactive
419// clients. Observation is opt-in; the default path remains term.New().
420//
421// The observed path composes the physical terminal, one VT mirror, the UI
422// service over that mirror, and the same broker connector and initial-navigation
423// translation the ordinary terminal attach uses, then delegates the whole run to
424// the shared runBrokerClient. It owns no daemon dialer, no remote carriage, and
425// no session: mirroring observes bytes at the serialized terminal writer and the
426// broker owns every transport.
427func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) {
428	if !options.observe && !options.control && options.socket == "" {
429		return runAttach(ctx, intent, name, remoteTarget)
430	}
431	if options.control {
432		options.observe = true
433	}
434	ctx, stop := clientSignalContext(ctx)
435	defer stop()
436	log, logCloser, err := configureLogging(logging.Client, false)
437	if err != nil {
438		return err
439	}
440	defer func() { _ = logCloser.Close() }()
441	clk := clock.New()
442	// The process trace is still created and joined here so an operator's trace
443	// file keeps its exact lifecycle, including a close failure joined into the
444	// run's result. Client-side transport spans are no longer emitted: the
445	// broker owns carriage, so the observed composition keeps no dialer seam.
446	_, observerCloser, err := newPerformanceTrace(clk)
447	if err != nil {
448		return fmt.Errorf("vev: performance trace: %w", err)
449	}
450	if observerCloser != nil {
451		defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
452	}
453	physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil)
454	geometry, err := physical.Geometry()
455	if err != nil {
456		return fmt.Errorf("vev: reading terminal geometry: %w", err)
457	}
458	mirror, err := uiterm.NewMirror(ctx, geometry, "")
459	if err != nil {
460		return fmt.Errorf("vev: create UI mirror: %w", err)
461	}
462	defer mirror.Close()
463	physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror)
464	ui := client.NewUI(mirror, clk)
465	var endpoint *uidriver.UnixEndpoint
466	if options.observe {
467		server := uidriver.New(ui, clk)
468		socketPath := options.socket
469		if socketPath == "" {
470			socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle())
471		}
472		endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready {
473			ready := uidriver.Ready{Attachment: ui.Handle(), Control: options.control, Status: ports.UIStatusPicker}
474			if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil {
475				ready = uidriverReady(ui, snapshot, options.control)
476			}
477			return ready
478		})
479		if err != nil {
480			return err
481		}
482		fmt.Fprintln(os.Stderr, socketPath)
483		defer func() { _ = endpoint.Close() }()
484	}
485	terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
486	intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
487	if err != nil {
488		return err
489	}
490	navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
491	if err != nil {
492		if preconnected != nil {
493			_ = preconnected.Close()
494		}
495		return err
496	}
497	sessionEnv := terminalSessionEnvironment()
498	attachmentEnv := terminalAttachmentEnvironment()
499	attachmentEnv.Cwd = sessionEnv.Cwd
500	return runBrokerClient(ctx, brokerClientConfig{
501		Logger:                   log,
502		Connector:                withPreconnected(newProductionBrokerConnector(), preconnected),
503		Terminal:                 terminal,
504		Clock:                    clk,
505		UI:                       ui,
506		InitialNavigation:        navigation,
507		ResolveInitialNavigation: resolver,
508		AttachmentEnvironment:    attachmentEnv,
509		SessionEnvironment:       sessionEnv,
510	})
511}

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