1package daemon
2
3import (
4 "errors"
5 "fmt"
6 "maps"
7 "regexp"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/domain/terminalcap"
14 "github.com/bnema/vev/internal/usecase/command"
15 "github.com/bnema/vev/internal/usecase/keys"
16 "github.com/bnema/vev/internal/usecase/theme"
17)
18
19var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
20
21// themeConfigSnapshot is published as one immutable value. Its zero value is
22// the default theme configuration: automatic mode with palette inheritance on.
23type themeConfigSnapshot struct {
24 mode domain.ThemeMode
25 paletteOff bool
26 accent domain.ThemeAccent
27}
28
29func (d *Daemon) storeThemeConfig(cfg domain.Config) {
30 snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
31 d.themeConfig.Store(&snapshot)
32}
33
34func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
35 snapshot := d.themeConfig.Load()
36 if snapshot == nil {
37 return themeConfigSnapshot{}
38 }
39 return *snapshot
40}
41
42// ApplyConfig validates and atomically swaps daemon runtime configuration.
43func (d *Daemon) ApplyConfig(cfg domain.Config) {
44 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
45 overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)
46 allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)
47 scrollback := cfg.Scrollback
48 if !scrollback.Valid() {
49 scrollback = domain.DefaultScrollbackConfig()
50 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
51 }
52 d.scrollbackConfig.Store(&scrollback)
53 for _, warning := range allWarnings {
54 d.logConfigWarning(warning)
55 }
56 allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
57 d.bindings.Store(bindings)
58 d.codeOverrides.Store(&overrides)
59 d.restoreProcessAllowlist.Store(&allowlist)
60 floating := cfg.Floating
61 d.floatingConfig.Store(&floating)
62 copyConfig := cfg.Copy
63 d.copyConfig.Store(©Config)
64 palette := cfg.Palette
65 d.paletteConfig.Store(&palette)
66 nav := cfg.Nav
67 d.navConfig.Store(&nav)
68 tabs := cfg.Tabs
69 d.tabsConfig.Store(&tabs)
70 ephemeral := cfg.Ephemeral
71 d.ephemeralConfig.Store(&ephemeral)
72 d.storeThemeConfig(cfg)
73 d.applyHistoryLimits()
74 barChanged := false
75 if d.barScripts != nil {
76 d.barScripts.mu.Lock()
77 d.barScripts.initLocked()
78 barCfg := barConfigFromDomain(cfg.Bar)
79 if d.barScripts.cfg != barCfg {
80 d.barScripts.cfg = barCfg
81 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
82 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
83 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
84 d.barScripts.pending = make(map[domain.SessionID]bool)
85 d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
86 d.barScripts.version++
87 barChanged = true
88 }
89 d.barScripts.mu.Unlock()
90 }
91 if barChanged {
92 d.signalBarPollerReload()
93 d.refreshBarScriptsAllSessions()
94 }
95 d.reapplyThemeAllSessions()
96 d.repaintAllAttachedClients()
97
98 if len(allWarnings) > 0 {
99 d.notify(nil, domain.NoticeWarn, domain.NoticeConfigReload,
100 configReloadNoticeMessage(allWarnings), configWarningsError(allWarnings))
101 }
102}
103
104func configReloadNoticeMessage(warnings []domain.Warning) string {
105 first := warnings[0]
106 if first.Line > 0 {
107 return fmt.Sprintf("config reloaded with %d warning(s): line %d: %s", len(warnings), first.Line, first.Msg)
108 }
109 return fmt.Sprintf("config reloaded with %d warning(s): %s", len(warnings), first.Msg)
110}
111
112// configWarningsError joins every config warning into a single error whose
113// message lists them all, so noticeDetails (which renders a cause's own
114// Error() string) surfaces every warning line in one notice's Details.
115func configWarningsError(warnings []domain.Warning) error {
116 lines := make([]string, len(warnings))
117 for i, w := range warnings {
118 if w.Line > 0 {
119 lines[i] = fmt.Sprintf("line %d: %s", w.Line, w.Msg)
120 } else {
121 lines[i] = w.Msg
122 }
123 }
124 return errors.New(strings.Join(lines, "; "))
125}
126
127func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {
128 commands := command.PaletteRegistry()
129 bySlug := make(map[string]command.Command, len(commands))
130 for _, cmd := range commands {
131 bySlug[cmd.Slug] = cmd
132 }
133
134 var warnings []domain.Warning
135 desired := make(map[string]string)
136 for _, slug := range sortedKeys(configured) {
137 if _, ok := bySlug[slug]; !ok {
138 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
139 continue
140 }
141 code := strings.ToUpper(strings.TrimSpace(configured[slug]))
142 if !commandCodePattern.MatchString(code) {
143 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
144 continue
145 }
146 desired[slug] = code
147 }
148
149 // The effective code for a slug is its accepted override if it has one,
150 // or its registry default otherwise. Resolve to a fixpoint rather than a
151 // single sorted pass: dropping one slug's override to resolve a conflict
152 // can free up its default code, which may in turn conflict with a
153 // *different* slug's override — that must be re-checked regardless of
154 // where the two slugs fall in sort order.
155 accepted := make(map[string]string, len(desired))
156 maps.Copy(accepted, desired)
157 effectiveCode := func(slug string) string {
158 if code, ok := accepted[slug]; ok {
159 return code
160 }
161 return bySlug[slug].Code
162 }
163
164 for {
165 claimants := make(map[string][]string, len(commands))
166 for _, cmd := range commands {
167 code := effectiveCode(cmd.Slug)
168 claimants[code] = append(claimants[code], cmd.Slug)
169 }
170
171 dropped := false
172 conflictCodes := make([]string, 0, len(claimants))
173 for code, slugs := range claimants {
174 if len(slugs) > 1 {
175 conflictCodes = append(conflictCodes, code)
176 }
177 }
178 sort.Strings(conflictCodes)
179 for _, code := range conflictCodes {
180 slugs := claimants[code]
181 sort.Strings(slugs)
182
183 // If one claimant holds this code by default (no accepted
184 // override), every override claimant loses to it and is
185 // dropped. Otherwise every claimant is an override; keep the
186 // lexicographically smallest slug and drop the rest.
187 keep := ""
188 for _, slug := range slugs {
189 if _, isOverride := accepted[slug]; !isOverride {
190 keep = slug
191 break
192 }
193 }
194 if keep == "" {
195 keep = slugs[0]
196 }
197 for _, slug := range slugs {
198 if slug == keep {
199 continue
200 }
201 if _, isOverride := accepted[slug]; !isOverride {
202 continue
203 }
204 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
205 delete(accepted, slug)
206 dropped = true
207 }
208 }
209 if !dropped {
210 break
211 }
212 }
213 return accepted, warnings
214}
215
216func sortedKeys(values map[string]string) []string {
217 keys := make([]string, 0, len(values))
218 for key := range values {
219 keys = append(keys, key)
220 }
221 sort.Strings(keys)
222 return keys
223}
224
225func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
226 if cfg.RestoreProcessesSet {
227 return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
228 }
229 if len(cfg.RestoreProcesses) > 0 {
230 return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
231 }
232 return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
233}
234
235func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
236 allow := make(map[string]struct{}, len(values))
237 for _, value := range values {
238 value = strings.TrimSpace(value)
239 if value == "" {
240 continue
241 }
242 allow[value] = struct{}{}
243 }
244 return allow
245}
246
247func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
248 allow := d.restoreProcessAllowlist.Load()
249 if allow == nil {
250 return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
251 }
252 return *allow
253}
254
255func (d *Daemon) logConfigWarning(w domain.Warning) {
256 if w.Line > 0 {
257 d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
258 return
259 }
260 d.log.Warn("config warning", "msg", w.Msg)
261}
262
263func (d *Daemon) currentCopyConfig() domain.CopyConfig {
264 if cfg := d.copyConfig.Load(); cfg != nil {
265 return *cfg
266 }
267 return domain.Defaults().Copy
268}
269
270func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
271 if cfg := d.paletteConfig.Load(); cfg != nil {
272 return *cfg
273 }
274 return domain.Defaults().Palette
275}
276
277func (d *Daemon) currentNavConfig() domain.NavConfig {
278 if cfg := d.navConfig.Load(); cfg != nil {
279 return *cfg
280 }
281 return domain.Defaults().Nav
282}
283
284func (d *Daemon) currentTabsConfig() domain.TabsConfig {
285 if cfg := d.tabsConfig.Load(); cfg != nil {
286 return *cfg
287 }
288 return domain.Defaults().Tabs
289}
290
291func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {
292 if cfg := d.ephemeralConfig.Load(); cfg != nil {
293 return *cfg
294 }
295 return domain.Defaults().Ephemeral
296}
297
298func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
299 if cfg := d.floatingConfig.Load(); cfg != nil {
300 return *cfg
301 }
302 return domain.Defaults().Floating
303}
304
305func (d *Daemon) codeOverrideSnapshot() map[string]string {
306 overrides := d.codeOverrides.Load()
307 if overrides == nil {
308 return nil
309 }
310 return *overrides
311}
312
313func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
314 if code, ok := overrides[cmd.Slug]; ok {
315 cmd.Code = code
316 }
317 return cmd
318}
319
320func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
321 code = strings.ToUpper(strings.TrimSpace(code))
322 overrides := d.codeOverrideSnapshot()
323 for _, cmd := range command.PaletteRegistry() {
324 cmd = commandWithOverrides(cmd, overrides)
325 if cmd.Code == code {
326 return cmd, true
327 }
328 }
329 return command.Command{}, false
330}
331
332func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme {
333 switch config.mode {
334 case domain.ThemeDark:
335 return theme.BuiltinDark
336 case domain.ThemeLight:
337 return theme.BuiltinLight
338 default:
339 clientTheme.UsePalette = !config.paletteOff
340 return clientTheme
341 }
342}
343
344func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
345 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
346}
347
348// resolveAppliedTheme resolves chrome for one attachment's color capability;
349// ANSI16 and monochrome attachments get attribute-based chrome, so the result
350// is never shared between attachments of different modes.
351func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme {
352 config := d.currentThemeConfig()
353 effective := effectiveThemeForConfig(raw, config)
354 resolved := theme.ResolveForColor(effective, config.accent, color)
355 return appliedTheme{Raw: resolved.Theme, Resolved: resolved}
356}
357
358func (d *Daemon) reapplyThemeAllSessions() {
359 d.mu.Lock()
360 sessions := sessionsSnapshot(d.sessions)
361 d.mu.Unlock()
362
363 for _, sess := range sessions {
364 d.reapplyThemeSession(sess)
365 }
366}
367
368func (d *Daemon) reapplyThemeSession(sess *session) {
369 attachments := sess.snapshotAttachments()
370 if len(attachments) == 0 {
371 d.applyHostTheme(sess, nil, theme.Theme{}, true)
372 return
373 }
374 for _, ac := range attachments {
375 ac.sendMu.Lock()
376 sess.themeMu.Lock()
377 d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
378 sess.themeMu.Unlock()
379 ac.sendMu.Unlock()
380 }
381}