feat(lsp): show user-configured LSPs in the UI (#2192)

Carlos Alexandro Becker and Christian Rocha created

* feat(lsp): show user-configured LSPs in the UI

This will show the user-configured LSPs as stopped in the UI.

Maybe we should have a different state for "waiting"?

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore(lsp): mark "unstarted" LSPs as such, use named styles

* fix: add unstarted state

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

internal/app/app.go          |  5 +++
internal/lsp/client.go       |  3 +
internal/lsp/manager.go      | 18 +++++++++++++
internal/ui/model/lsp.go     | 50 +++++++++++++++++++-------------------
internal/ui/model/mcp.go     | 26 +++++++++---------
internal/ui/styles/styles.go | 24 ++++++++++++------
6 files changed, 79 insertions(+), 47 deletions(-)

Detailed changes

internal/app/app.go πŸ”—

@@ -126,9 +126,14 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
 
 	// Set up callback for LSP state updates.
 	app.LSPManager.SetCallback(func(name string, client *lsp.Client) {
+		if client == nil {
+			updateLSPState(name, lsp.StateUnstarted, nil, nil, 0)
+			return
+		}
 		client.SetDiagnosticsCallback(updateLSPDiagnostics)
 		updateLSPState(name, client.GetServerState(), nil, client, 0)
 	})
+	go app.LSPManager.TrackConfigured()
 
 	return app, nil
 }

internal/lsp/client.go πŸ”—

@@ -241,10 +241,11 @@ func (c *Client) Restart() error {
 type ServerState int
 
 const (
-	StateStopped ServerState = iota
+	StateUnstarted ServerState = iota
 	StateStarting
 	StateReady
 	StateError
+	StateStopped
 	StateDisabled
 )
 

internal/lsp/manager.go πŸ”—

@@ -77,6 +77,24 @@ func (s *Manager) SetCallback(cb func(name string, client *Client)) {
 	s.callback = cb
 }
 
+// TrackConfigured will callback the user-configured LSPs, but will not create
+// any clients.
+func (s *Manager) TrackConfigured() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	var wg sync.WaitGroup
+	for name := range s.manager.GetServers() {
+		if !s.isUserConfigured(name) {
+			continue
+		}
+		wg.Go(func() {
+			s.callback(name, nil)
+		})
+	}
+	wg.Wait()
+}
+
 // 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, path string) {

internal/ui/model/lsp.go πŸ”—

@@ -31,26 +31,23 @@ func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 
 	var lsps []LSPInfo
 	for _, state := range states {
-		client, ok := m.com.App.LSPManager.Clients().Get(state.Name)
-		if !ok {
-			continue
-		}
-		counts := client.GetDiagnosticCounts()
-		lspErrs := map[protocol.DiagnosticSeverity]int{
-			protocol.SeverityError:       counts.Error,
-			protocol.SeverityWarning:     counts.Warning,
-			protocol.SeverityHint:        counts.Hint,
-			protocol.SeverityInformation: counts.Information,
+		lspErrs := map[protocol.DiagnosticSeverity]int{}
+		if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
+			counts := client.GetDiagnosticCounts()
+			lspErrs[protocol.SeverityError] = counts.Error
+			lspErrs[protocol.SeverityWarning] = counts.Warning
+			lspErrs[protocol.SeverityHint] = counts.Hint
+			lspErrs[protocol.SeverityInformation] = counts.Information
 		}
 
 		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
 	}
 
-	title := t.Subtle.Render("LSPs")
+	title := t.ResourceGroupTitle.Render("LSPs")
 	if isSection {
 		title = common.Section(t, title, width)
 	}
-	list := t.Subtle.Render("None")
+	list := t.ResourceAdditionalText.Render("None")
 	if len(lsps) > 0 {
 		list = lspList(t, lsps, width, maxItems)
 	}
@@ -85,30 +82,33 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 	var renderedLsps []string
 	for _, l := range lsps {
 		var icon string
-		title := l.Name
+		title := t.ResourceName.Render(l.Name)
 		var description string
 		var diagnostics string
 		switch l.State {
+		case lsp.StateUnstarted:
+			icon = t.ResourceOfflineIcon.String()
+			description = t.ResourceStatus.Render("unstarted")
 		case lsp.StateStopped:
-			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
-			description = t.Subtle.Render("stopped")
+			icon = t.ResourceOfflineIcon.String()
+			description = t.ResourceStatus.Render("stopped")
 		case lsp.StateStarting:
-			icon = t.ItemBusyIcon.String()
-			description = t.Subtle.Render("starting...")
+			icon = t.ResourceBusyIcon.String()
+			description = t.ResourceStatus.Render("starting...")
 		case lsp.StateReady:
-			icon = t.ItemOnlineIcon.String()
+			icon = t.ResourceOnlineIcon.String()
 			diagnostics = lspDiagnostics(t, l.Diagnostics)
 		case lsp.StateError:
-			icon = t.ItemErrorIcon.String()
-			description = t.Subtle.Render("error")
+			icon = t.ResourceErrorIcon.String()
+			description = t.ResourceStatus.Render("error")
 			if l.Error != nil {
-				description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error()))
+				description = t.ResourceStatus.Render(fmt.Sprintf("error: %s", l.Error.Error()))
 			}
 		case lsp.StateDisabled:
