// Package watch is a tiny wrapper around fsnotify that debounces bursty
// file-change events into a single rebuild callback.
//
// Editors like Neovim save by renaming a temp file over the target, which
// fsnotify surfaces as CREATE+RENAME+WRITE within a few milliseconds. A
// flat 150ms debounce collapses those into one rebuild without noticeably
// lagging the "save then reload browser" loop.
package watch
import (
"log"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// Loop watches root recursively and calls onChange after at least `wait`
// has elapsed since the last event. The call is synchronous; returns only
// when the watcher errors fatally.
func Loop(root string, wait time.Duration, onChange func()) {
w, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("watch: %v", err)
}
defer w.Close()
if err := addRecursive(w, root); err != nil {
log.Fatalf("watch add: %v", err)
}
var timer *time.Timer
for {
select {
case ev, ok := <-w.Events:
if !ok {
return
}
if !isInteresting(ev) {
continue
}
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(wait, onChange)
case err, ok := <-w.Errors:
if !ok {
return
}
log.Printf("watch: %v", err)
}
}
}
// addRecursive walks root and adds every directory to the watcher. fsnotify
// doesn't do recursion on Linux; we have to walk up front and add sub-dirs
// from CREATE events ourselves. For a notes directory that's basically
// flat this is fine.
func addRecursive(w *fsnotify.Watcher, root string) error {
return filepath.Walk(root, func(p string, info stubInfo, err error) error {
if err != nil || !info.IsDir() {
return err
}
if strings.HasPrefix(filepath.Base(p), ".") {
return filepath.SkipDir
}
return w.Add(p)
})
}
// stubInfo is an alias so the walk closure above compiles against the
// os.FileInfo interface without pulling os into the public signature.
type stubInfo interface {
IsDir() bool
}
func isInteresting(ev fsnotify.Event) bool {
base := filepath.Base(ev.Name)
if strings.HasPrefix(base, ".") {
return false
}
return strings.HasSuffix(base, ".md") ||
strings.HasSuffix(base, ".tmpl")
}