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

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