Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Sources/CodeEditorCore/Sources/Document/Diagnostic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions Sources/CodeEditorUI/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
39 changes: 37 additions & 2 deletions Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
)
}
}
}
10 changes: 10 additions & 0 deletions Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
210 changes: 210 additions & 0 deletions Sources/CodeEditorUI/Sources/Editor/DiagnosticsListView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
36 changes: 36 additions & 0 deletions Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading