From c53c54a5d97aa03437c3251e697f8028eb66c373 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Tue, 1 Jul 2025 21:14:16 -0500 Subject: [PATCH] refactor DirectoryWatcher to Watcher --- watchers/watcher.go | 236 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 watchers/watcher.go diff --git a/watchers/watcher.go b/watchers/watcher.go new file mode 100644 index 0000000..a6ff037 --- /dev/null +++ b/watchers/watcher.go @@ -0,0 +1,236 @@ +package watchers + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "Moonshark/logger" +) + +const ( + defaultDebounceTime = 300 * time.Millisecond + defaultPollInterval = 1 * time.Second +) + +type FileChange struct { + Path string + IsNew bool + IsDeleted bool +} + +type FileInfo struct { + ModTime time.Time +} + +// Watcher is now self-contained and manages its own polling +type Watcher struct { + dir string + files map[string]FileInfo + filesMu sync.RWMutex + callback func([]FileChange) error + debounceTime time.Duration + pollInterval time.Duration + recursive bool + + // Self-management + done chan struct{} + debounceTimer *time.Timer + debouncing bool + debounceMu sync.Mutex + wg sync.WaitGroup +} + +type WatcherConfig struct { + Dir string + Callback func([]FileChange) error + DebounceTime time.Duration + PollInterval time.Duration + Recursive bool +} + +func NewWatcher(config WatcherConfig) (*Watcher, error) { + if config.DebounceTime == 0 { + config.DebounceTime = defaultDebounceTime + } + if config.PollInterval == 0 { + config.PollInterval = defaultPollInterval + } + + w := &Watcher{ + dir: config.Dir, + files: make(map[string]FileInfo), + callback: config.Callback, + debounceTime: config.DebounceTime, + pollInterval: config.PollInterval, + recursive: config.Recursive, + done: make(chan struct{}), + } + + if err := w.scanDirectory(); err != nil { + return nil, err + } + + w.wg.Add(1) + go w.watchLoop() + + return w, nil +} + +func (w *Watcher) Close() { + close(w.done) + w.wg.Wait() + + w.debounceMu.Lock() + if w.debounceTimer != nil { + w.debounceTimer.Stop() + } + w.debounceMu.Unlock() +} + +func (w *Watcher) GetDir() string { + return w.dir +} + +// watchLoop is the main polling loop for this watcher +func (w *Watcher) watchLoop() { + defer w.wg.Done() + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.checkAndNotify() + case <-w.done: + return + } + } +} + +// checkAndNotify combines change detection and notification +func (w *Watcher) checkAndNotify() { + changed, changedFiles := w.detectChanges() + if changed { + w.notifyChange(changedFiles) + } +} + +// detectChanges scans directory and returns changes +func (w *Watcher) detectChanges() (bool, []FileChange) { + newFiles := make(map[string]FileInfo) + var changedFiles []FileChange + changed := false + + err := filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if !w.recursive && info.IsDir() && path != w.dir { + return filepath.SkipDir + } + + currentInfo := FileInfo{ModTime: info.ModTime()} + newFiles[path] = currentInfo + + w.filesMu.RLock() + prevInfo, exists := w.files[path] + w.filesMu.RUnlock() + + if !exists { + changed = true + changedFiles = append(changedFiles, FileChange{Path: path, IsNew: true}) + w.logDebug("File added: %s", path) + } else if currentInfo.ModTime != prevInfo.ModTime { + changed = true + changedFiles = append(changedFiles, FileChange{Path: path}) + w.logDebug("File changed: %s", path) + } + + return nil + }) + + if err != nil { + w.logError("Error scanning directory: %v", err) + return false, nil + } + + // Check for deletions + w.filesMu.RLock() + for path := range w.files { + if _, exists := newFiles[path]; !exists { + changed = true + changedFiles = append(changedFiles, FileChange{Path: path, IsDeleted: true}) + w.logDebug("File deleted: %s", path) + } + } + w.filesMu.RUnlock() + + if changed { + w.filesMu.Lock() + w.files = newFiles + w.filesMu.Unlock() + } + + return changed, changedFiles +} + +func (w *Watcher) scanDirectory() error { + w.filesMu.Lock() + defer w.filesMu.Unlock() + + w.files = make(map[string]FileInfo) + + return filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if !w.recursive && info.IsDir() && path != w.dir { + return filepath.SkipDir + } + + w.files[path] = FileInfo{ModTime: info.ModTime()} + return nil + }) +} + +func (w *Watcher) notifyChange(changedFiles []FileChange) { + w.debounceMu.Lock() + defer w.debounceMu.Unlock() + + if w.debouncing && w.debounceTimer != nil { + w.debounceTimer.Stop() + } + w.debouncing = true + + // Copy to avoid race conditions + filesCopy := make([]FileChange, len(changedFiles)) + copy(filesCopy, changedFiles) + + w.debounceTimer = time.AfterFunc(w.debounceTime, func() { + var err error + if w.callback != nil { + err = w.callback(filesCopy) + } + + if err != nil { + w.logError("Callback error: %v", err) + } + + w.debounceMu.Lock() + w.debouncing = false + w.debounceMu.Unlock() + }) +} + +func (w *Watcher) logDebug(format string, args ...any) { + logger.Debugf("[Watcher] [%s] %s", w.dir, fmt.Sprintf(format, args...)) +} + +func (w *Watcher) logError(format string, args ...any) { + logger.Errorf("[Watcher] [%s] %s", w.dir, fmt.Sprintf(format, args...)) +}