Files
2026-05-28 08:35:50 -04:00

224 lines
4.4 KiB
Go

package app
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
const fileWatcherDebounce = 500 * time.Millisecond
type FileWatcher struct {
watcher *fsnotify.Watcher
rootDir string
onChange func(path string)
logger *slog.Logger
debounce time.Duration
}
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("create fsnotify watcher: %w", err)
}
fw := &FileWatcher{
watcher: watcher,
rootDir: rootDir,
logger: logger,
debounce: fileWatcherDebounce,
}
if err := fw.addWatches(); err != nil {
watcher.Close()
return nil, err
}
return fw, nil
}
func (fw *FileWatcher) addWatches() error {
return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return nil
}
if !entry.IsDir() {
return nil
}
if path != fw.rootDir && fw.shouldIgnorePath(path) {
return filepath.SkipDir
}
if err := fw.watcher.Add(path); err != nil {
fw.logger.Warn("watch directory", "path", path, "error", err)
}
return nil
})
}
func (fw *FileWatcher) addWatchIfDirectory(path string) {
if fw.shouldIgnorePath(path) {
return
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return
}
if err := fw.watcher.Add(path); err != nil {
fw.logger.Warn("watch new directory", "path", path, "error", err)
}
}
func (fw *FileWatcher) flushPending(pending map[string]struct{}) {
if len(pending) == 0 || fw.onChange == nil {
return
}
var changedPath string
for path := range pending {
changedPath = path
break
}
clear(pending)
fw.onChange(changedPath)
}
func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
pending[path] = struct{}{}
if *timer == nil {
*timer = time.NewTimer(fw.debounce)
*timerC = (*timer).C
return
}
if !(*timer).Stop() {
select {
case <-(*timer).C:
default:
}
}
(*timer).Reset(fw.debounce)
}
func (fw *FileWatcher) stopTimer(timer *time.Timer) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
func (fw *FileWatcher) isMarkdownPath(path string) bool {
return strings.EqualFold(filepath.Ext(path), ".md")
}
func (fw *FileWatcher) shouldIgnorePath(path string) bool {
rel, err := filepath.Rel(fw.rootDir, path)
if err != nil {
return true
}
if rel == "." {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return true
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" {
continue
}
if strings.HasPrefix(part, ".") {
return true
}
if isTempFileName(part) {
return true
}
}
return false
}
func isTempFileName(name string) bool {
if strings.HasSuffix(name, "~") {
return true
}
if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") {
return true
}
switch strings.ToLower(filepath.Ext(name)) {
case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload":
return true
}
switch name {
case ".DS_Store", "Thumbs.db":
return true
default:
return false
}
}
func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool {
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
return false
}
if fw.shouldIgnorePath(event.Name) {
return false
}
return fw.isMarkdownPath(event.Name)
}
func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 {
fw.addWatchIfDirectory(event.Name)
}
if fw.isProcessableEvent(event) {
fw.scheduleSync(event.Name, pending, timer, timerC)
}
}
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
fw.onChange = fn
}
func (fw *FileWatcher) Run(ctx context.Context) {
defer fw.watcher.Close()
pending := make(map[string]struct{})
var timer *time.Timer
var timerC <-chan time.Time
defer fw.stopTimer(timer)
for {
select {
case <-ctx.Done():
return
case <-timerC:
fw.flushPending(pending)
timer = nil
timerC = nil
case event, ok := <-fw.watcher.Events:
if !ok {
return
}
fw.handleEvent(event, pending, &timer, &timerC)
case err, ok := <-fw.watcher.Errors:
if !ok {
return
}
fw.logger.Warn("fsnotify error", "error", err)
}
}
}