diff --git a/main.go b/main.go index e1afa4c..0b18718 100644 --- a/main.go +++ b/main.go @@ -260,22 +260,30 @@ func runDaemon(customConfigPath string) { fmt.Printf("[axctl-config] Error applying config: %v\n", applyErr) } } - - // Watch for config changes - watcher, watchErr := config.NewConfigWatcher() - if watchErr != nil { - fmt.Printf("[axctl-config] Warning: could not start watcher: %v\n", watchErr) - } else { - watcher.Start(configPath, func(newCfg *config.TOMLConfig) { - fmt.Println("[axctl-config] Config changed, reloading...") - if applyErr := config.ApplyConfig(newCfg, comp); applyErr != nil { - fmt.Printf("[axctl-config] Error applying config: %v\n", applyErr) - } - }) - cfgWatcher = watcher + } else { + // On a fresh home the shell writes the config only after the daemon + // has started. Make sure the parent directory exists so the watcher + // below can pick the file up the moment it appears. + fmt.Printf("[axctl-config] No config file at %s yet, waiting for it to appear\n", configPath) + if mkErr := os.MkdirAll(filepath.Dir(configPath), 0755); mkErr != nil { + fmt.Printf("[axctl-config] Warning: could not create config dir: %v\n", mkErr) } + } + + // Watch for config changes — started even when the file doesn't exist + // yet, so the first config ever written is applied immediately instead + // of being ignored until the next session. + watcher, watchErr := config.NewConfigWatcher() + if watchErr != nil { + fmt.Printf("[axctl-config] Warning: could not start watcher: %v\n", watchErr) } else { - fmt.Printf("[axctl-config] No config file at %s, skipping\n", configPath) + watcher.Start(configPath, func(newCfg *config.TOMLConfig) { + fmt.Println("[axctl-config] Config changed, reloading...") + if applyErr := config.ApplyConfig(newCfg, comp); applyErr != nil { + fmt.Printf("[axctl-config] Error applying config: %v\n", applyErr) + } + }) + cfgWatcher = watcher } sig := make(chan os.Signal, 1) diff --git a/pkg/config/watcher.go b/pkg/config/watcher.go index a300f5a..73d9d3b 100644 --- a/pkg/config/watcher.go +++ b/pkg/config/watcher.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "path/filepath" "sync" "time" @@ -10,12 +11,13 @@ import ( // ConfigWatcher watches config files for changes and triggers reloads. type ConfigWatcher struct { - watcher *fsnotify.Watcher - configPath string - callback func(*TOMLConfig) - watched map[string]bool - mu sync.Mutex - done chan struct{} + watcher *fsnotify.Watcher + configPath string + callback func(*TOMLConfig) + watched map[string]bool + watchedDirs map[string]bool + mu sync.Mutex + done chan struct{} } // NewConfigWatcher creates a new config file watcher. @@ -25,9 +27,10 @@ func NewConfigWatcher() (*ConfigWatcher, error) { return nil, fmt.Errorf("creating fsnotify watcher: %w", err) } return &ConfigWatcher{ - watcher: w, - watched: make(map[string]bool), - done: make(chan struct{}), + watcher: w, + watched: make(map[string]bool), + watchedDirs: make(map[string]bool), + done: make(chan struct{}), }, nil } @@ -68,6 +71,13 @@ func (cw *ConfigWatcher) loop() { continue } + // Parent directories are watched too (to catch config files that + // don't exist yet), so filter out events for unrelated files + // living in the same directory. + if !cw.isConfigPath(event.Name) { + continue + } + // Debounce: reset timer on each event if debounceTimer != nil { debounceTimer.Stop() @@ -102,6 +112,14 @@ func (cw *ConfigWatcher) reload() { } } +// isConfigPath reports whether an fsnotify event path refers to one of the +// watched config files (as opposed to an unrelated file in a watched dir). +func (cw *ConfigWatcher) isConfigPath(name string) bool { + cw.mu.Lock() + defer cw.mu.Unlock() + return cw.watched[filepath.Clean(name)] +} + func (cw *ConfigWatcher) updateWatchedFiles() { cw.mu.Lock() defer cw.mu.Unlock() @@ -110,11 +128,26 @@ func (cw *ConfigWatcher) updateWatchedFiles() { newWatched := make(map[string]bool) for _, p := range paths { + p = filepath.Clean(p) newWatched[p] = true if !cw.watched[p] { if err := cw.watcher.Add(p); err != nil { - // File might not exist yet — not an error - fmt.Printf("[axctl-config] Warning: cannot watch %s: %v\n", p, err) + // File might not exist yet — the parent-directory watch + // below picks up its creation. + fmt.Printf("[axctl-config] Note: cannot watch %s yet: %v\n", p, err) + } + } + + // Also watch the parent directory: fsnotify cannot watch a path that + // does not exist yet, and file-level watches break when editors save + // via rename. The event loop filters events back down to the config + // paths, so unrelated files in the directory don't trigger reloads. + dir := filepath.Dir(p) + if !cw.watchedDirs[dir] { + if err := cw.watcher.Add(dir); err != nil { + fmt.Printf("[axctl-config] Warning: cannot watch dir %s: %v\n", dir, err) + } else { + cw.watchedDirs[dir] = true } } }