v2.31.0-dev10 f49cffc

controlplane_local.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sort"
8	"time"
9
10	"github.com/bnema/gordon/internal/adapters/dto"
11	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
12	"github.com/bnema/gordon/internal/app"
13	"github.com/bnema/gordon/internal/boundaries/in"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/config"
16)
17
18type localControlPlane struct {
19	configSvc    in.ConfigService
20	secretSvc    in.SecretService
21	containerSvc in.ContainerService
22	backupSvc    in.BackupService
23	registrySvc  in.RegistryService
24	deployCoord  in.DeployCoordinator
25	healthSvc    in.HealthService
26	logSvc       in.LogService
27	volumeSvc    in.VolumeService
28}
29
30func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
31	if kernel == nil {
32		return &localControlPlane{}
33	}
34
35	registrySvc := kernel.Registry()
36	var deployCoord in.DeployCoordinator
37	if registrySvc != nil {
38		if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
39			deployCoord = coordinator
40		}
41	}
42
43	return &localControlPlane{
44		configSvc:    kernel.Config(),
45		secretSvc:    kernel.Secrets(),
46		containerSvc: kernel.Container(),
47		backupSvc:    kernel.Backup(),
48		registrySvc:  registrySvc,
49		deployCoord:  deployCoord,
50		healthSvc:    kernel.Health(),
51		logSvc:       kernel.Logs(),
52		volumeSvc:    kernel.Volumes(),
53	}
54}
55
56func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
57	if l.containerSvc != nil {
58		return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil
59	}
60
61	if l.configSvc == nil {
62		return nil, fmt.Errorf("local config service unavailable")
63	}
64
65	routes := l.configSvc.GetRoutes(ctx)
66	infos := make([]remote.RouteInfo, 0, len(routes))
67	for _, route := range routes {
68		infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
69	}
70	return infos, nil
71}
72
73func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
74	if l.healthSvc == nil {
75		return map[string]*remote.RouteHealth{}, nil
76	}
77
78	health := l.healthSvc.CheckAllRoutes(ctx)
79	result := make(map[string]*remote.RouteHealth, len(health))
80	for domainName, h := range health {
81		if h == nil {
82			continue
83		}
84		result[domainName] = &remote.RouteHealth{
85			ContainerStatus: h.ContainerStatus,
86			HTTPStatus:      h.HTTPStatus,
87			ResponseTimeMs:  h.ResponseTimeMs,
88			Healthy:         h.Healthy,
89			Error:           h.Error,
90		}
91	}
92	return result, nil
93}
94
95func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
96	if l.configSvc == nil {
97		return nil, fmt.Errorf("local config service unavailable")
98	}
99	return l.configSvc.GetRoute(ctx, routeDomain)
100}
101
102func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
103	if l.configSvc == nil {
104		return nil, fmt.Errorf("local config service unavailable")
105	}
106	return l.configSvc.FindRoutesByImage(ctx, imageName), nil
107}
108
109func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
110	if l.configSvc == nil {
111		return fmt.Errorf("local config service unavailable")
112	}
113	return l.configSvc.AddRoute(ctx, route)
114}
115
116func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
117	if l.configSvc == nil {
118		return fmt.Errorf("local config service unavailable")
119	}
120	return l.configSvc.UpdateRoute(ctx, route)
121}
122
123func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
124	if l.configSvc == nil {
125		return fmt.Errorf("local config service unavailable")
126	}
127	return l.configSvc.RemoveRoute(ctx, routeDomain)
128}
129
130func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
131	registryDomain := ""
132	if l.configSvc != nil {
133		registryDomain = l.configSvc.GetRegistryDomain()
134	}
135	normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
136	if err != nil {
137		return nil, fmt.Errorf("invalid image: %w", err)
138	}
139
140	resp := &dto.BootstrapResponse{
141		Domain: req.Domain,
142		Image:  normalizedImage,
143		Next:   fmt.Sprintf("push %s to trigger deployment", normalizedImage),
144	}
145
146	if l.configSvc == nil {
147		return resp, fmt.Errorf("local config service unavailable")
148	}
149	needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
150	if needsSecrets && l.secretSvc == nil {
151		return resp, fmt.Errorf("local secret service unavailable")
152	}
153
154	addStep := func(name, status string) {
155		resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
156	}
157
158	err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
159	switch err {
160	case nil:
161		addStep("route", "configured")
162	default:
163		addStep("route", "failed")
164		return resp, err
165	}
166
167	if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
168		return resp, err
169	}
170
171	if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
172		return resp, err
173	}
174
175	if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
176		return resp, err
177	}
178
179	return resp, nil
180}
181
182func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
183	for _, attachment := range req.Attachments {
184		err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
185		if err == nil {
186			addStep("attachment:"+attachment, "created")
187			continue
188		}
189		if errors.Is(err, domain.ErrAttachmentExists) {
190			addStep("attachment:"+attachment, "noop")
191			continue
192		}
193		addStep("attachment:"+attachment, "failed")
194		return err
195	}
196
197	return nil
198}
199
200func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
201	if len(req.Env) == 0 {
202		return nil
203	}
204
205	if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
206		addStep("env", "failed")
207		return err
208	}
209
210	addStep("env", "updated")
211	return nil
212}
213
214func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
215	for service, env := range req.AttachmentEnv {
216		if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
217			addStep("attachment_env:"+service, "failed")
218			return err
219		}
220		addStep("attachment_env:"+service, "updated")
221	}
222
223	return nil
224}
225
226func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
227	if l.secretSvc == nil {
228		return nil, fmt.Errorf("local secret service unavailable")
229	}
230
231	keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
232	if err != nil {
233		return nil, err
234	}
235
236	result := &remote.SecretsListResult{
237		Domain: secretDomain,
238		Keys:   keys,
239	}
240	for _, att := range attachments {
241		result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
242			Service: att.Service,
243			Keys:    att.Keys,
244		})
245	}
246
247	return result, nil
248}
249
250func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
251	if l.secretSvc == nil {
252		return fmt.Errorf("local secret service unavailable")
253	}
254	return l.secretSvc.Set(ctx, secretDomain, secrets)
255}
256
257func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
258	if l.secretSvc == nil {
259		return fmt.Errorf("local secret service unavailable")
260	}
261	return l.secretSvc.Delete(ctx, secretDomain, key)
262}
263
264func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
265	if l.secretSvc == nil {
266		return fmt.Errorf("local secret service unavailable")
267	}
268	return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
269}
270
271func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
272	if l.secretSvc == nil {
273		return fmt.Errorf("local secret service unavailable")
274	}
275	return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
276}
277
278func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
279	if l.configSvc == nil {
280		return nil, fmt.Errorf("local config service unavailable")
281	}
282	return l.configSvc.GetAllAttachments(ctx), nil
283}
284
285func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
286	if l.configSvc == nil {
287		return nil, fmt.Errorf("local config service unavailable")
288	}
289	return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
290}
291
292func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
293	if l.configSvc == nil {
294		return nil, fmt.Errorf("local config service unavailable")
295	}
296	return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil
297}
298
299func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
300	if l.configSvc == nil {
301		return fmt.Errorf("local config service unavailable")
302	}
303	return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
304}
305
306func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
307	if l.configSvc == nil {
308		return fmt.Errorf("local config service unavailable")
309	}
310	return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
311}
312
313func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
314	if l.configSvc == nil {
315		return nil, fmt.Errorf("local config service unavailable")
316	}
317	return l.configSvc.GetAutoRouteAllowedDomains(ctx)
318}
319
320func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
321	if l.configSvc == nil {
322		return fmt.Errorf("local config service unavailable")
323	}
324	return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
325}
326
327func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
328	if l.configSvc == nil {
329		return fmt.Errorf("local config service unavailable")
330	}
331	return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
332}
333
334func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
335	if l.configSvc == nil {
336		return nil, fmt.Errorf("local config service unavailable")
337	}
338
339	status := &remote.Status{
340		Routes:           len(l.configSvc.GetRoutes(ctx)),
341		RegistryDomain:   l.configSvc.GetRegistryDomain(),
342		RegistryPort:     l.configSvc.GetRegistryPort(),
343		ServerPort:       l.configSvc.GetServerPort(),
344		AutoRoute:        l.configSvc.IsAutoRouteEnabled(),
345		NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
346		ContainerStatus:  map[string]string{},
347	}
348
349	if l.containerSvc != nil {
350		for domainName, container := range l.containerSvc.List(ctx) {
351			if container == nil {
352				continue
353			}
354			status.ContainerStatus[domainName] = container.Status
355		}
356	}
357
358	return status, nil
359}
360
361func (l *localControlPlane) Reload(_ context.Context) error {
362	return app.SendReloadSignal()
363}
364
365func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
366	if l.containerSvc == nil {
367		return nil, fmt.Errorf("container service unavailable")
368	}
369	return l.containerSvc.ListNetworks(ctx)
370}
371
372func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
373	if l.configSvc == nil {
374		return nil, fmt.Errorf("config service unavailable")
375	}
376	externalRoutes := l.configSvc.GetExternalRoutes()
377	externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes))
378	for domainName := range externalRoutes {
379		externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName})
380	}
381	sort.Slice(externalResponses, func(i, j int) bool {
382		return externalResponses[i].Domain < externalResponses[j].Domain
383	})
384	cfg := &remote.Config{
385		Routes:         l.configSvc.GetRoutes(ctx),
386		ExternalRoutes: externalResponses,
387	}
388	cfg.Server.Port = l.configSvc.GetServerPort()
389	cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
390	cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
391	cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
392	cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
393	cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
394	return cfg, nil
395}
396
397func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
398	if l.deployCoord != nil {
399		l.deployCoord.SuppressDeployEvent(imageName)
400	}
401	return nil
402}
403
404func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
405	if l.containerSvc != nil && l.configSvc != nil {
406		route, err := l.configSvc.GetRoute(ctx, deployDomain)
407		if err != nil {
408			return nil, err
409		}
410		container, err := l.containerSvc.Deploy(ctx, *route)
411		if err != nil {
412			return nil, err
413		}
414		result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
415		if container != nil {
416			result.ContainerID = container.ID
417		}
418		return result, nil
419	}
420
421	domainName, err := app.SendDeploySignal(deployDomain)
422	if err != nil {
423		return nil, err
424	}
425	return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
426}
427
428func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
429	if l.containerSvc == nil {
430		if withAttachments {
431			return nil, fmt.Errorf("local restart with attachments requires active local container service")
432		}
433		domainName, err := app.SendDeploySignal(restartDomain)
434		if err != nil {
435			return nil, err
436		}
437		return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
438	}
439
440	if err := l.containerSvc.SyncContainers(ctx); err != nil {
441		return nil, err
442	}
443	if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
444		return nil, err
445	}
446
447	return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
448}
449
450func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
451	if l.registrySvc == nil {
452		return nil, fmt.Errorf("local registry service unavailable")
453	}
454	return l.registrySvc.ListTags(ctx, repository)
455}
456
457func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
458	if l.backupSvc == nil {
459		return nil, fmt.Errorf("local backup service unavailable")
460	}
461	jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
462	if err != nil {
463		return nil, err
464	}
465	return toDTOBackupJobs(jobs), nil
466}
467
468func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
469	if l.backupSvc == nil {
470		return nil, fmt.Errorf("local backup service unavailable")
471	}
472	jobs, err := l.backupSvc.Status(ctx)
473	if err != nil {
474		return nil, err
475	}
476	return toDTOBackupJobs(jobs), nil
477}
478
479func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
480	if l.backupSvc == nil {
481		return nil, fmt.Errorf("local backup service unavailable")
482	}
483	result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
484	if err != nil {
485		return nil, err
486	}
487	if result == nil {
488		return &dto.BackupRunResponse{Status: "ok"}, nil
489	}
490	job := toDTOBackupJob(result.Job)
491	return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
492}
493
494func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
495	if l.backupSvc == nil {
496		return nil, fmt.Errorf("local backup service unavailable")
497	}
498	dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
499	if err != nil {
500		return nil, err
501	}
502	out := make([]dto.DatabaseInfo, 0, len(dbs))
503	for _, db := range dbs {
504		out = append(out, dto.DatabaseInfo{
505			Type:        string(db.Type),
506			Name:        db.Name,
507			Version:     db.Version,
508			Host:        db.Host,
509			Port:        db.Port,
510			ContainerID: db.ContainerID,
511			ImageName:   db.ImageName,
512		})
513	}
514	return out, nil
515}
516
517func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
518	if l.logSvc == nil {
519		return nil, fmt.Errorf("local log service unavailable")
520	}
521	return l.logSvc.GetProcessLogs(ctx, lines)
522}
523
524func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
525	if l.logSvc == nil {
526		return nil, fmt.Errorf("local log service unavailable")
527	}
528	return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
529}
530
531func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
532	if l.logSvc == nil {
533		return nil, fmt.Errorf("local log service unavailable")
534	}
535	return l.logSvc.FollowProcessLogs(ctx, lines)
536}
537
538func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
539	if l.logSvc == nil {
540		return nil, fmt.Errorf("local log service unavailable")
541	}
542	return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
543}
544
545func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
546	if l.volumeSvc == nil {
547		return nil, fmt.Errorf("volume service unavailable")
548	}
549	vols, err := l.volumeSvc.ListVolumes(ctx)
550	if err != nil {
551		return nil, err
552	}
553
554	result := make([]dto.Volume, len(vols))
555	for i, v := range vols {
556		result[i] = dto.Volume{
557			Name:       v.Name,
558			Driver:     v.Driver,
559			MountPoint: v.MountPoint,
560			Size:       v.Size,
561			CreatedAt:  v.CreatedAt,
562			InUse:      v.InUse,
563			Containers: v.Containers,
564			Labels:     v.Labels,
565		}
566	}
567	return result, nil
568}
569
570func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
571	if l.volumeSvc == nil {
572		return nil, fmt.Errorf("volume service unavailable")
573	}
574	report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
575	if err != nil {
576		return nil, err
577	}
578
579	vols := make([]dto.Volume, len(removed))
580	for i, v := range removed {
581		vols[i] = dto.Volume{
582			Name: v.Name,
583			Size: v.Size,
584		}
585	}
586
587	return &dto.VolumePruneResponse{
588		VolumesRemoved: report.VolumesRemoved,
589		SpaceReclaimed: report.SpaceReclaimed,
590		Volumes:        vols,
591	}, nil
592}
593
594func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
595	out := make([]remote.RouteInfo, 0, len(routes))
596	for _, route := range routes {
597		attachments := make([]dto.Attachment, 0, len(route.Attachments))
598		for _, attachment := range route.Attachments {
599			attachments = append(attachments, dto.Attachment{
600				Name:        attachment.Name,
601				Image:       attachment.Image,
602				ContainerID: attachment.ContainerID,
603				Status:      attachment.Status,
604				Network:     attachment.Network,
605			})
606		}
607
608		out = append(out, remote.RouteInfo{
609			Domain:          route.Domain,
610			Image:           route.Image,
611			ContainerID:     route.ContainerID,
612			ContainerStatus: route.ContainerStatus,
613			Network:         route.Network,
614			Attachments:     attachments,
615		})
616	}
617	return out
618}
619
620func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
621	out := make([]dto.BackupJob, 0, len(jobs))
622	for _, job := range jobs {
623		out = append(out, toDTOBackupJob(job))
624	}
625	return out
626}
627
628func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
629	var startedAt *time.Time
630	if !job.StartedAt.IsZero() {
631		t := job.StartedAt
632		startedAt = &t
633	}
634	var completedAt *time.Time
635	if !job.CompletedAt.IsZero() {
636		t := job.CompletedAt
637		completedAt = &t
638	}
639
640	return dto.BackupJob{
641		ID:          job.ID,
642		Domain:      job.Domain,
643		DBName:      job.DBName,
644		Schedule:    string(job.Schedule),
645		Type:        string(job.Type),
646		Status:      string(job.Status),
647		StartedAt:   startedAt,
648		CompletedAt: completedAt,
649		SizeBytes:   job.SizeBytes,
650		Error:       job.Error,
651	}
652}

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 7

Tags 106

↑↓ moveenter openesc close