Files
Ed Zynda 5d45aa196b fix(watcher): remove debug logging that corrupts TUI
Remove charmbracelet/log debug statements from the file watcher that
were writing directly to stderr, corrupting the Bubble Tea terminal UI.

- Remove log.Debug calls for directory operations and file changes
- Remove log.Warn for watcher errors (silently ignore instead)
- Remove the charmbracelet/log import entirely
2026-04-07 16:31:29 +03:00

260 lines
6.2 KiB
Go

// Package watcher provides a general-purpose file watcher that monitors
// directories for changes to files matching specified extensions. It uses
// fsnotify for kernel-level notifications with debouncing to coalesce
// rapid editor writes.
package watcher
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// ContentWatcher monitors directories for file changes matching a set of
// extensions and triggers a reload callback when changes are detected.
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
type ContentWatcher struct {
watcher *fsnotify.Watcher
onReload func()
extensions []string // e.g. [".md", ".txt"]
label string // for logging (e.g. "prompts", "skills")
debounce time.Duration
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
}
// Options configures a ContentWatcher.
type Options struct {
// Dirs are the directories to watch.
Dirs []string
// Extensions are the file extensions to watch for (e.g. ".md", ".txt").
// Include the leading dot.
Extensions []string
// OnReload is called when a matching file changes (after debouncing).
OnReload func()
// Label is a human-readable name for logging (e.g. "prompts", "skills").
Label string
// Debounce is the debounce duration. Defaults to 300ms if zero.
Debounce time.Duration
}
// New creates a ContentWatcher that monitors the given directories for
// file changes matching the specified extensions. When a change is detected
// (after debouncing), onReload is called. The watcher must be started with
// Start() and stopped with Close().
func New(opts Options) (*ContentWatcher, error) {
if len(opts.Dirs) == 0 {
return nil, fmt.Errorf("no directories to watch")
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating file watcher: %w", err)
}
for _, dir := range opts.Dirs {
if err := fsw.Add(dir); err != nil {
continue
}
// Also watch immediate subdirectories (for skill/SKILL.md pattern).
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
_ = fsw.Add(subdir)
}
}
}
debounce := opts.Debounce
if debounce == 0 {
debounce = 300 * time.Millisecond
}
return &ContentWatcher{
watcher: fsw,
onReload: opts.OnReload,
extensions: opts.Extensions,
label: opts.Label,
debounce: debounce,
done: make(chan struct{}),
}, nil
}
// Start begins watching for file changes. It blocks until the context
// is cancelled or Close() is called. Typically called in a goroutine.
func (w *ContentWatcher) Start(ctx context.Context) {
w.mu.Lock()
ctx, w.cancel = context.WithCancel(ctx)
w.mu.Unlock()
defer close(w.done)
var timer *time.Timer
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// When a new subdirectory is created, start watching it so
// that files added inside (e.g. new-skill/SKILL.md) trigger
// reload events. Also schedule a reload in case the directory
// was created with matching files already inside.
if event.Op&fsnotify.Create != 0 {
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
if addErr := w.watcher.Add(event.Name); addErr == nil {
// Check if the new directory already contains matching files.
if w.dirContainsMatchingFiles(event.Name) {
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
}
}
continue
}
}
// Only care about files matching our extensions.
if !w.matchesExtension(event.Name) {
continue
}
// React to write, create, remove, rename events.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
continue
}
// Debounce: reset timer on each event.
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
case <-timerC:
timerC = nil
timer = nil
w.onReload()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
_ = err
}
}
}
// Close stops the watcher and releases resources.
func (w *ContentWatcher) Close() error {
w.mu.Lock()
cancel := w.cancel
w.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait for the event loop to finish.
<-w.done
return w.watcher.Close()
}
// matchesExtension returns true if the file name ends with one of the
// watched extensions.
func (w *ContentWatcher) matchesExtension(name string) bool {
for _, ext := range w.extensions {
if strings.HasSuffix(name, ext) {
return true
}
}
return false
}
// dirContainsMatchingFiles returns true if the directory contains at least
// one file matching the watched extensions. Used to detect cases where a
// directory is created with files already inside (e.g. cp -r).
func (w *ContentWatcher) dirContainsMatchingFiles(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() && w.matchesExtension(entry.Name()) {
return true
}
}
return false
}
// CollectDirs returns the directories to watch for a given set of standard
// directories and extra paths. Directories are deduplicated by absolute path
// and verified to exist. For explicit file paths, the parent directory is
// watched instead.
func CollectDirs(standardDirs []string, extraPaths []string) []string {
var dirs []string
seen := make(map[string]bool)
add := func(dir string) {
abs, err := filepath.Abs(dir)
if err != nil {
return
}
if seen[abs] {
return
}
// Verify the directory exists.
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return
}
seen[abs] = true
dirs = append(dirs, abs)
}
for _, d := range standardDirs {
add(d)
}
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
}
}
return dirs
}