Skip to content

Issue Navigator #2006

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.098",
"green" : "0.161",
"red" : "0.725"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.255",
"green" : "0.231",
"red" : "0.855"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.114",
"green" : "0.741",
"red" : "0.965"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.271",
"green" : "0.784",
"red" : "0.965"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
253 changes: 253 additions & 0 deletions CodeEdit/Features/Diagnostics/DiagnosticsManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//
// DiagnosticsManager.swift
// CodeEdit
//
// Created by Abe Malla on 3/15/25.
//

import Combine
import SwiftUI
import Foundation
import LanguageServerProtocol

class DiagnosticsManager: ObservableObject {
@Published var rootNode: ProjectIssueNode?
@Published var filterOptions = IssueFilterOptions()
@Published private(set) var filteredRootNode: ProjectIssueNode?

let diagnosticsDidChangePublisher = PassthroughSubject<Void, Never>()

private var fileNodesByUri: [DocumentUri: FileIssueNode] = [:]
private var expandedFileUris: Set<DocumentUri> = []

func initialize(projectName: String) {
self.rootNode = ProjectIssueNode(name: projectName)
self.filteredRootNode = ProjectIssueNode(name: projectName)
}

func updateDiagnostics(params: PublishDiagnosticsParams) {
let uri = params.uri
let diagnostics = params.diagnostics

if diagnostics.isEmpty {
// Remove the file node if no diagnostics
fileNodesByUri.removeValue(forKey: uri)
expandedFileUris.remove(uri)
} else {
// Get or create file node
let fileNode: FileIssueNode
if let existingNode = fileNodesByUri[uri] {
fileNode = existingNode
fileNode.diagnostics.removeAll(keepingCapacity: true)
} else {
// Create new file node
let fileName = getFileName(from: uri)
fileNode = FileIssueNode(uri: uri, name: fileName)
fileNodesByUri[uri] = fileNode
}

// Convert diagnostics to diagnostic nodes and add to file node
let diagnosticNodes = diagnostics.map { diagnostic in
DiagnosticIssueNode(diagnostic: diagnostic, fileUri: uri)
}

// Sort diagnostics by severity and line number
let sortedDiagnosticNodes = diagnosticNodes.sorted { node1, node2 in
let severity1 = node1.diagnostic.severity?.rawValue ?? Int.max
let severity2 = node2.diagnostic.severity?.rawValue ?? Int.max

if severity1 == severity2 {
// If same severity, sort by line number
return node1.diagnostic.range.start.line < node2.diagnostic.range.start.line
}

return severity1 < severity2
}

fileNode.diagnostics = sortedDiagnosticNodes

// Restore expansion state if it was previously expanded
if expandedFileUris.contains(uri) {
fileNode.isExpanded = true
}
}

rebuildTree()
diagnosticsDidChangePublisher.send()
}

func clearDiagnostics() {
fileNodesByUri.removeAll()
expandedFileUris.removeAll()
rebuildTree()
diagnosticsDidChangePublisher.send()
}

func removeDiagnostics(uri: DocumentUri) {
fileNodesByUri.removeValue(forKey: uri)
expandedFileUris.remove(uri)
rebuildTree()
diagnosticsDidChangePublisher.send()
}

func updateFilter(options: IssueFilterOptions) {
self.filterOptions = options
applyFilter()
diagnosticsDidChangePublisher.send()
}

/// Save expansion state for a file
func setFileExpanded(_ uri: DocumentUri, isExpanded: Bool) {
if isExpanded {
expandedFileUris.insert(uri)
} else {
expandedFileUris.remove(uri)
}

if let fileNode = fileNodesByUri[uri] {
fileNode.isExpanded = isExpanded
}
}

/// Get all expanded file URIs for persistence
func getExpandedFileUris() -> Set<DocumentUri> {
return expandedFileUris
}

/// Restore expansion state from persisted data
func restoreExpandedFileUris(_ uris: Set<DocumentUri>) {
expandedFileUris = uris

// Apply to existing file nodes
for uri in uris {
if let fileNode = fileNodesByUri[uri] {
fileNode.isExpanded = true
}
}
}

private func applyFilter() {
guard let rootNode else { return }
let filteredRoot = ProjectIssueNode(name: rootNode.name)
filteredRoot.isExpanded = rootNode.isExpanded

// Filter files and diagnostics
for fileNode in rootNode.files {
let filteredDiagnostics = fileNode.diagnostics.filter {
filterOptions.shouldShow(diagnostic: $0.diagnostic)
}

if !filteredDiagnostics.isEmpty {
let filteredFileNode = FileIssueNode(
uri: fileNode.uri,
name: fileNode.name,
diagnostics: filteredDiagnostics,
isExpanded: fileNode.isExpanded
)
filteredRoot.files.append(filteredFileNode)
}
}

filteredRoot.files.sort { $0.name < $1.name }
filteredRootNode = filteredRoot
}

/// Rebuilds the tree structure based on current diagnostics
private func rebuildTree() {
guard let rootNode else { return }

let projectExpanded = rootNode.isExpanded
let sortedFileNodes = fileNodesByUri.values
.sorted { $0.name < $1.name }

rootNode.files = sortedFileNodes
rootNode.isExpanded = projectExpanded

applyFilter()
}

/// Extracts file name from document URI
private func getFileName(from uri: DocumentUri) -> String {
if let url = URL(string: uri) {
return url.lastPathComponent
}

let components = uri.split(separator: "/")
return String(components.last ?? "Unknown")
}

func getAllDiagnostics() -> [Diagnostic] {
return fileNodesByUri.values.flatMap { fileNode in
fileNode.diagnostics.map { $0.diagnostic }
}
}

func getDiagnostics(for uri: DocumentUri) -> [Diagnostic]? {
return fileNodesByUri[uri]?.diagnostics.map { $0.diagnostic }
}

func getFileNode(for uri: DocumentUri) -> FileIssueNode? {
return fileNodesByUri[uri]
}

func getDiagnosticCountBySeverity() -> [DiagnosticSeverity?: Int] {
let allDiagnostics = getAllDiagnostics()
var countBySeverity: [DiagnosticSeverity?: Int] = [:]

for severity in DiagnosticSeverity.allCases {
countBySeverity[severity] = allDiagnostics.filter { $0.severity == severity }.count
}

countBySeverity[nil] = allDiagnostics.filter { $0.severity == nil }.count
return countBySeverity
}

func getDiagnosticAt(uri: DocumentUri, line: Int, character: Int) -> Diagnostic? {
guard let fileNode = fileNodesByUri[uri] else { return nil }

return fileNode.diagnostics.first { diagnosticNode in
let range = diagnosticNode.diagnostic.range

// Check if position is within the diagnostic range
if line < range.start.line || line > range.end.line {
return false
}
if line == range.start.line && character < range.start.character {
return false
}
if line == range.end.line && character > range.end.character {
return false
}
return true
}?.diagnostic
}
}

