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}