Files
yellowjacket/backend/config/config.go
T
yonluandClaude Opus 5 3d375adab1 feat(downloads): bound auto-pick by bitrate, and take a good copy
Three faults, one subsystem, and the middle one is why a request that
looked obviously satisfiable came back refused.

**The guardrails were in megabytes, which cannot mean anything.** 300 MB
is a generous FLAC single and a suspiciously small boxset, and whoever
fills the field in has no idea which release the pipeline will apply it
to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided
by how long the music is, so one number holds across a nine-minute EP
and a three-hour opera. The runtime comes from `Download.Expected`,
which every anchored request already carries, so this costs no lookup;
the rate is audio bytes over that, falling back to the mean stated
per-file bitrate when the runtime is unknown. Artwork is excluded from
the numerator, or a folder with 30 MB of scans reads as a better rip.

An unknown runtime *passes* the window rather than failing it: the
window is a statement about quality, and refusing everything the moment
MusicBrainz is missing a track length would be a silent embargo.
`MaxFileSizeMB` survives as a separate ceiling, still in megabytes on
purpose -- it is a question about disk space, and it has to apply to a
candidate whose bitrate cannot be worked out at all.

**Auto-pick required daylight over the runner-up**, 0.08 on the
combined score, and so fired hardest in the case it was never written
for: a popular album turns up five *correct* copies, all matching the
tracklist at 95%+ and differing only in format and seeders, their
scores land within a point of each other, and it refused forever on the
grounds that the choice was the user's. It was not. There was no
question about what to fetch, only about which copy -- and abundance is
the condition under which that matters least. A candidate no longer has
to beat the field, only clear the bars on its own terms; where several
do, ranking puts the one closest to the preferred bitrate first.

That tie-break needed the preference to carry weight or it would have
been decorative in a new unit: `BitrateFit` was 0.05 against format's
0.42, so asking for 320 and being handed a FLAC every time was the
designed behaviour. When a preference is set the weights shift to fit
0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics
that exist as stand-ins for the preference the user has now given.
Health and priority are untouched. And the fit spans 0.5 to 1.0 rather
than 0 to 1, so a preference can promote the copy that matches it and
can never push the others under `minQuality` -- turning "I like 320"
into "never take anything else" silently is what `MinKbps`/`MaxKbps`
are for, out loud.

**And a refusal quoted numbers that passed.** The request list built its
message from `ranked[0]` -- the best candidate *before* the guardrails
and before the lead check -- so a request killed by the size window, or
by having too many good copies, reported "best of 12 found is not a
confident enough match (match 96%, quality 88%)". `AutoPickVeto` names
the gate that actually refused, and `AutoPickable` is that returning
empty.

Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not
migrated. A number meaning "300 MB" cannot be reinterpreted as a rate
without knowing the album it was aimed at, so carrying it over would be
inventing an intent nobody expressed. Those two fall back to no window,
which is the permissive default and what a fresh install gets;
`MaxFileSizeMB` carries over unchanged, because a ceiling on bytes
still means exactly what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:51 -04:00

955 lines
21 KiB
Go

