internal/watcher/watcher.go
Ref: Size: 2.7 KiB
package watcher
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/xanderle/claudealong/internal/ignore"
)
type Event struct {
Path string
}
type Watcher struct {
root string
ig *ignore.Matcher
debounce time.Duration
fs *fsnotify.Watcher
events chan Event
mu sync.Mutex
pending map[string]*time.Timer
closed bool
}
func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) {
fw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
return &Watcher{
root: root,
ig: ig,
debounce: debounce,
fs: fw,
events: make(chan Event, 16),
pending: make(map[string]*time.Timer),
}, nil
}
func (w *Watcher) Events() <-chan Event { return w.events }
func (w *Watcher) Start(ctx context.Context) error {
if err := w.addRecursive(w.root); err != nil {
return err
}
go w.run(ctx)
return nil
}
func (w *Watcher) addRecursive(root string) error {
return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil // best-effort
}
if !info.IsDir() {
return nil
}
if w.ig.ShouldIgnore(p) {
return filepath.SkipDir
}
// Skip symlinked dirs
if info.Mode()&os.ModeSymlink != 0 {
return filepath.SkipDir
}
return w.fs.Add(p)
})
}
func (w *Watcher) run(ctx context.Context) {
defer close(w.events)
for {
select {
case <-ctx.Done():
return
case ev, ok := <-w.fs.Events:
if !ok {
return
}
w.handle(ev)
case _, ok := <-w.fs.Errors:
if !ok {
return
}
}
}
}
func (w *Watcher) handle(ev fsnotify.Event) {
if w.ig.ShouldIgnore(ev.Name) {
return
}
info, err := os.Lstat(ev.Name)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
return // skip symlinks
}
// Re-watch on rename/create of a directory
if ev.Op&fsnotify.Create != 0 && err == nil && info.IsDir() {
_ = w.addRecursive(ev.Name)
return
}
if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
return
}
if err == nil && info.IsDir() {
return
}
w.scheduleEvent(ev.Name)
}
func (w *Watcher) scheduleEvent(path string) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return
}
if t, ok := w.pending[path]; ok {
t.Stop()
}
w.pending[path] = time.AfterFunc(w.debounce, func() {
w.mu.Lock()
delete(w.pending, path)
closed := w.closed
w.mu.Unlock()
if closed {
return
}
select {
case w.events <- Event{Path: path}:
default:
// drop if buffer full
}
})
}
func (w *Watcher) Close() error {
w.mu.Lock()
w.closed = true
for _, t := range w.pending {
t.Stop()
}
w.mu.Unlock()
if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
return err
}
return nil
}