@@ -9,6 +9,7 @@ import (
99 "sync"
1010 "time"
1111
12+ "github.com/bmatcuk/doublestar/v4"
1213 "github.com/fsnotify/fsnotify"
1314 "github.com/kujtimiihoxha/opencode/internal/config"
1415 "github.com/kujtimiihoxha/opencode/internal/logging"
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
4344// AddRegistrations adds file watchers to track
4445func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
4546 cnf := config.Get()
47+
48+ logging.Debug("Adding file watcher registrations")
4649 w.registrationMu.Lock()
4750 defer w.registrationMu.Unlock()
4851
@@ -55,7 +58,6 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5558 "id", id,
5659 "watchers", len(watchers),
5760 "total", len(w.registrations),
58− "watchers", watchers,
5961 )
6062
6163 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
8890 }
8991
9092 logging.Debug("WatchKind", "kind", watchKind)
91−
92− // Test match against some example paths
93− testPaths := []string{
94− "/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go",
95− "/Users/phil/dev/mcp-language-server/go.mod",
96− }
97−
98− for _, testPath := range testPaths {
99− isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
100− logging.Debug("Test path", "path", testPath, "matches", isMatch)
101− }
10293 }
10394 }
10495
105− // Find and open all existing files that match the newly registered patterns
106− // TODO: not all language servers require this, but typescript does. Make this configurable
107− go func() {
108− startTime := time.Now()
109− filesOpened := 0
110−
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
112− if err != nil {
113− return err
96+ // Determine server type for specialized handling
97+ serverName := getServerNameFromContext(ctx)
98+ logging.Debug("Server type detected", "serverName", serverName)
99+
100+ // Check if this server has sent file watchers
101+ hasFileWatchers := len(watchers) > 0
102+
103+ // For servers that need file preloading, we'll use a smart approach
104+ if shouldPreloadFiles(serverName) || !hasFileWatchers {
105+ go func() {
106+ startTime := time.Now()
107+ filesOpened := 0
108+
109+ // Determine max files to open based on server type
110+ maxFilesToOpen := 50 // Default conservative limit
111+
112+ switch serverName {
113+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
114+ // TypeScript servers benefit from seeing more files
115+ maxFilesToOpen = 100
116+ case "java", "jdtls":
117+ // Java servers need to see many files for project model
118+ maxFilesToOpen = 200
119+ }
120+
121+ // First, open high-priority files
122+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123+ filesOpened += highPriorityFilesOpened
124+
125+ if cnf.DebugLSP {
126+ logging.Debug("Opened high-priority files",
127+ "count", highPriorityFilesOpened,
128+ "serverName", serverName)
114129 }
130+
131+ // If we've already opened enough high-priority files, we might not need more
132+ if filesOpened >= maxFilesToOpen {
133+ if cnf.DebugLSP {
134+ logging.Debug("Reached file limit with high-priority files",
135+ "filesOpened", filesOpened,
136+ "maxFiles", maxFilesToOpen)
137+ }
138+ return
139+ }
140+
141+ // For the remaining slots, walk the directory and open matching files
142+
143+ err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
144+ if err != nil {
145+ return err
146+ }
115147
116− // Skip directories that should be excluded
117− if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.DebugLSP {
120− logging.Debug("Skipping excluded directory", "path", path)
148+ // Skip directories that should be excluded
149+ if d.IsDir() {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
151+ if cnf.DebugLSP {
152+ logging.Debug("Skipping excluded directory", "path", path)
153+ }
154+ return filepath.SkipDir
155+ }
156+ } else {
157+ // Process files, but limit the total number
158+ if filesOpened < maxFilesToOpen {
159+ // Only process if it's not already open (high-priority files were opened earlier)
160+ if !w.client.IsFileOpen(path) {
161+ w.openMatchingFile(ctx, path)
162+ filesOpened++
163+
164+ // Add a small delay after every 10 files to prevent overwhelming the server
165+ if filesOpened%10 == 0 {
166+ time.Sleep(50 * time.Millisecond)
167+ }
168+ }
169+ } else {
170+ // We've reached our limit, stop walking
171+ return filepath.SkipAll
121172 }
122− return filepath.SkipDir
123173 }
124− } else {
125− // Process files
126− w.openMatchingFile(ctx, path)
127− filesOpened++
128174
129− // Add a small delay after every 100 files to prevent overwhelming the server
130− if filesOpened%100 == 0 {
131− time.Sleep(10 * time.Millisecond)
132− }
175+ return nil
176+ })
177+
178+ elapsedTime := time.Since(startTime)
179+ if cnf.DebugLSP {
180+ logging.Debug("Limited workspace scan complete",
181+ "filesOpened", filesOpened,
182+ "maxFiles", maxFilesToOpen,
183+ "elapsedTime", elapsedTime.Seconds(),
184+ "workspacePath", w.workspacePath,
185+ )
133186 }
134187
135− return nil
136− })
188+ if err != nil && cnf.DebugLSP {
189+ logging.Debug("Error scanning workspace for files to open", "error", err)
190+ }
191+ }()
192+ } else if cnf.DebugLSP {
193+ logging.Debug("Using on-demand file loading for server", "server", serverName)
194+ }
195+}
137196
138− elapsedTime := time.Since(startTime)
139− if cnf.DebugLSP {
140− logging.Debug("Workspace scan complete",
141− "filesOpened", filesOpened,
142− "elapsedTime", elapsedTime.Seconds(),
143− "workspacePath", w.workspacePath,
144− )
197+// openHighPriorityFiles opens important files for the server type
198+// Returns the number of files opened
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
200+ cnf := config.Get()
201+ filesOpened := 0
202+
203+ // Define patterns for high-priority files based on server type
204+ var patterns []string
205+
206+ switch serverName {
207+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
208+ patterns = []string{
209+ "**/tsconfig.json",
210+ "**/package.json",
211+ "**/jsconfig.json",
212+ "**/index.ts",
213+ "**/index.js",
214+ "**/main.ts",
215+ "**/main.js",
145216 }
146−
147− if err != nil && cnf.DebugLSP {
148− logging.Debug("Error scanning workspace for files to open", "error", err)
217+ case "gopls":
218+ patterns = []string{
219+ "**/go.mod",
220+ "**/go.sum",
221+ "**/main.go",
222+ }
223+ case "rust-analyzer":
224+ patterns = []string{
225+ "**/Cargo.toml",
226+ "**/Cargo.lock",
227+ "**/src/lib.rs",
228+ "**/src/main.rs",
229+ }
230+ case "python", "pyright", "pylsp":
231+ patterns = []string{
232+ "**/pyproject.toml",
233+ "**/setup.py",
234+ "**/requirements.txt",
235+ "**/__init__.py",
236+ "**/__main__.py",
237+ }
238+ case "clangd":
239+ patterns = []string{
240+ "**/CMakeLists.txt",
241+ "**/Makefile",
242+ "**/compile_commands.json",
243+ }
244+ case "java", "jdtls":
245+ patterns = []string{
246+ "**/pom.xml",
247+ "**/build.gradle",
248+ "**/src/main/java/**/*.java",
149249 }
150− }()
250+ default:
251+ // For unknown servers, use common configuration files
252+ patterns = []string{
253+ "**/package.json",
254+ "**/Makefile",
255+ "**/CMakeLists.txt",
256+ "**/.editorconfig",
257+ }
258+ }
259+
260+ // For each pattern, find and open matching files
261+ for _, pattern := range patterns {
262+ // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
263+ matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
264+ if err != nil {
265+ if cnf.DebugLSP {
266+ logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
267+ }
268+ continue
269+ }
270+
271+ for _, match := range matches {
272+ // Convert relative path to absolute
273+ fullPath := filepath.Join(w.workspacePath, match)
274+
275+ // Skip directories and excluded files
276+ info, err := os.Stat(fullPath)
277+ if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
278+ continue
279+ }
280+
281+ // Open the file
282+ if err := w.client.OpenFile(ctx, fullPath); err != nil {
283+ if cnf.DebugLSP {
284+ logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
285+ }
286+ } else {
287+ filesOpened++
288+ if cnf.DebugLSP {
289+ logging.Debug("Opened high-priority file", "path", fullPath)
290+ }
291+ }
292+
293+ // Add a small delay to prevent overwhelming the server
294+ time.Sleep(20 * time.Millisecond)
295+
296+ // Limit the number of files opened per pattern
297+ if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") {
298+ break
299+ }
300+ }
301+ }
302+
303+ return filesOpened
151304}
152305
153306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155308 cnf := config.Get()
156309 w.workspacePath = workspacePath
157310
311+ // Store the watcher in the context for later use
312+ ctx = context.WithValue(ctx, "workspaceWatcher", w)
313+
314+ // If the server name isn't already in the context, try to detect it
315+ if _, ok := ctx.Value("serverName").(string); !ok {
316+ serverName := getServerNameFromContext(ctx)
317+ ctx = context.WithValue(ctx, "serverName", serverName)
318+ }
319+
320+ serverName := getServerNameFromContext(ctx)
321+ logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
322+
158323 // Register handler for file watcher registrations from the server
159324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160325 w.AddRegistrations(ctx, id, watchers)
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510675 return w.client.DidChangeWatchedFiles(ctx, params)
511676}
512677
678+// getServerNameFromContext extracts the server name from the context
679+// This is a best-effort function that tries to identify which LSP server we're dealing with
680+func getServerNameFromContext(ctx context.Context) string {
681+ // First check if the server name is directly stored in the context
682+ if serverName, ok := ctx.Value("serverName").(string); ok && serverName != "" {
683+ return strings.ToLower(serverName)
684+ }
685+
686+ // Otherwise, try to extract server name from the client command path
687+ if w, ok := ctx.Value("workspaceWatcher").(*WorkspaceWatcher); ok && w != nil && w.client != nil && w.client.Cmd != nil {
688+ path := strings.ToLower(w.client.Cmd.Path)
689+
690+ // Extract server name from path
691+ if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
692+ return "typescript"
693+ } else if strings.Contains(path, "gopls") {
694+ return "gopls"
695+ } else if strings.Contains(path, "rust-analyzer") {
696+ return "rust-analyzer"
697+ } else if strings.Contains(path, "pyright") || strings.Contains(path, "pylsp") || strings.Contains(path, "python") {
698+ return "python"
699+ } else if strings.Contains(path, "clangd") {
700+ return "clangd"
701+ } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
702+ return "java"
703+ }
704+
705+ // Return the base name as fallback
706+ return filepath.Base(path)
707+ }
708+
709+ return "unknown"
710+}
711+
712+// shouldPreloadFiles determines if we should preload files for a specific language server
713+// Some servers work better with preloaded files, others don't need it
714+func shouldPreloadFiles(serverName string) bool {
715+ // TypeScript/JavaScript servers typically need some files preloaded
716+ // to properly resolve imports and provide intellisense
717+ switch serverName {
718+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
719+ return true
720+ case "java", "jdtls":
721+ // Java servers often need to see source files to build the project model
722+ return true
723+ default:
724+ // For most servers, we'll use lazy loading by default
725+ return false
726+ }
727+}
728+
513729// Common patterns for directories and files to exclude
514730// TODO: make configurable
515731var (
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
647863
648864 // Check if this path should be watched according to server registrations
649865 if watched, _ := w.isPathWatched(path); watched {
650− // Don't need to check if it's already open - the client.OpenFile handles that
651− if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652− logging.Error("Error opening file", "path", path, "error", err)
866+ // Get server name for specialized handling
867+ serverName := getServerNameFromContext(ctx)
868+
869+ // Check if the file is a high-priority file that should be opened immediately
870+ // This helps with project initialization for certain language servers
871+ if isHighPriorityFile(path, serverName) {
872+ if cnf.DebugLSP {
873+ logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
874+ }
875+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
876+ logging.Error("Error opening high-priority file", "path", path, "error", err)
877+ }
878+ return
879+ }
880+
881+ // For non-high-priority files, we'll use different strategies based on server type
882+ if shouldPreloadFiles(serverName) {
883+ // For servers that benefit from preloading, open files but with limits
884+
885+ // Check file size - for preloading we're more conservative
886+ if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
887+ if cnf.DebugLSP {
888+ logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
889+ }
890+ return
891+ }
892+
893+ // Check file extension for common source files
894+ ext := strings.ToLower(filepath.Ext(path))
895+
896+ // Only preload source files for the specific language
897+ shouldOpen := false
898+
899+ switch serverName {
900+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
901+ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
902+ case "gopls":
903+ shouldOpen = ext == ".go"
904+ case "rust-analyzer":
905+ shouldOpen = ext == ".rs"
906+ case "python", "pyright", "pylsp":
907+ shouldOpen = ext == ".py"
908+ case "clangd":
909+ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
910+ case "java", "jdtls":
911+ shouldOpen = ext == ".java"
912+ default:
913+ // For unknown servers, be conservative
914+ shouldOpen = false
915+ }
916+
917+ if shouldOpen {
918+ // Don't need to check if it's already open - the client.OpenFile handles that
919+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
920+ logging.Error("Error opening file", "path", path, "error", err)
921+ }
922+ }
653923 }
654924 }
655925}
926+
927+// isHighPriorityFile determines if a file should be opened immediately
928+// regardless of the preloading strategy
929+func isHighPriorityFile(path string, serverName string) bool {
930+ fileName := filepath.Base(path)
931+ ext := filepath.Ext(path)
932+
933+ switch serverName {
934+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
935+ // For TypeScript, we want to open configuration files immediately
936+ return fileName == "tsconfig.json" ||
937+ fileName == "package.json" ||
938+ fileName == "jsconfig.json" ||
939+ // Also open main entry points
940+ fileName == "index.ts" ||
941+ fileName == "index.js" ||
942+ fileName == "main.ts" ||
943+ fileName == "main.js"
944+ case "gopls":
945+ // For Go, we want to open go.mod files immediately
946+ return fileName == "go.mod" ||
947+ fileName == "go.sum" ||
948+ // Also open main.go files
949+ fileName == "main.go"
950+ case "rust-analyzer":
951+ // For Rust, we want to open Cargo.toml files immediately
952+ return fileName == "Cargo.toml" ||
953+ fileName == "Cargo.lock" ||
954+ // Also open lib.rs and main.rs
955+ fileName == "lib.rs" ||
956+ fileName == "main.rs"
957+ case "python", "pyright", "pylsp":
958+ // For Python, open key project files
959+ return fileName == "pyproject.toml" ||
960+ fileName == "setup.py" ||
961+ fileName == "requirements.txt" ||
962+ fileName == "__init__.py" ||
963+ fileName == "__main__.py"
964+ case "clangd":
965+ // For C/C++, open key project files
966+ return fileName == "CMakeLists.txt" ||
967+ fileName == "Makefile" ||
968+ fileName == "compile_commands.json"
969+ case "java", "jdtls":
970+ // For Java, open key project files
971+ return fileName == "pom.xml" ||
972+ fileName == "build.gradle" ||
973+ ext == ".java" // Java servers often need to see source files
974+ }
975+
976+ // For unknown servers, prioritize common configuration files
977+ return fileName == "package.json" ||
978+ fileName == "Makefile" ||
979+ fileName == "CMakeLists.txt" ||
980+ fileName == ".editorconfig"
981+}