internal/watch/watch.go

// 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")
}