v0.3.0 99dc266

remote_hosts.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"log/slog"
9	"os"
10	"sort"
11	"text/tabwriter"
12
13	remoteadapter "github.com/bnema/vev/internal/adapters/remote"
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/platform"
16	"github.com/bnema/vev/internal/ports"
17)
18
19const (
20	hostActionAdd  = "add"
21	hostActionRm   = "rm"
22	hostActionList = "list"
23)
24
25// remoteHostDeps holds injectable seams for host management and remote listing.
26type remoteHostDeps struct {
27	stateDir  func() string
28	store     ports.RemoteHostStore
29	catalog   ports.RemoteCatalogClient
30	localList func(context.Context) ([]ports.SessionInfo, error)
31	stdout    io.Writer
32}
33
34func defaultRemoteHostDeps() remoteHostDeps {
35	return remoteHostDeps{
36		stateDir:  platform.StateDir,
37		catalog:   remoteadapter.NewCatalogClient(),
38		localList: listLocalSessions,
39		stdout:    os.Stdout,
40	}
41}
42
43func (d remoteHostDeps) withDefaults() remoteHostDeps {
44	if d.stateDir == nil {
45		d.stateDir = platform.StateDir
46	}
47	if d.store == nil {
48		d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir()))
49	}
50	if d.catalog == nil {
51		d.catalog = remoteadapter.NewCatalogClient()
52	}
53	if d.localList == nil {
54		d.localList = listLocalSessions
55	}
56	if d.stdout == nil {
57		d.stdout = os.Stdout
58	}
59	return d
60}
61
62func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
63	return d.store
64}
65
66func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
67	_ = ctx
68	deps = deps.withDefaults()
69
70	switch cmd.hostAction {
71	case hostActionAdd:
72		return hostAdd(deps, cmd.hostTarget)
73	case hostActionRm:
74		return hostRm(deps, cmd.hostTarget)
75	case hostActionList:
76		return hostList(deps)
77	default:
78		return usagef("unknown host action %q", cmd.hostAction)
79	}
80}
81
82func hostAdd(deps remoteHostDeps, target string) error {
83	if err := domain.ValidateRemoteHostTarget(target); err != nil {
84		return err
85	}
86	if err := deps.hostStore().AddPinned(target); err != nil {
87		return fmt.Errorf("vev: adding pinned host %q: %w", target, err)
88	}
89	return nil
90}
91
92func hostRm(deps remoteHostDeps, target string) error {
93	if err := domain.ValidateRemoteHostTarget(target); err != nil {
94		return err
95	}
96	deleted, err := deps.hostStore().Remove(target)
97	if err != nil {
98		return fmt.Errorf("vev: removing host %q: %w", target, err)
99	}
100	if !deleted {
101		return fmt.Errorf("vev: unknown host %q", target)
102	}
103	return nil
104}
105
106func hostList(deps remoteHostDeps) error {
107	hosts, err := mergeKnownHosts(deps)
108	if err != nil {
109		return err
110	}
111	if len(hosts) == 0 {
112		_, _ = fmt.Fprintln(deps.stdout, "no hosts")
113		return nil
114	}
115	tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
116	_, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
117	for _, host := range hosts {
118		_, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))
119	}
120	return tw.Flush()
121}
122
123func remoteHostSource(host domain.RemoteHost) string {
124	switch {
125	case host.Pinned && host.Learned:
126		return "pinned,learned"
127	case host.Pinned:
128		return "pinned"
129	case host.Learned:
130		return "learned"
131	default:
132		return ""
133	}
134}
135
136func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {
137	deps = deps.withDefaults()
138
139	hosts, err := mergeKnownHosts(deps)
140	if err != nil {
141		return err
142	}
143	if cmd.listHost != "" {
144		return listOneRemoteHost(ctx, deps, hosts, cmd.listHost)
145	}
146	return listAllSessions(ctx, deps, hosts)
147}
148
149func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error {
150	known := false
151	for _, host := range hosts {
152		if host.Target == target {
153			known = true
154			break
155		}
156	}
157	if !known {
158		return fmt.Errorf("vev: unknown host %q", target)
159	}
160	catalog, err := deps.catalog.List(ctx, target)
161	if err != nil {
162		return fmt.Errorf("vev: listing sessions on %q: %w", target, err)
163	}
164	printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions))
165	return nil
166}
167
168// listAllSessions queries hosts sequentially for deterministic output ordering.
169func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error {
170	local, err := deps.localList(ctx)
171	if err != nil {
172		if local != nil {
173			printSessions(deps.stdout, local)
174		}
175		return err
176	}
177	all := append([]ports.SessionInfo(nil), local...)
178	var hostErrs []error
179	for _, host := range hosts {
180		if err := ctx.Err(); err != nil {
181			printSessions(deps.stdout, all)
182			return err
183		}
184		catalog, listErr := deps.catalog.List(ctx, host.Target)
185		if listErr != nil {
186			hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr))
187			continue
188		}
189		all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...)
190	}
191	printSessions(deps.stdout, all)
192	if len(hostErrs) > 0 {
193		return errors.Join(hostErrs...)
194	}
195	return nil
196}
197
198func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []ports.SessionInfo {
199	out := make([]ports.SessionInfo, 0, len(sessions))
200	for _, session := range sessions {
201		info := ports.SessionInfo{
202			Name:      session.Name + "@" + host,
203			Ephemeral: session.Ephemeral,
204			Tabs:      ports.SaturateUint16(ports.CatalogTabCount(session)),
205			Attached:  session.Attached,
206		}
207		switch session.State {
208		case "up":
209			info.State = ports.SessionUp
210		case "down":
211			info.State = ports.SessionDown
212		case "broken":
213			info.State = ports.SessionBroken
214		default:
215			slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
216			info.State = ports.SessionBroken
217		}
218		out = append(out, info)
219	}
220	return out
221}
222
223func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
224	pinned, learned, err := deps.hostStore().Hosts()
225	if err != nil {
226		return nil, fmt.Errorf("vev: reading hosts: %w", err)
227	}
228	return mergeRemoteHosts(pinned, learned), nil
229}
230
231// mergeRemoteHosts returns pinned hosts in stored order, then learned-only
232// hosts in lexical order. Duplicates keep first occurrence and mark both sources.
233func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost {
234	pinned = domain.UniqueRemoteHostTargets(pinned)
235	learnedSet := make(map[string]struct{}, len(learned))
236	for _, target := range learned {
237		learnedSet[target] = struct{}{}
238	}
239	pinnedSet := make(map[string]struct{}, len(pinned))
240	out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
241	for _, target := range pinned {
242		pinnedSet[target] = struct{}{}
243		_, isLearned := learnedSet[target]
244		out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned})
245	}
246	learnedOnly := make([]string, 0, len(learned))
247	for _, target := range learned {
248		if _, ok := pinnedSet[target]; !ok {
249			learnedOnly = append(learnedOnly, target)
250		}
251	}
252	learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly)
253	sort.Strings(learnedOnly)
254	for _, target := range learnedOnly {
255		out = append(out, domain.RemoteHost{Target: target, Learned: true})
256	}
257	return out
258}
259
260type remoteHostLearner struct {
261	store  ports.RemoteHostStore
262	target string
263}
264
265func (l remoteHostLearner) RememberRemoteHost() error {
266	return l.store.Remember(l.target)
267}
268
269func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner {
270	if remoteTarget == "" {
271		return nil
272	}
273	store := deps.hostStore
274	if store == nil {
275		stateDir := deps.stateDir
276		if stateDir == nil {
277			stateDir = platform.StateDir
278		}
279		store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir()))
280	}
281	return remoteHostLearner{store: store, target: remoteTarget}
282}

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