@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
+ "path/filepath"
"sync"
"sync/atomic"
"time"
@@ -13,22 +14,23 @@ import (
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/lsp/protocol"
- "github.com/fsnotify/fsnotify"
+ "github.com/rjeczalik/notify"
)
-// global manages a single fsnotify.Watcher instance shared across all LSP clients.
+// global manages file watching shared across all LSP clients.
//
-// IMPORTANT: This implementation only watches directories, not individual files.
-// The fsnotify library automatically provides events for all files within watched
-// directories, making this approach much more efficient than watching individual files.
+// IMPORTANT: This implementation uses github.com/rjeczalik/notify which provides
+// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it
+// uses inotify (with recursion handled by the library), and on Windows it uses
+// ReadDirectoryChangesW.
//
-// Key benefits of directory-only watching:
-// - Significantly fewer file descriptors used
-// - Automatic coverage of new files created in watched directories
-// - Better performance with large codebases
-// - fsnotify handles deduplication internally (no need to track watched dirs)
+// Key benefits:
+// - Single watch point for entire directory tree
+// - Automatic recursive watching without manually adding subdirectories
+// - No file descriptor exhaustion issues
type global struct {
- watcher *fsnotify.Watcher
+ // Channel for receiving file system events
+ events chan notify.EventInfo
// Map of workspace watchers by client name
watchers *csync.Map[string, *Client]
@@ -54,6 +56,7 @@ type global struct {
var instance = sync.OnceValue(func() *global {
ctx, cancel := context.WithCancel(context.Background())
gw := &global{
+ events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events
watchers: csync.NewMap[string, *Client](),
debounceTime: 300 * time.Millisecond,
debounceMap: csync.NewMap[string, *time.Timer](),
@@ -61,15 +64,6 @@ var instance = sync.OnceValue(func() *global {
cancel: cancel,
}
- // Initialize the fsnotify watcher
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
- return gw
- }
-
- gw.watcher = watcher
-
return gw
})
@@ -85,11 +79,11 @@ func (gw *global) unregister(name string) {
slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
}
-// Start walks the given path and sets up the watcher on it.
+// Start sets up recursive watching on the workspace root.
//
-// Note: We only watch directories, not individual files. fsnotify automatically provides
-// events for all files within watched directories. Multiple calls with the same workspace
-// are safe since fsnotify handles directory deduplication internally.
+// Note: We use github.com/rjeczalik/notify which provides recursive watching
+// with a single watch point. The "..." suffix means watch recursively.
+// This is much more efficient than manually walking and watching each directory.
func Start() error {
gw := instance()
@@ -107,59 +101,33 @@ func Start() error {
gw.root = root
gw.started.Store(true)
- // Start the event processing goroutine now that we're initialized
+ // Start the event processing goroutine
gw.wg.Add(1)
go gw.processEvents()
- // Walk the workspace and add only directories to the watcher
- // fsnotify will automatically provide events for all files within these directories
- // Multiple calls with the same directories are safe (fsnotify deduplicates)
- err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- // Add directory to watcher (fsnotify handles deduplication automatically)
- if err := gw.addDirectoryToWatcher(path); err != nil {
- slog.Error("lsp watcher: Error watching directory", "path", path, "error", err)
- }
-
- return nil
- })
- if err != nil {
- return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
- }
-
- return nil
-}
+ // Set up recursive watching on the root directory
+ // The "..." suffix tells notify to watch recursively
+ watchPath := filepath.Join(root, "...")
-// addDirectoryToWatcher adds a directory to the fsnotify watcher.
-// fsnotify handles deduplication internally, so we don't need to track watched directories.
-func (gw *global) addDirectoryToWatcher(dirPath string) error {
- if gw.watcher == nil {
- return fmt.Errorf("lsp watcher: global watcher not initialized")
- }
+ // Watch for all event types we care about
+ events := notify.Create | notify.Write | notify.Remove | notify.Rename
- // Add directory to fsnotify watcher - fsnotify handles deduplication
- // "A path can only be watched once; watching it more than once is a no-op"
- err := gw.watcher.Add(dirPath)
- if err != nil {
- return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
+ if err := notify.Watch(watchPath, gw.events, events); err != nil {
+ return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
}
- slog.Debug("lsp watcher: watching directory", "path", dirPath)
+ slog.Info("lsp watcher: Started recursive watching", "root", root)
return nil
}
-// processEvents processes file system events and handles them centrally.
-// Since we only watch directories, we automatically get events for all files
-// within those directories. When new directories are created, we add them
-// to the watcher to ensure complete coverage.
+// processEvents processes file system events from the notify library.
+// Since notify handles recursive watching for us, we don't need to manually
+// add new directories - they're automatically included.
func (gw *global) processEvents() {
defer gw.wg.Done()
cfg := config.Get()
- if gw.watcher == nil || !gw.started.Load() {
+ if !gw.started.Load() {
slog.Error("lsp watcher: Global watcher not initialized")
return
}
@@ -169,68 +137,89 @@ func (gw *global) processEvents() {
case <-gw.ctx.Done():
return
- case event, ok := <-gw.watcher.Events:
+ case event, ok := <-gw.events:
if !ok {
return
}
- // Handle directory creation globally (only once)
- // When new directories are created, we need to add them to the watcher
- // to ensure we get events for files created within them
- if event.Op&fsnotify.Create != 0 {
- if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
- if !fsext.ShouldExcludeFile(gw.root, event.Name) {
- if err := gw.addDirectoryToWatcher(event.Name); err != nil {
- slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
- }
- } else if cfg != nil && cfg.Options.DebugLSP {
- slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
- }
- }
+ path := event.Path()
+
+ // Skip ignored files
+ if fsext.ShouldExcludeFile(gw.root, path) {
+ continue
}
if cfg != nil && cfg.Options.DebugLSP {
- slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
+ slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String())
}
- // Process the event centrally
+ // Convert notify event to our internal format and handle it
gw.handleFileEvent(event)
-
- case err, ok := <-gw.watcher.Errors:
- if !ok {
- return
- }
- slog.Error("lsp watcher: Global watcher error", "error", err)
}
}
}
// handleFileEvent processes a file system event and distributes notifications to relevant clients
-func (gw *global) handleFileEvent(event fsnotify.Event) {
+func (gw *global) handleFileEvent(event notify.EventInfo) {
cfg := config.Get()
- uri := string(protocol.URIFromPath(event.Name))
-
- // Handle file creation for all relevant clients (only once)
- if event.Op&fsnotify.Create != 0 {
- if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
- if !fsext.ShouldExcludeFile(gw.root, event.Name) {
- gw.openMatchingFileForClients(event.Name)
+ path := event.Path()
+ uri := string(protocol.URIFromPath(path))
+
+ // Map notify events to our change types
+ var changeType protocol.FileChangeType
+ var watchKindNeeded protocol.WatchKind
+
+ switch event.Event() {
+ case notify.Create:
+ changeType = protocol.FileChangeType(protocol.Created)
+ watchKindNeeded = protocol.WatchCreate
+ // Handle file creation for all relevant clients
+ if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) {
+ gw.openMatchingFileForClients(path)
+ }
+ case notify.Write:
+ changeType = protocol.FileChangeType(protocol.Changed)
+ watchKindNeeded = protocol.WatchChange
+ case notify.Remove:
+ changeType = protocol.FileChangeType(protocol.Deleted)
+ watchKindNeeded = protocol.WatchDelete
+ case notify.Rename:
+ // Treat rename as delete + create
+ // First handle as delete
+ for _, watcher := range gw.watchers.Seq2() {
+ if !watcher.client.HandlesFile(path) {
+ continue
}
+ if watched, watchKind := watcher.isPathWatched(path); watched {
+ if watchKind&protocol.WatchDelete != 0 {
+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
+ }
+ }
+ }
+ // Then check if renamed file exists and treat as create
+ if !isDir(path) {
+ changeType = protocol.FileChangeType(protocol.Created)
+ watchKindNeeded = protocol.WatchCreate
+ } else {
+ return // Already handled delete, nothing more to do for directories
}
+ default:
+ // Unknown event type, skip
+ return
}
// Process the event for each relevant client
for client, watcher := range gw.watchers.Seq2() {
- if !watcher.client.HandlesFile(event.Name) {
+ if !watcher.client.HandlesFile(path) {
continue // client doesn't handle this filetype
}
// Debug logging per client
if cfg.Options.DebugLSP {
- matched, kind := watcher.isPathWatched(event.Name)
+ matched, kind := watcher.isPathWatched(path)
slog.Debug("lsp watcher: File event for client",
- "path", event.Name,
- "operation", event.Op.String(),
+ "path", path,
+ "event", event.Event().String(),
"watched", matched,
"kind", kind,
"client", client,
@@ -238,46 +227,31 @@ func (gw *global) handleFileEvent(event fsnotify.Event) {
}
// Check if this path should be watched according to server registrations
- if watched, watchKind := watcher.isPathWatched(event.Name); watched {
- switch {
- case event.Op&fsnotify.Write != 0:
- if watchKind&protocol.WatchChange != 0 {
- gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
- }
- case event.Op&fsnotify.Create != 0:
- // File creation was already handled globally above
- // Just send the notification if needed
- info, err := os.Stat(event.Name)
- if err != nil {
- if !os.IsNotExist(err) {
- slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
- }
+ if watched, watchKind := watcher.isPathWatched(path); watched {
+ if watchKind&watchKindNeeded != 0 {
+ // Skip directory events for non-delete operations
+ if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) {
continue
}
- if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
- gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
- }
- case event.Op&fsnotify.Remove != 0:
- if watchKind&protocol.WatchDelete != 0 {
- gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
- }
- case event.Op&fsnotify.Rename != 0:
- // For renames, first delete
- if watchKind&protocol.WatchDelete != 0 {
- gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
- }
- // Then check if the new file exists and create an event
- if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
- if watchKind&protocol.WatchCreate != 0 {
- gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
- }
+ if changeType == protocol.FileChangeType(protocol.Deleted) {
+ // Don't debounce deletes
+ gw.handleFileEventForClient(watcher, uri, changeType)
+ } else {
+ // Debounce creates and changes
+ gw.debounceHandleFileEventForClient(watcher, uri, changeType)
}
}
}
}
}
+// isDir checks if a path is a directory
+func isDir(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
func (gw *global) openMatchingFileForClients(path string) {
// Skip directories
@@ -349,10 +323,9 @@ func (gw *global) shutdown() {
gw.cancel()
}
- if gw.watcher != nil {
- gw.watcher.Close()
- gw.watcher = nil
- }
+ // Stop watching and close the event channel
+ notify.Stop(gw.events)
+ close(gw.events)
gw.wg.Wait()
slog.Debug("lsp watcher: Global watcher shutdown complete")
@@ -8,7 +8,7 @@ import (
"time"
"github.com/charmbracelet/crush/internal/csync"
- "github.com/fsnotify/fsnotify"
+ "github.com/rjeczalik/notify"
)
func TestGlobalWatcher(t *testing.T) {
@@ -60,15 +60,8 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- // Create a real fsnotify watcher for testing
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- t.Fatalf("Failed to create fsnotify watcher: %v", err)
- }
- defer watcher.Close()
-
gw := &global{
- watcher: watcher,
+ events: make(chan notify.EventInfo, 100),
watchers: csync.NewMap[string, *Client](),
debounceTime: 300 * time.Millisecond,
debounceMap: csync.NewMap[string, *time.Timer](),
@@ -77,26 +70,31 @@ func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
}
// Test that watching the same workspace multiple times is safe (idempotent)
- err1 := gw.addDirectoryToWatcher(tempDir)
+ // With notify, we use recursive watching with "..."
+ watchPath := filepath.Join(tempDir, "...")
+
+ err1 := notify.Watch(watchPath, gw.events, notify.All)
if err1 != nil {
- t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
+ t.Fatalf("First Watch call failed: %v", err1)
}
+ defer notify.Stop(gw.events)
- err2 := gw.addDirectoryToWatcher(tempDir)
+ // Watching the same path again should be safe (notify handles this)
+ err2 := notify.Watch(watchPath, gw.events, notify.All)
if err2 != nil {
- t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
+ t.Fatalf("Second Watch call failed: %v", err2)
}
- err3 := gw.addDirectoryToWatcher(tempDir)
+ err3 := notify.Watch(watchPath, gw.events, notify.All)
if err3 != nil {
- t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
+ t.Fatalf("Third Watch call failed: %v", err3)
}
- // All calls should succeed - fsnotify handles deduplication internally
- // This test verifies that multiple WatchWorkspace calls are safe
+ // All calls should succeed - notify handles deduplication internally
+ // This test verifies that multiple Watch calls are safe
}
-func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
+func TestGlobalWatcherRecursiveWatching(t *testing.T) {
t.Parallel()
// Create a temporary directory structure for testing
@@ -120,29 +118,24 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- // Create a real fsnotify watcher for testing
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- t.Fatalf("Failed to create fsnotify watcher: %v", err)
- }
- defer watcher.Close()
-
gw := &global{
- watcher: watcher,
+ events: make(chan notify.EventInfo, 100),
watchers: csync.NewMap[string, *Client](),
debounceTime: 300 * time.Millisecond,
debounceMap: csync.NewMap[string, *time.Timer](),
ctx: ctx,
cancel: cancel,
+ root: tempDir,
}
- // Watch the workspace
- err = gw.addDirectoryToWatcher(tempDir)
- if err != nil {
- t.Fatalf("addDirectoryToWatcher failed: %v", err)
+ // Set up recursive watching on the root directory
+ watchPath := filepath.Join(tempDir, "...")
+ if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
+ t.Fatalf("Failed to set up recursive watch: %v", err)
}
+ defer notify.Stop(gw.events)
- // Verify that our expected directories exist and can be watched
+ // Verify that our expected directories and files exist
expectedDirs := []string{tempDir, subDir}
for _, expectedDir := range expectedDirs {
@@ -153,15 +146,9 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
if !info.IsDir() {
t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
}
-
- // Try to add it again - fsnotify should handle this gracefully
- err = gw.addDirectoryToWatcher(expectedDir)
- if err != nil {
- t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
- }
}
- // Verify that files exist but we don't try to watch them directly
+ // Verify that files exist
testFiles := []string{file1, file2}
for _, file := range testFiles {
info, err := os.Stat(file)
@@ -172,39 +159,61 @@ func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
t.Fatalf("Expected %s to be a file, but it's a directory", file)
}
}
+
+ // Create a new file in the subdirectory to test recursive watching
+ newFile := filepath.Join(subDir, "new.txt")
+ if err := os.WriteFile(newFile, []byte("new content"), 0o644); err != nil {
+ t.Fatalf("Failed to create new file: %v", err)
+ }
+
+ // We should receive an event for the file creation
+ select {
+ case event := <-gw.events:
+ // On macOS, paths might have /private prefix, so we need to compare the real paths
+ eventPath, _ := filepath.EvalSymlinks(event.Path())
+ expectedPath, _ := filepath.EvalSymlinks(newFile)
+ if eventPath != expectedPath {
+ // Also try comparing just the base names as a fallback
+ if filepath.Base(event.Path()) != filepath.Base(newFile) {
+ t.Errorf("Expected event for %s, got %s", newFile, event.Path())
+ }
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Timeout waiting for file creation event")
+ }
}
-func TestFsnotifyDeduplication(t *testing.T) {
+func TestNotifyDeduplication(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing
tempDir := t.TempDir()
- // Create a real fsnotify watcher
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- t.Fatalf("Failed to create fsnotify watcher: %v", err)
- }
- defer watcher.Close()
+ // Create an event channel
+ events := make(chan notify.EventInfo, 100)
+ defer close(events)
+
+ // Add the same directory multiple times with recursive watching
+ watchPath := filepath.Join(tempDir, "...")
- // Add the same directory multiple times
- err1 := watcher.Add(tempDir)
+ err1 := notify.Watch(watchPath, events, notify.All)
if err1 != nil {
- t.Fatalf("First Add failed: %v", err1)
+ t.Fatalf("First Watch failed: %v", err1)
}
+ defer notify.Stop(events)
- err2 := watcher.Add(tempDir)
+ err2 := notify.Watch(watchPath, events, notify.All)
if err2 != nil {
- t.Fatalf("Second Add failed: %v", err2)
+ t.Fatalf("Second Watch failed: %v", err2)
}
- err3 := watcher.Add(tempDir)
+ err3 := notify.Watch(watchPath, events, notify.All)
if err3 != nil {
- t.Fatalf("Third Add failed: %v", err3)
+ t.Fatalf("Third Watch failed: %v", err3)
}
- // All should succeed - fsnotify handles deduplication internally
- // This test verifies the fsnotify behavior we're relying on
+ // All should succeed - notify handles deduplication internally
+ // This test verifies the notify behavior we're relying on
}
func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
@@ -241,31 +250,26 @@ func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- // Create a real fsnotify watcher for testing
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- t.Fatalf("Failed to create fsnotify watcher: %v", err)
- }
- defer watcher.Close()
-
gw := &global{
- watcher: watcher,
+ events: make(chan notify.EventInfo, 100),
watchers: csync.NewMap[string, *Client](),
debounceTime: 300 * time.Millisecond,
debounceMap: csync.NewMap[string, *time.Timer](),
ctx: ctx,
cancel: cancel,
+ root: tempDir,
}
- // Watch the workspace
- err = gw.addDirectoryToWatcher(tempDir)
- if err != nil {
- t.Fatalf("addDirectoryToWatcher failed: %v", err)
+ // Set up recursive watching
+ watchPath := filepath.Join(tempDir, "...")
+ if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
+ t.Fatalf("Failed to set up recursive watch: %v", err)
}
+ defer notify.Stop(gw.events)
- // This test verifies that the watcher can successfully add directories to fsnotify
- // The actual ignore logic is tested in the fsext package
- // Here we just verify that the watcher integration works
+ // The notify library watches everything, but our processEvents
+ // function should filter out ignored files using fsext.ShouldExcludeFile
+ // This test verifies that the structure is set up correctly
}
func TestGlobalWatcherShutdown(t *testing.T) {
@@ -277,6 +281,7 @@ func TestGlobalWatcherShutdown(t *testing.T) {
// Create a temporary global watcher for testing
gw := &global{
+ events: make(chan notify.EventInfo, 100),
watchers: csync.NewMap[string, *Client](),
debounceTime: 300 * time.Millisecond,
debounceMap: csync.NewMap[string, *time.Timer](),