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 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(&copyConfig)
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}

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