// Package watcher provides file watching for hot reload package watcher import ( "os" "path/filepath" "strings" "sync" "time" ) // Event represents a file change event type Event struct { Path string Dir string // The service directory that was affected } // Watcher watches directories for file changes type Watcher struct { dirs []string events chan Event done chan struct{} interval time.Duration debounce time.Duration mu sync.Mutex modTimes map[string]time.Time } // Option configures the watcher type Option func(*Watcher) // WithInterval sets the polling interval func WithInterval(d time.Duration) Option { return func(w *Watcher) { w.interval = d } } // WithDebounce sets the debounce duration for rapid changes func WithDebounce(d time.Duration) Option { return func(w *Watcher) { w.debounce = d } } // New creates a new file watcher for the given directories func New(dirs []string, opts ...Option) *Watcher { w := &Watcher{ dirs: dirs, events: make(chan Event, 100), done: make(chan struct{}), interval: 500 * time.Millisecond, debounce: 300 * time.Millisecond, modTimes: make(map[string]time.Time), } for _, opt := range opts { opt(w) } return w } // Events returns the channel of file change events func (w *Watcher) Events() <-chan Event { return w.events } // Start begins watching for file changes func (w *Watcher) Start() { // Initial scan to populate mod times w.scan(false) go w.watch() } // AddDir adds a new directory to watch func (w *Watcher) AddDir(dir string) { w.mu.Lock() defer w.mu.Unlock() for _, d := range w.dirs { if d == dir { return } } w.dirs = append(w.dirs, dir) } // Dirs returns the currently watched directories func (w *Watcher) Dirs() []string { w.mu.Lock() defer w.mu.Unlock() return append([]string{}, w.dirs...) } // Stop stops the watcher func (w *Watcher) Stop() { close(w.done) } func (w *Watcher) watch() { ticker := time.NewTicker(w.interval) defer ticker.Stop() // Track pending events per directory for debouncing pending := make(map[string]time.Time) var pendingMu sync.Mutex for { select { case <-w.done: return case <-ticker.C: changed := w.scan(true) now := time.Now() pendingMu.Lock() for _, dir := range changed { pending[dir] = now } // Emit events for directories that have been stable for dir, t := range pending { if now.Sub(t) >= w.debounce { select { case w.events <- Event{Dir: dir}: default: // Channel full, skip } delete(pending, dir) } } pendingMu.Unlock() } } } func (w *Watcher) scan(notify bool) []string { w.mu.Lock() defer w.mu.Unlock() var changed []string changedDirs := make(map[string]bool) for _, dir := range w.dirs { absDir, err := filepath.Abs(dir) if err != nil { continue } _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } // Skip hidden directories and vendor if info.IsDir() { name := info.Name() if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" { return filepath.SkipDir } return nil } // Only watch .go files if !strings.HasSuffix(path, ".go") { return nil } modTime := info.ModTime() if oldTime, exists := w.modTimes[path]; exists { if modTime.After(oldTime) && notify { if !changedDirs[absDir] { changedDirs[absDir] = true changed = append(changed, absDir) } } } w.modTimes[path] = modTime return nil }) } return changed }