v2.16.0 ebd2ab3

service.go RawHistoryFolder

1// Package config implements the configuration management use case.
2package config
3
4import (
5	"context"
6	"strings"
7	"sync"
8	"sync/atomic"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/fsnotify/fsnotify"
13	"github.com/spf13/viper"
14
15	"github.com/bnema/gordon/internal/boundaries/out"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19// Config holds the loaded configuration.
20type Config struct {
21	ServerPort           int
22	RegistryPort         int
23	RegistryDomain       string
24	DataDir              string
25	AutoRouteEnabled     bool
26	NetworkIsolation     bool
27	NetworkPrefix        string
28	Routes               map[string]string
29	ExternalRoutes       map[string]string // domain -> "host:port"
30	RegistryAuthEnabled  bool
31	RegistryAuthUsername string
32	RegistryAuthPassword string
33	VolumeAutoCreate     bool
34	VolumePrefix         string
35	VolumePreserve       bool
36	NetworkGroups        map[string][]string
37	Attachments          map[string][]string
38}
39
40// Service implements the ConfigService interface.
41type Service struct {
42	viper         *viper.Viper
43	eventBus      out.EventPublisher
44	config        Config
45	mu            sync.RWMutex
46	lastSaveTime  int64 // Unix nano timestamp of last save (to debounce file watcher)
47	debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
48}
49
50// NewService creates a new config service.
51func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
52	return &Service{
53		viper:         v,
54		eventBus:      eventBus,
55		debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
56	}
57}
58
59// Load loads the configuration from the configured source.
60func (s *Service) Load(ctx context.Context) error {
61	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62		zerowrap.FieldLayer:   "usecase",
63		zerowrap.FieldUseCase: "Load",
64	})
65	log := zerowrap.FromCtx(ctx)
66
67	s.mu.Lock()
68	defer s.mu.Unlock()
69
70	// Load configuration from viper
71	s.config = s.loadConfigValues()
72
73	// Load complex nested structures
74	s.config.Routes = loadStringMap(s.viper.Get("routes"))
75	s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
76	s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
77	s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
78
79	log.Info().
80		Int("server_port", s.config.ServerPort).
81		Int("registry_port", s.config.RegistryPort).
82		Int(zerowrap.FieldCount, len(s.config.Routes)).
83		Msg("configuration loaded")
84
85	return nil
86}
87
88// Reload re-reads the configuration file from disk and loads it into memory.
89// This should be used when you want to pick up external changes to the config file.
90// It differs from Load() which only loads from cached viper values.
91func (s *Service) Reload(ctx context.Context) error {
92	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
93		zerowrap.FieldLayer:   "usecase",
94		zerowrap.FieldUseCase: "Reload",
95	})
96	log := zerowrap.FromCtx(ctx)
97
98	// Re-read the config file from disk
99	if err := s.viper.ReadInConfig(); err != nil {
100		return log.WrapErr(err, "failed to read config file")
101	}
102
103	// Load the values into memory
104	return s.Load(ctx)
105}
106
107// loadConfigValues loads simple config values from viper.
108func (s *Service) loadConfigValues() Config {
109	// Prefer gordon_domain over registry_domain
110	registryDomain := s.viper.GetString("server.gordon_domain")
111	if registryDomain == "" {
112		registryDomain = s.viper.GetString("server.registry_domain")
113	}
114
115	return Config{
116		ServerPort:           s.viper.GetInt("server.port"),
117		RegistryPort:         s.viper.GetInt("server.registry_port"),
118		RegistryDomain:       registryDomain,
119		DataDir:              s.viper.GetString("server.data_dir"),
120		AutoRouteEnabled:     s.viper.GetBool("auto_route.enabled"),
121		NetworkIsolation:     s.viper.GetBool("network_isolation.enabled"),
122		NetworkPrefix:        s.viper.GetString("network_isolation.network_prefix"),
123		RegistryAuthEnabled:  s.viper.GetBool("auth.enabled"),
124		RegistryAuthUsername: s.viper.GetString("auth.username"),
125		RegistryAuthPassword: s.viper.GetString("auth.password"),
126		VolumeAutoCreate:     s.viper.GetBool("volumes.auto_create"),
127		VolumePrefix:         s.viper.GetString("volumes.prefix"),
128		VolumePreserve:       s.viper.GetBool("volumes.preserve"),
129		Routes:               make(map[string]string),
130		ExternalRoutes:       make(map[string]string),
131		NetworkGroups:        make(map[string][]string),
132		Attachments:          make(map[string][]string),
133	}
134}
135
136// loadStringMap loads a map[string]string from a viper value.
137func loadStringMap(raw any) map[string]string {
138	result := make(map[string]string)
139	if raw == nil {
140		return result
141	}
142	m, ok := raw.(map[string]any)
143	if !ok {
144		return result
145	}
146	for k, v := range m {
147		if vs, ok := v.(string); ok {
148			result[k] = vs
149		}
150	}
151	return result
152}
153
154// loadStringArrayMap loads a map[string][]string from a viper value.
155func loadStringArrayMap(raw any) map[string][]string {
156	result := make(map[string][]string)
157	if raw == nil {
158		return result
159	}
160	m, ok := raw.(map[string]any)
161	if !ok {
162		return result
163	}
164	for k, v := range m {
165		if arr, ok := v.([]any); ok {
166			var strs []string
167			for _, item := range arr {
168				if s, ok := item.(string); ok {
169					strs = append(strs, s)
170				}
171			}
172			result[k] = strs
173		}
174	}
175	return result
176}
177
178// GetRoutes returns all configured routes.
179func (s *Service) GetRoutes(_ context.Context) []domain.Route {
180	s.mu.RLock()
181	defer s.mu.RUnlock()
182
183	var routes []domain.Route
184	for domainName, image := range s.config.Routes {
185		route := domain.Route{
186			Image: image,
187			HTTPS: true,
188		}
189
190		// Check if domain has http:// prefix
191		if strings.HasPrefix(domainName, "http://") {
192			route.Domain = strings.TrimPrefix(domainName, "http://")
193			route.HTTPS = false
194		} else {
195			route.Domain = domainName
196		}
197
198		routes = append(routes, route)
199	}
200
201	return routes
202}
203
204// GetRoute returns a single route by domain.
205func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
206	s.mu.RLock()
207	defer s.mu.RUnlock()
208
209	image, exists := s.config.Routes[domainName]
210	if !exists {
211		return nil, domain.ErrRouteNotFound
212	}
213
214	route := &domain.Route{
215		Domain: domainName,
216		Image:  image,
217		HTTPS:  true,
218	}
219
220	// Check if domain has http:// prefix
221	if strings.HasPrefix(domainName, "http://") {
222		route.Domain = strings.TrimPrefix(domainName, "http://")
223		route.HTTPS = false
224	}
225
226	return route, nil
227}
228
229// FindRoutesByImage returns all routes whose image matches the given image name.
230// Image names are normalized by stripping the registry domain prefix before comparison.
231// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
232func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
233	s.mu.RLock()
234	defer s.mu.RUnlock()
235
236	registryDomain := s.config.RegistryDomain
237	normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238	inputName, inputHasTag := splitImageNameTag(normalizedImage)
239
240	var routes []domain.Route
241	for domainName, image := range s.config.Routes {
242		normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
243
244		match := false
245		if inputHasTag {
246			// Exact match when the caller provided a tag (e.g. event handler).
247			match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248		} else {
249			// Name-only match when no tag was provided (e.g. CLI push).
250			routeName, _ := splitImageNameTag(normalizedRouteImage)
251			match = strings.EqualFold(routeName, inputName)
252		}
253
254		if match {
255			route := domain.Route{
256				Image: image,
257				HTTPS: true,
258			}
259			if strings.HasPrefix(domainName, "http://") {
260				route.Domain = strings.TrimPrefix(domainName, "http://")
261				route.HTTPS = false
262			} else {
263				route.Domain = domainName
264			}
265			routes = append(routes, route)
266		}
267	}
268
269	return routes
270}
271
272// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273func splitImageNameTag(image string) (name string, hasTag bool) {
274	if idx := strings.LastIndex(image, ":"); idx != -1 {
275		return image[:idx], true
276	}
277	return image, false
278}
279
280// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
281func NormalizeRegistryImage(imageName, registryDomain string) string {
282	registryDomain = strings.TrimSuffix(registryDomain, "/")
283	if registryDomain == "" {
284		return imageName
285	}
286
287	prefix := registryDomain + "/"
288	if strings.HasPrefix(imageName, prefix) {
289		return strings.TrimPrefix(imageName, prefix)
290	}
291
292	return imageName
293}
294
295// AddRoute adds a new route to the configuration and persists it.
296func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
297	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
298		zerowrap.FieldLayer:   "usecase",
299		zerowrap.FieldUseCase: "AddRoute",
300		"domain":              route.Domain,
301	})
302	log := zerowrap.FromCtx(ctx)
303
304	// Validate route
305	if route.Domain == "" {
306		return domain.ErrRouteDomainEmpty
307	}
308	if route.Image == "" {
309		return domain.ErrRouteImageEmpty
310	}
311
312	// Store previous value for rollback
313	s.mu.Lock()
314	previousImage, existed := s.config.Routes[route.Domain]
315	if s.config.Routes == nil {
316		s.config.Routes = make(map[string]string)
317	}
318	s.config.Routes[route.Domain] = route.Image
319	s.mu.Unlock()
320
321	// Persist to disk - rollback on failure
322	if err := s.Save(ctx); err != nil {
323		log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
324		s.mu.Lock()
325		if existed {
326			s.config.Routes[route.Domain] = previousImage
327		} else {
328			delete(s.config.Routes, route.Domain)
329		}
330		s.mu.Unlock()
331		return err
332	}
333
334	log.Info().Str("image", route.Image).Msg("route added to configuration")
335	return nil
336}
337
338// UpdateRoute updates an existing route and persists it.
339func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
340	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
341		zerowrap.FieldLayer:   "usecase",
342		zerowrap.FieldUseCase: "UpdateRoute",
343		"domain":              route.Domain,
344	})
345	log := zerowrap.FromCtx(ctx)
346
347	// Validate route
348	if route.Domain == "" {
349		return domain.ErrRouteDomainEmpty
350	}
351	if route.Image == "" {
352		return domain.ErrRouteImageEmpty
353	}
354
355	// Store previous value for rollback
356	s.mu.Lock()
357	previousImage, exists := s.config.Routes[route.Domain]
358	if !exists {
359		s.mu.Unlock()
360		return domain.ErrRouteNotFound
361	}
362	s.config.Routes[route.Domain] = route.Image
363	s.mu.Unlock()
364
365	// Persist to disk - rollback on failure
366	if err := s.Save(ctx); err != nil {
367		log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
368		s.mu.Lock()
369		s.config.Routes[route.Domain] = previousImage
370		s.mu.Unlock()
371		return err
372	}
373
374	log.Info().Str("image", route.Image).Msg("route updated")
375	return nil
376}
377
378// RemoveRoute removes a route from the configuration and persists it.
379func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
380	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
381		zerowrap.FieldLayer:   "usecase",
382		zerowrap.FieldUseCase: "RemoveRoute",
383		"domain":              domainName,
384	})
385	log := zerowrap.FromCtx(ctx)
386
387	// Store previous value for rollback
388	s.mu.Lock()
389	previousImage, exists := s.config.Routes[domainName]
390	if !exists {
391		s.mu.Unlock()
392		return domain.ErrRouteNotFound
393	}
394	delete(s.config.Routes, domainName)
395	s.mu.Unlock()
396
397	// Persist to disk - rollback on failure
398	if err := s.Save(ctx); err != nil {
399		log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
400		s.mu.Lock()
401		s.config.Routes[domainName] = previousImage
402		s.mu.Unlock()
403		return err
404	}
405
406	log.Info().Msg("route removed")
407	return nil
408}
409
410// Save persists the current configuration to disk.
411func (s *Service) Save(ctx context.Context) error {
412	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
413		zerowrap.FieldLayer:   "usecase",
414		zerowrap.FieldUseCase: "SaveConfig",
415	})
416	log := zerowrap.FromCtx(ctx)
417
418	s.mu.Lock()
419	defer s.mu.Unlock()
420
421	// Update viper with current config
422	s.viper.Set("routes", s.config.Routes)
423	s.viper.Set("attachments", s.config.Attachments)
424
425	// Record save time to debounce file watcher events
426	atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
427
428	// Write config to disk
429	if err := s.viper.WriteConfig(); err != nil {
430		return log.WrapErr(err, "failed to write config")
431	}
432
433	log.Info().Msg("configuration saved to disk")
434	return nil
435}
436
437// Watch starts watching for configuration changes.
438func (s *Service) Watch(ctx context.Context, onChange func()) error {
439	log := zerowrap.FromCtx(ctx)
440
441	s.viper.OnConfigChange(func(e fsnotify.Event) {
442		// Check if this event is within the debounce window of our own Save
443		lastSave := atomic.LoadInt64(&s.lastSaveTime)
444		if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
445			log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
446			return
447		}
448
449		log.Info().Str("file", e.Name).Msg("config file changed")
450
451		if err := s.viper.ReadInConfig(); err != nil {
452			log.WrapErr(err, "failed to reload config")
453			return
454		}
455
456		if err := s.Load(ctx); err != nil {
457			log.WrapErr(err, "failed to load updated config")
458			return
459		}
460
461		// Publish config reload event
462		if s.eventBus != nil {
463			if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
464				Source: "file",
465			}); err != nil {
466				log.WrapErr(err, "failed to publish config reload event")
467			}
468		}
469
470		if onChange != nil {
471			onChange()
472		}
473	})
474
475	s.viper.WatchConfig()
476	log.Info().Msg("watching for configuration changes")
477
478	return nil
479}
480
481// GetServerPort returns the configured server port.
482func (s *Service) GetServerPort() int {
483	s.mu.RLock()
484	defer s.mu.RUnlock()
485	return s.config.ServerPort
486}
487
488// GetRegistryPort returns the configured registry port.
489func (s *Service) GetRegistryPort() int {
490	s.mu.RLock()
491	defer s.mu.RUnlock()
492	return s.config.RegistryPort
493}
494
495// GetRegistryDomain returns the configured registry domain.
496func (s *Service) GetRegistryDomain() string {
497	s.mu.RLock()
498	defer s.mu.RUnlock()
499	return s.config.RegistryDomain
500}
501
502// GetDataDir returns the configured data directory.
503func (s *Service) GetDataDir() string {
504	s.mu.RLock()
505	defer s.mu.RUnlock()
506	return s.config.DataDir
507}
508
509// IsAutoRouteEnabled returns whether auto-route is enabled.
510func (s *Service) IsAutoRouteEnabled() bool {
511	s.mu.RLock()
512	defer s.mu.RUnlock()
513	return s.config.AutoRouteEnabled
514}
515
516// IsNetworkIsolationEnabled returns whether network isolation is enabled.
517func (s *Service) IsNetworkIsolationEnabled() bool {
518	s.mu.RLock()
519	defer s.mu.RUnlock()
520	return s.config.NetworkIsolation
521}
522
523// GetNetworkPrefix returns the prefix for created networks.
524func (s *Service) GetNetworkPrefix() string {
525	s.mu.RLock()
526	defer s.mu.RUnlock()
527	return s.config.NetworkPrefix
528}
529
530// GetConfig returns a copy of the current configuration.
531func (s *Service) GetConfig() Config {
532	s.mu.RLock()
533	defer s.mu.RUnlock()
534	return s.config
535}
536
537// GetRegistryAuthConfig returns registry authentication configuration.
538func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
539	s.mu.RLock()
540	defer s.mu.RUnlock()
541	return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
542}
543
544// GetVolumeConfig returns volume configuration.
545func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
546	s.mu.RLock()
547	defer s.mu.RUnlock()
548	return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
549}
550
551// GetNetworkGroups returns network group configuration.
552func (s *Service) GetNetworkGroups() map[string][]string {
553	s.mu.RLock()
554	defer s.mu.RUnlock()
555
556	result := make(map[string][]string)
557	for k, v := range s.config.NetworkGroups {
558		result[k] = append([]string{}, v...)
559	}
560	return result
561}
562
563// GetAttachments returns attachment configuration.
564// Deprecated: Use GetAllAttachments instead.
565func (s *Service) GetAttachments() map[string][]string {
566	s.mu.RLock()
567	defer s.mu.RUnlock()
568
569	result := make(map[string][]string)
570	for k, v := range s.config.Attachments {
571		result[k] = append([]string{}, v...)
572	}
573	return result
574}
575
576// GetAllAttachments returns all configured attachments.
577func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
578	s.mu.RLock()
579	defer s.mu.RUnlock()
580
581	result := make(map[string][]string)
582	for k, v := range s.config.Attachments {
583		result[k] = append([]string{}, v...)
584	}
585	return result
586}
587
588// GetAttachmentsFor returns attachments for a specific domain or network group.
589func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
590	s.mu.RLock()
591	defer s.mu.RUnlock()
592
593	images, exists := s.config.Attachments[domainOrGroup]
594	if !exists {
595		return nil, domain.ErrAttachmentNotFound
596	}
597
598	return append([]string{}, images...), nil
599}
600
601// AddAttachment adds an image to a domain/group's attachments.
602func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
603	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
604		zerowrap.FieldLayer:   "usecase",
605		zerowrap.FieldUseCase: "AddAttachment",
606		"target":              domainOrGroup,
607		"image":               image,
608	})
609	log := zerowrap.FromCtx(ctx)
610
611	// Validate input
612	if domainOrGroup == "" {
613		return domain.ErrAttachmentTargetEmpty
614	}
615	if image == "" {
616		return domain.ErrAttachmentImageEmpty
617	}
618
619	s.mu.Lock()
620
621	// Initialize map if needed
622	if s.config.Attachments == nil {
623		s.config.Attachments = make(map[string][]string)
624	}
625
626	// Check if already exists
627	existing := s.config.Attachments[domainOrGroup]
628	for _, img := range existing {
629		if img == image {
630			s.mu.Unlock()
631			return domain.ErrAttachmentExists
632		}
633	}
634
635	// Store previous value for rollback
636	previousImages := append([]string{}, existing...)
637	hadKey := len(existing) > 0
638
639	// Add the image
640	s.config.Attachments[domainOrGroup] = append(existing, image)
641	s.mu.Unlock()
642
643	// Persist to disk - rollback on failure
644	if err := s.Save(ctx); err != nil {
645		log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
646		s.mu.Lock()
647		if hadKey {
648			s.config.Attachments[domainOrGroup] = previousImages
649		} else {
650			delete(s.config.Attachments, domainOrGroup)
651		}
652		s.mu.Unlock()
653		return err
654	}
655
656	log.Info().Msg("attachment added to configuration")
657	return nil
658}
659
660// RemoveAttachment removes an image from a domain/group's attachments.
661func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
662	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
663		zerowrap.FieldLayer:   "usecase",
664		zerowrap.FieldUseCase: "RemoveAttachment",
665		"target":              domainOrGroup,
666		"image":               image,
667	})
668	log := zerowrap.FromCtx(ctx)
669
670	// Validate input
671	if domainOrGroup == "" {
672		return domain.ErrAttachmentTargetEmpty
673	}
674	if image == "" {
675		return domain.ErrAttachmentImageEmpty
676	}
677
678	s.mu.Lock()
679
680	existing, exists := s.config.Attachments[domainOrGroup]
681	if !exists {
682		s.mu.Unlock()
683		return domain.ErrAttachmentNotFound
684	}
685
686	// Find and remove the image
687	found := false
688	newImages := make([]string, 0, len(existing))
689	for _, img := range existing {
690		if img == image {
691			found = true
692		} else {
693			newImages = append(newImages, img)
694		}
695	}
696
697	if !found {
698		s.mu.Unlock()
699		return domain.ErrAttachmentNotFound
700	}
701
702	// Store previous value for rollback
703	previousImages := existing
704
705	// Update or remove the key
706	if len(newImages) == 0 {
707		delete(s.config.Attachments, domainOrGroup)
708	} else {
709		s.config.Attachments[domainOrGroup] = newImages
710	}
711	s.mu.Unlock()
712
713	// Persist to disk - rollback on failure
714	if err := s.Save(ctx); err != nil {
715		log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
716		s.mu.Lock()
717		s.config.Attachments[domainOrGroup] = previousImages
718		s.mu.Unlock()
719		return err
720	}
721
722	log.Info().Msg("attachment removed from configuration")
723	return nil
724}
725
726// GetExternalRoutes returns all configured external routes.
727func (s *Service) GetExternalRoutes() map[string]string {
728	s.mu.RLock()
729	defer s.mu.RUnlock()
730
731	result := make(map[string]string)
732	for k, v := range s.config.ExternalRoutes {
733		result[k] = v
734	}
735	return result
736}
737
738// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
739func ExtractDomainFromImageName(imageName string) (string, bool) {
740	parts := strings.Split(imageName, ":")
741	imageNamePart := parts[0]
742
743	// Simple domain check - contains at least one dot and valid characters
744	if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
745		// Additional check: should not look like a registry path
746		if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
747			return imageNamePart, true
748		}
749	}
750
751	return "", false
752}

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