// Package config manages application configuration persistence.
package config
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path"
"github.com/BurntSushi/toml"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
"yellowjacket/backend/favorites"
"yellowjacket/backend/library"
"yellowjacket/backend/shortcuts"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
"yellowjacket/backend/tracklist"
)
// errSaveBeforeLoad is returned by Save when the in-memory config
// hasn't been hydrated from disk yet. Prevents writing a default-
// only struct over a real config file during abnormal lifecycle
// sequences (failed startup, racing shutdown).
var errSaveBeforeLoad = errors.New("refusing to save: config not loaded from disk")
// Config represents the application configuration.
type Config struct {
ctx context.Context
logger *slog.Logger
filePath string // required
loaded bool // true once Load() succeeds
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
General *GeneralConfig `toml:"General"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
Shortcuts *shortcuts.Config `toml:"Shortcuts"`
Downloads *download.UserConfig `toml:"Downloads"`
}
// NewConfig creates a new config by loading it from disk.
func NewConfig(logger *slog.Logger) (*Config, error) {
confDir, err := system.GetUserConfigDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user config directory: %w", err)
}
conf := &Config{
filePath: path.Join(confDir, "config.toml"),
}
conf.applyDefaults()
conf.logger = logger.WithGroup("config").With("config", conf)
if err := conf.Load(); err != nil {
return nil, fmt.Errorf("could not load config: %w", err)
}
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return conf, nil
}
// Validate returns errors if there is a breaking issue with the config.
func (c *Config) Validate() error {
var configErrs error
if c.Library != nil {
if len(c.Library.DirectoryPath) != 0 {
if err := c.Library.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
}
if c.Theme != nil {
if err := c.Theme.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.General != nil {
if err := c.General.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.TrackList != nil {
if err := c.TrackList.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.Favorites != nil {
if err := c.Favorites.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.Shortcuts != nil {
if err := c.Shortcuts.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if configErrs != nil {
return fmt.Errorf(
"one or more config parts are invalid: %w",
configErrs,
)
}
return nil
}
// Load reads and parses the config file from disk.
func (c *Config) Load() error {
if _, err := os.Stat(c.filePath); err != nil {
if os.IsNotExist(err) {
c.logger.Debug("no config file exists, creating empty config")
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save empty config to file (%s): %w",
c.filePath,
err,
)
}
} else {
return fmt.Errorf("could not get file info (%s): %w", c.filePath, err)
}
}
// read in the file
confFileData, err := os.ReadFile(c.filePath)
if err != nil {
return fmt.Errorf("problem reading config file %s: %w", c.filePath, err)
}
// parse it into the config struct
_, err = toml.Decode(string(confFileData), c)
if err != nil {
return fmt.Errorf("problem parsing config file %s: %w", c.filePath, err)
}
c.applyDefaults()
// validate the config
if err = c.Validate(); err != nil {
return fmt.Errorf("invalid config file at %s: %w", c.filePath, err)
}
c.logger.Debug("loaded config file", "file", c.filePath)
c.loaded = true
return nil
}
// Save writes the config to disk. Refuses to write if the config
// was never successfully loaded — prevents overwriting user config
// with defaults during abnormal startup/shutdown sequences.
func (c *Config) Save() error {
if !c.loaded {
// Allow the initial save when the file doesn't exist yet.
if _, err := os.Stat(c.filePath); err == nil {
return errSaveBeforeLoad
}
}
if err := c.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
confFileData, err := toml.Marshal(c)
if err != nil {
return fmt.Errorf("could not marshal config struct: %w", err)
}
// Write atomically: marshal into a temp file in the same directory,
// then rename it over the target. os.WriteFile truncates the file
// in place before writing, so a crash or kill mid-write (common
// during dev restarts) can leave a truncated — often empty — config.
// An empty TOML file loads "successfully" as all-defaults and then
// gets re-saved as defaults, silently wiping the user's settings.
// A temp-file + rename makes the replacement atomic: a reader always
// sees either the previous file or the complete new one.
tmp, err := os.CreateTemp(path.Dir(c.filePath), "config-*.toml.tmp")
if err != nil {
return fmt.Errorf("could not create temp config file: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if we bail before the rename succeeds.
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(confFileData); err != nil {
_ = tmp.Close()
return fmt.Errorf("could not write temp config file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("could not sync temp config file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("could not close temp config file: %w", err)
}
if err := os.Chmod(tmpName, 0o644); err != nil {
return fmt.Errorf("could not set config file permissions: %w", err)
}
if err := os.Rename(tmpName, c.filePath); err != nil {
return fmt.Errorf("could not replace config file (%s): %w", c.filePath, err)
}
c.logger.Debug("saved config to file", "file", c.filePath)
return nil
}
// applyDefaults ensures all config sections have valid defaults.
func (c *Config) applyDefaults() {
if c.Window == nil {
c.Window = NewDefaultWindowConfig()
} else {
c.Window.applyDefaults()
}
if c.Library != nil {
c.Library.ApplyDefaults()
}
if c.Theme == nil {
c.Theme = &theme.Config{}
}
c.Theme.ApplyDefaults()
if c.General == nil {
c.General = &GeneralConfig{}
}
c.General.ApplyDefaults()
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.ApplyDefaults()
if c.Favorites == nil {
c.Favorites = &favorites.Config{
PinDefault: true,
}
}
c.Favorites.ApplyDefaults()
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
}
c.Shortcuts.ApplyDefaults()
if c.Downloads == nil {
c.Downloads = &download.UserConfig{}
}
c.Downloads.ApplyDefaults()
}
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (c *Config) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
c.ctx = ctx
return nil
}
// GetLibraryDirectory returns the currently configured library directory path.
func (c *Config) GetLibraryDirectory() string {
if c.Library == nil {
return ""
}
return string(c.Library.DirectoryPath)
}
// SetLibraryDirectory validates and saves a new library directory,
// then emits the LibraryConfigChanged event so listeners (e.g. the
// Library scanner) can react.
func (c *Config) SetLibraryDirectory(dir string) error {
newLibConf, err := library.NewConfig(dir)
if err != nil {
return fmt.Errorf(
"invalid library directory: %w", err,
)
}
// Preserve existing scan concurrency setting.
if c.Library != nil {
newLibConf.ScanConcurrency = c.Library.ScanConcurrency
}
c.Library = newLibConf
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config after directory change: %w", err,
)
}
events.Emit(
c.ctx,
events.LibraryConfigChanged,
map[string]any{
"DirectoryPath": dir,
},
)
c.logger.Info(
"library directory updated",
"directory", dir,
)
return nil
}
// GetScanConcurrency returns the configured scan concurrency mode.
func (c *Config) GetScanConcurrency() string {
if c.Library == nil {
return string(library.DefaultScanConcurrency)
}
return string(c.Library.ScanConcurrency)
}
// SetScanConcurrency validates and saves a new scan concurrency
// mode. The change takes effect on the next scan.
func (c *Config) SetScanConcurrency(mode string) error {
if c.Library == nil {
c.Library = &library.Config{}
c.Library.ApplyDefaults()
}
c.Library.ScanConcurrency = library.ScanConcurrency(
mode,
)
if err := c.Library.Validate(); err != nil {
return fmt.Errorf(
"invalid scan concurrency mode: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.logger.Info(
"scan concurrency updated", "mode", mode,
)
return nil
}
// GetDownloadPreferences returns the configured auto-download
// guardrails.
func (c *Config) GetDownloadPreferences() download.AutoDownloadPrefs {
if c.Downloads == nil {
return download.AutoDownloadPrefs{}
}
return c.Downloads.AutoDownloadPrefs()
}
// SetDownloadPreferences saves new auto-download guardrails. This only
// persists them; the download package cannot depend on config (config
// already depends on download for UserConfig), so making the change
// live without a restart is the caller's job — the frontend settings
// save calls this and download.Service.SetPreferences in the same
// action, and app.go's initDownloadRuntime applies the saved value to
// the running Manager at startup.
func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error {
if c.Downloads == nil {
c.Downloads = &download.UserConfig{}
c.Downloads.ApplyDefaults()
}
formats := make([]string, 0, len(prefs.AllowedFormats))
for _, f := range prefs.AllowedFormats {
formats = append(formats, string(f))
}
c.Downloads.MinKbps = prefs.MinKbps
c.Downloads.MaxKbps = prefs.MaxKbps
c.Downloads.PreferredKbps = prefs.PreferredKbps
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
c.Downloads.AllowedFormats = formats
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.logger.Info("download auto-pick preferences updated")
return nil
}
// GetThemeAccentColor returns the configured accent colour.
func (c *Config) GetThemeAccentColor() string {
if c.Theme == nil {
return theme.DefaultAccentColor
}
return c.Theme.AccentColor
}
// GetThemeBackgroundShade returns the configured background shade.
func (c *Config) GetThemeBackgroundShade() string {
if c.Theme == nil {
return string(theme.DefaultBackgroundShade)
}
return string(c.Theme.BackgroundShade)
}
// SetThemeAccentColor validates and saves a new accent colour.
func (c *Config) SetThemeAccentColor(
color string,
) error {
if c.Theme == nil {
c.Theme = &theme.Config{}
c.Theme.ApplyDefaults()
}
c.Theme.AccentColor = color
if err := c.Theme.Validate(); err != nil {
return fmt.Errorf(
"invalid theme accent color: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitThemeChanged()
c.logger.Info(
"theme accent color updated",
"color", color,
)
return nil
}
// SetThemeBackgroundShade validates and saves a new background shade.
func (c *Config) SetThemeBackgroundShade(
shade string,
) error {
if c.Theme == nil {
c.Theme = &theme.Config{}
c.Theme.ApplyDefaults()
}
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
if err := c.Theme.Validate(); err != nil {
return fmt.Errorf(
"invalid theme background shade: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitThemeChanged()
c.logger.Info(
"theme background shade updated",
"shade", shade,
)
return nil
}
// emitThemeChanged sends the ThemeConfigChanged event to the frontend.
func (c *Config) emitThemeChanged() {
if c.Theme == nil {
return
}
events.Emit(
c.ctx,
events.ThemeConfigChanged,
map[string]any{
"AccentColor": c.Theme.AccentColor,
"BackgroundShade": string(c.Theme.BackgroundShade),
},
)
}
// GetDefaultPage returns the view the app opens to on launch.
func (c *Config) GetDefaultPage() string {
if c.General == nil {
return string(DefaultDefaultPage)
}
return string(c.General.DefaultPage)
}
// SetDefaultPage validates and saves a new launch page.
func (c *Config) SetDefaultPage(page string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid default page: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"DefaultPage": string(c.General.DefaultPage),
},
)
c.logger.Info(
"default page updated",
"page", page,
)
return nil
}
// GetQueueFallback returns what plays, if anything, once the queue
// runs out.
func (c *Config) GetQueueFallback() string {
if c.General == nil {
return string(DefaultQueueFallback)
}
return string(c.General.QueueFallback)
}
// SetQueueFallback validates and saves a new queue-fallback mode.
func (c *Config) SetQueueFallback(mode string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.QueueFallback = QueueFallback(mode)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid queue fallback: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"QueueFallback": string(c.General.QueueFallback),
},
)
c.logger.Info(
"queue fallback updated",
"mode", mode,
)
return nil
}
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
// catalog may be fetched on a metered connection.
func (c *Config) GetAllowMeteredCatalogDownload() bool {
if c.General == nil {
return false
}
return c.General.AllowMeteredCatalogDownload
}
// SetAllowMeteredCatalogDownload saves the metered-download permission.
//
// There is nothing to validate and nothing to restart: the policy is
// read at the moment a download would start, so turning it on takes
// effect on the next attempt rather than needing this launch to be over.
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.AllowMeteredCatalogDownload = allow
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"AllowMeteredCatalogDownload": allow,
},
)
c.logger.Info(
"metered catalog download permission updated",
"allow", allow,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
return tracklist.DefaultColumns
}
return c.TrackList.Columns
}
// SetTrackListColumns validates and saves a new column layout.
func (c *Config) SetTrackListColumns(
columns []tracklist.Column,
) error {
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.Columns = columns
if err := c.TrackList.Validate(); err != nil {
return fmt.Errorf(
"invalid track-list columns: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitTrackListChanged()
c.logger.Info(
"track-list columns updated",
"count", len(columns),
)
return nil
}
// emitTrackListChanged sends the TrackListConfigChanged event
// to the frontend.
func (c *Config) emitTrackListChanged() {
if c.ctx == nil || c.TrackList == nil {
return
}
cols := make([]map[string]any, 0, len(c.TrackList.Columns))
for _, col := range c.TrackList.Columns {
cols = append(cols, map[string]any{
"id": string(col.ID),
})
}
events.Emit(
c.ctx,
events.TrackListConfigChanged,
map[string]any{
"columns": cols,
},
)
}
// GetFavoritesPlaylistID returns the configured default playlist ID.
func (c *Config) GetFavoritesPlaylistID() int64 {
if c.Favorites == nil {
return 0
}
return c.Favorites.PlaylistID
}
// SetFavoritesPlaylistID saves a new default playlist ID.
func (c *Config) SetFavoritesPlaylistID(id int64) error {
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
c.Favorites.ApplyDefaults()
}
c.Favorites.PlaylistID = id
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitFavoritesChanged()
c.logger.Info(
"favorites playlist ID updated",
"playlistId", id,
)
return nil
}
// GetFavoritesIconStyle returns the configured icon style.
func (c *Config) GetFavoritesIconStyle() string {
if c.Favorites == nil {
return string(favorites.DefaultIconStyle)
}
return string(c.Favorites.IconStyle)
}
// SetFavoritesIconStyle validates and saves a new icon style.
func (c *Config) SetFavoritesIconStyle(
style string,
) error {
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
c.Favorites.ApplyDefaults()
}
c.Favorites.IconStyle = favorites.IconStyle(style)
if err := c.Favorites.Validate(); err != nil {
return fmt.Errorf(
"invalid favorites icon style: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitFavoritesChanged()
c.logger.Info(
"favorites icon style updated",
"style", style,
)
return nil
}
// GetPinDefaultPlaylist returns whether the default playlist
// is pinned to the top of the playlist view.
func (c *Config) GetPinDefaultPlaylist() bool {
if c.Favorites == nil {
return true // default: pinned
}
return c.Favorites.PinDefault
}
// SetPinDefaultPlaylist saves whether the default playlist
// should be pinned to the top of the playlist view.
func (c *Config) SetPinDefaultPlaylist(pin bool) error {
if c.Favorites == nil {
c.Favorites = &favorites.Config{}
c.Favorites.ApplyDefaults()
}
c.Favorites.PinDefault = pin
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitFavoritesChanged()
c.logger.Info(
"pin default playlist updated",
"pin", pin,
)
return nil
}
// emitFavoritesChanged sends the FavoritesConfigChanged event
// to the frontend.
func (c *Config) emitFavoritesChanged() {
if c.Favorites == nil {
return
}
events.Emit(
c.ctx,
events.FavoritesConfigChanged,
map[string]any{
"PlaylistID": c.Favorites.PlaylistID,
"IconStyle": string(c.Favorites.IconStyle),
"PinDefault": c.Favorites.PinDefault,
},
)
}
// GetShortcuts returns the current shortcut bindings map.
func (c *Config) GetShortcuts() map[string]string {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
c.Shortcuts.ApplyDefaults()
}
return c.Shortcuts.Bindings
}
// SetShortcuts saves the entire shortcut bindings map.
func (c *Config) SetShortcuts(
bindings map[string]string,
) error {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
}
c.Shortcuts.Bindings = bindings
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save shortcuts config: %w", err,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
bindings,
)
c.logger.Info("shortcuts config updated")
return nil
}
// SetShortcut saves a single shortcut binding.
func (c *Config) SetShortcut(
action string, key string,
) error {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
c.Shortcuts.ApplyDefaults()
}
c.Shortcuts.Bindings[action] = key
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save shortcut: %w", err,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
c.logger.Info(
"shortcut updated",
"action", action,
"key", key,
)
return nil
}
// ResetShortcuts resets all shortcuts to defaults.
func (c *Config) ResetShortcuts() error {
c.Shortcuts = &shortcuts.Config{
Bindings: shortcuts.DefaultBindings(),
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save shortcuts reset: %w", err,
)
}
events.Emit(
c.ctx,
events.ShortcutsConfigChanged,
c.Shortcuts.Bindings,
)
c.logger.Info("shortcuts reset to defaults")
return nil
}