From 02f66a2e8614a6c029260bea7d3ca1565786915a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 16:12:18 -0300 Subject: [PATCH] feat(lsp): start LSPs on demand, improve auto-start (#2103) * feat(lsp): start LSPs on demand, improve auto-start Signed-off-by: Carlos Alexandro Becker * refactor: simplify Signed-off-by: Carlos Alexandro Becker * chore: fmt Signed-off-by: Carlos Alexandro Becker * feat: handle new session/load session Signed-off-by: Carlos Alexandro Becker * refactor: manager holds client list reference Signed-off-by: Carlos Alexandro Becker * fix: do io in cmd Signed-off-by: Carlos Alexandro Becker * fix: better manager Signed-off-by: Carlos Alexandro Becker * fix: nil Signed-off-by: Carlos Alexandro Becker * fix: err Signed-off-by: Carlos Alexandro Becker * fix: properly handle restart Signed-off-by: Carlos Alexandro Becker * fix: add stopped state Signed-off-by: Carlos Alexandro Becker * fix: root markers Signed-off-by: Carlos Alexandro Becker * fix: load for read files as well Signed-off-by: Carlos Alexandro Becker * chore(go.mod): move indirect dependency to the right block --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- go.mod | 2 +- internal/agent/agentic_fetch_tool.go | 2 +- internal/agent/common_test.go | 8 +- internal/agent/coordinator.go | 20 +- internal/agent/tools/diagnostics.go | 32 +++- internal/agent/tools/edit.go | 7 +- internal/agent/tools/lsp_restart.go | 9 +- internal/agent/tools/multiedit.go | 7 +- internal/agent/tools/references.go | 13 +- internal/agent/tools/view.go | 7 +- internal/agent/tools/write.go | 7 +- internal/app/app.go | 30 ++- internal/app/lsp.go | 163 ---------------- internal/db/db.go | 10 + internal/db/querier.go | 1 + internal/db/read_files.sql.go | 33 ++++ internal/db/sql/read_files.sql | 5 + internal/filetracker/service.go | 23 +++ internal/lsp/client.go | 77 +------- internal/lsp/filtermatching_test.go | 111 ----------- internal/lsp/manager.go | 271 +++++++++++++++++++++++++++ internal/ui/model/header.go | 8 +- internal/ui/model/lsp.go | 7 +- internal/ui/model/session.go | 54 +++++- internal/ui/model/ui.go | 19 +- 25 files changed, 500 insertions(+), 426 deletions(-) delete mode 100644 internal/app/lsp.go delete mode 100644 internal/lsp/filtermatching_test.go create mode 100644 internal/lsp/manager.go diff --git a/go.mod b/go.mod index bda063569bac9702747085c90f9e57ec93593a06..3a73c9cd258cadce8237f6603e52380667f3a444 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sahilm/fuzzy v0.1.1 + github.com/sourcegraph/jsonrpc2 v0.2.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 @@ -150,7 +151,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 9bf592413b07c651171d10785104294da8fb39a3..9bb27327516da66323fee454b9048cf5f9f69b6b 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -169,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( tools.NewGlobTool(tmpDir), tools.NewGrepTool(tmpDir), tools.NewSourcegraphTool(client), - tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir), } agent := NewSessionAgent(SessionAgentOptions{ diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 4f96c3cfbb1728f533c71a7c05b7e1ab85975b45..1a420e2b40b84027db7469a71ca9212b69f6e380 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -204,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel allTools := []fantasy.AgentTool{ tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName), tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()), - tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), - tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewGlobTool(env.workingDir), tools.NewGrepTool(env.workingDir), tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls), tools.NewSourcegraphTool(r.GetDefaultClient()), - tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir), - tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir), + tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), } return testSessionAgent(env, large, small, systemPrompt, allTools...), nil diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index d4e23af0c676307756f4e39fda7e10dfb2b6da5e..a6048a7620bef5236ef8266612538685dcf48aac 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -21,7 +21,6 @@ import ( "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/log" @@ -67,7 +66,7 @@ type coordinator struct { permissions permission.Service history history.Service filetracker filetracker.Service - lspClients *csync.Map[string, *lsp.Client] + lspManager *lsp.Manager currentAgent SessionAgent agents map[string]SessionAgent @@ -83,7 +82,7 @@ func NewCoordinator( permissions permission.Service, history history.Service, filetracker filetracker.Service, - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -92,7 +91,7 @@ func NewCoordinator( permissions: permissions, history: history, filetracker: filetracker, - lspClients: lspClients, + lspManager: lspManager, agents: make(map[string]SessionAgent), } @@ -423,20 +422,21 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewJobOutputTool(), tools.NewJobKillTool(), tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), - tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), - tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewGlobTool(c.cfg.WorkingDir()), tools.NewGrepTool(c.cfg.WorkingDir()), tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), - tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), - tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), + tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) - if c.lspClients.Len() > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) + // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true). + if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP { + allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager)) } if len(c.cfg.MCP) > 0 { diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index 9af0da43c396d9fa8aa9776f4f7fb177af6b5806..04cf79ee793a742c00f7c8d4a1e0e869663569e4 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -10,7 +10,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -24,25 +23,36 @@ const DiagnosticsToolName = "lsp_diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte -func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewDiagnosticsTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( DiagnosticsToolName, string(diagnosticsDescription), func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available"), nil } - notifyLSPs(ctx, lspClients, params.FilePath) - output := getDiagnostics(params.FilePath, lspClients) + notifyLSPs(ctx, lspManager, params.FilePath) + output := getDiagnostics(params.FilePath, lspManager) return fantasy.NewTextResponse(output), nil }) } -func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) { +func notifyLSPs( + ctx context.Context, + manager *lsp.Manager, + filepath string, +) { if filepath == "" { return } - for client := range lsps.Seq() { + + if manager == nil { + return + } + + manager.Start(ctx, filepath) + + for client := range manager.Clients().Seq() { if !client.HandlesFile(filepath) { continue } @@ -52,11 +62,15 @@ func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filep } } -func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string { +func getDiagnostics(filePath string, manager *lsp.Manager) string { + if manager == nil { + return "" + } + fileDiagnostics := []string{} projectDiagnostics := []string{} - for lspName, client := range lsps.Seq2() { + for lspName, client := range manager.Clients().Seq2() { for location, diags := range client.GetDiagnostics() { path, err := location.Path() if err != nil { diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 74b84c784796a97db2f379cf61fb3eb8b18934d4..8d17902f097f6e0b4ebee7d0d684618c91bb0e04 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -61,7 +60,7 @@ type editContext struct { } func NewEditTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -99,10 +98,10 @@ func NewEditTool( return response, nil } - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) text := fmt.Sprintf("\n%s\n\n", response.Content) - text += getDiagnostics(params.FilePath, lspClients) + text += getDiagnostics(params.FilePath, lspManager) response.Content = text return response, nil }) diff --git a/internal/agent/tools/lsp_restart.go b/internal/agent/tools/lsp_restart.go index 5e5a8a90a11927079086fe407384f32ceecf10c5..588f27bfe097326b99d6090067ad9b78243a3986 100644 --- a/internal/agent/tools/lsp_restart.go +++ b/internal/agent/tools/lsp_restart.go @@ -10,7 +10,6 @@ import ( "sync" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" ) @@ -25,20 +24,20 @@ type LSPRestartParams struct { Name string `json:"name,omitempty"` } -func NewLSPRestartTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewLSPRestartTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( LSPRestartToolName, string(lspRestartDescription), func(ctx context.Context, params LSPRestartParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available to restart"), nil } clientsToRestart := make(map[string]*lsp.Client) if params.Name == "" { - maps.Insert(clientsToRestart, lspClients.Seq2()) + maps.Insert(clientsToRestart, lspManager.Clients().Seq2()) } else { - client, exists := lspClients.Get(params.Name) + client, exists := lspManager.Clients().Get(params.Name) if !exists { return fantasy.NewTextErrorResponse(fmt.Sprintf("LSP client '%s' not found", params.Name)), nil } diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 48736ebf311230a28b51702e0ddd3ff8df19b284..28af9206a6485900dc05356c68bcdc091c01fe02 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -59,7 +58,7 @@ const MultiEditToolName = "multiedit" var multieditDescription []byte func NewMultiEditTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -104,11 +103,11 @@ func NewMultiEditTool( } // Notify LSP clients about the change - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) // Wait for LSP diagnostics and add them to the response text := fmt.Sprintf("\n%s\n\n", response.Content) - text += getDiagnostics(params.FilePath, lspClients) + text += getDiagnostics(params.FilePath, lspManager) response.Content = text return response, nil }) diff --git a/internal/agent/tools/references.go b/internal/agent/tools/references.go index 7f2a0d8cfebea708bbd9e00cc34076e57fb07520..c544886b9de3e60ef6932cbc2932fc0a0ab639f0 100644 --- a/internal/agent/tools/references.go +++ b/internal/agent/tools/references.go @@ -15,7 +15,6 @@ import ( "strings" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -26,7 +25,7 @@ type ReferencesParams struct { } type referencesTool struct { - lspClients *csync.Map[string, *lsp.Client] + lspManager *lsp.Manager } const ReferencesToolName = "lsp_references" @@ -34,7 +33,7 @@ const ReferencesToolName = "lsp_references" //go:embed references.md var referencesDescription []byte -func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { +func NewReferencesTool(lspManager *lsp.Manager) fantasy.AgentTool { return fantasy.NewAgentTool( ReferencesToolName, string(referencesDescription), @@ -43,7 +42,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent return fantasy.NewTextErrorResponse("symbol is required"), nil } - if lspClients.Len() == 0 { + if lspManager.Clients().Len() == 0 { return fantasy.NewTextErrorResponse("no LSP clients available"), nil } @@ -61,7 +60,7 @@ func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.Agent var allLocations []protocol.Location var allErrs error for _, match := range matches { - locations, err := find(ctx, lspClients, params.Symbol, match) + locations, err := find(ctx, lspManager, params.Symbol, match) if err != nil { if strings.Contains(err.Error(), "no identifier found") { // grep probably matched a comment, string value, or something else that's irrelevant @@ -91,14 +90,14 @@ func (r *referencesTool) Name() string { return ReferencesToolName } -func find(ctx context.Context, lspClients *csync.Map[string, *lsp.Client], symbol string, match grepMatch) ([]protocol.Location, error) { +func find(ctx context.Context, lspManager *lsp.Manager, symbol string, match grepMatch) ([]protocol.Location, error) { absPath, err := filepath.Abs(match.path) if err != nil { return nil, fmt.Errorf("failed to get absolute path: %s", err) } var client *lsp.Client - for c := range lspClients.Seq() { + for c := range lspManager.Clients().Seq() { if c.HandlesFile(absPath) { client = c break diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index b26267fcef3b296babc3c9dbcee64336ef162b75..0a754dcb4fd05cc975f84e85532eeab1525c7002 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -13,7 +13,6 @@ import ( "unicode/utf8" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/lsp" @@ -48,7 +47,7 @@ const ( ) func NewViewTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, filetracker filetracker.Service, workingDir string, @@ -184,7 +183,7 @@ func NewViewTool( return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err) } - notifyLSPs(ctx, lspClients, filePath) + notifyLSPs(ctx, lspManager, filePath) output := "\n" // Format the output with line numbers output += addLineNumbers(content, params.Offset+1) @@ -195,7 +194,7 @@ func NewViewTool( params.Offset+len(strings.Split(content, "\n"))) } output += "\n\n" - output += getDiagnostics(filePath, lspClients) + output += getDiagnostics(filePath, lspManager) filetracker.RecordRead(ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse(output), diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f..fbc2b8f11e9a84a9848af8eba5d2c2d1aa8ca258 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -11,7 +11,6 @@ import ( "time" "charm.land/fantasy" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/filepathext" "github.com/charmbracelet/crush/internal/filetracker" @@ -45,7 +44,7 @@ type WriteResponseMetadata struct { const WriteToolName = "write" func NewWriteTool( - lspClients *csync.Map[string, *lsp.Client], + lspManager *lsp.Manager, permissions permission.Service, files history.Service, filetracker filetracker.Service, @@ -161,11 +160,11 @@ func NewWriteTool( filetracker.RecordRead(ctx, sessionID, filePath) - notifyLSPs(ctx, lspClients, params.FilePath) + notifyLSPs(ctx, lspManager, params.FilePath) result := fmt.Sprintf("File successfully written: %s", filePath) result = fmt.Sprintf("\n%s\n", result) - result += getDiagnostics(filePath, lspClients) + result += getDiagnostics(filePath, lspManager) return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), WriteResponseMetadata{ Diff: diff, diff --git a/internal/app/app.go b/internal/app/app.go index 35534629f64e29dc39beb95c55e5873b551218a4..7e16e294c17553a030d412ecde4ad95a90d53ecc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,7 +21,6 @@ import ( "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/format" @@ -58,7 +57,7 @@ type App struct { AgentCoordinator agent.Coordinator - LSPClients *csync.Map[string, *lsp.Client] + LSPManager *lsp.Manager config *config.Config @@ -90,7 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { History: files, Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), FileTracker: filetracker.NewService(q), - LSPClients: csync.NewMap[string, *lsp.Client](), + LSPManager: lsp.NewManager(cfg), globalCtx: ctx, @@ -103,9 +102,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() - // Initialize LSP clients in the background. - go app.initLSPClients(ctx) - // Check for updates in the background. go app.checkForUpdates(ctx) @@ -122,6 +118,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { if err := app.InitCoderAgent(ctx); err != nil { return nil, fmt.Errorf("failed to initialize coder agent: %w", err) } + + // Set up callback for LSP state updates. + app.LSPManager.SetCallback(func(name string, client *lsp.Client) { + client.SetDiagnosticsCallback(updateLSPDiagnostics) + updateLSPState(name, client.GetServerState(), nil, client, 0) + }) + return app, nil } @@ -482,7 +485,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Permissions, app.History, app.FileTracker, - app.LSPClients, + app.LSPManager, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) @@ -545,16 +548,9 @@ func (app *App) Shutdown() { // Shutdown all LSP clients. shutdownCtx, cancel := context.WithTimeout(app.globalCtx, 5*time.Second) defer cancel() - for name, client := range app.LSPClients.Seq2() { - wg.Go(func() { - if err := client.Close(shutdownCtx); err != nil && - !errors.Is(err, io.EOF) && - !errors.Is(err, context.Canceled) && - err.Error() != "signal: killed" { - slog.Warn("Failed to shutdown LSP client", "name", name, "error", err) - } - }) - } + wg.Go(func() { + app.LSPManager.StopAll(shutdownCtx) + }) // Call all cleanup functions. for _, cleanup := range app.cleanupFuncs { diff --git a/internal/app/lsp.go b/internal/app/lsp.go deleted file mode 100644 index 2bb20fad3878a771ce8b6a2a4dc3688de44ba5dd..0000000000000000000000000000000000000000 --- a/internal/app/lsp.go +++ /dev/null @@ -1,163 +0,0 @@ -package app - -import ( - "cmp" - "context" - "log/slog" - "os/exec" - "slices" - "sync" - "time" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/lsp" - powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" -) - -// initLSPClients initializes LSP clients. -func (app *App) initLSPClients(ctx context.Context) { - slog.Info("LSP clients initialization started") - - manager := powernapconfig.NewManager() - manager.LoadDefaults() - - var userConfiguredLSPs []string - for name, clientConfig := range app.config.LSP { - if clientConfig.Disabled { - slog.Info("Skipping disabled LSP client", "name", name) - manager.RemoveServer(name) - continue - } - - // HACK: the user might have the command name in their config, instead - // of the actual name. This finds out these cases, and adjusts the name - // accordingly. - if _, ok := manager.GetServer(name); !ok { - for sname, server := range manager.GetServers() { - if server.Command == name { - name = sname - break - } - } - } - userConfiguredLSPs = append(userConfiguredLSPs, name) - manager.AddServer(name, &powernapconfig.ServerConfig{ - Command: clientConfig.Command, - Args: clientConfig.Args, - Environment: clientConfig.Env, - FileTypes: clientConfig.FileTypes, - RootMarkers: clientConfig.RootMarkers, - InitOptions: clientConfig.InitOptions, - Settings: clientConfig.Options, - }) - } - - servers := manager.GetServers() - filtered := lsp.FilterMatching(app.config.WorkingDir(), servers) - - for _, name := range userConfiguredLSPs { - if _, ok := filtered[name]; !ok { - updateLSPState(name, lsp.StateDisabled, nil, nil, 0) - } - } - - var wg sync.WaitGroup - for name, server := range filtered { - if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) { - slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name) - continue - } - wg.Go(func() { - app.createAndStartLSPClient( - ctx, name, - toOurConfig(server, app.config.LSP[name]), - slices.Contains(userConfiguredLSPs, name), - ) - }) - } - wg.Wait() - - if app.AgentCoordinator != nil { - if err := app.AgentCoordinator.UpdateModels(ctx); err != nil { - slog.Error("Failed to refresh tools after LSP startup", "error", err) - } - } -} - -// toOurConfig merges powernap default config with user config. -// If user config is zero value, it means no user override exists. -func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig { - return config.LSPConfig{ - Command: in.Command, - Args: in.Args, - Env: in.Environment, - FileTypes: in.FileTypes, - RootMarkers: in.RootMarkers, - InitOptions: in.InitOptions, - Options: in.Settings, - Timeout: user.Timeout, - } -} - -// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher. -func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) { - if !userConfigured { - if _, err := exec.LookPath(config.Command); err != nil { - slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err) - return - } - } - - slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) - - // Update state to starting. - updateLSPState(name, lsp.StateStarting, nil, nil, 0) - - // Create LSP client. - lspClient, err := lsp.New(ctx, name, config, app.config.Resolver(), app.config.Options.DebugLSP) - if err != nil { - if !userConfigured { - slog.Warn("Default LSP config skipped due to error", "name", name, "error", err) - updateLSPState(name, lsp.StateDisabled, nil, nil, 0) - return - } - slog.Error("Failed to create LSP client for", "name", name, "error", err) - updateLSPState(name, lsp.StateError, err, nil, 0) - return - } - - // Set diagnostics callback - lspClient.SetDiagnosticsCallback(updateLSPDiagnostics) - - // Increase initialization timeout as some servers take more time to start. - initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second) - defer cancel() - - // Initialize LSP client. - _, err = lspClient.Initialize(initCtx, app.config.WorkingDir()) - if err != nil { - slog.Error("LSP client initialization failed", "name", name, "error", err) - updateLSPState(name, lsp.StateError, err, lspClient, 0) - lspClient.Close(ctx) - return - } - - // Wait for the server to be ready. - if err := lspClient.WaitForServerReady(initCtx); err != nil { - slog.Error("Server failed to become ready", "name", name, "error", err) - // Server never reached a ready state, but let's continue anyway, as - // some functionality might still work. - lspClient.SetServerState(lsp.StateError) - updateLSPState(name, lsp.StateError, err, lspClient, 0) - } else { - // Server reached a ready state successfully. - slog.Debug("LSP server is ready", "name", name) - lspClient.SetServerState(lsp.StateReady) - updateLSPState(name, lsp.StateReady, nil, lspClient, 0) - } - - slog.Debug("LSP client initialized", "name", name) - - // Add to map with mutex protection before starting goroutine - app.LSPClients.Set(name, lspClient) -} diff --git a/internal/db/db.go b/internal/db/db.go index 739c2087e1c1e125875d5006c86f85de37fed3be..ec4e3807057bf4ac456ad9c066a4edb00c1771d5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil { return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err) } + if q.listSessionReadFilesStmt, err = db.PrepareContext(ctx, listSessionReadFiles); err != nil { + return nil, fmt.Errorf("error preparing query ListSessionReadFiles: %w", err) + } if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil { return nil, fmt.Errorf("error preparing query ListSessions: %w", err) } @@ -271,6 +274,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr) } } + if q.listSessionReadFilesStmt != nil { + if cerr := q.listSessionReadFilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listSessionReadFilesStmt: %w", cerr) + } + } if q.listSessionsStmt != nil { if cerr := q.listSessionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listSessionsStmt: %w", cerr) @@ -368,6 +376,7 @@ type Queries struct { listLatestSessionFilesStmt *sql.Stmt listMessagesBySessionStmt *sql.Stmt listNewFilesStmt *sql.Stmt + listSessionReadFilesStmt *sql.Stmt listSessionsStmt *sql.Stmt listUserMessagesBySessionStmt *sql.Stmt recordFileReadStmt *sql.Stmt @@ -408,6 +417,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, listMessagesBySessionStmt: q.listMessagesBySessionStmt, listNewFilesStmt: q.listNewFilesStmt, + listSessionReadFilesStmt: q.listSessionReadFilesStmt, listSessionsStmt: q.listSessionsStmt, listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt, recordFileReadStmt: q.recordFileReadStmt, diff --git a/internal/db/querier.go b/internal/db/querier.go index c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f..9a72be02c12a2760a6ab2acef8765cabb0f6bd0c 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -37,6 +37,7 @@ type Querier interface { ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) + ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) ListSessions(ctx context.Context) ([]Session, error) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go index b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5..c1cda5ee633ede07b2faebe38619292c994a9f50 100644 --- a/internal/db/read_files.sql.go +++ b/internal/db/read_files.sql.go @@ -48,6 +48,39 @@ type RecordFileReadParams struct { Path string `json:"path"` } +const listSessionReadFiles = `-- name: ListSessionReadFiles :many +SELECT session_id, path, read_at FROM read_files +WHERE session_id = ? +ORDER BY read_at DESC +` + +func (q *Queries) ListSessionReadFiles(ctx context.Context, sessionID string) ([]ReadFile, error) { + rows, err := q.query(ctx, q.listSessionReadFilesStmt, listSessionReadFiles, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReadFile{} + for rows.Next() { + var i ReadFile + if err := rows.Scan( + &i.SessionID, + &i.Path, + &i.ReadAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error { _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead, arg.SessionID, diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql index f607312c2ba8660aa2c7030e415ce2ca7320cd6d..1ef3ce7e684b60038aef8352b914adf4c598a033 100644 --- a/internal/db/sql/read_files.sql +++ b/internal/db/sql/read_files.sql @@ -13,3 +13,8 @@ INSERT INTO read_files ( -- name: GetFileRead :one SELECT * FROM read_files WHERE session_id = ? AND path = ? LIMIT 1; + +-- name: ListSessionReadFiles :many +SELECT * FROM read_files +WHERE session_id = ? +ORDER BY read_at DESC; diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go index 8f080d124e49dfc32f43796194c09ac22beaa9f1..5a92d4de1d0c2ac585c25f7f31834b94564a0f5d 100644 --- a/internal/filetracker/service.go +++ b/internal/filetracker/service.go @@ -3,6 +3,7 @@ package filetracker import ( "context" + "fmt" "log/slog" "os" "path/filepath" @@ -19,6 +20,9 @@ type Service interface { // LastReadTime returns when a file was last read. // Returns zero time if never read. LastReadTime(ctx context.Context, sessionID, path string) time.Time + + // ListReadFiles returns the paths of all files read in a session. + ListReadFiles(ctx context.Context, sessionID string) ([]string, error) } type service struct { @@ -68,3 +72,22 @@ func relpath(path string) string { } return relpath } + +// ListReadFiles returns the paths of all files read in a session. +func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) { + readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("listing read files: %w", err) + } + + basepath, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("getting working directory: %w", err) + } + + paths := make([]string, 0, len(readFiles)) + for _, rf := range readFiles { + paths = append(paths, filepath.Join(basepath, rf.Path)) + } + return paths, nil +} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 6420cec050e283b3061b2f87275606b4bf9720a1..c4d80d8af918a202b97d71d5d939338aa3cf1c77 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -13,12 +13,9 @@ import ( "sync/atomic" "time" - "github.com/bmatcuk/doublestar/v4" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/home" - powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" "github.com/charmbracelet/x/powernap/pkg/transport" @@ -200,6 +197,8 @@ func (c *Client) Restart() error { slog.Warn("Error closing client during restart", "name", c.name, "error", err) } + c.SetServerState(StateStopped) + c.diagCountsCache = DiagnosticCounts{} c.diagCountsVersion = 0 @@ -237,7 +236,8 @@ func (c *Client) Restart() error { type ServerState int const ( - StateStarting ServerState = iota + StateStopped ServerState = iota + StateStarting StateReady StateError StateDisabled @@ -578,72 +578,3 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) } - -// FilterMatching gets a list of configs and only returns the ones with -// matching root markers. -func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig { - result := map[string]*powernapconfig.ServerConfig{} - if len(servers) == 0 { - return result - } - - type serverPatterns struct { - server *powernapconfig.ServerConfig - patterns []string - } - normalized := make(map[string]serverPatterns, len(servers)) - for name, server := range servers { - var patterns []string - for _, p := range server.RootMarkers { - if p == ".git" { - // ignore .git for discovery - continue - } - patterns = append(patterns, filepath.ToSlash(p)) - } - if len(patterns) == 0 { - slog.Debug("ignoring lsp with no root markers", "name", name) - continue - } - normalized[name] = serverPatterns{server: server, patterns: patterns} - } - - walker := fsext.NewFastGlobWalker(dir) - _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - - if walker.ShouldSkip(path) { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - relPath, err := filepath.Rel(dir, path) - if err != nil { - return nil - } - relPath = filepath.ToSlash(relPath) - - for name, sp := range normalized { - for _, pattern := range sp.patterns { - matched, err := doublestar.Match(pattern, relPath) - if err != nil || !matched { - continue - } - result[name] = sp.server - delete(normalized, name) - break - } - } - - if len(normalized) == 0 { - return filepath.SkipAll - } - return nil - }) - - return result -} diff --git a/internal/lsp/filtermatching_test.go b/internal/lsp/filtermatching_test.go deleted file mode 100644 index 40c796916b73169b882404eecfb4625e7baaa85b..0000000000000000000000000000000000000000 --- a/internal/lsp/filtermatching_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package lsp - -import ( - "os" - "path/filepath" - "testing" - - powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" - "github.com/stretchr/testify/require" -) - -func TestFilterMatching(t *testing.T) { - t.Parallel() - - t.Run("matches servers with existing root markers", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) - - servers := map[string]*powernapconfig.ServerConfig{ - "gopls": {RootMarkers: []string{"go.mod", "go.work"}}, - "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, - "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}}, - } - - result := FilterMatching(tmpDir, servers) - - require.Contains(t, result, "gopls") - require.Contains(t, result, "rust-analyzer") - require.NotContains(t, result, "typescript-lsp") - }) - - t.Run("returns empty for empty servers", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{}) - - require.Empty(t, result) - }) - - t.Run("returns empty when no markers match", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - servers := map[string]*powernapconfig.ServerConfig{ - "gopls": {RootMarkers: []string{"go.mod"}}, - "python": {RootMarkers: []string{"pyproject.toml"}}, - } - - result := FilterMatching(tmpDir, servers) - - require.Empty(t, result) - }) - - t.Run("glob patterns work", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644)) - - servers := map[string]*powernapconfig.ServerConfig{ - "gopls": {RootMarkers: []string{"**/*.go"}}, - "python": {RootMarkers: []string{"**/*.py"}}, - } - - result := FilterMatching(tmpDir, servers) - - require.Contains(t, result, "gopls") - require.NotContains(t, result, "python") - }) - - t.Run("servers with empty root markers are not included", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) - - servers := map[string]*powernapconfig.ServerConfig{ - "gopls": {RootMarkers: []string{"go.mod"}}, - "generic": {RootMarkers: []string{}}, - } - - result := FilterMatching(tmpDir, servers) - - require.Contains(t, result, "gopls") - require.NotContains(t, result, "generic") - }) - - t.Run("stops early when all servers match", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) - - servers := map[string]*powernapconfig.ServerConfig{ - "gopls": {RootMarkers: []string{"go.mod"}}, - "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, - } - - result := FilterMatching(tmpDir, servers) - - require.Len(t, result, 2) - require.Contains(t, result, "gopls") - require.Contains(t, result, "rust-analyzer") - }) -} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..b59819e0d64a592d2c5fd7d9e8c6c9ec8d2fed38 --- /dev/null +++ b/internal/lsp/manager.go @@ -0,0 +1,271 @@ +// Package lsp provides a manager for Language Server Protocol (LSP) clients. +package lsp + +import ( + "cmp" + "context" + "errors" + "io" + "log/slog" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/fsext" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" + "github.com/charmbracelet/x/powernap/pkg/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" + "github.com/sourcegraph/jsonrpc2" +) + +// Manager handles lazy initialization of LSP clients based on file types. +type Manager struct { + clients *csync.Map[string, *Client] + cfg *config.Config + manager *powernapconfig.Manager + callback func(name string, client *Client) + mu sync.Mutex +} + +// NewManager creates a new LSP manager service. +func NewManager(cfg *config.Config) *Manager { + manager := powernapconfig.NewManager() + manager.LoadDefaults() + + // Merge user-configured LSPs into the manager. + for name, clientConfig := range cfg.LSP { + if clientConfig.Disabled { + slog.Debug("LSP disabled by user config", "name", name) + manager.RemoveServer(name) + continue + } + + // HACK: the user might have the command name in their config instead + // of the actual name. Find and use the correct name. + actualName := resolveServerName(manager, name) + manager.AddServer(actualName, &powernapconfig.ServerConfig{ + Command: clientConfig.Command, + Args: clientConfig.Args, + Environment: clientConfig.Env, + FileTypes: clientConfig.FileTypes, + RootMarkers: clientConfig.RootMarkers, + InitOptions: clientConfig.InitOptions, + Settings: clientConfig.Options, + }) + } + + return &Manager{ + clients: csync.NewMap[string, *Client](), + cfg: cfg, + manager: manager, + } +} + +// Clients returns the map of LSP clients. +func (m *Manager) Clients() *csync.Map[string, *Client] { + return m.clients +} + +// SetCallback sets a callback that is invoked when a new LSP +// client is successfully started. This allows the coordinator to add LSP tools. +func (s *Manager) SetCallback(cb func(name string, client *Client)) { + s.mu.Lock() + defer s.mu.Unlock() + s.callback = cb +} + +// Start starts an LSP server that can handle the given file path. +// If an appropriate LSP is already running, this is a no-op. +func (s *Manager) Start(ctx context.Context, filePath string) { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name, server := range s.manager.GetServers() { + if !handles(server, filePath, s.cfg.WorkingDir()) { + continue + } + wg.Go(func() { + s.startServer(ctx, name, server) + }) + } + wg.Wait() +} + +// skipAutoStartCommands contains commands that are too generic or ambiguous to +// auto-start without explicit user configuration. +var skipAutoStartCommands = map[string]bool{ + "buck2": true, + "buf": true, + "cue": true, + "dart": true, + "deno": true, + "dotnet": true, + "dprint": true, + "gleam": true, + "java": true, + "julia": true, + "koka": true, + "node": true, + "npx": true, + "perl": true, + "plz": true, + "python": true, + "python3": true, + "R": true, + "racket": true, + "rome": true, + "rubocop": true, + "ruff": true, + "scarb": true, + "solc": true, + "stylua": true, + "swipl": true, + "tflint": true, +} + +func (s *Manager) startServer(ctx context.Context, name string, server *powernapconfig.ServerConfig) { + userConfigured := s.isUserConfigured(name) + + if !userConfigured { + if _, err := exec.LookPath(server.Command); err != nil { + slog.Debug("LSP server not installed, skipping", "name", name, "command", server.Command) + return + } + if skipAutoStartCommands[server.Command] { + slog.Debug("LSP command too generic for auto-start, skipping", "name", name, "command", server.Command) + return + } + } + + cfg := s.buildConfig(name, server) + if client, ok := s.clients.Get(name); ok { + switch client.GetServerState() { + case StateReady, StateStarting: + s.callback(name, client) + // already done, return + return + } + } + client, err := New(ctx, name, cfg, s.cfg.Resolver(), s.cfg.Options.DebugLSP) + if err != nil { + slog.Error("Failed to create LSP client", "name", name, "error", err) + return + } + s.callback(name, client) + + defer func() { + s.clients.Set(name, client) + s.callback(name, client) + }() + + initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(cfg.Timeout, 30))*time.Second) + defer cancel() + + if _, err := client.Initialize(initCtx, s.cfg.WorkingDir()); err != nil { + slog.Error("LSP client initialization failed", "name", name, "error", err) + client.Close(ctx) + return + } + + if err := client.WaitForServerReady(initCtx); err != nil { + slog.Warn("LSP server not fully ready, continuing anyway", "name", name, "error", err) + client.SetServerState(StateError) + } else { + client.SetServerState(StateReady) + } + + slog.Debug("LSP client started", "name", name) +} + +func (s *Manager) isUserConfigured(name string) bool { + cfg, ok := s.cfg.LSP[name] + return ok && !cfg.Disabled +} + +func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) config.LSPConfig { + cfg := config.LSPConfig{ + Command: server.Command, + Args: server.Args, + Env: server.Environment, + FileTypes: server.FileTypes, + RootMarkers: server.RootMarkers, + InitOptions: server.InitOptions, + Options: server.Settings, + } + if userCfg, ok := s.cfg.LSP[name]; ok { + cfg.Timeout = userCfg.Timeout + } + return cfg +} + +func resolveServerName(manager *powernapconfig.Manager, name string) string { + if _, ok := manager.GetServer(name); ok { + return name + } + for sname, server := range manager.GetServers() { + if server.Command == name { + return sname + } + } + return name +} + +func handlesFiletype(server *powernapconfig.ServerConfig, ext string, language protocol.LanguageKind) bool { + for _, ft := range server.FileTypes { + if protocol.LanguageKind(ft) == language || + ft == strings.TrimPrefix(ext, ".") || + "."+ft == ext { + return true + } + } + return false +} + +func hasRootMarkers(dir string, markers []string) bool { + if len(markers) == 0 { + return true + } + for _, pattern := range markers { + // Use fsext.GlobWithDoubleStar to find matches + matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1) + if err == nil && len(matches) > 0 { + return true + } + } + return false +} + +func handles(server *powernapconfig.ServerConfig, filePath, workDir string) bool { + language := lsp.DetectLanguage(filePath) + ext := filepath.Ext(filePath) + return handlesFiletype(server, ext, language) && + hasRootMarkers(workDir, server.RootMarkers) +} + +// StopAll stops all running LSP clients and clears the client map. +func (s *Manager) StopAll(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + var wg sync.WaitGroup + for name, client := range s.clients.Seq2() { + wg.Go(func() { + defer func() { s.callback(name, client) }() + if err := client.Close(ctx); err != nil && + !errors.Is(err, io.EOF) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, jsonrpc2.ErrClosed) && + err.Error() != "signal: killed" { + slog.Warn("Failed to stop LSP client", "name", name, "error", err) + } + client.SetServerState(StateStopped) + slog.Debug("Stopped LSP client", "name", name) + }) + } + wg.Wait() +} diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 2f6e093027783dca62f3d6cde12d61126c6061bb..92321d5d9cf67c96731fab102436f662f86cdc1b 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -74,7 +74,13 @@ func (h *header) drawHeader( b.WriteString(h.compactLogo) availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth) + details := renderHeaderDetails( + h.com, + session, + h.com.App.LSPManager.Clients(), + detailsOpen, + availDetailWidth, + ) remainingWidth := width - lipgloss.Width(b.String()) - diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index ef78ebfb2c4e069901e0b4433587e948f98643d1..f597c1100682a535cada2ae2c694471d9743c40a 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -31,7 +31,7 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string { var lsps []LSPInfo for _, state := range states { - client, ok := m.com.App.LSPClients.Get(state.Name) + client, ok := m.com.App.LSPManager.Clients().Get(state.Name) if !ok { continue } @@ -89,6 +89,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { var description string var diagnostics string switch l.State { + case lsp.StateStopped: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("stopped") case lsp.StateStarting: icon = t.ItemBusyIcon.String() description = t.Subtle.Render("starting...") @@ -103,7 +106,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { } case lsp.StateDisabled: icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() - description = t.Subtle.Render("inactive") + description = t.Subtle.Render("disabled") default: icon = t.ItemOfflineIcon.String() } diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 1438d0a914556574d513d3606bb1481cde008709..c043255c041c20523a2e14b85285bccc7ee7eeb1 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -3,6 +3,7 @@ package model import ( "context" "fmt" + "log/slog" "path/filepath" "slices" "strings" @@ -22,8 +23,32 @@ import ( // loadSessionMsg is a message indicating that a session and its files have // been loaded. type loadSessionMsg struct { - session *session.Session - files []SessionFile + session *session.Session + files []SessionFile + readFiles []string +} + +// lspFilePaths returns deduplicated file paths from both modified and read +// files for starting LSP servers. +func (msg loadSessionMsg) lspFilePaths() []string { + seen := make(map[string]struct{}, len(msg.files)+len(msg.readFiles)) + paths := make([]string, 0, len(msg.files)+len(msg.readFiles)) + for _, f := range msg.files { + p := f.LatestVersion.Path + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + for _, p := range msg.readFiles { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + paths = append(paths, p) + } + return paths } // SessionFile tracks the first and latest versions of a file in a session, @@ -51,9 +76,15 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { return util.ReportError(err) } + readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID) + if err != nil { + slog.Error("Failed to load read files for session", "error", err) + } + return loadSessionMsg{ - session: &session, - files: sessionFiles, + session: &session, + files: sessionFiles, + readFiles: readFiles, } } } @@ -200,3 +231,18 @@ func fileList(t *styles.Styles, cwd string, filesWithChanges []SessionFile, widt return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) } + +// startLSPs starts LSP servers for the given file paths. +func (m *UI) startLSPs(paths []string) tea.Cmd { + if len(paths) == 0 { + return nil + } + + return func() tea.Msg { + ctx := context.Background() + for _, path := range paths { + m.com.App.LSPManager.Start(ctx, path) + } + return nil + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 05fa503eb40b60560fb6cf185a16ec5342144349..71f5d977ba8715a553d6c5f162f458dcf9c1855c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -397,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setState(uiChat, m.focus) m.session = msg.session m.sessionFiles = msg.files + cmds = append(cmds, m.startLSPs(msg.lspFilePaths())) msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) if err != nil { cmds = append(cmds, util.ReportError(err)) @@ -417,8 +418,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyReset() cmds = append(cmds, m.loadPromptHistory()) m.updateLayoutAndSize() + case sessionFilesUpdatesMsg: m.sessionFiles = msg.sessionFiles + var paths []string + for _, f := range msg.sessionFiles { + paths = append(paths, f.LatestVersion.Path) + } + cmds = append(cmds, m.startLSPs(paths)) case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) @@ -2706,8 +2713,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. m.setState(uiChat, m.focus) } + ctx := context.Background() for _, path := range m.sessionFileReads { - m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path) + m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path) + m.com.App.LSPManager.Start(ctx, path) } // Capture session ID to avoid race with main goroutine updating m.session. @@ -2965,7 +2974,13 @@ func (m *UI) newSession() tea.Cmd { m.promptQueue = 0 m.pillsView = "" m.historyReset() - return m.loadPromptHistory() + return tea.Batch( + func() tea.Msg { + m.com.App.LSPManager.StopAll(context.Background()) + return nil + }, + m.loadPromptHistory(), + ) } // handlePasteMsg handles a paste message.