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