diff --git a/core/watchers/watcher.go b/core/watchers/watcher.go new file mode 100644 index 0000000..206c02f --- /dev/null +++ b/core/watchers/watcher.go @@ -0,0 +1,255 @@ +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) + } +}