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