/// Options for filtering diagnostics in the issue navigator
struct IssueFilterOptions {
var showErrors: Bool = true
var showWarnings: Bool = true
var showInformation: Bool = true
var showHints: Bool = true
var searchText: String = ""

func shouldShow(diagnostic: Diagnostic) -> Bool {
if let severity = diagnostic.severity {
switch severity {
case .error:
guard showErrors else { return false }
case .warning:
guard showWarnings else { return false }
case .information:
guard showInformation else { return false }
case .hint:
guard showHints else { return false }
}
}

if !searchText.isEmpty {
return diagnostic.message.lowercased().contains(searchText.lowercased())
}
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
var editorManager: EditorManager? = EditorManager()
var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel()
var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel()
var diagnosticsManager: DiagnosticsManager? = DiagnosticsManager()
var searchState: SearchState?
var openQuicklyViewModel: OpenQuicklyViewModel?
var commandsPaletteState: QuickActionsViewModel?
Expand Down Expand Up @@ -162,6 +163,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {

editorManager?.restoreFromState(self)
utilityAreaModel?.restoreFromState(self)

diagnosticsManager?.initialize(projectName: displayName)
}

override func read(from url: URL, ofType typeName: String) throws {
Expand Down
2 changes: 1 addition & 1 deletion CodeEdit/Features/Editor/Models/EditorManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class EditorManager: ObservableObject {
/// History of last-used editors.
var activeEditorHistory: Deque<() -> Editor?> = []

/// notify listeners whenever tab selection changes on the active editor.
/// Notify listeners whenever tab selection changes on the active editor.
var tabBarTabIdSubject = PassthroughSubject<String?, Never>()
var cancellable: AnyCancellable?

Expand Down
6 changes: 6 additions & 0 deletions CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class LanguageServer {
let binary: LanguageServerBinary
/// A cache to hold responses from the server, to minimize duplicate server requests
let lspCache = LSPCache()
/// The workspace document that this server is associated with
let workspace: WorkspaceDocument

/// Tracks documents and their associated objects.
/// Use this property when adding new objects that need to track file data, or have a state associated with the
Expand All @@ -41,12 +43,14 @@ class LanguageServer {
init(
languageId: LanguageIdentifier,
binary: LanguageServerBinary,
workspace: WorkspaceDocument,
lspInstance: InitializingServer,
serverCapabilities: ServerCapabilities,
rootPath: URL
) {
self.languageId = languageId
self.binary = binary
self.workspace = workspace
self.lspInstance = lspInstance
self.serverCapabilities = serverCapabilities
self.rootPath = rootPath
Expand All @@ -71,6 +75,7 @@ class LanguageServer {
static func createServer(
for languageId: LanguageIdentifier,
with binary: LanguageServerBinary,
workspace: WorkspaceDocument,
workspacePath: String
) async throws -> LanguageServer {
let executionParams = Process.ExecutionParameters(
Expand All @@ -87,6 +92,7 @@ class LanguageServer {
return LanguageServer(
languageId: languageId,
binary: binary,
workspace: workspace,
lspInstance: server,
serverCapabilities: capabilities,
rootPath: URL(filePath: workspacePath)
Expand Down
Loading
Loading