224 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|