@@ -5,6 +5,7 @@ import (
9 "sync/atomic"
10 "sync/atomic"
@@ -13,22 +14,23 @@ import (
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/lsp/protocol"
16 "github.com/charmbracelet/crush/internal/lsp/protocol"
16− "github.com/fsnotify/fsnotify"
17+ "github.com/rjeczalik/notify"
19−// global manages a single fsnotify.Watcher instance shared across all LSP clients.
20+// global manages file watching shared across all LSP clients.
21−// IMPORTANT: This implementation only watches directories, not individual files.
22+// IMPORTANT: This implementation uses github.com/rjeczalik/notify which provides
22−// The fsnotify library automatically provides events for all files within watched
23+// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it
23−// directories, making this approach much more efficient than watching individual files.
24+// uses inotify (with recursion handled by the library), and on Windows it uses
25+// ReadDirectoryChangesW.
25−// Key benefits of directory-only watching:
27+// Key benefits:
26−// - Significantly fewer file descriptors used
28+// - Single watch point for entire directory tree
27−// - Automatic coverage of new files created in watched directories
29+// - Automatic recursive watching without manually adding subdirectories
28−// - Better performance with large codebases
30+// - No file descriptor exhaustion issues
29−// - fsnotify handles deduplication internally (no need to track watched dirs)
30type global struct {
31type global struct {
31− watcher *fsnotify.Watcher
32+ // Channel for receiving file system events
33+ events chan notify.EventInfo
33 // Map of workspace watchers by client name
35 // Map of workspace watchers by client name
34 watchers *csync.Map[string, *Client]
36 watchers *csync.Map[string, *Client]
@@ -54,6 +56,7 @@ type global struct {
54var instance = sync.OnceValue(func() *global {
56var instance = sync.OnceValue(func() *global {
55 ctx, cancel := context.WithCancel(context.Background())
57 ctx, cancel := context.WithCancel(context.Background())
56 gw := &global{
58 gw := &global{
59+ events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events
57 watchers: csync.NewMap[string, *Client](),
60 watchers: csync.NewMap[string, *Client](),
58 debounceTime: 300 * time.Millisecond,
61 debounceTime: 300 * time.Millisecond,
59 debounceMap: csync.NewMap[string, *time.Timer](),
62 debounceMap: csync.NewMap[string, *time.Timer](),
@@ -61,15 +64,6 @@ var instance = sync.OnceValue(func() *global {
61 cancel: cancel,
64 cancel: cancel,
64− // Initialize the fsnotify watcher
65− watcher, err := fsnotify.NewWatcher()
67− slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
@@ -85,11 +79,11 @@ func (gw *global) unregister(name string) {
85 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
79 slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
88−// Start walks the given path and sets up the watcher on it.
82+// Start sets up recursive watching on the workspace root.
90−// Note: We only watch directories, not individual files. fsnotify automatically provides
84+// Note: We use github.com/rjeczalik/notify which provides recursive watching
91−// events for all files within watched directories. Multiple calls with the same workspace
85+// with a single watch point. The "..." suffix means watch recursively.
92−// are safe since fsnotify handles directory deduplication internally.
86+// This is much more efficient than manually walking and watching each directory.
93func Start() error {
87func Start() error {
94 gw := instance()
88 gw := instance()
@@ -107,59 +101,33 @@ func Start() error {
107 gw.root = root
101 gw.root = root
108 gw.started.Store(true)
102 gw.started.Store(true)
110− // Start the event processing goroutine now that we're initialized
104+ // Start the event processing goroutine
111 gw.wg.Add(1)
105 gw.wg.Add(1)
112 go gw.processEvents()
106 go gw.processEvents()
114− // Walk the workspace and add only directories to the watcher
108+ // Set up recursive watching on the root directory
115− // fsnotify will automatically provide events for all files within these directories
109+ // The "..." suffix tells notify to watch recursively
116− // Multiple calls with the same directories are safe (fsnotify deduplicates)
110+ watchPath := filepath.Join(root, "...")
117− err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
122− // Add directory to watcher (fsnotify handles deduplication automatically)
123− if err := gw.addDirectoryToWatcher(path); err != nil {
124− slog.Error("lsp watcher: Error watching directory", "path", path, "error", err)
130− return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
136−// addDirectoryToWatcher adds a directory to the fsnotify watcher.
112+ // Watch for all event types we care about
137−// fsnotify handles deduplication internally, so we don't need to track watched directories.
113+ events := notify.Create | notify.Write | notify.Remove | notify.Rename
138−func (gw *global) addDirectoryToWatcher(dirPath string) error {
139− if gw.watcher == nil {
140− return fmt.Errorf("lsp watcher: global watcher not initialized")
143− // Add directory to fsnotify watcher - fsnotify handles deduplication
115+ if err := notify.Watch(watchPath, gw.events, events); err != nil {
144− // "A path can only be watched once; watching it more than once is a no-op"
145− err := gw.watcher.Add(dirPath)
147− return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
116+ return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
150− slog.Debug("lsp watcher: watching directory", "path", dirPath)
119+ slog.Info("lsp watcher: Started recursive watching", "root", root)
151 return nil
120 return nil
154−// processEvents processes file system events and handles them centrally.
123+// processEvents processes file system events from the notify library.
155−// Since we only watch directories, we automatically get events for all files
124+// Since notify handles recursive watching for us, we don't need to manually
156−// within those directories. When new directories are created, we add them
125+// add new directories - they're automatically included.
157−// to the watcher to ensure complete coverage.
158func (gw *global) processEvents() {
126func (gw *global) processEvents() {
159 defer gw.wg.Done()
127 defer gw.wg.Done()
160 cfg := config.Get()
128 cfg := config.Get()
162− if gw.watcher == nil || !gw.started.Load() {
130+ if !gw.started.Load() {
163 slog.Error("lsp watcher: Global watcher not initialized")
131 slog.Error("lsp watcher: Global watcher not initialized")
@@ -169,68 +137,89 @@ func (gw *global) processEvents() {
169 case <-gw.ctx.Done():
137 case <-gw.ctx.Done():
172− case event, ok := <-gw.watcher.Events:
140+ case event, ok := <-gw.events:
177− // Handle directory creation globally (only once)
145+ path := event.Path()
178− // When new directories are created, we need to add them to the watcher
146+
179− // to ensure we get events for files created within them
147+ // Skip ignored files
180− if event.Op&fsnotify.Create != 0 {
181− if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
182− if !fsext.ShouldExcludeFile(gw.root, event.Name) {
148+ if fsext.ShouldExcludeFile(gw.root, path) {
183− if err := gw.addDirectoryToWatcher(event.Name); err != nil {
149+ continue
184− slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
186− } else if cfg != nil && cfg.Options.DebugLSP {
187− slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
192 if cfg != nil && cfg.Options.DebugLSP {
152 if cfg != nil && cfg.Options.DebugLSP {
193− slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
153+ slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String())
196− // Process the event centrally
156+ // Convert notify event to our internal format and handle it
197 gw.handleFileEvent(event)
157 gw.handleFileEvent(event)
199− case err, ok := <-gw.watcher.Errors:
203− slog.Error("lsp watcher: Global watcher error", "error", err)
208// handleFileEvent processes a file system event and distributes notifications to relevant clients
162// handleFileEvent processes a file system event and distributes notifications to relevant clients
209−func (gw *global) handleFileEvent(event fsnotify.Event) {
163+func (gw *global) handleFileEvent(event notify.EventInfo) {
210 cfg := config.Get()
164 cfg := config.Get()
165+ path := event.Path()
211− uri := string(protocol.URIFromPath(event.Name))
166+ uri := string(protocol.URIFromPath(path))
168+ // Map notify events to our change types
169+ var changeType protocol.FileChangeType
170+ var watchKindNeeded protocol.WatchKind
172+ switch event.Event() {
174+ changeType = protocol.FileChangeType(protocol.Created)
175+ watchKindNeeded = protocol.WatchCreate
213− // Handle file creation for all relevant clients (only once)
176+ // Handle file creation for all relevant clients
214− if event.Op&fsnotify.Create != 0 {
215− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
216− if !fsext.ShouldExcludeFile(gw.root, event.Name) {
177+ if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) {
217− gw.openMatchingFileForClients(event.Name)
178+ gw.openMatchingFileForClients(path)
181+ changeType = protocol.FileChangeType(protocol.Changed)
182+ watchKindNeeded = protocol.WatchChange
184+ changeType = protocol.FileChangeType(protocol.Deleted)
185+ watchKindNeeded = protocol.WatchDelete
187+ // Treat rename as delete + create
188+ // First handle as delete
189+ for _, watcher := range gw.watchers.Seq2() {
190+ if !watcher.client.HandlesFile(path) {
193+ if watched, watchKind := watcher.isPathWatched(path); watched {
194+ if watchKind&protocol.WatchDelete != 0 {
195+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
199+ // Then check if renamed file exists and treat as create
201+ changeType = protocol.FileChangeType(protocol.Created)
202+ watchKindNeeded = protocol.WatchCreate
204+ return // Already handled delete, nothing more to do for directories
207+ // Unknown event type, skip
222 // Process the event for each relevant client
211 // Process the event for each relevant client
223 for client, watcher := range gw.watchers.Seq2() {
212 for client, watcher := range gw.watchers.Seq2() {
224− if !watcher.client.HandlesFile(event.Name) {
213+ if !watcher.client.HandlesFile(path) {
225 continue // client doesn't handle this filetype
214 continue // client doesn't handle this filetype
228 // Debug logging per client
217 // Debug logging per client
229 if cfg.Options.DebugLSP {
218 if cfg.Options.DebugLSP {
230− matched, kind := watcher.isPathWatched(event.Name)
219+ matched, kind := watcher.isPathWatched(path)
231 slog.Debug("lsp watcher: File event for client",
220 slog.Debug("lsp watcher: File event for client",
232− "path", event.Name,
221+ "path", path,
233− "operation", event.Op.String(),
222+ "event", event.Event().String(),
234 "watched", matched,
223 "watched", matched,
235 "kind", kind,
224 "kind", kind,
236 "client", client,
225 "client", client,
@@ -238,46 +227,31 @@ func (gw *global) handleFileEvent(event fsnotify.Event) {
240 // Check if this path should be watched according to server registrations
229 // Check if this path should be watched according to server registrations
241− if watched, watchKind := watcher.isPathWatched(event.Name); watched {
230+ if watched, watchKind := watcher.isPathWatched(path); watched {
242− switch {
231+ if watchKind&watchKindNeeded != 0 {
243− case event.Op&fsnotify.Write != 0:
232+ // Skip directory events for non-delete operations
244− if watchKind&protocol.WatchChange != 0 {
245− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
233+ if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) {
247− case event.Op&fsnotify.Create != 0:
248− // File creation was already handled globally above
249− // Just send the notification if needed
250− info, err := os.Stat(event.Name)
252− if !os.IsNotExist(err) {
253− slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
257− if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
258− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
260− case event.Op&fsnotify.Remove != 0:
261− if watchKind&protocol.WatchDelete != 0 {
262− gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
264− case event.Op&fsnotify.Rename != 0:
265− // For renames, first delete
266− if watchKind&protocol.WatchDelete != 0 {
267− gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
270− // Then check if the new file exists and create an event
271− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
272− if watchKind&protocol.WatchCreate != 0 {
273− gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
237+ if changeType == protocol.FileChangeType(protocol.Deleted) {
274− }
238+ // Don't debounce deletes
239+ gw.handleFileEventForClient(watcher, uri, changeType)
241+ // Debounce creates and changes
242+ gw.debounceHandleFileEventForClient(watcher, uri, changeType)
249+// isDir checks if a path is a directory
250+func isDir(path string) bool {
251+ info, err := os.Stat(path)
252+ return err == nil && info.IsDir()
281// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
255// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
282func (gw *global) openMatchingFileForClients(path string) {
256func (gw *global) openMatchingFileForClients(path string) {
283 // Skip directories
257 // Skip directories
@@ -349,10 +323,9 @@ func (gw *global) shutdown() {
349 gw.cancel()
323 gw.cancel()
352− if gw.watcher != nil {
326+ // Stop watching and close the event channel
353− gw.watcher.Close()
327+ notify.Stop(gw.events)
354− gw.watcher = nil
328+ close(gw.events)
357 gw.wg.Wait()
330 gw.wg.Wait()
358 slog.Debug("lsp watcher: Global watcher shutdown complete")
331 slog.Debug("lsp watcher: Global watcher shutdown complete")