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