2.0.0 4803e67

docker.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"encoding/base64"
6	"encoding/json"
7	"fmt"
8	"gordon/pkg/runtime"
9	"io"
10	"strconv"
11	"strings"
12
13	"github.com/docker/docker/api/types/container"
14	"github.com/docker/docker/api/types/image"
15	"github.com/docker/docker/api/types/network"
16	"github.com/docker/docker/api/types/registry"
17	"github.com/docker/docker/api/types/volume"
18	"github.com/docker/docker/client"
19	"github.com/docker/docker/errdefs"
20	"github.com/docker/go-connections/nat"
21	"github.com/rs/zerolog/log"
22)
23
24// DockerRuntime implements the Runtime interface using Docker API
25type DockerRuntime struct {
26	client *client.Client
27}
28
29// NewDockerRuntime creates a new Docker runtime instance
30func NewDockerRuntime() (*DockerRuntime, error) {
31	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
32	if err != nil {
33		return nil, fmt.Errorf("failed to create Docker client: %w", err)
34	}
35
36	return &DockerRuntime{
37		client: cli,
38	}, nil
39}
40
41// NewDockerRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing)
42func NewDockerRuntimeWithClient(cli *client.Client) *DockerRuntime {
43	return &DockerRuntime{
44		client: cli,
45	}
46}
47
48// CreateContainer creates a new container
49func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
50	// Convert ports to Docker format
51	exposedPorts := make(nat.PortSet)
52	portBindings := make(nat.PortMap)
53
54	for _, port := range config.Ports {
55		containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
56		exposedPorts[containerPort] = struct{}{}
57
58		// Bind to random available port on host
59		portBindings[containerPort] = []nat.PortBinding{
60			{
61				HostIP:   "0.0.0.0",
62				HostPort: "0", // Docker will assign a random available port
63			},
64		}
65	}
66
67	// Convert volumes to Docker format
68	var binds []string
69	if config.Volumes != nil {
70		for containerPath, volumeName := range config.Volumes {
71			bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
72			binds = append(binds, bind)
73			log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("Adding volume mount")
74		}
75	}
76
77	// Create container configuration
78	containerConfig := &container.Config{
79		Image:        config.Image,
80		Env:          config.Env,
81		ExposedPorts: exposedPorts,
82		WorkingDir:   config.WorkingDir,
83		Cmd:          config.Cmd,
84		Labels:       config.Labels,
85	}
86
87	hostConfig := &container.HostConfig{
88		PortBindings: portBindings,
89		AutoRemove:   config.AutoRemove,
90		Binds:        binds,
91		NetworkMode:  container.NetworkMode(config.NetworkMode),
92	}
93
94	// Create network configuration for container
95	var networkConfig *network.NetworkingConfig
96	if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" {
97		networkConfig = &network.NetworkingConfig{
98			EndpointsConfig: map[string]*network.EndpointSettings{
99				config.NetworkMode: {
100					Aliases: config.Aliases,
101				},
102			},
103		}
104		if config.Hostname != "" {
105			networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode
106		}
107	}
108
109	// Create the container
110	resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name)
111	if err != nil {
112		return nil, fmt.Errorf("failed to create container: %w", err)
113	}
114
115	log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
116
117	// Inspect the created container to get full details
118	return d.InspectContainer(ctx, resp.ID)
119}
120
121// StartContainer starts a container
122func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
123	err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
124	if err != nil {
125		return fmt.Errorf("failed to start container %s: %w", containerID, err)
126	}
127
128	log.Info().Str("id", containerID).Msg("Container started")
129	return nil
130}
131
132// StopContainer stops a container
133func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
134	timeout := 30 // 30 seconds timeout
135	err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
136	if err != nil {
137		return fmt.Errorf("failed to stop container %s: %w", containerID, err)
138	}
139
140	log.Info().Str("id", containerID).Msg("Container stopped")
141	return nil
142}
143
144// RestartContainer restarts a container
145func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
146	timeout := 30 // 30 seconds timeout
147	err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
148	if err != nil {
149		return fmt.Errorf("failed to restart container %s: %w", containerID, err)
150	}
151
152	log.Info().Str("id", containerID).Msg("Container restarted")
153	return nil
154}
155
156// RemoveContainer removes a container
157func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
158	err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
159	if err != nil {
160		return fmt.Errorf("failed to remove container %s: %w", containerID, err)
161	}
162
163	log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
164	return nil
165}
166
167// ListContainers lists containers
168func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
169	containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
170	if err != nil {
171		return nil, fmt.Errorf("failed to list containers: %w", err)
172	}
173
174	var result []*runtime.Container
175	for _, c := range containers {
176		// Extract ports
177		var ports []int
178		for _, port := range c.Ports {
179			if port.PublicPort > 0 {
180				ports = append(ports, int(port.PublicPort))
181			}
182		}
183
184		// Get the primary name (remove leading slash)
185		name := ""
186		if len(c.Names) > 0 {
187			name = strings.TrimPrefix(c.Names[0], "/")
188		}
189
190		result = append(result, &runtime.Container{
191			ID:     c.ID,
192			Image:  c.Image,
193			Name:   name,
194			Status: c.Status,
195			Ports:  ports,
196			Labels: c.Labels,
197		})
198	}
199
200	return result, nil
201}
202
203// InspectContainer inspects a container
204func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
205	resp, err := d.client.ContainerInspect(ctx, containerID)
206	if err != nil {
207		return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
208	}
209
210	// Extract published ports
211	var ports []int
212	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
213		for _, bindings := range resp.NetworkSettings.Ports {
214			for _, binding := range bindings {
215				if binding.HostPort != "" {
216					if port, err := strconv.Atoi(binding.HostPort); err == nil {
217						ports = append(ports, port)
218					}
219				}
220			}
221		}
222	}
223
224	// Get container name (remove leading slash)
225	name := strings.TrimPrefix(resp.Name, "/")
226
227	return &runtime.Container{
228		ID:     resp.ID,
229		Image:  resp.Config.Image,
230		Name:   name,
231		Status: resp.State.Status,
232		Ports:  ports,
233		Labels: resp.Config.Labels,
234	}, nil
235}
236
237// GetContainerLogs gets container logs
238func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
239	logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
240		ShowStdout: true,
241		ShowStderr: true,
242		Follow:     follow,
243		Timestamps: true,
244	})
245	if err != nil {
246		return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
247	}
248
249	return logs, nil
250}
251
252// PullImage pulls an image
253func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
254	log.Info().Str("image", imageRef).Msg("Pulling image")
255
256	reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
257	if err != nil {
258		return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
259	}
260	defer reader.Close()
261
262	// Read the response to completion (this is required for the pull to complete)
263	_, err = io.Copy(io.Discard, reader)
264	if err != nil {
265		return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266	}
267
268	log.Info().Str("image", imageRef).Msg("Image pulled successfully")
269	return nil
270}
271
272// PullImageWithAuth pulls an image with authentication
273func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
274	log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
275
276	// Create authentication configuration
277	authConfig := registry.AuthConfig{
278		Username: username,
279		Password: password,
280	}
281
282	// Encode authentication to base64 JSON
283	authConfigBytes, err := json.Marshal(authConfig)
284	if err != nil {
285		return fmt.Errorf("failed to marshal auth config: %w", err)
286	}
287	authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
288
289	// Pull with authentication
290	reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
291		RegistryAuth: authStr,
292	})
293	if err != nil {
294		return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
295	}
296	defer reader.Close()
297
298	// Read the response to completion (this is required for the pull to complete)
299	_, err = io.Copy(io.Discard, reader)
300	if err != nil {
301		return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
302	}
303
304	log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
305	return nil
306}
307
308// RemoveImage removes an image
309func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
310	_, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
311	if err != nil {
312		return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
313	}
314
315	log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
316	return nil
317}
318
319// ListImages lists images
320func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
321	images, err := d.client.ImageList(ctx, image.ListOptions{})
322	if err != nil {
323		return nil, fmt.Errorf("failed to list images: %w", err)
324	}
325
326	var result []string
327	for _, img := range images {
328		for _, tag := range img.RepoTags {
329			if tag != "<none>:<none>" {
330				result = append(result, tag)
331			}
332		}
333	}
334
335	return result, nil
336}
337
338// Ping checks if Docker is responsive
339func (d *DockerRuntime) Ping(ctx context.Context) error {
340	_, err := d.client.Ping(ctx)
341	if err != nil {
342		return fmt.Errorf("Docker ping failed: %w", err)
343	}
344	return nil
345}
346
347// Version returns Docker version
348func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
349	version, err := d.client.ServerVersion(ctx)
350	if err != nil {
351		return "", fmt.Errorf("failed to get Docker version: %w", err)
352	}
353	return version.Version, nil
354}
355
356// IsContainerRunning checks if a container is running
357func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
358	container, err := d.InspectContainer(ctx, containerID)
359	if err != nil {
360		return false, err
361	}
362	return container.Status == "running", nil
363}
364
365// GetContainerPort gets the host port for a container's internal port
366func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
367	resp, err := d.client.ContainerInspect(ctx, containerID)
368	if err != nil {
369		return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
370	}
371
372	if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
373		return 0, fmt.Errorf("no port mappings found for container %s", containerID)
374	}
375
376	containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
377	bindings, exists := resp.NetworkSettings.Ports[containerPort]
378	if !exists || len(bindings) == 0 {
379		return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
380	}
381
382	hostPort, err := strconv.Atoi(bindings[0].HostPort)
383	if err != nil {
384		return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
385	}
386
387	return hostPort, nil
388}
389
390// GetImageExposedPorts gets the exposed ports from an image
391func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
392	imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
393	if err != nil {
394		return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
395	}
396
397	var ports []int
398	if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
399		for portSpec := range imageInspect.Config.ExposedPorts {
400			// Parse port from format like "80/tcp"
401			portStr := strings.Split(string(portSpec), "/")[0]
402			if port, err := strconv.Atoi(portStr); err == nil {
403				ports = append(ports, port)
404			}
405		}
406	}
407
408	if len(ports) == 0 {
409		return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
410	}
411
412	log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
413	return ports, nil
414}
415
416// GetContainerExposedPorts gets all exposed ports from a running container
417func (d *DockerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
418	resp, err := d.client.ContainerInspect(ctx, containerID)
419	if err != nil {
420		return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
421	}
422
423	var ports []int
424	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
425		for portSpec := range resp.NetworkSettings.Ports {
426			// Parse port from format like "80/tcp"
427			portStr := strings.Split(string(portSpec), "/")[0]
428			if port, err := strconv.Atoi(portStr); err == nil {
429				ports = append(ports, port)
430			}
431		}
432	}
433
434	return ports, nil
435}
436
437// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
438func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
439	resp, err := d.client.ContainerInspect(ctx, containerID)
440	if err != nil {
441		return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
442	}
443
444	// Get container's internal IP address
445	var containerIP string
446	if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
447		// Use the first available network (usually bridge or custom network)
448		for _, network := range resp.NetworkSettings.Networks {
449			if network.IPAddress != "" {
450				containerIP = network.IPAddress
451				break
452			}
453		}
454	}
455
456	if containerIP == "" {
457		return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
458	}
459
460	// Get exposed ports from container
461	var exposedPorts []int
462	if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
463		for portSpec := range resp.NetworkSettings.Ports {
464			// Parse port from format like "80/tcp"
465			portStr := strings.Split(string(portSpec), "/")[0]
466			if port, err := strconv.Atoi(portStr); err == nil {
467				exposedPorts = append(exposedPorts, port)
468			}
469		}
470	}
471
472	if len(exposedPorts) == 0 {
473		return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
474	}
475
476	// Use the first exposed port
477	selectedPort := exposedPorts[0]
478
479	log.Info().
480		Str("container_id", containerID).
481		Str("ip", containerIP).
482		Int("selected_port", selectedPort).
483		Ints("exposed_ports", exposedPorts).
484		Msg("Container network info detected")
485
486	return containerIP, selectedPort, nil
487}
488
489// InspectImageVolumes gets the volume mount points declared in the image
490func (d *DockerRuntime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) {
491	imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
492	if err != nil {
493		return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
494	}
495
496	var volumes []string
497	if imageInspect.Config != nil && imageInspect.Config.Volumes != nil {
498		for volumePath := range imageInspect.Config.Volumes {
499			volumes = append(volumes, volumePath)
500		}
501	}
502
503	if len(volumes) > 0 {
504		log.Info().Str("image", imageRef).Strs("volumes", volumes).Msg("Found VOLUME directives in image")
505	} else {
506		log.Debug().Str("image", imageRef).Msg("No VOLUME directives found in image")
507	}
508
509	return volumes, nil
510}
511
512// VolumeExists checks if a Docker volume exists
513func (d *DockerRuntime) VolumeExists(ctx context.Context, volumeName string) (bool, error) {
514	_, err := d.client.VolumeInspect(ctx, volumeName)
515	if err != nil {
516		if errdefs.IsNotFound(err) {
517			return false, nil
518		}
519		return false, fmt.Errorf("failed to inspect volume %s: %w", volumeName, err)
520	}
521	return true, nil
522}
523
524// CreateVolume creates a new Docker volume
525func (d *DockerRuntime) CreateVolume(ctx context.Context, volumeName string) error {
526	_, err := d.client.VolumeCreate(ctx, volume.CreateOptions{
527		Name: volumeName,
528		Labels: map[string]string{
529			"gordon.managed": "true",
530			"gordon.created": "auto",
531		},
532	})
533	if err != nil {
534		return fmt.Errorf("failed to create volume %s: %w", volumeName, err)
535	}
536
537	log.Info().Str("volume", volumeName).Msg("Volume created")
538	return nil
539}
540
541// RemoveVolume removes a Docker volume
542func (d *DockerRuntime) RemoveVolume(ctx context.Context, volumeName string, force bool) error {
543	err := d.client.VolumeRemove(ctx, volumeName, force)
544	if err != nil {
545		if errdefs.IsNotFound(err) {
546			log.Debug().Str("volume", volumeName).Msg("Volume not found, already removed")
547			return nil
548		}
549		return fmt.Errorf("failed to remove volume %s: %w", volumeName, err)
550	}
551
552	log.Info().Str("volume", volumeName).Msg("Volume removed")
553	return nil
554}
555
556// InspectImageEnv gets the environment variables declared in the image
557func (d *DockerRuntime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
558	imageInspect, _, err := d.client.ImageInspectWithRaw(ctx, imageRef)
559	if err != nil {
560		return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
561	}
562
563	var envVars []string
564	if imageInspect.Config != nil && imageInspect.Config.Env != nil {
565		envVars = imageInspect.Config.Env
566	}
567
568	if len(envVars) > 0 {
569		log.Debug().Str("image", imageRef).Strs("env_vars", envVars).Msg("Found ENV directives in image")
570	} else {
571		log.Debug().Str("image", imageRef).Msg("No ENV directives found in image")
572	}
573
574	return envVars, nil
575}
576
577// CreateNetwork creates a new Docker network
578func (d *DockerRuntime) CreateNetwork(ctx context.Context, name string, options map[string]string) error {
579	// Set default driver to bridge if not specified
580	driver := "bridge"
581	if driverOption, exists := options["driver"]; exists {
582		driver = driverOption
583	}
584
585	createOptions := network.CreateOptions{
586		Driver: driver,
587		Labels: map[string]string{
588			"gordon.managed": "true",
589		},
590	}
591
592	// Add any additional options to labels
593	for key, value := range options {
594		if key != "driver" {
595			createOptions.Labels["gordon."+key] = value
596		}
597	}
598
599	_, err := d.client.NetworkCreate(ctx, name, createOptions)
600	if err != nil {
601		return fmt.Errorf("failed to create network %s: %w", name, err)
602	}
603
604	log.Info().Str("network", name).Str("driver", driver).Msg("Network created")
605	return nil
606}
607
608// RemoveNetwork removes a Docker network
609func (d *DockerRuntime) RemoveNetwork(ctx context.Context, name string) error {
610	err := d.client.NetworkRemove(ctx, name)
611	if err != nil {
612		if errdefs.IsNotFound(err) {
613			log.Debug().Str("network", name).Msg("Network not found, already removed")
614			return nil
615		}
616		return fmt.Errorf("failed to remove network %s: %w", name, err)
617	}
618
619	log.Info().Str("network", name).Msg("Network removed")
620	return nil
621}
622
623// ListNetworks lists all Docker networks
624func (d *DockerRuntime) ListNetworks(ctx context.Context) ([]*runtime.NetworkInfo, error) {
625	networks, err := d.client.NetworkList(ctx, network.ListOptions{})
626	if err != nil {
627		return nil, fmt.Errorf("failed to list networks: %w", err)
628	}
629
630	var result []*runtime.NetworkInfo
631	for _, net := range networks {
632		var containers []string
633		for containerID := range net.Containers {
634			containers = append(containers, containerID)
635		}
636
637		result = append(result, &runtime.NetworkInfo{
638			ID:         net.ID,
639			Name:       net.Name,
640			Driver:     net.Driver,
641			Containers: containers,
642			Labels:     net.Labels,
643		})
644	}
645
646	return result, nil
647}
648
649// NetworkExists checks if a Docker network exists
650func (d *DockerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
651	_, err := d.client.NetworkInspect(ctx, name, network.InspectOptions{})
652	if err != nil {
653		if errdefs.IsNotFound(err) {
654			return false, nil
655		}
656		return false, fmt.Errorf("failed to inspect network %s: %w", name, err)
657	}
658	return true, nil
659}
660
661// ConnectContainerToNetwork connects a container to a network
662func (d *DockerRuntime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error {
663	err := d.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{})
664	if err != nil {
665		return fmt.Errorf("failed to connect container %s to network %s: %w", containerName, networkName, err)
666	}
667
668	log.Info().Str("container", containerName).Str("network", networkName).Msg("Container connected to network")
669	return nil
670}
671
672// DisconnectContainerFromNetwork disconnects a container from a network
673func (d *DockerRuntime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error {
674	err := d.client.NetworkDisconnect(ctx, networkName, containerName, false)
675	if err != nil {
676		return fmt.Errorf("failed to disconnect container %s from network %s: %w", containerName, networkName, err)
677	}
678
679	log.Info().Str("container", containerName).Str("network", networkName).Msg("Container disconnected from network")
680	return nil
681}

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