From 33f613e04d161240f5103e74495b520a4b7044b8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 30 Jun 2025 23:09:14 +0200 Subject: [PATCH] feat: add diagnostics modal --- internal/lsp/client.go | 2 +- .../components/dialogs/commands/commands.go | 53 +++- .../dialogs/diagnostics/diagnostics.go | 264 ++++++++++++++++++ .../components/dialogs/diagnostics/keys.go | 66 +++++ internal/tui/tui.go | 9 +- 5 files changed, 380 insertions(+), 14 deletions(-) create mode 100644 internal/tui/components/dialogs/diagnostics/diagnostics.go create mode 100644 internal/tui/components/dialogs/diagnostics/keys.go diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 24ff0238c355edb5499640b93f9e06f0f07568c9..066d1601da2ca91f2eaba0f22a367d92559c6f77 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -56,7 +56,7 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er cmd := exec.CommandContext(ctx, command, args...) // Copy env cmd.Env = os.Environ() - + stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("failed to create stdin pipe: %w", err) diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index cbbbc989942268ebd2ac7c44a35a327cec1509fd..88dc550e5d7040e37bad03466251caef70657ce8 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -48,21 +49,23 @@ type commandDialogCmp struct { commandList list.ListModel keyMap CommandsDialogKeyMap help help.Model - commandType int // SystemCommands or UserCommands - userCommands []Command // User-defined commands - sessionID string // Current session ID + commandType int // SystemCommands or UserCommands + userCommands []Command // User-defined commands + sessionID string // Current session ID + lspClients map[string]*lsp.Client // LSP clients for diagnostics check } type ( SwitchSessionsMsg struct{} SwitchModelMsg struct{} ToggleCompactModeMsg struct{} + ShowDiagnosticsMsg struct{} CompactMsg struct { SessionID string } ) -func NewCommandDialog(sessionID string) CommandsDialog { +func NewCommandDialog(sessionID string, lspClients map[string]*lsp.Client) CommandsDialog { listKeyMap := list.DefaultKeyMap() keyMap := DefaultCommandsDialogKeyMap() @@ -85,12 +88,13 @@ func NewCommandDialog(sessionID string) CommandsDialog { help := help.New() help.Styles = t.S().Help return &commandDialogCmp{ - commandList: commandList, - width: defaultWidth, - keyMap: DefaultCommandsDialogKeyMap(), - help: help, - commandType: SystemCommands, - sessionID: sessionID, + commandList: commandList, + width: defaultWidth, + keyMap: DefaultCommandsDialogKeyMap(), + help: help, + commandType: SystemCommands, + sessionID: sessionID, + lspClients: lspClients, } } @@ -226,6 +230,16 @@ func (c *commandDialogCmp) Position() (int, int) { return row, col } +func (c *commandDialogCmp) hasDiagnostics() bool { + for _, client := range c.lspClients { + diagnostics := client.GetDiagnostics() + if len(diagnostics) > 0 { + return true + } + } + return false +} + func (c *commandDialogCmp) defaultCommands() []Command { commands := []Command{ { @@ -273,7 +287,7 @@ func (c *commandDialogCmp) defaultCommands() []Command { }) } - return append(commands, []Command{ + baseCommands := []Command{ { ID: "switch_session", Title: "Switch Session", @@ -291,7 +305,22 @@ func (c *commandDialogCmp) defaultCommands() []Command { return util.CmdHandler(SwitchModelMsg{}) }, }, - }...) + } + + // Add diagnostics command only if there are diagnostics available + if c.hasDiagnostics() { + diagnosticsCmd := Command{ + ID: "diagnostics", + Title: "Show Diagnostics", + Description: "View LSP diagnostics for the project", + Handler: func(cmd Command) tea.Cmd { + return util.CmdHandler(ShowDiagnosticsMsg{}) + }, + } + baseCommands = append([]Command{diagnosticsCmd}, baseCommands...) + } + + return append(commands, baseCommands...) } func (c *commandDialogCmp) ID() dialogs.DialogID { diff --git a/internal/tui/components/dialogs/diagnostics/diagnostics.go b/internal/tui/components/dialogs/diagnostics/diagnostics.go new file mode 100644 index 0000000000000000000000000000000000000000..3a94d14e6af785cda4d95768b599cc61180a29bb --- /dev/null +++ b/internal/tui/components/dialogs/diagnostics/diagnostics.go @@ -0,0 +1,264 @@ +package diagnostics + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + DiagnosticsDialogID dialogs.DialogID = "diagnostics" + + defaultWidth = 80 +) + +// DiagnosticItem represents a diagnostic entry +type DiagnosticItem struct { + FilePath string + Diagnostic protocol.Diagnostic + LSPName string +} + +// DiagnosticsDialog interface for the diagnostics dialog +type DiagnosticsDialog interface { + dialogs.DialogModel +} + +type diagnosticsDialogCmp struct { + width int + wWidth int + wHeight int + + diagnosticsList list.ListModel + keyMap KeyMap + help help.Model + lspClients map[string]*lsp.Client +} + +func NewDiagnosticsDialogCmp(lspClients map[string]*lsp.Client) DiagnosticsDialog { + listKeyMap := list.DefaultKeyMap() + keyMap := DefaultKeyMap() + + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + inputStyle := t.S().Base.Padding(0, 1, 0, 1) + diagnosticsList := list.New( + list.WithFilterable(true), + list.WithKeyMap(listKeyMap), + list.WithInputStyle(inputStyle), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help + + return &diagnosticsDialogCmp{ + diagnosticsList: diagnosticsList, + width: defaultWidth, + keyMap: DefaultKeyMap(), + help: help, + lspClients: lspClients, + } +} + +func (d *diagnosticsDialogCmp) Init() tea.Cmd { + d.loadDiagnostics() + return d.diagnosticsList.Init() +} + +func (d *diagnosticsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + d.wWidth = msg.Width + d.wHeight = msg.Height + d.loadDiagnostics() + return d, d.diagnosticsList.SetSize(d.listWidth(), d.listHeight()) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, d.keyMap.Select): + selectedItemInx := d.diagnosticsList.SelectedIndex() + if selectedItemInx == list.NoSelection { + return d, nil + } + items := d.diagnosticsList.Items() + if selectedItem, ok := items[selectedItemInx].(completions.CompletionItem); ok { + if diagItem, ok := selectedItem.Value().(DiagnosticItem); ok { + // Open the file at the diagnostic location + _ = fmt.Sprintf("%s:%d:%d", + diagItem.FilePath, + diagItem.Diagnostic.Range.Start.Line+1, + diagItem.Diagnostic.Range.Start.Character+1) + + return d, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + // You might want to add a message to open the file/location + // For now, we'll just close the dialog + ) + } + } + return d, nil + case key.Matches(msg, d.keyMap.Close): + return d, util.CmdHandler(dialogs.CloseDialogMsg{}) + default: + u, cmd := d.diagnosticsList.Update(msg) + d.diagnosticsList = u.(list.ListModel) + return d, cmd + } + } + return d, nil +} + +func (d *diagnosticsDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := d.diagnosticsList.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Diagnostics", d.width-5)), + listView.String(), + "", + t.S().Base.Width(d.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(d.help.View(d.keyMap)), + ) + v := tea.NewView(d.style().Render(content)) + if listView.Cursor() != nil { + c := d.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (d *diagnosticsDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(d.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (d *diagnosticsDialogCmp) listWidth() int { + return defaultWidth - 2 +} + +func (d *diagnosticsDialogCmp) listHeight() int { + listHeight := len(d.diagnosticsList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + return min(listHeight, d.wHeight/2) +} + +func (d *diagnosticsDialogCmp) Position() (int, int) { + row := d.wHeight/4 - 2 // just a bit above the center + col := d.wWidth / 2 + col -= d.width / 2 + return row, col +} + +func (d *diagnosticsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := d.Position() + offset := row + 3 // Border + title + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +func (d *diagnosticsDialogCmp) ID() dialogs.DialogID { + return DiagnosticsDialogID +} + +func (d *diagnosticsDialogCmp) loadDiagnostics() { + diagnosticItems := []util.Model{} + + // Group diagnostics by LSP + lspDiagnostics := make(map[string][]DiagnosticItem) + + for lspName, client := range d.lspClients { + diagnostics := client.GetDiagnostics() + var items []DiagnosticItem + + for location, diags := range diagnostics { + for _, diag := range diags { + items = append(items, DiagnosticItem{ + FilePath: location.Path(), + Diagnostic: diag, + LSPName: lspName, + }) + } + } + + // Sort diagnostics by severity (errors first) then by file path + sort.Slice(items, func(i, j int) bool { + iSeverity := items[i].Diagnostic.Severity + jSeverity := items[j].Diagnostic.Severity + if iSeverity != jSeverity { + return iSeverity < jSeverity // Lower severity number = higher priority + } + return items[i].FilePath < items[j].FilePath + }) + + if len(items) > 0 { + lspDiagnostics[lspName] = items + } + } + + // Add sections for each LSP with diagnostics + for lspName, items := range lspDiagnostics { + // Add section header + diagnosticItems = append(diagnosticItems, commands.NewItemSection(lspName)) + + // Add diagnostic items + for _, item := range items { + title := d.formatDiagnosticTitle(item) + diagnosticItems = append(diagnosticItems, completions.NewCompletionItem(title, item)) + } + } + + d.diagnosticsList.SetItems(diagnosticItems) +} + +func (d *diagnosticsDialogCmp) formatDiagnosticTitle(item DiagnosticItem) string { + severity := "Info" + switch item.Diagnostic.Severity { + case protocol.SeverityError: + severity = "Error" + case protocol.SeverityWarning: + severity = "Warn" + case protocol.SeverityHint: + severity = "Hint" + } + + // Extract filename from path + parts := strings.Split(item.FilePath, "/") + filename := parts[len(parts)-1] + + location := fmt.Sprintf("%s:%d:%d", + filename, + item.Diagnostic.Range.Start.Line+1, + item.Diagnostic.Range.Start.Character+1) + + // Truncate message if too long + message := item.Diagnostic.Message + if len(message) > 60 { + message = message[:57] + "..." + } + + return fmt.Sprintf("[%s] %s - %s", severity, location, message) +} \ No newline at end of file diff --git a/internal/tui/components/dialogs/diagnostics/keys.go b/internal/tui/components/dialogs/diagnostics/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..a50a2d41ae430239d48aa3b04a91845e0b5dd1fe --- /dev/null +++ b/internal/tui/components/dialogs/diagnostics/keys.go @@ -0,0 +1,66 @@ +package diagnostics + +import ( + "github.com/charmbracelet/bubbles/v2/key" +) + +type KeyMap struct { + Select, + Next, + Previous, + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "select"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// KeyBindings implements layout.KeyMapProvider +func (k KeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.Select, + k.Next, + k.Previous, + k.Close, + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := k.KeyBindings() + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + k.Close, + } +} \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fb77e5a8f30d8f4cd290d3a8d4026694c690a109..80452a7cab14ef1ade9cab0abfbd0b57e3ea7c78 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/diagnostics" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" @@ -164,6 +165,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Model: models.NewModelDialogCmp(), }, ) + case commands.ShowDiagnosticsMsg: + return a, util.CmdHandler( + dialogs.OpenDialogMsg{ + Model: diagnostics.NewDiagnosticsDialogCmp(a.app.LSPClients), + }, + ) // Compact case commands.CompactMsg: return a, util.CmdHandler(dialogs.OpenDialogMsg{ @@ -343,7 +350,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return util.CmdHandler(dialogs.CloseDialogMsg{}) } return util.CmdHandler(dialogs.OpenDialogMsg{ - Model: commands.NewCommandDialog(a.selectedSessionID), + Model: commands.NewCommandDialog(a.selectedSessionID, a.app.LSPClients), }) case key.Matches(msg, a.keyMap.Sessions): if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {