From c81b02f440a1c442fe731122f5b2150547ab8fd3 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 27 Jan 2026 16:26:00 -0300 Subject: [PATCH] feat(lsp): auto-discover LSPs (#1834) * feat(lsp): auto-discover LSPs - auto-discover LSPs defined in powernap - faster startup by walking dir only once to check root markers from all LSPs - errors on auto-found LSPs are ignored (e.g. it might match golangci-lint-server but if you don't have it installed it shouldn't show in the list) Signed-off-by: Carlos Alexandro Becker * fix: sidebar improvement Signed-off-by: Carlos Alexandro Becker * fix: if Signed-off-by: Carlos Alexandro Becker * fix: startup, disabled Signed-off-by: Carlos Alexandro Becker * fix: lint Signed-off-by: Carlos Alexandro Becker * fix: remove unneeded func Signed-off-by: Carlos Alexandro Becker * perf: skip empty Signed-off-by: Carlos Alexandro Becker * fix: server names Signed-off-by: Carlos Alexandro Becker * fix: do not show failing non configured lsps Signed-off-by: Carlos Alexandro Becker * fix: allow to disable auto lsp Signed-off-by: Carlos Alexandro Becker * chore: update powernap Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/app/app.go | 2 +- internal/app/lsp.go | 89 ++++++++++-- internal/config/config.go | 1 + internal/lsp/client.go | 79 +++++++++-- internal/lsp/filtermatching_test.go | 111 +++++++++++++++ internal/lsp/language.go | 132 ------------------ internal/lsp/rootmarkers_test.go | 37 ----- .../tui/components/chat/sidebar/sidebar.go | 2 - internal/tui/components/lsp/lsp.go | 31 ++-- 11 files changed, 273 insertions(+), 217 deletions(-) create mode 100644 internal/lsp/filtermatching_test.go delete mode 100644 internal/lsp/language.go delete mode 100644 internal/lsp/rootmarkers_test.go diff --git a/go.mod b/go.mod index 30c5613bf400e4568bf0662b6c340d371b1d4268..9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff github.com/charmbracelet/x/exp/strings v0.1.0 - github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b + github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec diff --git a/go.sum b/go.sum index 5f1787b0a9e5372580a3a92dfbb43e2786e582bb..c0d8fdcc25091a334f0f79fcf2e5f91247496fdc 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/app/app.go b/internal/app/app.go index b186c1aeb4f7d0adbc3d0fd443b660952a4def52..ef6e636e44eeea9407557ca48f8ba9bd8eba72b2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() // Initialize LSP clients in the background. - app.initLSPClients(ctx) + go app.initLSPClients(ctx) // Check for updates in the background. go app.checkForUpdates(ctx) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 23a5447af92872223f91d3283cf6663aae0d1d07..39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -3,41 +3,108 @@ package app import ( "context" "log/slog" + "os/exec" + "slices" "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 } - go app.createAndStartLSPClient(ctx, name, clientConfig) + + // 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) + } + } + 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 + } + go app.createAndStartLSPClient( + ctx, name, + toOurConfig(server), + slices.Contains(userConfiguredLSPs, name), + ) } - slog.Info("LSP clients initialization started in background") } -// 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) { - slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) +func toOurConfig(in *powernapconfig.ServerConfig) 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, + } +} - // Check if any root markers exist in the working directory (config now has defaults) - if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) { - slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers) - updateLSPState(name, lsp.StateDisabled, nil, nil, 0) - return +// 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 + } } - // Update state to starting + 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()) 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 diff --git a/internal/config/config.go b/internal/config/config.go index eb8394e11972de4c91017a4b92e59ccee804ef0c..510685325fa779c7f53842435049478efeb389fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,6 +257,7 @@ type Options struct { Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` + AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"` } type MCPs map[string]MCPConfig diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 1f0b09bf990bd50aa06198d88aea034ef3d6453c..98aa75966160ba97af8c431d98c642fb558e5dc7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -13,10 +13,12 @@ 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" @@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool { return true } + kind := powernap.DetectLanguage(path) name := strings.ToLower(filepath.Base(path)) for _, filetype := range c.fileTypes { suffix := strings.ToLower(filetype) if !strings.HasPrefix(suffix, ".") { suffix = "." + suffix } - if strings.HasSuffix(name, suffix) { - slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype) + if strings.HasSuffix(name, suffix) || filetype == string(kind) { + slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind) return true } } @@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error { } // Notify the server about the opened document - if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil { + if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil { return err } @@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) } -// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. -// Uses glob patterns to match files, allowing for more flexible matching. -func HasRootMarkers(dir string, rootMarkers []string) bool { - if len(rootMarkers) == 0 { - return true +// 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 } - for _, pattern := range rootMarkers { - // Use fsext.GlobWithDoubleStar to find matches - matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1) - if err == nil && len(matches) > 0 { - return true + + type serverPatterns struct { + server *powernapconfig.ServerConfig + patterns []string + } + normalized := make(map[string]serverPatterns, len(servers)) + for name, server := range servers { + if len(server.RootMarkers) == 0 { + continue + } + patterns := make([]string, len(server.RootMarkers)) + for i, p := range server.RootMarkers { + patterns[i] = filepath.ToSlash(p) } + normalized[name] = serverPatterns{server: server, patterns: patterns} } - return false + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..40c796916b73169b882404eecfb4625e7baaa85b --- /dev/null +++ b/internal/lsp/filtermatching_test.go @@ -0,0 +1,111 @@ +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/language.go b/internal/lsp/language.go deleted file mode 100644 index 7d6a1517e849b6f09352447b2acb05539b3220af..0000000000000000000000000000000000000000 --- a/internal/lsp/language.go +++ /dev/null @@ -1,132 +0,0 @@ -package lsp - -import ( - "path/filepath" - "strings" - - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" -) - -func DetectLanguageID(uri string) protocol.LanguageKind { - ext := strings.ToLower(filepath.Ext(uri)) - switch ext { - case ".abap": - return protocol.LangABAP - case ".bat": - return protocol.LangWindowsBat - case ".bib", ".bibtex": - return protocol.LangBibTeX - case ".clj": - return protocol.LangClojure - case ".coffee": - return protocol.LangCoffeescript - case ".c": - return protocol.LangC - case ".cpp", ".cxx", ".cc", ".c++": - return protocol.LangCPP - case ".cs": - return protocol.LangCSharp - case ".css": - return protocol.LangCSS - case ".d": - return protocol.LangD - case ".pas", ".pascal": - return protocol.LangDelphi - case ".diff", ".patch": - return protocol.LangDiff - case ".dart": - return protocol.LangDart - case ".dockerfile": - return protocol.LangDockerfile - case ".ex", ".exs": - return protocol.LangElixir - case ".erl", ".hrl": - return protocol.LangErlang - case ".fs", ".fsi", ".fsx", ".fsscript": - return protocol.LangFSharp - case ".gitcommit": - return protocol.LangGitCommit - case ".gitrebase": - return protocol.LangGitRebase - case ".go": - return protocol.LangGo - case ".groovy": - return protocol.LangGroovy - case ".hbs", ".handlebars": - return protocol.LangHandlebars - case ".hs": - return protocol.LangHaskell - case ".html", ".htm": - return protocol.LangHTML - case ".ini": - return protocol.LangIni - case ".java": - return protocol.LangJava - case ".js": - return protocol.LangJavaScript - case ".jsx": - return protocol.LangJavaScriptReact - case ".json": - return protocol.LangJSON - case ".tex", ".latex": - return protocol.LangLaTeX - case ".less": - return protocol.LangLess - case ".lua": - return protocol.LangLua - case ".makefile", "makefile": - return protocol.LangMakefile - case ".md", ".markdown": - return protocol.LangMarkdown - case ".m": - return protocol.LangObjectiveC - case ".mm": - return protocol.LangObjectiveCPP - case ".pl": - return protocol.LangPerl - case ".pm": - return protocol.LangPerl6 - case ".php": - return protocol.LangPHP - case ".ps1", ".psm1": - return protocol.LangPowershell - case ".pug", ".jade": - return protocol.LangPug - case ".py": - return protocol.LangPython - case ".r": - return protocol.LangR - case ".cshtml", ".razor": - return protocol.LangRazor - case ".rb": - return protocol.LangRuby - case ".rs": - return protocol.LangRust - case ".scss": - return protocol.LangSCSS - case ".sass": - return protocol.LangSASS - case ".scala": - return protocol.LangScala - case ".shader": - return protocol.LangShaderLab - case ".sh", ".bash", ".zsh", ".ksh": - return protocol.LangShellScript - case ".sql": - return protocol.LangSQL - case ".swift": - return protocol.LangSwift - case ".ts": - return protocol.LangTypeScript - case ".tsx": - return protocol.LangTypeScriptReact - case ".xml": - return protocol.LangXML - case ".xsl": - return protocol.LangXSL - case ".yaml", ".yml": - return protocol.LangYAML - default: - return protocol.LanguageKind("") // Unknown language - } -} diff --git a/internal/lsp/rootmarkers_test.go b/internal/lsp/rootmarkers_test.go deleted file mode 100644 index 7b3a3c0905799865808b9b1ae0dff992e00ed34c..0000000000000000000000000000000000000000 --- a/internal/lsp/rootmarkers_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package lsp - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestHasRootMarkers(t *testing.T) { - t.Parallel() - - // Create a temporary directory for testing - tmpDir := t.TempDir() - - // Test with empty root markers (should return true) - require.True(t, HasRootMarkers(tmpDir, []string{})) - - // Test with non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Create a go.mod file - goModPath := filepath.Join(tmpDir, "go.mod") - err := os.WriteFile(goModPath, []byte("module test"), 0o644) - require.NoError(t, err) - - // Test with existing marker - require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Test with only non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"})) - - // Test with glob patterns - require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"})) - require.False(t, HasRootMarkers(tmpDir, []string{"*.json"})) -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9b3d52dadb9a7677bdb5db4b3a8360e7385775ba..a454605c8ee2938fa02d98d9770704388d0bd38a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string { func (m *sidebarCmp) lspBlock() string { // Limit the number of LSPs shown _, maxLSPs, _ := m.getDynamicLimits() - lspConfigs := config.Get().LSP.Sorted() - maxLSPs = min(len(lspConfigs), maxLSPs) return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ MaxWidth: m.getMaxWidth(), diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f9118143cbfd9a7bf19aa569bc85448746debecd..3379c2c9acfd7e7e10d6e6777e2554d0b0db2144 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -2,6 +2,8 @@ package lsp import ( "fmt" + "maps" + "slices" "strings" "charm.land/lipgloss/v2" @@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption lspList = append(lspList, section, "") } - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) == 0 { + // Get LSP states + lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int { + return strings.Compare(a.Name, b.Name) + }) + if len(lsps) == 0 { lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None")) return lspList } - // Get LSP states - lspStates := app.GetLSPStates() - // Determine how many items to show - maxItems := len(lspConfigs) + maxItems := len(lsps) if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(lspConfigs)) + maxItems = min(opts.MaxItems, len(lsps)) } - for i, l := range lspConfigs { + for i, info := range lsps { if i >= maxItems { break } - icon, description := iconAndDescription(l, t, lspStates) + icon, description := iconAndDescription(t, info) // Calculate diagnostic counts if we have LSP clients var extraContent string if lspClients != nil { - if client, ok := lspClients.Get(l.Name); ok { + if client, ok := lspClients.Get(info.Name); ok { counts := client.GetDiagnosticCounts() errs := []string{} if counts.Error > 0 { @@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption core.Status( core.StatusOpts{ Icon: icon.String(), - Title: l.Name, + Title: info.Name, Description: description, ExtraContent: extraContent, }, @@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption return lspList } -func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) { - if l.LSP.Disabled { - return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled") - } - - info := states[l.Name] +func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) { switch info.State { case lsp.StateStarting: return t.ItemBusyIcon, t.S().Subtle.Render("starting...")