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}
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}

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