on main help sign in
feat: optimize LSP file watcher and ignore files (#959)
46a3a37Carlos Alexandro Becker
CRUSH.md+4
@@ -59,3 +59,7 @@ func TestYourFunction(t *testing.T) {
59 - If `goimports` is not available, use `gofmt`.
59 - If `goimports` is not available, use `gofmt`.
60 - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
60 - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
61 as long as `gofumpt` is on the `PATH`.
61 as long as `gofumpt` is on the `PATH`.
62+
63+## Committing
64+
65+- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
internal/app/app.go+9
@@ -21,6 +21,7 @@ import (
21 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/pubsub"
22
22
23 "github.com/charmbracelet/crush/internal/lsp"
23 "github.com/charmbracelet/crush/internal/lsp"
24+ "github.com/charmbracelet/crush/internal/lsp/watcher"
24 "github.com/charmbracelet/crush/internal/message"
25 "github.com/charmbracelet/crush/internal/message"
25 "github.com/charmbracelet/crush/internal/permission"
26 "github.com/charmbracelet/crush/internal/permission"
26 "github.com/charmbracelet/crush/internal/session"
27 "github.com/charmbracelet/crush/internal/session"
@@ -85,6 +86,11 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
85
86
86 app.setupEvents()
87 app.setupEvents()
87
88
89+ // Start the global watcher
90+ if err := watcher.Start(); err != nil {
91+ return nil, fmt.Errorf("app: %w", err)
92+ }
93+
88 // Initialize LSP clients in the background.
94 // Initialize LSP clients in the background.
89 app.initLSPClients(ctx)
95 app.initLSPClients(ctx)
90
96
@@ -352,6 +358,9 @@ func (app *App) Shutdown() {
352 cancel()
358 cancel()
353 }
359 }
354
360
361+ // Shutdown the global watcher
362+ watcher.Shutdown()
363+
355 // Call call cleanup functions.
364 // Call call cleanup functions.
356 for _, cleanup := range app.cleanupFuncs {
365 for _, cleanup := range app.cleanupFuncs {
357 if cleanup != nil {
366 if cleanup != nil {
internal/app/lsp.go+3−3
@@ -71,7 +71,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
71 watchCtx, cancelFunc := context.WithCancel(ctx)
71 watchCtx, cancelFunc := context.WithCancel(ctx)
72
72
73 // Create the workspace watcher.
73 // Create the workspace watcher.
74− workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient)
74+ workspaceWatcher := watcher.New(name, lspClient)
75
75
76 // Store the cancel function to be called during cleanup.
76 // Store the cancel function to be called during cleanup.
77 app.watcherCancelFuncs.Append(cancelFunc)
77 app.watcherCancelFuncs.Append(cancelFunc)
@@ -87,14 +87,14 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
87}
87}
88
88
89// runWorkspaceWatcher executes the workspace watcher for an LSP client.
89// runWorkspaceWatcher executes the workspace watcher for an LSP client.
90−func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
90+func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.Client) {
91 defer app.lspWatcherWG.Done()
91 defer app.lspWatcherWG.Done()
92 defer log.RecoverPanic("LSP-"+name, func() {
92 defer log.RecoverPanic("LSP-"+name, func() {
93 // Try to restart the client.
93 // Try to restart the client.
94 app.restartLSPClient(ctx, name)
94 app.restartLSPClient(ctx, name)
95 })
95 })
96
96
97− workspaceWatcher.WatchWorkspace(ctx, app.config.WorkingDir())
97+ workspaceWatcher.Watch(ctx, app.config.WorkingDir())
98 slog.Info("Workspace watcher stopped", "client", name)
98 slog.Info("Workspace watcher stopped", "client", name)
99}
99}
100
100
internal/lsp/watcher/global_watcher.gonew file+364
@@ -0,0 +1,364 @@
1+package watcher
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "os"
8+ "sync"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/lsp/protocol"
16+ "github.com/fsnotify/fsnotify"
17+)
18+
19+// global manages a single fsnotify.Watcher instance shared across all LSP clients.
20+//
21+// IMPORTANT: This implementation only watches directories, not individual files.
22+// The fsnotify library automatically provides events for all files within watched
23+// directories, making this approach much more efficient than watching individual files.
24+//
25+// Key benefits of directory-only watching:
26+// - Significantly fewer file descriptors used
27+// - Automatic coverage of new files created in watched directories
28+// - Better performance with large codebases
29+// - fsnotify handles deduplication internally (no need to track watched dirs)
30+type global struct {
31+ watcher *fsnotify.Watcher
32+
33+ // Map of workspace watchers by client name
34+ watchers *csync.Map[string, *Client]
35+
36+ // Single workspace root directory for ignore checking
37+ root string
38+
39+ started atomic.Bool
40+
41+ // Debouncing for file events (shared across all clients)
42+ debounceTime time.Duration
43+ debounceMap *csync.Map[string, *time.Timer]
44+
45+ // Context for shutdown
46+ ctx context.Context
47+ cancel context.CancelFunc
48+
49+ // Wait group for cleanup
50+ wg sync.WaitGroup
51+}
52+
53+// instance returns the singleton global watcher instance
54+var instance = sync.OnceValue(func() *global {
55+ ctx, cancel := context.WithCancel(context.Background())
56+ gw := &global{
57+ watchers: csync.NewMap[string, *Client](),
58+ debounceTime: 300 * time.Millisecond,
59+ debounceMap: csync.NewMap[string, *time.Timer](),
60+ ctx: ctx,
61+ cancel: cancel,
62+ }
63+
64+ // Initialize the fsnotify watcher
65+ watcher, err := fsnotify.NewWatcher()
66+ if err != nil {
67+ slog.Error("lsp watcher: Failed to create global file watcher", "error", err)
68+ return gw
69+ }
70+
71+ gw.watcher = watcher
72+
73+ return gw
74+})
75+
76+// register registers a workspace watcher with the global watcher
77+func (gw *global) register(name string, watcher *Client) {
78+ gw.watchers.Set(name, watcher)
79+ slog.Debug("lsp watcher: Registered workspace watcher", "name", name)
80+}
81+
82+// unregister removes a workspace watcher from the global watcher
83+func (gw *global) unregister(name string) {
84+ gw.watchers.Del(name)
85+ slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
86+}
87+
88+// Start walks the given path and sets up the watcher on it.
89+//
90+// Note: We only watch directories, not individual files. fsnotify automatically provides
91+// events for all files within watched directories. Multiple calls with the same workspace
92+// are safe since fsnotify handles directory deduplication internally.
93+func Start() error {
94+ gw := instance()
95+
96+ // technically workspace root is always the same...
97+ if gw.started.Load() {
98+ slog.Debug("lsp watcher: watcher already set up, skipping")
99+ return nil
100+ }
101+
102+ cfg := config.Get()
103+ root := cfg.WorkingDir()
104+ slog.Debug("lsp watcher: set workspace directory to global watcher", "path", root)
105+
106+ // Store the workspace root for hierarchical ignore checking
107+ gw.root = root
108+ gw.started.Store(true)
109+
110+ // Start the event processing goroutine now that we're initialized
111+ gw.wg.Add(1)
112+ go gw.processEvents()
113+
114+ // Walk the workspace and add only directories to the watcher
115+ // fsnotify will automatically provide events for all files within these directories
116+ // Multiple calls with the same directories are safe (fsnotify deduplicates)
117+ err := fsext.WalkDirectories(root, func(path string, d os.DirEntry, err error) error {
118+ if err != nil {
119+ return err
120+ }
121+
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)
125+ }
126+
127+ return nil
128+ })
129+ if err != nil {
130+ return fmt.Errorf("lsp watcher: error walking workspace %s: %w", root, err)
131+ }
132+
133+ return nil
134+}
135+
136+// addDirectoryToWatcher adds a directory to the fsnotify watcher.
137+// fsnotify handles deduplication internally, so we don't need to track watched directories.
138+func (gw *global) addDirectoryToWatcher(dirPath string) error {
139+ if gw.watcher == nil {
140+ return fmt.Errorf("lsp watcher: global watcher not initialized")
141+ }
142+
143+ // Add directory to fsnotify watcher - fsnotify handles deduplication
144+ // "A path can only be watched once; watching it more than once is a no-op"
145+ err := gw.watcher.Add(dirPath)
146+ if err != nil {
147+ return fmt.Errorf("lsp watcher: failed to watch directory %s: %w", dirPath, err)
148+ }
149+
150+ slog.Debug("lsp watcher: watching directory", "path", dirPath)
151+ return nil
152+}
153+
154+// processEvents processes file system events and handles them centrally.
155+// Since we only watch directories, we automatically get events for all files
156+// within those directories. When new directories are created, we add them
157+// to the watcher to ensure complete coverage.
158+func (gw *global) processEvents() {
159+ defer gw.wg.Done()
160+ cfg := config.Get()
161+
162+ if gw.watcher == nil || !gw.started.Load() {
163+ slog.Error("lsp watcher: Global watcher not initialized")
164+ return
165+ }
166+
167+ for {
168+ select {
169+ case <-gw.ctx.Done():
170+ return
171+
172+ case event, ok := <-gw.watcher.Events:
173+ if !ok {
174+ return
175+ }
176+
177+ // Handle directory creation globally (only once)
178+ // When new directories are created, we need to add them to the watcher
179+ // to ensure we get events for files created within them
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) {
183+ if err := gw.addDirectoryToWatcher(event.Name); err != nil {
184+ slog.Error("lsp watcher: Error adding new directory to watcher", "path", event.Name, "error", err)
185+ }
186+ } else if cfg != nil && cfg.Options.DebugLSP {
187+ slog.Debug("lsp watcher: Skipping ignored new directory", "path", event.Name)
188+ }
189+ }
190+ }
191+
192+ if cfg != nil && cfg.Options.DebugLSP {
193+ slog.Debug("lsp watcher: Global watcher received event", "path", event.Name, "op", event.Op.String())
194+ }
195+
196+ // Process the event centrally
197+ gw.handleFileEvent(event)
198+
199+ case err, ok := <-gw.watcher.Errors:
200+ if !ok {
201+ return
202+ }
203+ slog.Error("lsp watcher: Global watcher error", "error", err)
204+ }
205+ }
206+}
207+
208+// handleFileEvent processes a file system event and distributes notifications to relevant clients
209+func (gw *global) handleFileEvent(event fsnotify.Event) {
210+ cfg := config.Get()
211+ uri := string(protocol.URIFromPath(event.Name))
212+
213+ // Handle file creation for all relevant clients (only once)
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) {
217+ gw.openMatchingFileForClients(event.Name)
218+ }
219+ }
220+ }
221+
222+ // Process the event for each relevant client
223+ for client, watcher := range gw.watchers.Seq2() {
224+ if !watcher.client.HandlesFile(event.Name) {
225+ continue // client doesn't handle this filetype
226+ }
227+
228+ // Debug logging per client
229+ if cfg.Options.DebugLSP {
230+ matched, kind := watcher.isPathWatched(event.Name)
231+ slog.Debug("lsp watcher: File event for client",
232+ "path", event.Name,
233+ "operation", event.Op.String(),
234+ "watched", matched,
235+ "kind", kind,
236+ "client", client,
237+ )
238+ }
239+
240+ // Check if this path should be watched according to server registrations
241+ if watched, watchKind := watcher.isPathWatched(event.Name); watched {
242+ switch {
243+ case event.Op&fsnotify.Write != 0:
244+ if watchKind&protocol.WatchChange != 0 {
245+ gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Changed))
246+ }
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)
251+ if err != nil {
252+ if !os.IsNotExist(err) {
253+ slog.Debug("lsp watcher: Error getting file info", "path", event.Name, "error", err)
254+ }
255+ continue
256+ }
257+ if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
258+ gw.debounceHandleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Created))
259+ }
260+ case event.Op&fsnotify.Remove != 0:
261+ if watchKind&protocol.WatchDelete != 0 {
262+ gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
263+ }
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))
268+ }
269+
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))
274+ }
275+ }
276+ }
277+ }
278+ }
279+}
280+
281+// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
282+func (gw *global) openMatchingFileForClients(path string) {
283+ // Skip directories
284+ info, err := os.Stat(path)
285+ if err != nil || info.IsDir() {
286+ return
287+ }
288+
289+ // Skip excluded files
290+ if fsext.ShouldExcludeFile(gw.root, path) {
291+ return
292+ }
293+
294+ // Open the file for each client that handles it and has matching patterns
295+ for _, watcher := range gw.watchers.Seq2() {
296+ if watcher.client.HandlesFile(path) {
297+ watcher.openMatchingFile(gw.ctx, path)
298+ }
299+ }
300+}
301+
302+// debounceHandleFileEventForClient handles file events with debouncing for a specific client
303+func (gw *global) debounceHandleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
304+ // Create a unique key based on URI, change type, and client name
305+ key := fmt.Sprintf("%s:%d:%s", uri, changeType, watcher.name)
306+
307+ // Cancel existing timer if any
308+ if timer, exists := gw.debounceMap.Get(key); exists {
309+ timer.Stop()
310+ }
311+
312+ // Create new timer
313+ gw.debounceMap.Set(key, time.AfterFunc(gw.debounceTime, func() {
314+ gw.handleFileEventForClient(watcher, uri, changeType)
315+
316+ // Cleanup timer after execution
317+ gw.debounceMap.Del(key)
318+ }))
319+}
320+
321+// handleFileEventForClient sends file change notifications to a specific client
322+func (gw *global) handleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
323+ // If the file is open and it's a change event, use didChange notification
324+ filePath, err := protocol.DocumentURI(uri).Path()
325+ if err != nil {
326+ slog.Error("lsp watcher: Error converting URI to path", "uri", uri, "error", err)
327+ return
328+ }
329+
330+ if changeType == protocol.FileChangeType(protocol.Deleted) {
331+ watcher.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
332+ } else if changeType == protocol.FileChangeType(protocol.Changed) && watcher.client.IsFileOpen(filePath) {
333+ err := watcher.client.NotifyChange(gw.ctx, filePath)
334+ if err != nil {
335+ slog.Error("lsp watcher: Error notifying change", "error", err)
336+ }
337+ return
338+ }
339+
340+ // Notify LSP server about the file event using didChangeWatchedFiles
341+ if err := watcher.notifyFileEvent(gw.ctx, uri, changeType); err != nil {
342+ slog.Error("lsp watcher: Error notifying LSP server about file event", "error", err)
343+ }
344+}
345+
346+// shutdown gracefully shuts down the global watcher
347+func (gw *global) shutdown() {
348+ if gw.cancel != nil {
349+ gw.cancel()
350+ }
351+
352+ if gw.watcher != nil {
353+ gw.watcher.Close()
354+ gw.watcher = nil
355+ }
356+
357+ gw.wg.Wait()
358+ slog.Debug("lsp watcher: Global watcher shutdown complete")
359+}
360+
361+// Shutdown shuts down the singleton global watcher
362+func Shutdown() {
363+ instance().shutdown()
364+}
internal/lsp/watcher/global_watcher_test.gonew file+297
@@ -0,0 +1,297 @@
1+package watcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/charmbracelet/crush/internal/csync"
11+ "github.com/fsnotify/fsnotify"
12+)
13+
14+func TestGlobalWatcher(t *testing.T) {
15+ t.Parallel()
16+
17+ // Test that we can get the global watcher instance
18+ gw1 := instance()
19+ if gw1 == nil {
20+ t.Fatal("Expected global watcher instance, got nil")
21+ }
22+
23+ // Test that subsequent calls return the same instance (singleton)
24+ gw2 := instance()
25+ if gw1 != gw2 {
26+ t.Fatal("Expected same global watcher instance, got different instances")
27+ }
28+
29+ // Test registration and unregistration
30+ mockWatcher := &Client{
31+ name: "test-watcher",
32+ }
33+
34+ gw1.register("test", mockWatcher)
35+
36+ // Check that it was registered
37+ registered, _ := gw1.watchers.Get("test")
38+
39+ if registered != mockWatcher {
40+ t.Fatal("Expected workspace watcher to be registered")
41+ }
42+
43+ // Test unregistration
44+ gw1.unregister("test")
45+
46+ unregistered, _ := gw1.watchers.Get("test")
47+
48+ if unregistered != nil {
49+ t.Fatal("Expected workspace watcher to be unregistered")
50+ }
51+}
52+
53+func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
54+ t.Parallel()
55+
56+ // Create a temporary directory for testing
57+ tempDir := t.TempDir()
58+
59+ // Create a new global watcher instance for this test
60+ ctx, cancel := context.WithCancel(context.Background())
61+ defer cancel()
62+
63+ // Create a real fsnotify watcher for testing
64+ watcher, err := fsnotify.NewWatcher()
65+ if err != nil {
66+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
67+ }
68+ defer watcher.Close()
69+
70+ gw := &global{
71+ watcher: watcher,
72+ watchers: csync.NewMap[string, *Client](),
73+ debounceTime: 300 * time.Millisecond,
74+ debounceMap: csync.NewMap[string, *time.Timer](),
75+ ctx: ctx,
76+ cancel: cancel,
77+ }
78+
79+ // Test that watching the same workspace multiple times is safe (idempotent)
80+ err1 := gw.addDirectoryToWatcher(tempDir)
81+ if err1 != nil {
82+ t.Fatalf("First addDirectoryToWatcher call failed: %v", err1)
83+ }
84+
85+ err2 := gw.addDirectoryToWatcher(tempDir)
86+ if err2 != nil {
87+ t.Fatalf("Second addDirectoryToWatcher call failed: %v", err2)
88+ }
89+
90+ err3 := gw.addDirectoryToWatcher(tempDir)
91+ if err3 != nil {
92+ t.Fatalf("Third addDirectoryToWatcher call failed: %v", err3)
93+ }
94+
95+ // All calls should succeed - fsnotify handles deduplication internally
96+ // This test verifies that multiple WatchWorkspace calls are safe
97+}
98+
99+func TestGlobalWatcherOnlyWatchesDirectories(t *testing.T) {
100+ t.Parallel()
101+
102+ // Create a temporary directory structure for testing
103+ tempDir := t.TempDir()
104+ subDir := filepath.Join(tempDir, "subdir")
105+ if err := os.Mkdir(subDir, 0o755); err != nil {
106+ t.Fatalf("Failed to create subdirectory: %v", err)
107+ }
108+
109+ // Create some files
110+ file1 := filepath.Join(tempDir, "file1.txt")
111+ file2 := filepath.Join(subDir, "file2.txt")
112+ if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
113+ t.Fatalf("Failed to create file1: %v", err)
114+ }
115+ if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
116+ t.Fatalf("Failed to create file2: %v", err)
117+ }
118+
119+ // Create a new global watcher instance for this test
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+
123+ // Create a real fsnotify watcher for testing
124+ watcher, err := fsnotify.NewWatcher()
125+ if err != nil {
126+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
127+ }
128+ defer watcher.Close()
129+
130+ gw := &global{
131+ watcher: watcher,
132+ watchers: csync.NewMap[string, *Client](),
133+ debounceTime: 300 * time.Millisecond,
134+ debounceMap: csync.NewMap[string, *time.Timer](),
135+ ctx: ctx,
136+ cancel: cancel,
137+ }
138+
139+ // Watch the workspace
140+ err = gw.addDirectoryToWatcher(tempDir)
141+ if err != nil {
142+ t.Fatalf("addDirectoryToWatcher failed: %v", err)
143+ }
144+
145+ // Verify that our expected directories exist and can be watched
146+ expectedDirs := []string{tempDir, subDir}
147+
148+ for _, expectedDir := range expectedDirs {
149+ info, err := os.Stat(expectedDir)
150+ if err != nil {
151+ t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
152+ }
153+ if !info.IsDir() {
154+ t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
155+ }
156+
157+ // Try to add it again - fsnotify should handle this gracefully
158+ err = gw.addDirectoryToWatcher(expectedDir)
159+ if err != nil {
160+ t.Fatalf("Failed to add directory %s to watcher: %v", expectedDir, err)
161+ }
162+ }
163+
164+ // Verify that files exist but we don't try to watch them directly
165+ testFiles := []string{file1, file2}
166+ for _, file := range testFiles {
167+ info, err := os.Stat(file)
168+ if err != nil {
169+ t.Fatalf("Test file %s doesn't exist: %v", file, err)
170+ }
171+ if info.IsDir() {
172+ t.Fatalf("Expected %s to be a file, but it's a directory", file)
173+ }
174+ }
175+}
176+
177+func TestFsnotifyDeduplication(t *testing.T) {
178+ t.Parallel()
179+
180+ // Create a temporary directory for testing
181+ tempDir := t.TempDir()
182+
183+ // Create a real fsnotify watcher
184+ watcher, err := fsnotify.NewWatcher()
185+ if err != nil {
186+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
187+ }
188+ defer watcher.Close()
189+
190+ // Add the same directory multiple times
191+ err1 := watcher.Add(tempDir)
192+ if err1 != nil {
193+ t.Fatalf("First Add failed: %v", err1)
194+ }
195+
196+ err2 := watcher.Add(tempDir)
197+ if err2 != nil {
198+ t.Fatalf("Second Add failed: %v", err2)
199+ }
200+
201+ err3 := watcher.Add(tempDir)
202+ if err3 != nil {
203+ t.Fatalf("Third Add failed: %v", err3)
204+ }
205+
206+ // All should succeed - fsnotify handles deduplication internally
207+ // This test verifies the fsnotify behavior we're relying on
208+}
209+
210+func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
211+ t.Parallel()
212+
213+ // Create a temporary directory structure for testing
214+ tempDir := t.TempDir()
215+
216+ // Create directories that should be ignored
217+ nodeModules := filepath.Join(tempDir, "node_modules")
218+ target := filepath.Join(tempDir, "target")
219+ customIgnored := filepath.Join(tempDir, "custom_ignored")
220+ normalDir := filepath.Join(tempDir, "src")
221+
222+ for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
223+ if err := os.MkdirAll(dir, 0o755); err != nil {
224+ t.Fatalf("Failed to create directory %s: %v", dir, err)
225+ }
226+ }
227+
228+ // Create .gitignore file
229+ gitignoreContent := "node_modules/\ntarget/\n"
230+ if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
231+ t.Fatalf("Failed to create .gitignore: %v", err)
232+ }
233+
234+ // Create .crushignore file
235+ crushignoreContent := "custom_ignored/\n"
236+ if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
237+ t.Fatalf("Failed to create .crushignore: %v", err)
238+ }
239+
240+ // Create a new global watcher instance for this test
241+ ctx, cancel := context.WithCancel(context.Background())
242+ defer cancel()
243+
244+ // Create a real fsnotify watcher for testing
245+ watcher, err := fsnotify.NewWatcher()
246+ if err != nil {
247+ t.Fatalf("Failed to create fsnotify watcher: %v", err)
248+ }
249+ defer watcher.Close()
250+
251+ gw := &global{
252+ watcher: watcher,
253+ watchers: csync.NewMap[string, *Client](),
254+ debounceTime: 300 * time.Millisecond,
255+ debounceMap: csync.NewMap[string, *time.Timer](),
256+ ctx: ctx,
257+ cancel: cancel,
258+ }
259+
260+ // Watch the workspace
261+ err = gw.addDirectoryToWatcher(tempDir)
262+ if err != nil {
263+ t.Fatalf("addDirectoryToWatcher failed: %v", err)
264+ }
265+
266+ // This test verifies that the watcher can successfully add directories to fsnotify
267+ // The actual ignore logic is tested in the fsext package
268+ // Here we just verify that the watcher integration works
269+}
270+
271+func TestGlobalWatcherShutdown(t *testing.T) {
272+ t.Parallel()
273+
274+ // Create a new context for this test
275+ ctx, cancel := context.WithCancel(context.Background())
276+ defer cancel()
277+
278+ // Create a temporary global watcher for testing
279+ gw := &global{
280+ watchers: csync.NewMap[string, *Client](),
281+ debounceTime: 300 * time.Millisecond,
282+ debounceMap: csync.NewMap[string, *time.Timer](),
283+ ctx: ctx,
284+ cancel: cancel,
285+ }
286+
287+ // Test shutdown doesn't panic
288+ gw.shutdown()
289+
290+ // Verify context was cancelled
291+ select {
292+ case <-gw.ctx.Done():
293+ // Expected
294+ case <-time.After(100 * time.Millisecond):
295+ t.Fatal("Expected context to be cancelled after shutdown")
296+ }
297+}
internal/lsp/watcher/watcher.go+42−463
@@ -7,7 +7,6 @@ import (
7 "os"
7 "os"
8 "path/filepath"
8 "path/filepath"
9 "strings"
9 "strings"
10− "sync"
11 "time"
10 "time"
12
11
13 "github.com/bmatcuk/doublestar/v4"
12 "github.com/bmatcuk/doublestar/v4"
@@ -16,58 +15,39 @@ import (
16
15
17 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
19− "github.com/fsnotify/fsnotify"
20)
18)
21
19
22−// WorkspaceWatcher manages LSP file watching
20+// Client manages LSP file watching for a specific client
21+// It now delegates actual file watching to the GlobalWatcher
23−type WorkspaceWatcher struct {
22+type Client struct {
24 client *lsp.Client
23 client *lsp.Client
25 name string
24 name string
26 workspacePath string
25 workspacePath string
27
26
28− debounceTime time.Duration
29− debounceMap *csync.Map[string, *time.Timer]
30−
31 // File watchers registered by the server
27 // File watchers registered by the server
32− registrations []protocol.FileSystemWatcher
28+ registrations *csync.Slice[protocol.FileSystemWatcher]
33− registrationMu sync.RWMutex
34}
29}
35
30
36−func init() {
37− // Ensure the watcher is initialized with a reasonable file limit
38− if _, err := Ulimit(); err != nil {
39− slog.Error("Error setting file limit", "error", err)
40− }
41−}
42−
43−// NewWorkspaceWatcher creates a new workspace watcher
31+// New creates a new workspace watcher for the given client.
44−func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
32+func New(name string, client *lsp.Client) *Client {
45− return &WorkspaceWatcher{
33+ return &Client{
46 name: name,
34 name: name,
47 client: client,
35 client: client,
48− debounceTime: 300 * time.Millisecond,
36+ registrations: csync.NewSlice[protocol.FileSystemWatcher](),
49− debounceMap: csync.NewMap[string, *time.Timer](),
50− registrations: []protocol.FileSystemWatcher{},
51 }
37 }
52}
38}
53
39
54−// AddRegistrations adds file watchers to track
40+// register adds file watchers to track
55−func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
41+func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
56 cfg := config.Get()
42 cfg := config.Get()
57
43
58− slog.Debug("Adding file watcher registrations")
44+ w.registrations.Append(watchers...)
59− w.registrationMu.Lock()
60− defer w.registrationMu.Unlock()
61−
62− // Add new watchers
63− w.registrations = append(w.registrations, watchers...)
64
45
65− // Print detailed registration information for debugging
66 if cfg.Options.DebugLSP {
46 if cfg.Options.DebugLSP {
67 slog.Debug("Adding file watcher registrations",
47 slog.Debug("Adding file watcher registrations",
68 "id", id,
48 "id", id,
69 "watchers", len(watchers),
49 "watchers", len(watchers),
70− "total", len(w.registrations),
50+ "total", w.registrations.Len(),
71 )
51 )
72
52
73 for i, watcher := range watchers {
53 for i, watcher := range watchers {
@@ -103,116 +83,29 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
103 }
83 }
104 }
84 }
105
85
106− // Determine server type for specialized handling
107− serverName := w.name
108− slog.Debug("Server type detected", "serverName", serverName)
109−
110− // Check if this server has sent file watchers
111− hasFileWatchers := len(watchers) > 0
112−
113− // For servers that need file preloading, we'll use a smart approach
86+ // For servers that need file preloading, open high-priority files only
114− if shouldPreloadFiles(serverName) || !hasFileWatchers {
87+ if shouldPreloadFiles(w.name) {
115 go func() {
88 go func() {
116− startTime := time.Now()
117− filesOpened := 0
118−
119− // Determine max files to open based on server type
120− maxFilesToOpen := 50 // Default conservative limit
121−
122− switch serverName {
123− case "typescript", "typescript-language-server", "tsserver", "vtsls":
124− // TypeScript servers benefit from seeing more files
125− maxFilesToOpen = 100
126− case "java", "jdtls":
127− // Java servers need to see many files for project model
128− maxFilesToOpen = 200
129− }
130−
131− // First, open high-priority files
132− highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
89+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
133− filesOpened += highPriorityFilesOpened
134−
135 if cfg.Options.DebugLSP {
90 if cfg.Options.DebugLSP {
136 slog.Debug("Opened high-priority files",
91 slog.Debug("Opened high-priority files",
137 "count", highPriorityFilesOpened,
92 "count", highPriorityFilesOpened,
138− "serverName", serverName)
93+ "serverName", w.name)
139− }
140−
141− // If we've already opened enough high-priority files, we might not need more
142− if filesOpened >= maxFilesToOpen {
143− if cfg.Options.DebugLSP {
144− slog.Debug("Reached file limit with high-priority files",
145− "filesOpened", filesOpened,
146− "maxFiles", maxFilesToOpen)
147− }
148− return
149− }
150−
151− // For the remaining slots, walk the directory and open matching files
152−
153− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
154− if err != nil {
155− return err
156− }
157−
158− // Skip directories that should be excluded
159− if d.IsDir() {
160− if path != w.workspacePath && shouldExcludeDir(path) {
161− if cfg.Options.DebugLSP {
162− slog.Debug("Skipping excluded directory", "path", path)
163− }
164− return filepath.SkipDir
165− }
166− } else {
167− // Process files, but limit the total number
168− if filesOpened < maxFilesToOpen {
169− // Only process if it's not already open (high-priority files were opened earlier)
170− if !w.client.IsFileOpen(path) {
171− w.openMatchingFile(ctx, path)
172− filesOpened++
173−
174− // Add a small delay after every 10 files to prevent overwhelming the server
175− if filesOpened%10 == 0 {
176− time.Sleep(50 * time.Millisecond)
177− }
178− }
179− } else {
180− // We've reached our limit, stop walking
181− return filepath.SkipAll
182− }
183− }
184−
185− return nil
186− })
187−
188− elapsedTime := time.Since(startTime)
189− if cfg.Options.DebugLSP {
190− slog.Debug("Limited workspace scan complete",
191− "filesOpened", filesOpened,
192− "maxFiles", maxFilesToOpen,
193− "elapsedTime", elapsedTime.Seconds(),
194− "workspacePath", w.workspacePath,
195− )
196− }
197−
198− if err != nil && cfg.Options.DebugLSP {
199− slog.Debug("Error scanning workspace for files to open", "error", err)
200 }
94 }
201 }()
95 }()
202− } else if cfg.Options.DebugLSP {
203− slog.Debug("Using on-demand file loading for server", "server", serverName)
204 }
96 }
205}
97}
206
98
207// openHighPriorityFiles opens important files for the server type
99// openHighPriorityFiles opens important files for the server type
208// Returns the number of files opened
100// Returns the number of files opened
209−func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
101+func (w *Client) openHighPriorityFiles(ctx context.Context, serverName string) int {
210 cfg := config.Get()
102 cfg := config.Get()
211 filesOpened := 0
103 filesOpened := 0
212
104
213 // Define patterns for high-priority files based on server type
105 // Define patterns for high-priority files based on server type
214 var patterns []string
106 var patterns []string
215
107
108+ // TODO: move this to LSP config
216 switch serverName {
109 switch serverName {
217 case "typescript", "typescript-language-server", "tsserver", "vtsls":
110 case "typescript", "typescript-language-server", "tsserver", "vtsls":
218 patterns = []string{
111 patterns = []string{
@@ -329,160 +222,35 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
329 return filesOpened
222 return filesOpened
330}
223}
331
224
332−// WatchWorkspace sets up file watching for a workspace
225+// Watch sets up file watching for a workspace using the global watcher
333−func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
226+func (w *Client) Watch(ctx context.Context, workspacePath string) {
334− cfg := config.Get()
335 w.workspacePath = workspacePath
227 w.workspacePath = workspacePath
336
228
337 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
229 slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
338
230
231+ // Register this workspace watcher with the global watcher
232+ instance().register(w.name, w)
233+ defer instance().unregister(w.name)
234+
339 // Register handler for file watcher registrations from the server
235 // Register handler for file watcher registrations from the server
340 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
236 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
341− w.AddRegistrations(ctx, id, watchers)
237+ w.register(ctx, id, watchers)
342 })
238 })
343
239
344− watcher, err := fsnotify.NewWatcher()
240+ // Wait for context cancellation
345− if err != nil {
346− slog.Error("Error creating watcher", "error", err)
347− }
348− defer watcher.Close()
349−
350− // Watch the workspace recursively
351− err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
352− if err != nil {
353− return err
354− }
355−
356− // Skip excluded directories (except workspace root)
357− if d.IsDir() && path != workspacePath {
358− if shouldExcludeDir(path) {
359− if cfg.Options.DebugLSP {
360− slog.Debug("Skipping excluded directory", "path", path)
361− }
362− return filepath.SkipDir
363− }
364− }
365−
366− // Add directories to watcher
367− if d.IsDir() {
368− err = watcher.Add(path)
369− if err != nil {
370− slog.Error("Error watching path", "path", path, "error", err)
371− }
372− }
373−
374− return nil
375− })
376− if err != nil {
377− slog.Error("Error walking workspace", "error", err)
378− }
379−
380− // Event loop
381− for {
382− select {
383− case <-ctx.Done():
241+ <-ctx.Done()
384− return
242+ slog.Debug("Workspace watcher stopped", "name", w.name)
385− case event, ok := <-watcher.Events:
386− if !ok {
387− return
388− }
389−
390− if !w.client.HandlesFile(event.Name) {
391− continue // client doesn't handle this filetype
392− }
393−
394− uri := string(protocol.URIFromPath(event.Name))
395−
396− // Add new directories to the watcher
397− if event.Op&fsnotify.Create != 0 {
398− if info, err := os.Stat(event.Name); err == nil {
399− if info.IsDir() {
400− // Skip excluded directories
401− if !shouldExcludeDir(event.Name) {
402− if err := watcher.Add(event.Name); err != nil {
403− slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
404− }
405− }
406− } else {
407− // For newly created files
408− if !shouldExcludeFile(event.Name) {
409− w.openMatchingFile(ctx, event.Name)
410− }
411− }
412− }
413− }
414−
415− // Debug logging
416− if cfg.Options.DebugLSP {
417− matched, kind := w.isPathWatched(event.Name)
418− slog.Debug("File event",
419− "path", event.Name,
420− "operation", event.Op.String(),
421− "watched", matched,
422− "kind", kind,
423− )
424− }
425−
426− // Check if this path should be watched according to server registrations
427− if watched, watchKind := w.isPathWatched(event.Name); watched {
428− switch {
429− case event.Op&fsnotify.Write != 0:
430− if watchKind&protocol.WatchChange != 0 {
431− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
432− }
433− case event.Op&fsnotify.Create != 0:
434− // Already handled earlier in the event loop
435− // Just send the notification if needed
436− info, err := os.Stat(event.Name)
437− if err != nil {
438− if !os.IsNotExist(err) {
439− // Only log if it's not a "file not found" error
440− slog.Debug("Error getting file info", "path", event.Name, "error", err)
441− }
442− continue
443− }
444− if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
445− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
446− }
447− case event.Op&fsnotify.Remove != 0:
448− if watchKind&protocol.WatchDelete != 0 {
449− w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
450− }
451− case event.Op&fsnotify.Rename != 0:
452− // For renames, first delete
453− if watchKind&protocol.WatchDelete != 0 {
454− w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
455− }
456−
457− // Then check if the new file exists and create an event
458− if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
459− if watchKind&protocol.WatchCreate != 0 {
460− w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
461− }
462− }
463− }
464− }
465− case err, ok := <-watcher.Errors:
466− if !ok {
467− return
468− }
469− slog.Error("Error watching file", "error", err)
470− }
471− }
472}
243}
473
244
474// isPathWatched checks if a path should be watched based on server registrations
245// isPathWatched checks if a path should be watched based on server registrations
246+// If no explicit registrations, watch everything
475−func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
247+func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
476− w.registrationMu.RLock()
477− defer w.registrationMu.RUnlock()
478−
479− // If no explicit registrations, watch everything
480− if len(w.registrations) == 0 {
248+ if w.registrations.Len() == 0 {
481 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
249 return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
482 }
250 }
483
251
484 // Check each registration
252 // Check each registration
485− for _, reg := range w.registrations {
253+ for reg := range w.registrations.Seq() {
486 isMatch := w.matchesPattern(path, reg.GlobPattern)
254 isMatch := w.matchesPattern(path, reg.GlobPattern)
487 if isMatch {
255 if isMatch {
488 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
256 kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
@@ -496,110 +264,19 @@ func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind)
496 return false, 0
264 return false, 0
497}
265}
498
266
499−// matchesGlob handles advanced glob patterns including ** and alternatives
267+// matchesGlob handles glob patterns using the doublestar library
500func matchesGlob(pattern, path string) bool {
268func matchesGlob(pattern, path string) bool {
501− // Handle file extension patterns with braces like *.{go,mod,sum}
269+ // Use doublestar for all glob matching - it handles ** and other complex patterns
502− if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
503− // Extract extensions from pattern like "*.{go,mod,sum}"
504− parts := strings.SplitN(pattern, "{", 2)
505− if len(parts) == 2 {
506− prefix := parts[0]
507− extPart := strings.SplitN(parts[1], "}", 2)
508− if len(extPart) == 2 {
509− extensions := strings.Split(extPart[0], ",")
510− suffix := extPart[1]
511−
512− // Check if the path matches any of the extensions
513− for _, ext := range extensions {
514− extPattern := prefix + ext + suffix
515− isMatch := matchesSimpleGlob(extPattern, path)
516− if isMatch {
517− return true
518− }
519− }
520− return false
521− }
522− }
523− }
524−
525− return matchesSimpleGlob(pattern, path)
526−}
527−
528−// matchesSimpleGlob handles glob patterns with ** wildcards
529−func matchesSimpleGlob(pattern, path string) bool {
530− // Handle special case for **/*.ext pattern (common in LSP)
531− if after, ok := strings.CutPrefix(pattern, "**/"); ok {
532− rest := after
533−
534− // If the rest is a simple file extension pattern like *.go
535− if strings.HasPrefix(rest, "*.") {
536− ext := strings.TrimPrefix(rest, "*")
537− isMatch := strings.HasSuffix(path, ext)
538− return isMatch
539− }
540−
541− // Otherwise, try to check if the path ends with the rest part
542− isMatch := strings.HasSuffix(path, rest)
543−
544− // If it matches directly, great!
545− if isMatch {
546− return true
547− }
548−
549− // Otherwise, check if any path component matches
550− pathComponents := strings.Split(path, "/")
551− for i := range pathComponents {
552− subPath := strings.Join(pathComponents[i:], "/")
553− if strings.HasSuffix(subPath, rest) {
554− return true
555− }
556− }
557−
558− return false
559− }
560−
561− // Handle other ** wildcard pattern cases
562− if strings.Contains(pattern, "**") {
563− parts := strings.Split(pattern, "**")
564−
565− // Validate the path starts with the first part
566− if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
567− return false
568− }
569−
570− // For patterns like "**/*.go", just check the suffix
571− if len(parts) == 2 && parts[0] == "" {
572− isMatch := strings.HasSuffix(path, parts[1])
573− return isMatch
574− }
575−
576− // For other patterns, handle middle part
577− remaining := strings.TrimPrefix(path, parts[0])
578− if len(parts) == 2 {
579− isMatch := strings.HasSuffix(remaining, parts[1])
580− return isMatch
581− }
582− }
583−
584− // Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
585− if strings.HasPrefix(pattern, "*.") {
586− ext := strings.TrimPrefix(pattern, "*")
587− isMatch := strings.HasSuffix(path, ext)
588− return isMatch
589− }
590−
591− // Fall back to simple matching for simpler patterns
592− matched, err := filepath.Match(pattern, path)
270+ matched, err := doublestar.Match(pattern, path)
593 if err != nil {
271 if err != nil {
594 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
272 slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
595 return false
273 return false
596 }
274 }
597−
598 return matched
275 return matched
599}
276}
600
277
601// matchesPattern checks if a path matches the glob pattern
278// matchesPattern checks if a path matches the glob pattern
602−func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
279+func (w *Client) matchesPattern(path string, pattern protocol.GlobPattern) bool {
603 patternInfo, err := pattern.AsPattern()
280 patternInfo, err := pattern.AsPattern()
604 if err != nil {
281 if err != nil {
605 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
282 slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
@@ -637,53 +314,8 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
637 return isMatch
314 return isMatch
638}
315}
639
316
640−// debounceHandleFileEvent handles file events with debouncing to reduce notifications
641−func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
642− // Create a unique key based on URI and change type
643− key := fmt.Sprintf("%s:%d", uri, changeType)
644−
645− // Cancel existing timer if any
646− if timer, exists := w.debounceMap.Get(key); exists {
647− timer.Stop()
648− }
649−
650− // Create new timer
651− w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
652− w.handleFileEvent(ctx, uri, changeType)
653−
654− // Cleanup timer after execution
655− w.debounceMap.Del(key)
656− }))
657−}
658−
659−// handleFileEvent sends file change notifications
660−func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
661− // If the file is open and it's a change event, use didChange notification
662− filePath, err := protocol.DocumentURI(uri).Path()
663− if err != nil {
664− // XXX: Do we want to return here, or send the error up the stack?
665− slog.Error("Error converting URI to path", "uri", uri, "error", err)
666− return
667− }
668−
669− if changeType == protocol.FileChangeType(protocol.Deleted) {
670− w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
671− } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
672− err := w.client.NotifyChange(ctx, filePath)
673− if err != nil {
674− slog.Error("Error notifying change", "error", err)
675− }
676− return
677− }
678−
679− // Notify LSP server about the file event using didChangeWatchedFiles
680− if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
681− slog.Error("Error notifying LSP server about file event", "error", err)
682− }
683−}
684−
685// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
317// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
686−func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
318+func (w *Client) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
687 cfg := config.Get()
319 cfg := config.Get()
688 if cfg.Options.DebugLSP {
320 if cfg.Options.DebugLSP {
689 slog.Debug("Notifying file event",
321 slog.Debug("Notifying file event",
@@ -724,21 +356,6 @@ func shouldPreloadFiles(serverName string) bool {
724// Common patterns for directories and files to exclude
356// Common patterns for directories and files to exclude
725// TODO: make configurable
357// TODO: make configurable
726var (
358var (
727− excludedDirNames = map[string]bool{
728− ".git": true,
729− "node_modules": true,
730− "dist": true,
731− "build": true,
732− "out": true,
733− "bin": true,
734− ".idea": true,
735− ".vscode": true,
736− ".cache": true,
737− "coverage": true,
738− "target": true, // Rust build output
739− "vendor": true, // Go vendor directory
740− }
741−
742 excludedFileExtensions = map[string]bool{
359 excludedFileExtensions = map[string]bool{
743 ".swp": true,
360 ".swp": true,
744 ".swo": true,
361 ".swo": true,
@@ -780,23 +397,6 @@ var (
780 maxFileSize int64 = 5 * 1024 * 1024
397 maxFileSize int64 = 5 * 1024 * 1024
781)
398)
782
399
783−// shouldExcludeDir returns true if the directory should be excluded from watching/opening
784−func shouldExcludeDir(dirPath string) bool {
785− dirName := filepath.Base(dirPath)
786−
787− // Skip dot directories
788− if strings.HasPrefix(dirName, ".") {
789− return true
790− }
791−
792− // Skip common excluded directories
793− if excludedDirNames[dirName] {
794− return true
795− }
796−
797− return false
798−}
799−
800// shouldExcludeFile returns true if the file should be excluded from opening
400// shouldExcludeFile returns true if the file should be excluded from opening
801func shouldExcludeFile(filePath string) bool {
401func shouldExcludeFile(filePath string) bool {
802 fileName := filepath.Base(filePath)
402 fileName := filepath.Base(filePath)
@@ -838,7 +438,7 @@ func shouldExcludeFile(filePath string) bool {
838}
438}
839
439
840// openMatchingFile opens a file if it matches any of the registered patterns
440// openMatchingFile opens a file if it matches any of the registered patterns
841−func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
441+func (w *Client) openMatchingFile(ctx context.Context, path string) {
842 cfg := config.Get()
442 cfg := config.Get()
843 // Skip directories
443 // Skip directories
844 info, err := os.Stat(path)
444 info, err := os.Stat(path)
@@ -885,31 +485,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
885 return
485 return
886 }
486 }
887
487
888− // Check file extension for common source files
488+ // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
889− ext := strings.ToLower(filepath.Ext(path))
489+ // so we know this client handles this file type. Just open it.
890−
891− // Only preload source files for the specific language
892− var shouldOpen bool
893− switch serverName {
894− case "typescript", "typescript-language-server", "tsserver", "vtsls":
895− shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
896− case "gopls":
897− shouldOpen = ext == ".go"
898− case "rust-analyzer":
899− shouldOpen = ext == ".rs"
900− case "python", "pyright", "pylsp":
901− shouldOpen = ext == ".py"
902− case "clangd":
903− shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
904− case "java", "jdtls":
905− shouldOpen = ext == ".java"
906− }
907−
908− if shouldOpen {
909− // Don't need to check if it's already open - the client.OpenFile handles that
910− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
490+ if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
911− slog.Error("Error opening file", "path", path, "error", err)
491+ slog.Error("Error opening file", "path", path, "error", err)
912− }
913 }
492 }
914}
493}
915
494