@@ -9,6 +9,7 @@ import (
12+ "github.com/bmatcuk/doublestar/v4"
12 "github.com/fsnotify/fsnotify"
13 "github.com/fsnotify/fsnotify"
13 "github.com/kujtimiihoxha/opencode/internal/config"
14 "github.com/kujtimiihoxha/opencode/internal/config"
14 "github.com/kujtimiihoxha/opencode/internal/logging"
15 "github.com/kujtimiihoxha/opencode/internal/logging"
@@ -43,6 +44,8 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
43// AddRegistrations adds file watchers to track
44// AddRegistrations adds file watchers to track
44func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
45 cnf := config.Get()
46 cnf := config.Get()
48+ logging.Debug("Adding file watcher registrations")
46 w.registrationMu.Lock()
49 w.registrationMu.Lock()
47 defer w.registrationMu.Unlock()
50 defer w.registrationMu.Unlock()
@@ -55,7 +58,6 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
56 "watchers", len(watchers),
59 "watchers", len(watchers),
57 "total", len(w.registrations),
60 "total", len(w.registrations),
58− "watchers", watchers,
61 for i, watcher := range watchers {
63 for i, watcher := range watchers {
@@ -88,66 +90,217 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
90 logging.Debug("WatchKind", "kind", watchKind)
92 logging.Debug("WatchKind", "kind", watchKind)
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",
98− for _, testPath := range testPaths {
99− isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
100− logging.Debug("Test path", "path", testPath, "matches", isMatch)
105− // Find and open all existing files that match the newly registered patterns
96+ // Determine server type for specialized handling
106− // TODO: not all language servers require this, but typescript does. Make this configurable
97+ serverName := getServerNameFromContext(ctx)
98+ logging.Debug("Server type detected", "serverName", serverName)
100+ // Check if this server has sent file watchers
101+ hasFileWatchers := len(watchers) > 0
103+ // For servers that need file preloading, we'll use a smart approach
104+ if shouldPreloadFiles(serverName) || !hasFileWatchers {
107− go func() {
105+ go func() {
108− startTime := time.Now()
106+ startTime := time.Now()
109− filesOpened := 0
107+ filesOpened := 0
111− err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
109+ // Determine max files to open based on server type
112− if err != nil {
110+ maxFilesToOpen := 50 // Default conservative limit
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
121+ // First, open high-priority files
122+ highPriorityFilesOpened := w.openHighPriorityFiles(ctx, serverName)
123+ filesOpened += highPriorityFilesOpened
126+ logging.Debug("Opened high-priority files",
127+ "count", highPriorityFilesOpened,
128+ "serverName", serverName)
131+ // If we've already opened enough high-priority files, we might not need more
132+ if filesOpened >= maxFilesToOpen {
134+ logging.Debug("Reached file limit with high-priority files",
135+ "filesOpened", filesOpened,
136+ "maxFiles", maxFilesToOpen)
141+ // For the remaining slots, walk the directory and open matching files
143+ err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
116− // Skip directories that should be excluded
148+ // Skip directories that should be excluded
117− if d.IsDir() {
149+ if d.IsDir() {
118− if path != w.workspacePath && shouldExcludeDir(path) {
150+ if path != w.workspacePath && shouldExcludeDir(path) {
119− if cnf.DebugLSP {
151+ if cnf.DebugLSP {
120− logging.Debug("Skipping excluded directory", "path", path)
152+ logging.Debug("Skipping excluded directory", "path", path)
154+ return filepath.SkipDir
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)
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)
170+ // We've reached our limit, stop walking
171+ return filepath.SkipAll
122− return filepath.SkipDir
126− w.openMatchingFile(ctx, path)
129− // Add a small delay after every 100 files to prevent overwhelming the server
175+ return nil
130− if filesOpened%100 == 0 {
176+ })
131− time.Sleep(10 * time.Millisecond)
177+
132− }
178+ elapsedTime := time.Since(startTime)
180+ logging.Debug("Limited workspace scan complete",
181+ "filesOpened", filesOpened,
182+ "maxFiles", maxFilesToOpen,
183+ "elapsedTime", elapsedTime.Seconds(),
184+ "workspacePath", w.workspacePath,
135− return nil
188+ if err != nil && cnf.DebugLSP {
189+ logging.Debug("Error scanning workspace for files to open", "error", err)
192+ } else if cnf.DebugLSP {
193+ logging.Debug("Using on-demand file loading for server", "server", serverName)
138− elapsedTime := time.Since(startTime)
197+// openHighPriorityFiles opens important files for the server type
139− if cnf.DebugLSP {
198+// Returns the number of files opened
140− logging.Debug("Workspace scan complete",
199+func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName string) int {
141− "filesOpened", filesOpened,
200+ cnf := config.Get()
142− "elapsedTime", elapsedTime.Seconds(),
201+ filesOpened := 0
143− "workspacePath", w.workspacePath,
202+
144− )
203+ // Define patterns for high-priority files based on server type
204+ var patterns []string
207+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
208+ patterns = []string{
147− if err != nil && cnf.DebugLSP {
218+ patterns = []string{
148− logging.Debug("Error scanning workspace for files to open", "error", err)
219+ "**/go.mod",
223+ case "rust-analyzer":
224+ patterns = []string{
230+ case "python", "pyright", "pylsp":
231+ patterns = []string{
232+ "**/pyproject.toml",
234+ "**/requirements.txt",
239+ patterns = []string{
240+ "**/CMakeLists.txt",
242+ "**/compile_commands.json",
244+ case "java", "jdtls":
245+ patterns = []string{
248+ "**/src/main/java/**/*.java",
251+ // For unknown servers, use common configuration files
252+ patterns = []string{
255+ "**/CMakeLists.txt",
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)
266+ logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
271+ for _, match := range matches {
272+ // Convert relative path to absolute
273+ fullPath := filepath.Join(w.workspacePath, match)
275+ // Skip directories and excluded files
276+ info, err := os.Stat(fullPath)
277+ if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
282+ if err := w.client.OpenFile(ctx, fullPath); err != nil {
284+ logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
289+ logging.Debug("Opened high-priority file", "path", fullPath)
293+ // Add a small delay to prevent overwhelming the server
294+ time.Sleep(20 * time.Millisecond)
296+ // Limit the number of files opened per pattern
297+ if filesOpened >= 5 && (serverName != "java" && serverName != "jdtls") {
153// WatchWorkspace sets up file watching for a workspace
306// WatchWorkspace sets up file watching for a workspace
@@ -155,6 +308,18 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
155 cnf := config.Get()
308 cnf := config.Get()
156 w.workspacePath = workspacePath
309 w.workspacePath = workspacePath
311+ // Store the watcher in the context for later use
312+ ctx = context.WithValue(ctx, "workspaceWatcher", w)
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)
320+ serverName := getServerNameFromContext(ctx)
321+ logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
158 // Register handler for file watcher registrations from the server
323 // Register handler for file watcher registrations from the server
159 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
324 lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
160 w.AddRegistrations(ctx, id, watchers)
325 w.AddRegistrations(ctx, id, watchers)
@@ -510,6 +675,57 @@ func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, chan
510 return w.client.DidChangeWatchedFiles(ctx, params)
675 return w.client.DidChangeWatchedFiles(ctx, params)
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)
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)
690+ // Extract server name from path
691+ if strings.Contains(path, "typescript") || strings.Contains(path, "tsserver") || strings.Contains(path, "vtsls") {
693+ } else if strings.Contains(path, "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") {
699+ } else if strings.Contains(path, "clangd") {
701+ } else if strings.Contains(path, "jdtls") || strings.Contains(path, "java") {
705+ // Return the base name as fallback
706+ return filepath.Base(path)
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
718+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
720+ case "java", "jdtls":
721+ // Java servers often need to see source files to build the project model
724+ // For most servers, we'll use lazy loading by default
513// Common patterns for directories and files to exclude
729// Common patterns for directories and files to exclude
514// TODO: make configurable
730// TODO: make configurable
@@ -647,9 +863,119 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
648 // Check if this path should be watched according to server registrations
864 // Check if this path should be watched according to server registrations
649 if watched, _ := w.isPathWatched(path); watched {
865 if watched, _ := w.isPathWatched(path); watched {
866+ // Get server name for specialized handling
867+ serverName := getServerNameFromContext(ctx)
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) {
873+ logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
875+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
876+ logging.Error("Error opening high-priority file", "path", path, "error", err)
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
885+ // Check file size - for preloading we're more conservative
886+ if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
888+ logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
893+ // Check file extension for common source files
894+ ext := strings.ToLower(filepath.Ext(path))
896+ // Only preload source files for the specific language
900+ case "typescript", "typescript-language-server", "tsserver", "vtsls":
901+ shouldOpen = ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx"
903+ shouldOpen = ext == ".go"
904+ case "rust-analyzer":
905+ shouldOpen = ext == ".rs"
906+ case "python", "pyright", "pylsp":
907+ shouldOpen = ext == ".py"
909+ shouldOpen = ext == ".c" || ext == ".cpp" || ext == ".h" || ext == ".hpp"
910+ case "java", "jdtls":
911+ shouldOpen = ext == ".java"
913+ // For unknown servers, be conservative
650− // Don't need to check if it's already open - the client.OpenFile handles that
918+ // 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 {
919+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
652− logging.Error("Error opening file", "path", path, "error", err)
920+ logging.Error("Error opening file", "path", path, "error", err)
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)
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"
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"
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
976+ // For unknown servers, prioritize common configuration files
977+ return fileName == "package.json" ||
978+ fileName == "Makefile" ||
979+ fileName == "CMakeLists.txt" ||
980+ fileName == ".editorconfig"