package watchers import ( "fmt" "os" "path/filepath" "strings" "sync" "time" "git.sharkk.net/Sky/Moonshark/core/logger" "git.sharkk.net/Sky/Moonshark/core/runner" ) const ( defaultPollInterval = 1 * time.Second defaultDebounceTime = 300 * time.Millisecond ) // FileInfo stores metadata about a file for change detection type FileInfo struct { ModTime time.Time Size int64 IsDir bool } // FileWatcher watches a single directory for changes type FileWatcher struct { dir string callback func() error log *logger.Logger files map[string]FileInfo done chan struct{} ticker *time.Ticker debounceTime time.Duration debouncing bool debounceTimer *time.Timer debounceMu sync.Mutex filesMu sync.RWMutex wg sync.WaitGroup } // Registry for Lua runners to notify about .lua file changes var ( luaRunners = make([]*runner.LuaRunner, 0) runnersMutex sync.RWMutex ) // NewFileWatcher creates a new file watcher for a directory func NewFileWatcher(dir string, callback func() error, log *logger.Logger) (*FileWatcher, error) { w := &FileWatcher{ dir: dir, callback: callback, log: log, files: make(map[string]FileInfo), done: make(chan struct{}), debounceTime: defaultDebounceTime, } // Perform initial scan if err := w.scanDirectory(); err != nil { return nil, err } // Start the watcher w.ticker = time.NewTicker(defaultPollInterval) w.wg.Add(1) go w.watchLoop() log.Info("Started watching directory: %s", dir) return w, nil } // scanDirectory builds the initial file list func (w *FileWatcher) scanDirectory() error { w.filesMu.Lock() defer w.filesMu.Unlock() // Clear existing files w.files = make(map[string]FileInfo) return filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error { if err != nil { w.log.Warning("[Watcher] [%s] Error accessing path %s: %v", w.dir, path, err) return nil // Continue with other files } w.files[path] = FileInfo{ ModTime: info.ModTime(), Size: info.Size(), IsDir: info.IsDir(), } return nil }) } // watchLoop is the polling loop that checks for changes func (w *FileWatcher) watchLoop() { defer w.wg.Done() w.log.Debug("[Watcher] [%s] Starting watch loop", w.dir) for { select { case <-w.ticker.C: changedFiles, err := w.checkForChanges() if err != nil { w.log.Error("[Watcher] [%s] Error checking for changes: %v", w.dir, err) continue } if len(changedFiles) > 0 { w.log.Debug("[Watcher] [%s] Detected %d changed files", w.dir, len(changedFiles)) // Notify Lua runners about .lua file changes for _, file := range changedFiles { if strings.HasSuffix(file, ".lua") { w.notifyLuaRunners(file) } } // Trigger the callback with debouncing w.triggerCallback() } case <-w.done: w.log.Debug("[Watcher] [%s] Watch loop stopped", w.dir) return } } } // checkForChanges detects file changes and returns a list of changed files func (w *FileWatcher) checkForChanges() ([]string, error) { changedFiles := []string{} // Get current state currentFiles := make(map[string]FileInfo) err := filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error { if err != nil { // File might have been deleted between directory read and stat return nil } currentFiles[path] = FileInfo{ ModTime: info.ModTime(), Size: info.Size(), IsDir: info.IsDir(), } return nil }) if err != nil { return changedFiles, fmt.Errorf("error walking directory: %w", err) } // Compare with previous state w.filesMu.RLock() previousFiles := w.files w.filesMu.RUnlock() // Check for new or modified files for path, currentInfo := range currentFiles { prevInfo, exists := previousFiles[path] if !exists { // New file w.log.Debug("[Watcher] [%s] New file: %s", w.dir, path) changedFiles = append(changedFiles, path) } else if currentInfo.ModTime != prevInfo.ModTime || currentInfo.Size != prevInfo.Size { // Modified file w.log.Debug("[Watcher] [%s] Modified file: %s", w.dir, path) changedFiles = append(changedFiles, path) } } // Check for deleted files for path, info := range previousFiles { if _, exists := currentFiles[path]; !exists { // File deleted w.log.Debug("[Watcher] [%s] Deleted file: %s", w.dir, path) if !info.IsDir { // Only add files, not directories changedFiles = append(changedFiles, path) } } } // Update internal file list if changes were found if len(changedFiles) > 0 { w.filesMu.Lock() w.files = currentFiles w.filesMu.Unlock() } return changedFiles, nil } // triggerCallback executes the callback with debouncing func (w *FileWatcher) triggerCallback() { w.debounceMu.Lock() defer w.debounceMu.Unlock() w.log.Debug("[Watcher] [%s] Preparing to trigger callback", w.dir) if w.debouncing { // Reset timer if already debouncing if w.debounceTimer != nil { w.log.Debug("[Watcher] [%s] Resetting existing debounce timer", w.dir) w.debounceTimer.Stop() } } else { w.log.Debug("[Watcher] [%s] Starting debounce timer (%v)", w.dir, w.debounceTime) w.debouncing = true } w.debounceTimer = time.AfterFunc(w.debounceTime, func() { w.log.Debug("[Watcher] [%s] Executing callback", w.dir) if err := w.callback(); err != nil { w.log.Error("[Watcher] [%s] Callback error: %v", w.dir, err) } else { w.log.Debug("[Watcher] [%s] Callback executed successfully", w.dir) } // Reset debouncing state w.debounceMu.Lock() w.debouncing = false w.debounceMu.Unlock() }) } // Close stops the file watcher func (w *FileWatcher) Close() error { w.log.Debug("[Watcher] [%s] Stopping watcher", w.dir) close(w.done) if w.ticker != nil { w.ticker.Stop() } w.wg.Wait() return nil } // notifyLuaRunners notifies all registered Lua runners about a .lua file change func (w *FileWatcher) notifyLuaRunners(file string) { runnersMutex.RLock() defer runnersMutex.RUnlock() for _, r := range luaRunners { w.log.Debug("[Watcher] [%s] Notifying Lua runner about change to %s", w.dir, file) r.NotifyFileChanged(file) } }