detached 2607ac5

feat(colorscheme): add priority-based color scheme detection with gsettings, env, and adwaita detectors

commit
2607ac59cd696a10dcae9c682a0bbd1a8030d78e
author
bnema · · 2025-12-29 20:50 UTC
parent
ed7e207
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +778 -0
A internal/infrastructure/colorscheme/config_adapter.go +23 -0
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
A internal/infrastructure/colorscheme/detector_adwaita.go +62 -0
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
A internal/infrastructure/colorscheme/detector_env.go +50 -0
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
A internal/infrastructure/colorscheme/detector_gsettings.go +63 -0
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
A internal/infrastructure/colorscheme/resolver.go +155 -0
@@ -0,0 +1,155 @@
1+package colorscheme
2+
3+import (
4+ "sort"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+const (
12+ // sourceFallback indicates no detector provided the preference.
13+ sourceFallback = "fallback"
14+ // sourceConfig indicates the preference came from user config.
15+ sourceConfig = "config"
16+)
17+
18+// ConfigProvider provides access to the color scheme configuration.
19+type ConfigProvider interface {
20+ // GetColorScheme returns the configured color scheme preference.
21+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
22+ GetColorScheme() string
23+}
24+
25+// Resolver implements port.ColorSchemeResolver.
26+// It manages multiple detectors and respects config overrides.
27+type Resolver struct {
28+ mu sync.RWMutex
29+ config ConfigProvider
30+ detectors []port.ColorSchemeDetector
31+ current port.ColorSchemePreference
32+ callbacks []func(port.ColorSchemePreference)
33+}
34+
35+// NewResolver creates a new color scheme resolver.
36+// The config provider is used to check for explicit user preferences.
37+func NewResolver(config ConfigProvider) *Resolver {
38+ return &Resolver{
39+ config: config,
40+ detectors: make([]port.ColorSchemeDetector, 0),
41+ current: port.ColorSchemePreference{
42+ PrefersDark: true, // Default to dark until first Resolve()
43+ Source: sourceFallback,
44+ },
45+ }
46+}
47+
48+// Resolve implements port.ColorSchemeResolver.
49+func (r *Resolver) Resolve() port.ColorSchemePreference {
50+ r.mu.RLock()
51+ defer r.mu.RUnlock()
52+ return r.resolveInternal()
53+}
54+
55+// resolveInternal performs the actual resolution without locking.
56+// Caller must hold at least a read lock.
57+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
58+ // Check config for explicit override first
59+ if r.config != nil {
60+ scheme := r.config.GetColorScheme()
61+ switch strings.ToLower(scheme) {
62+ case "prefer-dark", "dark":
63+ return port.ColorSchemePreference{
64+ PrefersDark: true,
65+ Source: sourceConfig,
66+ }
67+ case "prefer-light", "light":
68+ return port.ColorSchemePreference{
69+ PrefersDark: false,
70+ Source: sourceConfig,
71+ }
72+ // "default" or empty falls through to detector chain
73+ }
74+ }
75+
76+ // Sort detectors by priority (highest first)
77+ sorted := make([]port.ColorSchemeDetector, len(r.detectors))
78+ copy(sorted, r.detectors)
79+ sort.Slice(sorted, func(i, j int) bool {
80+ return sorted[i].Priority() > sorted[j].Priority()
81+ })
82+
83+ // Try each detector in priority order
84+ for _, detector := range sorted {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+}
109+
110+// Refresh implements port.ColorSchemeResolver.
111+func (r *Resolver) Refresh() port.ColorSchemePreference {
112+ r.mu.Lock()
113+ defer r.mu.Unlock()
114+
115+ newPref := r.resolveInternal()
116+
117+ // Only notify if preference changed
118+ if newPref.PrefersDark != r.current.PrefersDark {
119+ r.current = newPref
120+ // Copy callbacks to avoid holding lock during callback invocation
121+ callbacks := make([]func(port.ColorSchemePreference), len(r.callbacks))
122+ copy(callbacks, r.callbacks)
123+
124+ // Invoke callbacks outside of lock
125+ r.mu.Unlock()
126+ for _, cb := range callbacks {
127+ cb(newPref)
128+ }
129+ r.mu.Lock()
130+ } else {
131+ r.current = newPref
132+ }
133+
134+ return newPref
135+}
136+
137+// OnChange implements port.ColorSchemeResolver.
138+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
139+ r.mu.Lock()
140+ defer r.mu.Unlock()
141+
142+ r.callbacks = append(r.callbacks, callback)
143+ idx := len(r.callbacks) - 1
144+
145+ // Return unregister function
146+ return func() {
147+ r.mu.Lock()
148+ defer r.mu.Unlock()
149+
150+ // Remove callback by setting to nil and compacting later
151+ if idx < len(r.callbacks) {
152+ r.callbacks = append(r.callbacks[:idx], r.callbacks[idx+1:]...)
153+ }
154+ }
155+}
@@ -0,0 +1,155 @@
1+package colorscheme
2+
3+import (
4+ "sort"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+const (
12+ // sourceFallback indicates no detector provided the preference.
13+ sourceFallback = "fallback"
14+ // sourceConfig indicates the preference came from user config.
15+ sourceConfig = "config"
16+)
17+
18+// ConfigProvider provides access to the color scheme configuration.
19+type ConfigProvider interface {
20+ // GetColorScheme returns the configured color scheme preference.
21+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
22+ GetColorScheme() string
23+}
24+
25+// Resolver implements port.ColorSchemeResolver.
26+// It manages multiple detectors and respects config overrides.
27+type Resolver struct {
28+ mu sync.RWMutex
29+ config ConfigProvider
30+ detectors []port.ColorSchemeDetector
31+ current port.ColorSchemePreference
32+ callbacks []func(port.ColorSchemePreference)
33+}
34+
35+// NewResolver creates a new color scheme resolver.
36+// The config provider is used to check for explicit user preferences.
37+func NewResolver(config ConfigProvider) *Resolver {
38+ return &Resolver{
39+ config: config,
40+ detectors: make([]port.ColorSchemeDetector, 0),
41+ current: port.ColorSchemePreference{
42+ PrefersDark: true, // Default to dark until first Resolve()
43+ Source: sourceFallback,
44+ },
45+ }
46+}
47+
48+// Resolve implements port.ColorSchemeResolver.
49+func (r *Resolver) Resolve() port.ColorSchemePreference {
50+ r.mu.RLock()
51+ defer r.mu.RUnlock()
52+ return r.resolveInternal()
53+}
54+
55+// resolveInternal performs the actual resolution without locking.
56+// Caller must hold at least a read lock.
57+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
58+ // Check config for explicit override first
59+ if r.config != nil {
60+ scheme := r.config.GetColorScheme()
61+ switch strings.ToLower(scheme) {
62+ case "prefer-dark", "dark":
63+ return port.ColorSchemePreference{
64+ PrefersDark: true,
65+ Source: sourceConfig,
66+ }
67+ case "prefer-light", "light":
68+ return port.ColorSchemePreference{
69+ PrefersDark: false,
70+ Source: sourceConfig,
71+ }
72+ // "default" or empty falls through to detector chain
73+ }
74+ }
75+
76+ // Sort detectors by priority (highest first)
77+ sorted := make([]port.ColorSchemeDetector, len(r.detectors))
78+ copy(sorted, r.detectors)
79+ sort.Slice(sorted, func(i, j int) bool {
80+ return sorted[i].Priority() > sorted[j].Priority()
81+ })
82+
83+ // Try each detector in priority order
84+ for _, detector := range sorted {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+}
109+
110+// Refresh implements port.ColorSchemeResolver.
111+func (r *Resolver) Refresh() port.ColorSchemePreference {
112+ r.mu.Lock()
113+ defer r.mu.Unlock()
114+
115+ newPref := r.resolveInternal()
116+
117+ // Only notify if preference changed
118+ if newPref.PrefersDark != r.current.PrefersDark {
119+ r.current = newPref
120+ // Copy callbacks to avoid holding lock during callback invocation
121+ callbacks := make([]func(port.ColorSchemePreference), len(r.callbacks))
122+ copy(callbacks, r.callbacks)
123+
124+ // Invoke callbacks outside of lock
125+ r.mu.Unlock()
126+ for _, cb := range callbacks {
127+ cb(newPref)
128+ }
129+ r.mu.Lock()
130+ } else {
131+ r.current = newPref
132+ }
133+
134+ return newPref
135+}
136+
137+// OnChange implements port.ColorSchemeResolver.
138+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
139+ r.mu.Lock()
140+ defer r.mu.Unlock()
141+
142+ r.callbacks = append(r.callbacks, callback)
143+ idx := len(r.callbacks) - 1
144+
145+ // Return unregister function
146+ return func() {
147+ r.mu.Lock()
148+ defer r.mu.Unlock()
149+
150+ // Remove callback by setting to nil and compacting later
151+ if idx < len(r.callbacks) {
152+ r.callbacks = append(r.callbacks[:idx], r.callbacks[idx+1:]...)
153+ }
154+ }
155+}
A internal/infrastructure/colorscheme/resolver_test.go +425 -0
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}

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 8

Tags 42

↑↓ moveenter openesc close