a73x

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
}