diagnostics.go

  1package diagnostics
  2
  3import (
  4	"fmt"
  5	"sort"
  6	"strings"
  7
  8	"github.com/charmbracelet/bubbles/v2/help"
  9	"github.com/charmbracelet/bubbles/v2/key"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/lsp"
 12	"github.com/charmbracelet/crush/internal/lsp/protocol"
 13	"github.com/charmbracelet/crush/internal/tui/components/completions"
 14	"github.com/charmbracelet/crush/internal/tui/components/core"
 15	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 16	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 17	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 18	"github.com/charmbracelet/crush/internal/tui/styles"
 19	"github.com/charmbracelet/crush/internal/tui/util"
 20	"github.com/charmbracelet/lipgloss/v2"
 21)
 22
 23const (
 24	DiagnosticsDialogID dialogs.DialogID = "diagnostics"
 25
 26	defaultWidth = 80
 27)
 28
 29// DiagnosticItem represents a diagnostic entry
 30type DiagnosticItem struct {
 31	FilePath   string
 32	Diagnostic protocol.Diagnostic
 33	LSPName    string
 34}
 35
 36// DiagnosticsDialog interface for the diagnostics dialog
 37type DiagnosticsDialog interface {
 38	dialogs.DialogModel
 39}
 40
 41type diagnosticsDialogCmp struct {
 42	width   int
 43	wWidth  int
 44	wHeight int
 45
 46	diagnosticsList list.ListModel
 47	keyMap          KeyMap
 48	help            help.Model
 49	lspClients      map[string]*lsp.Client
 50}
 51
 52func NewDiagnosticsDialogCmp(lspClients map[string]*lsp.Client) DiagnosticsDialog {
 53	listKeyMap := list.DefaultKeyMap()
 54	keyMap := DefaultKeyMap()
 55
 56	listKeyMap.Down.SetEnabled(false)
 57	listKeyMap.Up.SetEnabled(false)
 58	listKeyMap.HalfPageDown.SetEnabled(false)
 59	listKeyMap.HalfPageUp.SetEnabled(false)
 60	listKeyMap.Home.SetEnabled(false)
 61	listKeyMap.End.SetEnabled(false)
 62
 63	listKeyMap.DownOneItem = keyMap.Next
 64	listKeyMap.UpOneItem = keyMap.Previous
 65
 66	t := styles.CurrentTheme()
 67	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
 68	diagnosticsList := list.New(
 69		list.WithFilterable(true),
 70		list.WithKeyMap(listKeyMap),
 71		list.WithInputStyle(inputStyle),
 72		list.WithWrapNavigation(true),
 73	)
 74	help := help.New()
 75	help.Styles = t.S().Help
 76
 77	return &diagnosticsDialogCmp{
 78		diagnosticsList: diagnosticsList,
 79		width:           defaultWidth,
 80		keyMap:          DefaultKeyMap(),
 81		help:            help,
 82		lspClients:      lspClients,
 83	}
 84}
 85
 86func (d *diagnosticsDialogCmp) Init() tea.Cmd {
 87	d.loadDiagnostics()
 88	return d.diagnosticsList.Init()
 89}
 90
 91func (d *diagnosticsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 92	switch msg := msg.(type) {
 93	case tea.WindowSizeMsg:
 94		d.wWidth = msg.Width
 95		d.wHeight = msg.Height
 96		d.loadDiagnostics()
 97		return d, d.diagnosticsList.SetSize(d.listWidth(), d.listHeight())
 98	case tea.KeyPressMsg:
 99		switch {
100		case key.Matches(msg, d.keyMap.Select):
101			selectedItemInx := d.diagnosticsList.SelectedIndex()
102			if selectedItemInx == list.NoSelection {
103				return d, nil
104			}
105			items := d.diagnosticsList.Items()
106			if selectedItem, ok := items[selectedItemInx].(completions.CompletionItem); ok {
107				if diagItem, ok := selectedItem.Value().(DiagnosticItem); ok {
108					// Open the file at the diagnostic location
109					_ = fmt.Sprintf("%s:%d:%d", 
110						diagItem.FilePath, 
111						diagItem.Diagnostic.Range.Start.Line+1, 
112						diagItem.Diagnostic.Range.Start.Character+1)
113					
114					return d, tea.Sequence(
115						util.CmdHandler(dialogs.CloseDialogMsg{}),
116						// You might want to add a message to open the file/location
117						// For now, we'll just close the dialog
118					)
119				}
120			}
121			return d, nil
122		case key.Matches(msg, d.keyMap.Close):
123			return d, util.CmdHandler(dialogs.CloseDialogMsg{})
124		default:
125			u, cmd := d.diagnosticsList.Update(msg)
126			d.diagnosticsList = u.(list.ListModel)
127			return d, cmd
128		}
129	}
130	return d, nil
131}
132
133func (d *diagnosticsDialogCmp) View() tea.View {
134	t := styles.CurrentTheme()
135	listView := d.diagnosticsList.View()
136	content := lipgloss.JoinVertical(
137		lipgloss.Left,
138		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Diagnostics", d.width-5)),
139		listView.String(),
140		"",
141		t.S().Base.Width(d.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(d.help.View(d.keyMap)),
142	)
143	v := tea.NewView(d.style().Render(content))
144	if listView.Cursor() != nil {
145		c := d.moveCursor(listView.Cursor())
146		v.SetCursor(c)
147	}
148	return v
149}
150
151func (d *diagnosticsDialogCmp) style() lipgloss.Style {
152	t := styles.CurrentTheme()
153	return t.S().Base.
154		Width(d.width).
155		Border(lipgloss.RoundedBorder()).
156		BorderForeground(t.BorderFocus)
157}
158
159func (d *diagnosticsDialogCmp) listWidth() int {
160	return defaultWidth - 2
161}
162
163func (d *diagnosticsDialogCmp) listHeight() int {
164	listHeight := len(d.diagnosticsList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
165	return min(listHeight, d.wHeight/2)
166}
167
168func (d *diagnosticsDialogCmp) Position() (int, int) {
169	row := d.wHeight/4 - 2 // just a bit above the center
170	col := d.wWidth / 2
171	col -= d.width / 2
172	return row, col
173}
174
175func (d *diagnosticsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
176	row, col := d.Position()
177	offset := row + 3 // Border + title
178	cursor.Y += offset
179	cursor.X = cursor.X + col + 2
180	return cursor
181}
182
183func (d *diagnosticsDialogCmp) ID() dialogs.DialogID {
184	return DiagnosticsDialogID
185}
186
187func (d *diagnosticsDialogCmp) loadDiagnostics() {
188	diagnosticItems := []util.Model{}
189	
190	// Group diagnostics by LSP
191	lspDiagnostics := make(map[string][]DiagnosticItem)
192	
193	for lspName, client := range d.lspClients {
194		diagnostics := client.GetDiagnostics()
195		var items []DiagnosticItem
196		
197		for location, diags := range diagnostics {
198			for _, diag := range diags {
199				items = append(items, DiagnosticItem{
200					FilePath:   location.Path(),
201					Diagnostic: diag,
202					LSPName:    lspName,
203				})
204			}
205		}
206		
207		// Sort diagnostics by severity (errors first) then by file path
208		sort.Slice(items, func(i, j int) bool {
209			iSeverity := items[i].Diagnostic.Severity
210			jSeverity := items[j].Diagnostic.Severity
211			if iSeverity != jSeverity {
212				return iSeverity < jSeverity // Lower severity number = higher priority
213			}
214			return items[i].FilePath < items[j].FilePath
215		})
216		
217		if len(items) > 0 {
218			lspDiagnostics[lspName] = items
219		}
220	}
221	
222	// Add sections for each LSP with diagnostics
223	for lspName, items := range lspDiagnostics {
224		// Add section header
225		diagnosticItems = append(diagnosticItems, commands.NewItemSection(lspName))
226		
227		// Add diagnostic items
228		for _, item := range items {
229			title := d.formatDiagnosticTitle(item)
230			diagnosticItems = append(diagnosticItems, completions.NewCompletionItem(title, item))
231		}
232	}
233	
234	d.diagnosticsList.SetItems(diagnosticItems)
235}
236
237func (d *diagnosticsDialogCmp) formatDiagnosticTitle(item DiagnosticItem) string {
238	severity := "Info"
239	switch item.Diagnostic.Severity {
240	case protocol.SeverityError:
241		severity = "Error"
242	case protocol.SeverityWarning:
243		severity = "Warn"
244	case protocol.SeverityHint:
245		severity = "Hint"
246	}
247	
248	// Extract filename from path
249	parts := strings.Split(item.FilePath, "/")
250	filename := parts[len(parts)-1]
251	
252	location := fmt.Sprintf("%s:%d:%d", 
253		filename, 
254		item.Diagnostic.Range.Start.Line+1, 
255		item.Diagnostic.Range.Start.Character+1)
256	
257	// Truncate message if too long
258	message := item.Diagnostic.Message
259	if len(message) > 60 {
260		message = message[:57] + "..."
261	}
262	
263	return fmt.Sprintf("[%s] %s - %s", severity, location, message)
264}