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

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