v0.3.0 99dc266

cmd.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"sort"
10	"strings"
11	"text/tabwriter"
12
13	"github.com/bnema/vev/internal/adapters/clock"
14	"github.com/bnema/vev/internal/adapters/ipc"
15	"github.com/bnema/vev/internal/ports"
16	commandusecase "github.com/bnema/vev/internal/usecase/command"
17	"github.com/bnema/vev/internal/usecase/daemon"
18)
19
20type cmdInvocation struct {
21	slug    string
22	args    []string
23	session string
24	self    bool
25	jsonOut bool
26	help    bool
27}
28
29type exitCoded struct {
30	code int
31	err  error
32}
33
34func (e *exitCoded) Error() string { return e.err.Error() }
35func (e *exitCoded) Unwrap() error { return e.err }
36
37func exitCode(err error) int {
38	if err == nil {
39		return 0
40	}
41	if coded, ok := errors.AsType[*exitCoded](err); ok {
42		return coded.code
43	}
44	if _, ok := errors.AsType[*usageError](err); ok {
45		return 2
46	}
47	return 1
48}
49
50// ExitCode maps application outcomes to the CLI contract.
51func ExitCode(err error) int { return exitCode(err) }
52
53func parseCmdArgs(args []string) (cmdInvocation, error) {
54	invocation := cmdInvocation{}
55	index := 0
56	for index < len(args) && invocation.slug == "" {
57		switch args[index] {
58		case "--help", "-h":
59			invocation.help = true
60			index++
61		case "-s":
62			if index+1 >= len(args) || args[index+1] == "" {
63				return cmdInvocation{}, usagef("`cmd -s` requires a session name")
64			}
65			invocation.session = args[index+1]
66			index += 2
67		case "--self":
68			invocation.self = true
69			index++
70		case "--json":
71			invocation.jsonOut = true
72			index++
73		default:
74			if strings.HasPrefix(args[index], "-") {
75				return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index])
76			}
77			invocation.slug = args[index]
78			index++
79		}
80	}
81	if invocation.slug == "" {
82		invocation.help = true
83		return invocation, nil
84	}
85	if invocation.self && invocation.session != "" {
86		return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`")
87	}
88	cmd, ok := commandusecase.BySlug(invocation.slug)
89	if !ok || !cmd.Scriptable {
90		return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug)
91	}
92	for _, arg := range args[index:] {
93		switch arg {
94		case "--help", "-h":
95			invocation.help = true
96			return invocation, nil
97		case "--json":
98			invocation.jsonOut = true
99		default:
100			invocation.args = append(invocation.args, arg)
101		}
102	}
103	return invocation, nil
104}
105
106func cmdHelp(invocation cmdInvocation) string {
107	if invocation.slug != "" {
108		cmd, _ := commandusecase.BySlug(invocation.slug)
109		return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc)
110	}
111	var out strings.Builder
112	out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n")
113	writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0)
114	commands := commandusecase.Registry()
115	sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug })
116	for _, cmd := range commands {
117		if cmd.Scriptable {
118			_, _ = fmt.Fprintf(writer, "  %s\t%s\n", cmd.Usage, cmd.Desc)
119		}
120	}
121	_ = writer.Flush()
122	return out.String()
123}
124
125var errDaemonUnreachable = errors.New("daemon not running")
126
127type cmdDeps struct {
128	stdout io.Writer
129	getenv func(string) string
130	dial   func(context.Context, string) (ports.Transport, error)
131	ensure func(context.Context, string) (ports.Transport, error)
132	clock  ports.Clock
133}
134
135func runCmd(ctx context.Context, invocation cmdInvocation) error {
136	return runCmdWithDeps(ctx, invocation, cmdDeps{
137		stdout: os.Stdout,
138		getenv: os.Getenv,
139		dial:   realDial,
140		ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
141			return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
142		},
143		clock: clock.New(),
144	})
145}
146
147func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
148	if invocation.help {
149		_, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation))
150		return err
151	}
152	request := ports.CommandRequest{
153		Version:       ports.ProtocolVersion,
154		Self:          invocation.self,
155		Slug:          invocation.slug,
156		Args:          invocation.args,
157		TargetSession: invocation.session,
158		JSON:          invocation.jsonOut,
159	}
160	if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok {
161		if request.TargetSession == "" {
162			request.TargetSession = identity.session
163		}
164		if invocation.self || invocation.session == "" {
165			request.TargetTab = identity.tab
166			request.TargetPane = identity.pane
167		}
168	} else if invocation.self {
169		return usagef("--self requires running inside a vev pane")
170	}
171
172	if _, err := ports.MarshalCommandRequest(request); errors.Is(err, ports.ErrTooManyCommandArgs) {
173		return usagef("too many command arguments")
174	} else if err != nil {
175		return fmt.Errorf("encoding command: %w", err)
176	}
177
178	dial := deps.dial
179	if invocation.slug == "remote-catalog" && deps.ensure != nil {
180		dial = deps.ensure
181	}
182	transport, err := dial(ctx, ipc.SocketDir())
183	if err != nil {
184		return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
185	}
186	defer func() { _ = transport.Close() }()
187
188	tracker := daemon.NewCommandRequestTracker()
189	const generation = uint64(1)
190	requestID, outcome := tracker.Publish(generation)
191	request.RequestID = requestID
192	payload, err := ports.MarshalCommandRequest(request)
193	if err != nil {
194		tracker.Remove(requestID, generation)
195		return fmt.Errorf("encoding command: %w", err)
196	}
197	if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
198		tracker.Remove(requestID, generation)
199		return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
200	}
201	go func() {
202		reply, recvErr := transport.Recv()
203		if recvErr != nil {
204			tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr))
205			return
206		}
207		if reply.Type != ports.MsgCommandResult {
208			tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply type %d", reply.Type))
209			return
210		}
211		result, decodeErr := ports.UnmarshalCommandResult(reply.Payload)
212		if decodeErr != nil {
213			tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))
214			return
215		}
216		result.RequestID = requestID
217		tracker.Complete(generation, result)
218	}()
219	commandClock := deps.clock
220	if commandClock == nil {
221		commandClock = clock.New()
222	}
223	result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
224	if err != nil {
225		return &exitCoded{code: 3, err: err}
226	}
227	if !result.OK {
228		if result.Code == ports.ErrInvalidCommandArgs {
229			return &exitCoded{code: 2, err: errors.New(result.Text)}
230		}
231		return errors.New(result.Text)
232	}
233	if result.Output != "" {
234		if _, err := io.WriteString(deps.stdout, result.Output); err != nil {
235			return err
236		}
237		if !strings.HasSuffix(result.Output, "\n") {
238			_, err = fmt.Fprintln(deps.stdout)
239			return err
240		}
241	}
242	return nil
243}

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