on main help sign in
ci(sec): add more security jobs, improve build, enable race detector (#1849)
3fd9d97Carlos Alexandro Becker
.github/workflows/build.yml+22−6
@@ -1,11 +1,27 @@
1name: build
1name: build
2on: [push, pull_request]
2on: [push, pull_request]
3
3
4+permissions:
5+ contents: read
6+
7+concurrency:
8+ group: build-${{ github.event.pull_request.number || github.ref }}
9+ cancel-in-progress: true
10+
4jobs:
11jobs:
5 build:
12 build:
6− uses: charmbracelet/meta/.github/workflows/build.yml@main
13+ strategy:
7− with:
14+ matrix:
8− go-version: ""
15+ os: [ubuntu-latest, macos-latest, windows-latest]
9− go-version-file: ./go.mod
16+ runs-on: ${{ matrix.os }}
10− secrets:
17+ steps:
11− gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
18+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
19+ with:
20+ persist-credentials: false
21+ - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
22+ with:
23+ go-version-file: go.mod
24+ - run: go mod tidy
25+ - run: git diff --exit-code
26+ - run: go build -race ./...
27+ - run: go test -race -failfast ./...
.github/workflows/security.ymlnew file+88
@@ -0,0 +1,88 @@
1+name: "security"
2+
3+on:
4+ pull_request:
5+ push:
6+ branches: [main]
7+ schedule:
8+ - cron: "0 2 * * *"
9+
10+permissions:
11+ contents: read
12+
13+concurrency:
14+ group: security-${{ github.event.pull_request.number || github.ref }}
15+ cancel-in-progress: true
16+
17+jobs:
18+ codeql:
19+ runs-on: ubuntu-latest
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ language: ["go", "actions"]
24+ permissions:
25+ actions: read
26+ contents: read
27+ pull-requests: read
28+ security-events: write
29+ steps:
30+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
31+ with:
32+ persist-credentials: false
33+ - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
34+ with:
35+ languages: ${{ matrix.language }}
36+ - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
37+ - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
38+
39+ grype:
40+ runs-on: ubuntu-latest
41+ permissions:
42+ security-events: write
43+ actions: read
44+ contents: read
45+ steps:
46+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
47+ with:
48+ persist-credentials: false
49+ - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1
50+ id: scan
51+ with:
52+ path: "."
53+ fail-build: true
54+ severity-cutoff: critical
55+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
56+ with:
57+ sarif_file: ${{ steps.scan.outputs.sarif }}
58+
59+ govulncheck:
60+ runs-on: ubuntu-latest
61+ permissions:
62+ security-events: write
63+ contents: read
64+ steps:
65+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
66+ with:
67+ persist-credentials: false
68+ - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
69+ with:
70+ output-format: sarif
71+ output-file: results.sarif
72+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
73+ with:
74+ sarif_file: results.sarif
75+
76+ dependency-review:
77+ runs-on: ubuntu-latest
78+ if: github.event_name == 'pull_request'
79+ permissions:
80+ contents: read
81+ steps:
82+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
83+ with:
84+ persist-credentials: false
85+ - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
86+ with:
87+ fail-on-severity: critical
88+ allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC
Taskfile.yaml+6−4
@@ -5,6 +5,8 @@ version: "3"
5vars:
5vars:
6 VERSION:
6 VERSION:
7 sh: git describe --long 2>/dev/null || echo ""
7 sh: git describe --long 2>/dev/null || echo ""
8+ RACE:
9+ sh: test -f race.log && echo "1" || echo ""
8
10
9env:
11env:
10 CGO_ENABLED: 0
12 CGO_ENABLED: 0
@@ -37,20 +39,20 @@ tasks:
37 vars:
39 vars:
38 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
40 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
39 cmds:
41 cmds:
40− - go build {{.LDFLAGS}} .
42+ - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
41 generates:
43 generates:
42 - crush
44 - crush
43
45
44 run:
46 run:
45 desc: Run build
47 desc: Run build
46 cmds:
48 cmds:
47− - go build -o crush .
49+ - task: build
48− - ./crush {{.CLI_ARGS}}
50+ - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
49
51
50 test:
52 test:
51 desc: Run tests
53 desc: Run tests
52 cmds:
54 cmds:
53− - go test ./... {{.CLI_ARGS}}
55+ - go test -race -failfast ./... {{.CLI_ARGS}}
54
56
55 test:record:
57 test:record:
56 desc: Run tests and record all VCR cassettes again
58 desc: Run tests and record all VCR cassettes again
go.mod+1−1
@@ -29,7 +29,7 @@ require (
29 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
29 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
30 github.com/charmbracelet/x/exp/ordered v0.1.0
30 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
31 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32− github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
32+ github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
33 github.com/charmbracelet/x/term v0.2.2
33 github.com/charmbracelet/x/term v0.2.2
34 github.com/denisbrodbeck/machineid v1.0.1
34 github.com/denisbrodbeck/machineid v1.0.1
35 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
35 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
go.sum+2−2
@@ -118,8 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
118github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
118github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
119github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
119github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
121−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
121+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
122−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
122+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
123github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
123github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
124github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
124github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
125github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
125github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/csync/maps_test.go+4−3
@@ -4,6 +4,7 @@ import (
4 "encoding/json"
4 "encoding/json"
5 "maps"
5 "maps"
6 "sync"
6 "sync"
7+ "sync/atomic"
7 "testing"
8 "testing"
8 "testing/synctest"
9 "testing/synctest"
9 "time"
10 "time"
@@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) {
46
47
47 waiter := sync.Mutex{}
48 waiter := sync.Mutex{}
48 waiter.Lock()
49 waiter.Lock()
49− loadCalled := false
50+ var loadCalled atomic.Bool
50
51
51 loadFunc := func() map[string]int {
52 loadFunc := func() map[string]int {
52 waiter.Lock()
53 waiter.Lock()
53 defer waiter.Unlock()
54 defer waiter.Unlock()
54− loadCalled = true
55+ loadCalled.Store(true)
55 return map[string]int{
56 return map[string]int{
56 "key1": 1,
57 "key1": 1,
57 "key2": 2,
58 "key2": 2,
@@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) {
63
64
64 waiter.Unlock() // Allow the load function to proceed
65 waiter.Unlock() // Allow the load function to proceed
65 time.Sleep(100 * time.Millisecond)
66 time.Sleep(100 * time.Millisecond)
66− require.True(t, loadCalled)
67+ require.True(t, loadCalled.Load())
67 require.Equal(t, 2, m.Len())
68 require.Equal(t, 2, m.Len())
68
69
69 value, ok := m.Get("key1")
70 value, ok := m.Get("key1")
internal/permission/permission.go+11−2
@@ -68,8 +68,9 @@ type permissionService struct {
68 allowedTools []string
68 allowedTools []string
69
69
70 // used to make sure we only process one request at a time
70 // used to make sure we only process one request at a time
71− requestMu sync.Mutex
71+ requestMu sync.Mutex
72− activeRequest *PermissionRequest
72+ activeRequest *PermissionRequest
73+ activeRequestMu sync.Mutex
73}
74}
74
75
75func (s *permissionService) GrantPersistent(permission PermissionRequest) {
76func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
86 s.sessionPermissions = append(s.sessionPermissions, permission)
87 s.sessionPermissions = append(s.sessionPermissions, permission)
87 s.sessionPermissionsMu.Unlock()
88 s.sessionPermissionsMu.Unlock()
88
89
90+ s.activeRequestMu.Lock()
89 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
91 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
90 s.activeRequest = nil
92 s.activeRequest = nil
91 }
93 }
94+ s.activeRequestMu.Unlock()
92}
95}
93
96
94func (s *permissionService) Grant(permission PermissionRequest) {
97func (s *permissionService) Grant(permission PermissionRequest) {
@@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) {
101 respCh <- true
104 respCh <- true
102 }
105 }
103
106
107+ s.activeRequestMu.Lock()
104 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
108 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105 s.activeRequest = nil
109 s.activeRequest = nil
106 }
110 }
111+ s.activeRequestMu.Unlock()
107}
112}
108
113
109func (s *permissionService) Deny(permission PermissionRequest) {
114func (s *permissionService) Deny(permission PermissionRequest) {
@@ -117,9 +122,11 @@ func (s *permissionService) Deny(permission PermissionRequest) {
117 respCh <- false
122 respCh <- false
118 }
123 }
119
124
125+ s.activeRequestMu.Lock()
120 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
126 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121 s.activeRequest = nil
127 s.activeRequest = nil
122 }
128 }
129+ s.activeRequestMu.Unlock()
123}
130}
124
131
125func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
132func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
@@ -190,7 +197,9 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
190 }
197 }
191 s.sessionPermissionsMu.RUnlock()
198 s.sessionPermissionsMu.RUnlock()
192
199
200+ s.activeRequestMu.Lock()
193 s.activeRequest = &permission
201 s.activeRequest = &permission
202+ s.activeRequestMu.Unlock()
194
203
195 respCh := make(chan bool, 1)
204 respCh := make(chan bool, 1)
196 s.pendingRequests.Set(permission.ID, respCh)
205 s.pendingRequests.Set(permission.ID, respCh)
internal/permission/permission_test.go+2−2
@@ -189,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
189 events := service.Subscribe(t.Context())
189 events := service.Subscribe(t.Context())
190
190
191 var wg sync.WaitGroup
191 var wg sync.WaitGroup
192− results := make([]bool, 0)
192+ results := make([]bool, 3)
193
193
194 requests := []CreatePermissionRequest{
194 requests := []CreatePermissionRequest{
195 {
195 {
@@ -220,7 +220,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
220 go func(index int, request CreatePermissionRequest) {
220 go func(index int, request CreatePermissionRequest) {
221 defer wg.Done()
221 defer wg.Done()
222 result, _ := service.Request(t.Context(), request)
222 result, _ := service.Request(t.Context(), request)
223− results = append(results, result)
223+ results[index] = result
224 }(i, req)
224 }(i, req)
225 }
225 }
226
226
internal/shell/background.go+36−7
@@ -19,6 +19,30 @@ const (
19 CompletedJobRetentionMinutes = 8 * 60
19 CompletedJobRetentionMinutes = 8 * 60
20)
20)
21
21
22+// syncBuffer is a thread-safe wrapper around bytes.Buffer.
23+type syncBuffer struct {
24+ buf bytes.Buffer
25+ mu sync.RWMutex
26+}
27+
28+func (sb *syncBuffer) Write(p []byte) (n int, err error) {
29+ sb.mu.Lock()
30+ defer sb.mu.Unlock()
31+ return sb.buf.Write(p)
32+}
33+
34+func (sb *syncBuffer) WriteString(s string) (n int, err error) {
35+ sb.mu.Lock()
36+ defer sb.mu.Unlock()
37+ return sb.buf.WriteString(s)
38+}
39+
40+func (sb *syncBuffer) String() string {
41+ sb.mu.RLock()
42+ defer sb.mu.RUnlock()
43+ return sb.buf.String()
44+}
45+
22// BackgroundShell represents a shell running in the background.
46// BackgroundShell represents a shell running in the background.
23type BackgroundShell struct {
47type BackgroundShell struct {
24 ID string
48 ID string
@@ -28,8 +52,8 @@ type BackgroundShell struct {
28 WorkingDir string
52 WorkingDir string
29 ctx context.Context
53 ctx context.Context
30 cancel context.CancelFunc
54 cancel context.CancelFunc
31− stdout *bytes.Buffer
55+ stdout *syncBuffer
32− stderr *bytes.Buffer
56+ stderr *syncBuffer
33 done chan struct{}
57 done chan struct{}
34 exitErr error
58 exitErr error
35 completedAt int64 // Unix timestamp when job completed (0 if still running)
59 completedAt int64 // Unix timestamp when job completed (0 if still running)
@@ -46,12 +70,17 @@ var (
46 idCounter atomic.Uint64
70 idCounter atomic.Uint64
47)
71)
48
72
73+// newBackgroundShellManager creates a new BackgroundShellManager instance.
74+func newBackgroundShellManager() *BackgroundShellManager {
75+ return &BackgroundShellManager{
76+ shells: csync.NewMap[string, *BackgroundShell](),
77+ }
78+}
79+
49// GetBackgroundShellManager returns the singleton background shell manager.
80// GetBackgroundShellManager returns the singleton background shell manager.
50func GetBackgroundShellManager() *BackgroundShellManager {
81func GetBackgroundShellManager() *BackgroundShellManager {
51 backgroundManagerOnce.Do(func() {
82 backgroundManagerOnce.Do(func() {
52− backgroundManager = &BackgroundShellManager{
83+ backgroundManager = newBackgroundShellManager()
53− shells: csync.NewMap[string, *BackgroundShell](),
54− }
55 })
84 })
56 return backgroundManager
85 return backgroundManager
57}
86}
@@ -80,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b
80 Shell: shell,
109 Shell: shell,
81 ctx: shellCtx,
110 ctx: shellCtx,
82 cancel: cancel,
111 cancel: cancel,
83− stdout: &bytes.Buffer{},
112+ stdout: &syncBuffer{},
84− stderr: &bytes.Buffer{},
113+ stderr: &syncBuffer{},
85 done: make(chan struct{}),
114 done: make(chan struct{}),
86 }
115 }
87
116
internal/shell/background_test.go+8−8
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
14
14
15 ctx := context.Background()
15 ctx := context.Background()
16 workingDir := t.TempDir()
16 workingDir := t.TempDir()
17− manager := GetBackgroundShellManager()
17+ manager := newBackgroundShellManager()
18
18
19 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
19 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
20 if err != nil {
20 if err != nil {
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
51
51
52 ctx := context.Background()
52 ctx := context.Background()
53 workingDir := t.TempDir()
53 workingDir := t.TempDir()
54− manager := GetBackgroundShellManager()
54+ manager := newBackgroundShellManager()
55
55
56 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
56 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
57 if err != nil {
57 if err != nil {
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
77
77
78 ctx := context.Background()
78 ctx := context.Background()
79 workingDir := t.TempDir()
79 workingDir := t.TempDir()
80− manager := GetBackgroundShellManager()
80+ manager := newBackgroundShellManager()
81
81
82 // Start a long-running command
82 // Start a long-running command
83 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
83 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
107 t.Parallel()
107 t.Parallel()
108
108
109− manager := GetBackgroundShellManager()
109+ manager := newBackgroundShellManager()
110
110
111 err := manager.Kill("non-existent-id")
111 err := manager.Kill("non-existent-id")
112 if err == nil {
112 if err == nil {
@@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
119
119
120 ctx := context.Background()
120 ctx := context.Background()
121 workingDir := t.TempDir()
121 workingDir := t.TempDir()
122− manager := GetBackgroundShellManager()
122+ manager := newBackgroundShellManager()
123
123
124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
125 if err != nil {
125 if err != nil {
@@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
142
142
143 ctx := context.Background()
143 ctx := context.Background()
144 workingDir := t.TempDir()
144 workingDir := t.TempDir()
145− manager := GetBackgroundShellManager()
145+ manager := newBackgroundShellManager()
146
146
147 blockFuncs := []BlockFunc{
147 blockFuncs := []BlockFunc{
148 CommandsBlocker([]string{"curl", "wget"}),
148 CommandsBlocker([]string{"curl", "wget"}),
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
180
180
181 ctx := context.Background()
181 ctx := context.Background()
182 workingDir := t.TempDir()
182 workingDir := t.TempDir()
183− manager := GetBackgroundShellManager()
183+ manager := newBackgroundShellManager()
184
184
185 // Start two shells
185 // Start two shells
186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
224
224
225 ctx := context.Background()
225 ctx := context.Background()
226 workingDir := t.TempDir()
226 workingDir := t.TempDir()
227− manager := GetBackgroundShellManager()
227+ manager := newBackgroundShellManager()
228
228
229 // Start multiple long-running shells
229 // Start multiple long-running shells
230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
internal/tui/styles/theme.go+19−13
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "image/color"
5 "image/color"
6 "strings"
6 "strings"
7+ "sync"
7
8
8 "charm.land/bubbles/v2/filepicker"
9 "charm.land/bubbles/v2/filepicker"
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/help"
@@ -97,7 +98,8 @@ type Theme struct {
97 AuthBorderUnselected lipgloss.Style
98 AuthBorderUnselected lipgloss.Style
98 AuthTextUnselected lipgloss.Style
99 AuthTextUnselected lipgloss.Style
99
100
100− styles *Styles
101+ styles *Styles
102+ stylesOnce sync.Once
101}
103}
102
104
103type Styles struct {
105type Styles struct {
@@ -134,9 +136,9 @@ type Styles struct {
134}
136}
135
137
136func (t *Theme) S() *Styles {
138func (t *Theme) S() *Styles {
137− if t.styles == nil {
139+ t.stylesOnce.Do(func() {
138 t.styles = t.buildStyles()
140 t.styles = t.buildStyles()
139− }
141+ })
140 return t.styles
142 return t.styles
141}
143}
142
144
@@ -500,27 +502,31 @@ type Manager struct {
500 current *Theme
502 current *Theme
501}
503}
502
504
505+var (
503−var defaultManager *Manager
506+ defaultManager *Manager
507+ defaultManagerOnce sync.Once
508+)
509+
510+func initDefaultManager() *Manager {
511+ defaultManagerOnce.Do(func() {
512+ defaultManager = newManager()
513+ })
514+ return defaultManager
515+}
504
516
505func SetDefaultManager(m *Manager) {
517func SetDefaultManager(m *Manager) {
506 defaultManager = m
518 defaultManager = m
507}
519}
508
520
509func DefaultManager() *Manager {
521func DefaultManager() *Manager {
510− if defaultManager == nil {
522+ return initDefaultManager()
511− defaultManager = NewManager()
512− }
513− return defaultManager
514}
523}
515
524
516func CurrentTheme() *Theme {
525func CurrentTheme() *Theme {
517− if defaultManager == nil {
518− defaultManager = NewManager()
519− }
520− return defaultManager.Current()
526+ return initDefaultManager().Current()
521}
527}
522
528
523−func NewManager() *Manager {
529+func newManager() *Manager {
524 m := &Manager{
530 m := &Manager{
525 themes: make(map[string]*Theme),
531 themes: make(map[string]*Theme),
526 }
532 }