package template import ( "log/slog" "os" "path/filepath" "time" "github.com/fsnotify/fsnotify" ) // HotReloadWatcher watches template files for changes type HotReloadWatcher struct { watcher *fsnotify.Watcher loader *TemplateLoader configPath string logger *slog.Logger stopChan chan struct{} } // NewHotReloadWatcher creates a new hot reload watcher func NewHotReloadWatcher(loader *TemplateLoader, configPath string) (*HotReloadWatcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, err } return &HotReloadWatcher{ watcher: watcher, loader: loader, configPath: configPath, logger: slog.With("component", "hot_reload"), stopChan: make(chan struct{}), }, nil } // Start starts watching template files for changes func (hr *HotReloadWatcher) Start() error { // Watch template directories directories := []string{ hr.configPath, filepath.Join(hr.configPath, "custom"), filepath.Join(hr.configPath, "builtin"), filepath.Join(hr.configPath, "custom", "_default"), } for _, dir := range directories { if err := hr.addDirectory(dir); err != nil { hr.logger.Warn("failed to watch directory", "dir", dir, "error", err) continue } } go hr.watch() hr.logger.Info("hot reload watcher started", "directories", directories) return nil } // addDirectory adds a directory and its subdirectories to the watcher func (hr *HotReloadWatcher) addDirectory(dir string) error { return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return hr.watcher.Add(path) } return nil }) } // watch watches for file changes and triggers reloads func (hr *HotReloadWatcher) watch() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() debounce := make(map[string]time.Time) for { select { case event := <-hr.watcher.Events: if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { // Debounce rapid file changes lastChange := debounce[event.Name] if time.Since(lastChange) > 100*time.Millisecond { debounce[event.Name] = time.Now() hr.handleFileChange(event.Name) } } case err := <-hr.watcher.Errors: if err != nil { hr.logger.Error("watcher error", "error", err) } case <-hr.stopChan: return case <-ticker.C: // Cleanup old debounce entries now := time.Now() for file, lastChange := range debounce { if now.Sub(lastChange) > 5*time.Second { delete(debounce, file) } } } } } // handleFileChange handles a file change event func (hr *HotReloadWatcher) handleFileChange(filePath string) { ext := filepath.Ext(filePath) if ext != ".html" && ext != ".yaml" && ext != ".yml" { return } hr.logger.Info("detected file change", "file", filePath) // Reload templates hr.loader.ClearCache() // Notify clients via WebSocket or SSE hr.notifyClients() } // notifyClients notifies connected clients about template changes func (hr *HotReloadWatcher) notifyClients() { // This could be extended to use WebSocket or Server-Sent Events // For now, just log the change hr.logger.Info("templates reloaded") } // Stop stops the hot reload watcher func (hr *HotReloadWatcher) Stop() { close(hr.stopChan) hr.watcher.Close() hr.logger.Info("hot reload watcher stopped") } // AddTemplateDirectory adds a new template directory to watch func (hr *HotReloadWatcher) AddTemplateDirectory(path string) error { return hr.addDirectory(path) } // RemoveTemplateDirectory removes a template directory from watch func (hr *HotReloadWatcher) RemoveTemplateDirectory(path string) error { return hr.watcher.Remove(path) } // GetWatchedDirectories returns currently watched directories func (hr *HotReloadWatcher) GetWatchedDirectories() []string { return hr.watcher.WatchList() } // IsWatching returns whether the watcher is active func (hr *HotReloadWatcher) IsWatching() bool { select { case <-hr.stopChan: return false default: return true } } // ReloadTemplates triggers a manual template reload func (hr *HotReloadWatcher) ReloadTemplates() { hr.loader.ClearCache() hr.logger.Info("templates manually reloaded") } // GetReloadStats returns hot reload statistics func (hr *HotReloadWatcher) GetReloadStats() map[string]interface{} { watched := hr.GetWatchedDirectories() return map[string]interface{}{ "watched_directories": len(watched), "watched_paths": watched, "is_active": hr.IsWatching(), } }