detached ece7682

config.go RawHistoryFolder

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}

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 12

Tags 11

↑↓ moveenter openesc close