detached c65109c

refactor(colorscheme): cache sorted detectors and fix Refresh deadlock

commit
c65109cccf6857ab1f0b2e88fd782b863c77b860
author
bnema · · 2025-12-30 04:11 UTC
parent
1d91c2c
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +32 -36
M internal/infrastructure/colorscheme/resolver.go +32 -26
@@ -1,7 +1,8 @@
11 package colorscheme
22
33 import (
4- "sort"
4+ "cmp"
5+ "slices"
56 "strings"
67 "sync"
78
@@ -30,11 +31,12 @@ type callbackWrapper struct {
3031 // Resolver implements port.ColorSchemeResolver.
3132 // It manages multiple detectors and respects config overrides.
3233 type Resolver struct {
33- mu sync.RWMutex
34- config ConfigProvider
35- detectors []port.ColorSchemeDetector
36- current port.ColorSchemePreference
37- callbacks []*callbackWrapper
34+ mu sync.RWMutex
35+ config ConfigProvider
36+ detectors []port.ColorSchemeDetector
37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
3840 }
3941
4042 // NewResolver creates a new color scheme resolver.
@@ -78,15 +80,8 @@ func (r *Resolver) resolveInternal() port.ColorSchemePreference {
7880 }
7981 }
8082
81- // Sort detectors by priority (highest first)
82- sorted := make([]port.ColorSchemeDetector, len(r.detectors))
83- copy(sorted, r.detectors)
84- sort.Slice(sorted, func(i, j int) bool {
85- return sorted[i].Priority() > sorted[j].Priority()
86- })
87-
88- // Try each detector in priority order
89- for _, detector := range sorted {
83+ // Try each detector in priority order (already sorted)
84+ for _, detector := range r.sortedDetectors {
9085 if !detector.Available() {
9186 continue
9287 }
@@ -110,32 +105,43 @@ func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
110105 r.mu.Lock()
111106 defer r.mu.Unlock()
112107 r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
113119 }
114120
115121 // Refresh implements port.ColorSchemeResolver.
116122 func (r *Resolver) Refresh() port.ColorSchemePreference {
117123 r.mu.Lock()
118- defer r.mu.Unlock()
119124
120125 newPref := r.resolveInternal()
121126
122- // Only notify if preference changed
127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
123129 if newPref.PrefersDark != r.current.PrefersDark {
124130 r.current = newPref
125- // Copy callbacks to avoid holding lock during callback invocation
126- callbacks := make([]*callbackWrapper, len(r.callbacks))
131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
127132 copy(callbacks, r.callbacks)
128-
129- // Invoke callbacks outside of lock
130- r.mu.Unlock()
131- for _, cb := range callbacks {
132- cb.fn(newPref)
133- }
134- r.mu.Lock()
135133 } else {
136134 r.current = newPref
137135 }
138136
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
139145 return newPref
140146 }
141147
@@ -1,7 +1,8 @@
1 package colorscheme1 package colorscheme
2 2
3 import (3 import (
4- "sort"4+ "cmp"
5+ "slices"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 8
@@ -30,11 +31,12 @@ type callbackWrapper struct {
30 // Resolver implements port.ColorSchemeResolver.31 // Resolver implements port.ColorSchemeResolver.
31 // It manages multiple detectors and respects config overrides.32 // It manages multiple detectors and respects config overrides.
32 type Resolver struct {33 type Resolver struct {
33- mu sync.RWMutex34+ mu sync.RWMutex
34- config ConfigProvider35+ config ConfigProvider
35- detectors []port.ColorSchemeDetector36+ detectors []port.ColorSchemeDetector
36- current port.ColorSchemePreference37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
37- callbacks []*callbackWrapper38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
38 }40 }
39 41
40 // NewResolver creates a new color scheme resolver.42 // NewResolver creates a new color scheme resolver.
@@ -78,15 +80,8 @@ func (r *Resolver) resolveInternal() port.ColorSchemePreference {
78 }80 }
79 }81 }
80 82
81- // Sort detectors by priority (highest first)83+ // Try each detector in priority order (already sorted)
82- sorted := make([]port.ColorSchemeDetector, len(r.detectors))84+ for _, detector := range r.sortedDetectors {
83- copy(sorted, r.detectors)
84- sort.Slice(sorted, func(i, j int) bool {
85- return sorted[i].Priority() > sorted[j].Priority()
86- })
87-
88- // Try each detector in priority order
89- for _, detector := range sorted {
90 if !detector.Available() {85 if !detector.Available() {
91 continue86 continue
92 }87 }
@@ -110,32 +105,43 @@ func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
110 r.mu.Lock()105 r.mu.Lock()
111 defer r.mu.Unlock()106 defer r.mu.Unlock()
112 r.detectors = append(r.detectors, detector)107 r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
113 }119 }
114 120
115 // Refresh implements port.ColorSchemeResolver.121 // Refresh implements port.ColorSchemeResolver.
116 func (r *Resolver) Refresh() port.ColorSchemePreference {122 func (r *Resolver) Refresh() port.ColorSchemePreference {
117 r.mu.Lock()123 r.mu.Lock()
118- defer r.mu.Unlock()
119 124
120 newPref := r.resolveInternal()125 newPref := r.resolveInternal()
121 126
122- // Only notify if preference changed127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
123 if newPref.PrefersDark != r.current.PrefersDark {129 if newPref.PrefersDark != r.current.PrefersDark {
124 r.current = newPref130 r.current = newPref
125- // Copy callbacks to avoid holding lock during callback invocation131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
126- callbacks := make([]*callbackWrapper, len(r.callbacks))
127 copy(callbacks, r.callbacks)132 copy(callbacks, r.callbacks)
128-
129- // Invoke callbacks outside of lock
130- r.mu.Unlock()
131- for _, cb := range callbacks {
132- cb.fn(newPref)
133- }
134- r.mu.Lock()
135 } else {133 } else {
136 r.current = newPref134 r.current = newPref
137 }135 }
138 136
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
139 return newPref145 return newPref
140 }146 }
141 147
M internal/ui/theme/manager.go +0 -10
@@ -24,16 +24,6 @@ type Manager struct {
2424 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
2525 }
2626
27-// ManagerOption is a functional option for configuring Manager.
28-type ManagerOption func(*Manager)
29-
30-// WithResolver configures the Manager to use a ColorSchemeResolver.
31-func WithResolver(resolver port.ColorSchemeResolver) ManagerOption {
32- return func(m *Manager) {
33- m.colorResolver = resolver
34- }
35-}
36-
3727 // NewManager creates a new theme manager from configuration.
3828 // The ColorSchemeResolver is required for proper color scheme detection.
3929 func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
@@ -24,16 +24,6 @@ type Manager struct {
24 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection24 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
25 }25 }
26 26
27-// ManagerOption is a functional option for configuring Manager.
28-type ManagerOption func(*Manager)
29-
30-// WithResolver configures the Manager to use a ColorSchemeResolver.
31-func WithResolver(resolver port.ColorSchemeResolver) ManagerOption {
32- return func(m *Manager) {
33- m.colorResolver = resolver
34- }
35-}
36-
37 // NewManager creates a new theme manager from configuration.27 // NewManager creates a new theme manager from configuration.
38 // The ColorSchemeResolver is required for proper color scheme detection.28 // The ColorSchemeResolver is required for proper color scheme detection.
39 func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {29 func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {

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