-			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
-			description = t.Subtle.Render("disabled")
+			icon = t.ResourceOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.ResourceStatus.Render("disabled")
 		default:
-			icon = t.ItemOfflineIcon.String()
+			icon = t.ResourceOfflineIcon.String()
 		}
 		renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{
 			Icon:         icon,
@@ -121,7 +121,7 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
 	if len(renderedLsps) > maxItems {
 		visibleItems := renderedLsps[:maxItems-1]
 		remaining := len(renderedLsps) - maxItems
-		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		visibleItems = append(visibleItems, t.ResourceAdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
 		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
 	}
 	return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...)

internal/ui/model/mcp.go πŸ”—

@@ -22,11 +22,11 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
 		}
 	}
 
-	title := t.Subtle.Render("MCPs")
+	title := t.ResourceGroupTitle.Render("MCPs")
 	if isSection {
 		title = common.Section(t, title, width)
 	}
-	list := t.Subtle.Render("None")
+	list := t.ResourceAdditionalText.Render("None")
 	if len(mcps) > 0 {
 		list = mcpList(t, mcps, width, maxItems)
 	}
@@ -59,28 +59,28 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
 
 	for _, m := range mcps {
 		var icon string
-		title := m.Name
+		title := t.ResourceName.Render(m.Name)
 		var description string
 		var extraContent string
 
 		switch m.State {
 		case mcp.StateStarting:
-			icon = t.ItemBusyIcon.String()
-			description = t.Subtle.Render("starting...")
+			icon = t.ResourceBusyIcon.String()
+			description = t.ResourceStatus.Render("starting...")
 		case mcp.StateConnected:
-			icon = t.ItemOnlineIcon.String()
+			icon = t.ResourceOnlineIcon.String()
 			extraContent = mcpCounts(t, m.Counts)
 		case mcp.StateError:
-			icon = t.ItemErrorIcon.String()
-			description = t.Subtle.Render("error")
+			icon = t.ResourceErrorIcon.String()
+			description = t.ResourceStatus.Render("error")
 			if m.Error != nil {
-				description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error()))
+				description = t.ResourceStatus.Render(fmt.Sprintf("error: %s", m.Error.Error()))
 			}
 		case mcp.StateDisabled:
-			icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
-			description = t.Subtle.Render("disabled")
+			icon = t.ResourceOfflineIcon.Foreground(t.Muted.GetBackground()).String()
+			description = t.ResourceStatus.Render("disabled")
 		default:
-			icon = t.ItemOfflineIcon.String()
+			icon = t.ResourceOfflineIcon.String()
 		}
 
 		renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{
@@ -94,7 +94,7 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
 	if len(renderedMcps) > maxItems {
 		visibleItems := renderedMcps[:maxItems-1]
 		remaining := len(renderedMcps) - maxItems
-		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+		visibleItems = append(visibleItems, t.ResourceAdditionalText.Render(fmt.Sprintf("…and %d more", remaining)))
 		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
 	}
 	return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...)

internal/ui/styles/styles.go πŸ”—

@@ -109,10 +109,14 @@ type Styles struct {
 	TextSelection lipgloss.Style
 
 	// LSP and MCP status indicators
-	ItemOfflineIcon lipgloss.Style
-	ItemBusyIcon    lipgloss.Style
-	ItemErrorIcon   lipgloss.Style
-	ItemOnlineIcon  lipgloss.Style
+	ResourceGroupTitle     lipgloss.Style
+	ResourceOfflineIcon    lipgloss.Style
+	ResourceBusyIcon       lipgloss.Style
+	ResourceErrorIcon      lipgloss.Style
+	ResourceOnlineIcon     lipgloss.Style
+	ResourceName           lipgloss.Style
+	ResourceStatus         lipgloss.Style
+	ResourceAdditionalText lipgloss.Style
 
 	// Markdown & Chroma
 	Markdown      ansi.StyleConfig
@@ -1199,10 +1203,14 @@ func DefaultStyles() Styles {
 	s.Initialize.Accent = s.Base.Foreground(greenDark)
 
 	// LSP and MCP status.
-	s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
-	s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron)
-	s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral)
-	s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac)
+	s.ResourceGroupTitle = lipgloss.NewStyle().Foreground(charmtone.Oyster)
+	s.ResourceOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Iron).SetString("●")
+	s.ResourceBusyIcon = s.ResourceOfflineIcon.Foreground(charmtone.Citron)
+	s.ResourceErrorIcon = s.ResourceOfflineIcon.Foreground(charmtone.Coral)
+	s.ResourceOnlineIcon = s.ResourceOfflineIcon.Foreground(charmtone.Guac)
+	s.ResourceName = lipgloss.NewStyle().Foreground(charmtone.Squid)
+	s.ResourceStatus = lipgloss.NewStyle().Foreground(charmtone.Oyster)
+	s.ResourceAdditionalText = lipgloss.NewStyle().Foreground(charmtone.Oyster)
 
 	// LSP
 	s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark)