diff --git a/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift index 9d0e157..c122d55 100644 --- a/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift +++ b/Sources/CodeEditorCore/Sources/Document/Diagnostic.swift @@ -16,13 +16,17 @@ public enum DocumentDiagnosticSeverity: Int, CaseIterable, Sendable { /// /// Built from the LSP `Diagnostic` by `LSPTextViewCoordinator`; the Core model never /// imports the LSP module. -public struct DocumentDiagnostic: Equatable, Sendable { +public struct DocumentDiagnostic: Equatable, Sendable, Identifiable { /// Offset range of the diagnostic, matching `Document` offsets. public let range: TextSelection public let severity: DocumentDiagnosticSeverity public let message: String public let source: String? + public var id: String { + "\(range.start):\(range.end):\(severity.rawValue):\(source ?? ""):\(message)" + } + public init(range: TextSelection, severity: DocumentDiagnosticSeverity, message: String, source: String? = nil) { self.range = range self.severity = severity diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 3f753da..1bb6aed 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -43,6 +43,9 @@ public final class AppState { // Recent Projects public var recentProjects: [RecentProject] = [] + // Diagnostic navigation + public var diagnosticJumpRequest: DiagnosticJumpRequest? + /// Terminal panel height in points, persisted. Drag-resized, never fixed. public var terminalHeight: Double { didSet { @@ -526,6 +529,16 @@ public final class AppState { } } + // MARK: - Diagnostic navigation + + /// Navigates the editor to the specified diagnostic, focusing the document if needed. + public func jumpTo(diagnostic: DocumentDiagnostic, in document: CodeEditorCore.Document? = nil) { + if let doc = document, documentManager.activeDocument?.id != doc.id { + documentManager.activate(doc) + } + diagnosticJumpRequest = DiagnosticJumpRequest(diagnostic: diagnostic) + } + /// Tear down every language server. Call on workspace switch and on quit. public func disconnectLanguageServers() { let clients = Array(languageServers.values) @@ -537,6 +550,17 @@ public final class AppState { } } +/// Carries a diagnostic jump target with a unique identity so repeat clicks trigger. +public struct DiagnosticJumpRequest: Equatable, Sendable { + public let id: UUID + public let diagnostic: DocumentDiagnostic + + public init(diagnostic: DocumentDiagnostic) { + self.id = UUID() + self.diagnostic = diagnostic + } +} + // MARK: - DocumentManagerDelegate extension AppState: DocumentManagerDelegate { diff --git a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift index 0192657..d6c78fd 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift @@ -9,6 +9,9 @@ import SwiftUI public struct StatusBarView: View { let appState: AppState + @State private var isDiagnosticsPopoverPresented = false + @State private var isDiagnosticsHovered = false + public init(appState: AppState) { self.appState = appState } @@ -80,15 +83,47 @@ public struct StatusBarView: View { .fixedSize() } - /// A compact error/warning count, coloured by the worst severity present. + /// A compact error/warning count button, coloured by the worst severity present. + /// Clicking opens a scrollable pop-up list of problems with click-to-jump navigation. @ViewBuilder private func diagnosticSummary(for document: CodeEditorCore.Document) -> some View { let errors = document.diagnostics.filter { $0.severity == .error }.count let warnings = document.diagnostics.filter { $0.severity == .warning }.count let color: Color = errors > 0 ? appState.theme.semantic.danger.color : appState.theme.semantic.warning.color - Text("\(errors) error\(errors == 1 ? "" : "s"), \(warnings) warning\(warnings == 1 ? "" : "s")") + + Button { + isDiagnosticsPopoverPresented.toggle() + } label: { + HStack(spacing: 4) { + if errors > 0 { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + } else if warnings > 0 { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10, weight: .bold)) + } + Text("\(errors) error\(errors == 1 ? "" : "s"), \(warnings) warning\(warnings == 1 ? "" : "s")") + } .foregroundStyle(color) .padding(.vertical, 2) + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(isDiagnosticsHovered ? appState.theme.chrome.rowHover.color : Color.clear) + ) .fixedSize() + } + .buttonStyle(.plain) + .onHover { isDiagnosticsHovered = $0 } + .popover(isPresented: $isDiagnosticsPopoverPresented, arrowEdge: .bottom) { + DiagnosticsListView( + document: document, + theme: appState.theme, + onSelect: { diagnostic in + appState.jumpTo(diagnostic: diagnostic, in: document) + isDiagnosticsPopoverPresented = false + } + ) + } } } diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index 930aabe..cd177e9 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -78,6 +78,16 @@ public struct CodeEditorView: View { .onChange(of: editorState.isFindVisible) { _, visible in sourceEditorState.findPanelVisible = visible } + .onChange(of: appState.diagnosticJumpRequest) { _, request in + guard let request else { return } + let diagnostic = request.diagnostic + if let range = document.nsRange(from: diagnostic.range.start, to: diagnostic.range.end) { + lspCoordinator.jumpTo(range: range) + document.setSelection(from: diagnostic.range.start, to: diagnostic.range.end) + editorState.syncSelection(of: document) + } + appState.diagnosticJumpRequest = nil + } } // MARK: - Configuration diff --git a/Sources/CodeEditorUI/Sources/Editor/DiagnosticsListView.swift b/Sources/CodeEditorUI/Sources/Editor/DiagnosticsListView.swift new file mode 100644 index 0000000..907514d --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Editor/DiagnosticsListView.swift @@ -0,0 +1,210 @@ +import AppKit +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +/// Scrollable list of problems/diagnostics for the active document. +/// Displayed as a popover when clicking the diagnostic summary in the status bar. +public struct DiagnosticsListView: View { + public let document: CodeEditorCore.Document + public let theme: Theme + public let onSelect: (DocumentDiagnostic) -> Void + + @State private var hoveredDiagnosticId: String? + + public init( + document: CodeEditorCore.Document, + theme: Theme, + onSelect: @escaping (DocumentDiagnostic) -> Void + ) { + self.document = document + self.theme = theme + self.onSelect = onSelect + } + + /// Diagnostics sorted by line ascending, then column ascending. + private var sortedDiagnostics: [DocumentDiagnostic] { + document.diagnostics.sorted { lhs, rhs in + let lhsPos = document.lineAndColumn(for: lhs.range.start) ?? (line: 0, column: 0) + let rhsPos = document.lineAndColumn(for: rhs.range.start) ?? (line: 0, column: 0) + if lhsPos.line != rhsPos.line { + return lhsPos.line < rhsPos.line + } + if lhs.severity.rawValue != rhs.severity.rawValue { + return lhs.severity.rawValue < rhs.severity.rawValue + } + return lhsPos.column < rhsPos.column + } + } + + private var errorsCount: Int { + document.diagnostics.filter { $0.severity == .error }.count + } + + private var warningsCount: Int { + document.diagnostics.filter { $0.severity == .warning }.count + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + if sortedDiagnostics.isEmpty { + emptyState + } else { + diagnosticScrollView + } + } + .frame(minWidth: 380, idealWidth: 420, maxWidth: 460) + .background(theme.chrome.overlayBackground.color) + } + + private var header: some View { + HStack(spacing: Metrics.Space.compact) { + Image(systemName: "checklist") + .foregroundStyle(theme.secondaryText.color) + .font(.system(size: 13, weight: .semibold)) + + Text("Problems") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(theme.text.color) + + if !document.title.isEmpty { + Text("— \(document.title)") + .font(.system(size: 12)) + .foregroundStyle(theme.secondaryText.color) + .lineLimit(1) + } + + Spacer(minLength: Metrics.Space.compact) + + HStack(spacing: 8) { + if errorsCount > 0 { + HStack(spacing: 3) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(theme.semantic.danger.color) + .font(.system(size: 11)) + Text("\(errorsCount)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.semantic.danger.color) + } + } + if warningsCount > 0 { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(theme.semantic.warning.color) + .font(.system(size: 11)) + Text("\(warningsCount)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.semantic.warning.color) + } + } + } + } + .padding(.horizontal, Metrics.Space.regular) + .padding(.vertical, Metrics.Space.compact) + } + + private var emptyState: some View { + HStack(spacing: Metrics.Space.compact) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(theme.semantic.success.color) + .font(.system(size: 15)) + Text("No problems detected in this file") + .font(.system(size: 12)) + .foregroundStyle(theme.secondaryText.color) + } + .padding(Metrics.Space.regular) + .frame(maxWidth: .infinity, alignment: .center) + } + + private var diagnosticScrollView: some View { + ScrollView(.vertical, showsIndicators: true) { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(sortedDiagnostics) { diag in + diagnosticRow(for: diag) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + } + .frame(maxHeight: 280) + } + + @ViewBuilder + private func diagnosticRow(for diag: DocumentDiagnostic) -> some View { + let isHovered = hoveredDiagnosticId == diag.id + let lineCol = document.lineAndColumn(for: diag.range.start) + let line = (lineCol?.line ?? 0) + 1 + let col = (lineCol?.column ?? 0) + 1 + + Button { + onSelect(diag) + } label: { + HStack(alignment: .top, spacing: Metrics.Space.compact) { + Image(systemName: iconName(for: diag.severity)) + .foregroundStyle(iconColor(for: diag.severity)) + .font(.system(size: 12, weight: .semibold)) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("Ln \(line), Col \(col)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.secondaryText.color) + + if let source = diag.source, !source.isEmpty { + Text(source) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(theme.secondaryText.color) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(theme.chrome.border.color.opacity(0.3)) + ) + } + + Spacer(minLength: 0) + } + + Text(diag.message) + .font(.system(size: 12)) + .foregroundStyle(theme.text.color) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isHovered ? theme.chrome.rowHover.color : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + hoveredDiagnosticId = hovering ? diag.id : (hoveredDiagnosticId == diag.id ? nil : hoveredDiagnosticId) + } + } + + private func iconName(for severity: DocumentDiagnosticSeverity) -> String { + switch severity { + case .error: return "xmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .information: return "info.circle.fill" + case .hint: return "lightbulb.fill" + } + } + + private func iconColor(for severity: DocumentDiagnosticSeverity) -> Color { + switch severity { + case .error: return theme.semantic.danger.color + case .warning: return theme.semantic.warning.color + case .information: return theme.semantic.success.color + case .hint: return theme.secondaryText.color + } + } +} diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index 50ab167..01f06b6 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -336,6 +336,42 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, crashShown = false } + // MARK: - Navigation & Jump + + private static let jumpEmphasisGroupId = "CodeEditor.diagnosticJump" + + /// Centers and selects the target range in the editor, flashing an emphasis highlight. + func jumpTo(range: NSRange) { + guard let tv = controller?.textView else { return } + let docLength = (tv.string as NSString).length + guard docLength > 0 else { return } + + var targetRange = range + if targetRange.location >= docLength { + targetRange = NSRange(location: max(0, docLength - 1), length: 1) + } + + var effectiveRange = targetRange + if effectiveRange.length == 0 { + effectiveRange = expandedTokenRange( + at: effectiveRange.location, in: tv.string as NSString, docLength: docLength) + } else if effectiveRange.location + effectiveRange.length > docLength { + effectiveRange.length = docLength - effectiveRange.location + } + + tv.selectionManager.setSelectedRange(targetRange) + tv.scrollToRange(targetRange, center: true) + tv.window?.makeFirstResponder(tv) + + let jumpEmphasis = Emphasis( + range: effectiveRange, + style: .standard, + flash: true, + selectInDocument: true + ) + tv.emphasisManager?.addEmphasis(jumpEmphasis, for: Self.jumpEmphasisGroupId) + } + // MARK: - JumpToDefinitionDelegate /// Builds a definition link at the hovered/⌘-clicked range. Also fetches a hover diff --git a/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift index a0463a7..d00f405 100644 --- a/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift +++ b/Tests/CodeEditorTests/DocumentDiagnosticsTests.swift @@ -68,4 +68,67 @@ struct DocumentDiagnosticsTests { #expect(doc.diagnostics.isEmpty) #expect(doc.isModified) } + + @Test("DocumentDiagnostic Identifiable produces stable and distinguishing IDs") + func diagnosticIdentifiable() { + let diag1 = DocumentDiagnostic( + range: TextSelection(start: 10, end: 15), + severity: .error, + message: "syntax error", + source: "swift" + ) + let diag2 = DocumentDiagnostic( + range: TextSelection(start: 10, end: 15), + severity: .error, + message: "syntax error", + source: "swift" + ) + let diag3 = DocumentDiagnostic( + range: TextSelection(start: 20, end: 25), + severity: .warning, + message: "unused variable", + source: "swift" + ) + + #expect(diag1.id == diag2.id) + #expect(diag1.id != diag3.id) + #expect(!diag1.id.isEmpty) + } + + @Test("document diagnostic sorting orders by line, severity, and column") + func diagnosticSorting() { + let doc = Document(content: "line 1\nline 2 error here\nline 3 warning") + let diag1 = DocumentDiagnostic( + range: TextSelection(start: 7, end: 13), // line 2 + severity: .warning, + message: "warning in line 2" + ) + let diag2 = DocumentDiagnostic( + range: TextSelection(start: 7, end: 13), // line 2 + severity: .error, + message: "error in line 2" + ) + let diag3 = DocumentDiagnostic( + range: TextSelection(start: 25, end: 32), // line 3 + severity: .error, + message: "error in line 3" + ) + let diags = [diag3, diag1, diag2] + + let sorted = diags.sorted { lhs, rhs in + let lhsPos = doc.lineAndColumn(for: lhs.range.start) ?? (line: 0, column: 0) + let rhsPos = doc.lineAndColumn(for: rhs.range.start) ?? (line: 0, column: 0) + if lhsPos.line != rhsPos.line { + return lhsPos.line < rhsPos.line + } + if lhs.severity.rawValue != rhs.severity.rawValue { + return lhs.severity.rawValue < rhs.severity.rawValue + } + return lhsPos.column < rhsPos.column + } + + #expect(sorted[0].message == "error in line 2") + #expect(sorted[1].message == "warning in line 2") + #expect(sorted[2].message == "error in line 3") + } } diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 4387509..6526b47 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -34,7 +34,7 @@ The window was rebuilt around native macOS chrome. The design tokens live in | **Sidebar** | Click or arrow to a row to activate: files open, folders expand. The active document is revealed by expanding its ancestors, shown bold with an accent icon. Delete asks first. | | **Tabs** | 32pt strip, active tab filled with the canvas colour and rounded so it merges with the editor. Dirty dot beside the title; close button always present. | | **Status bar** | Caret, selection count, UTF-8, line ending, language, a live error/warning - count from LSP diagnostics, and a "Changed on disk" hint while a save conflict is + count from LSP diagnostics (clicking opens a scrollable Problems popover with click-to-jump navigation and line emphasis), and a "Changed on disk" hint while a save conflict is pending. The byte count is gone. | | **Empty state** | One, in the canvas. With a workspace open it points at the sidebar; without one it offers Open Folder as the single prominent action. | | **Find/replace** | ⌘F opens CodeEdit's own panel through `SourceEditorState.findPanelVisible`. |