From bee555c8d8a5ff6e48031cf9a156d9def57c7a35 Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 14 Mar 2025 18:52:36 -0700 Subject: [PATCH 01/15] Added issue navigator menu --- .../IssueNavigator/IssueNavigatorView.swift | 16 ++++++++++++++++ .../NavigatorArea/Models/NavigatorTab.swift | 7 +++++++ .../NavigatorArea/Views/NavigatorAreaView.swift | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift new file mode 100644 index 000000000..79d047acf --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift @@ -0,0 +1,16 @@ +// +// IssueNavigatorView.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +import SwiftUI + +struct IssueNavigatorView: View { + var body: some View { + VStack { + Spacer() + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift b/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift index f8d240e79..1411b0bb5 100644 --- a/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift +++ b/CodeEdit/Features/NavigatorArea/Models/NavigatorTab.swift @@ -13,6 +13,7 @@ enum NavigatorTab: WorkspacePanelTab { case project case sourceControl case search + case issues case uiExtension(endpoint: AppExtensionIdentity, data: ResolvedSidebar.SidebarStore) var systemImage: String { @@ -23,6 +24,8 @@ enum NavigatorTab: WorkspacePanelTab { return "vault" case .search: return "magnifyingglass" + case .issues: + return "exclamationmark.triangle" case .uiExtension(_, let data): return data.icon ?? "e.square" } @@ -43,6 +46,8 @@ enum NavigatorTab: WorkspacePanelTab { return "Source Control" case .search: return "Search" + case .issues: + return "Issues" case .uiExtension(_, let data): return data.help ?? data.sceneID } @@ -56,6 +61,8 @@ enum NavigatorTab: WorkspacePanelTab { SourceControlNavigatorView() case .search: FindNavigatorView() + case .issues: + IssueNavigatorView() case let .uiExtension(endpoint, data): ExtensionSceneView(with: endpoint, sceneID: data.sceneID) } diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift index a3fdabf65..085efc0a6 100644 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift +++ b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift @@ -19,7 +19,7 @@ struct NavigatorAreaView: View { self.workspace = workspace self.viewModel = viewModel - viewModel.tabItems = [.project, .sourceControl, .search] + + viewModel.tabItems = [.project, .sourceControl, .search, .issues] + extensionManager .extensions .map { ext in From 7dd8256f720dbb6b8ca652396e7c89b4901a9d63 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 1 Apr 2025 19:41:44 -0700 Subject: [PATCH 02/15] First implementation of issue navigator --- .../WorkspaceDocument/WorkspaceDocument.swift | 4 + .../LSP/LanguageServer/LanguageServer.swift | 6 + .../LSP/Service/LSPService+Events.swift | 45 ++- .../Features/LSP/Service/LSPService.swift | 8 +- .../IssueNavigator/IssueNavigatorView.swift | 9 +- .../IssueNavigatorOutlineView.swift | 31 ++ ...ewController+NSOutlineViewDataSource.swift | 53 +++ ...ViewController+NSOutlineViewDelegate.swift | 59 +++ .../IssueNavigatorViewController.swift | 91 +++++ .../OutlineView/IssueTableViewCell.swift | 8 + ...ViewController+NSOutlineViewDelegate.swift | 4 +- .../ViewModels/IssueNavigatorViewModel.swift | 361 ++++++++++++++++++ 12 files changed, 658 insertions(+), 21 deletions(-) create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift create mode 100644 CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift create mode 100644 CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 1b96e4fca..5cefc8876 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -33,6 +33,10 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var editorManager: EditorManager? = EditorManager() var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel() var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel() + // TODO: GRAB PROJECT NAME FROM ROOT FOLDER + var issueNavigatorViewModel: IssueNavigatorViewModel? = IssueNavigatorViewModel( + projectName: "Test" + ) var searchState: SearchState? var openQuicklyViewModel: OpenQuicklyViewModel? var commandsPaletteState: QuickActionsViewModel? diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index eab8be550..4b3d1db78 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -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 @@ -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 @@ -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( @@ -87,6 +92,7 @@ class LanguageServer { return LanguageServer( languageId: languageId, binary: binary, + workspace: workspace, lspInstance: server, serverCapabilities: capabilities, rootPath: URL(filePath: workspacePath) diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index b4baa73bb..d259e84eb 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -18,7 +18,7 @@ extension LSPService { // Create a new Task to listen to the events let task = Task.detached { [weak self] in for await event in languageClient.lspInstance.eventSequence { - await self?.handleEvent(event, for: key) + await self?.handleEvent(event, for: languageClient) } } eventListeningTasks[key] = task @@ -31,20 +31,28 @@ extension LSPService { } } - private func handleEvent(_ event: ServerEvent, for key: ClientKey) { + private func handleEvent( + _ event: ServerEvent, + for languageClient: LanguageServer + ) { // TODO: Handle Events -// switch event { + switch event { // case let .request(id, request): -// print("Request ID: \(id) for \(key.languageId.rawValue)") -// handleRequest(request) -// case let .notification(notification): -// handleNotification(notification) +// print("Request ID: \(id) for \(languageClient.languageId.rawValue)") +// handleRequest(request, languageClient) + case let .notification(notification): + handleNotification(notification, languageClient) // case let .error(error): -// print("Error from EventStream for \(key.languageId.rawValue): \(error)") -// } +// print("Error from EventStream for \(languageClient.languageId.rawValue): \(error)") + default: + return + } } - private func handleRequest(_ request: ServerRequest) { + private func handleRequest( + _ request: ServerRequest, + _ languageClient: LanguageServer + ) { // TODO: Handle Requests // switch request { // case let .workspaceConfiguration(params, _): @@ -73,15 +81,20 @@ extension LSPService { // } } - private func handleNotification(_ notification: ServerNotification) { + private func handleNotification( + _ notification: ServerNotification, + _ languageClient: LanguageServer + ) { // TODO: Handle Notifications -// switch notification { + switch notification { // case let .windowLogMessage(params): // print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") // case let .windowShowMessage(params): // print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") -// case let .textDocumentPublishDiagnostics(params): -// print("textDocumentPublishDiagnostics: \(params)") + case let .textDocumentPublishDiagnostics(params): + print("textDocumentPublishDiagnostics: \(params.diagnostics)") + languageClient.workspace.issueNavigatorViewModel? + .updateDiagnostics(params: params) // case let .telemetryEvent(params): // print("telemetryEvent: \(params)") // case let .protocolCancelRequest(params): @@ -90,6 +103,8 @@ extension LSPService { // print("protocolProgress: \(params)") // case let .protocolLogTrace(params): // print("protocolLogTrace: \(params)") -// } + default: + return + } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 5110c6c3e..fe6d10ec2 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -173,6 +173,7 @@ final class LSPService: ObservableObject { /// - Returns: The new language server. func startServer( for languageId: LanguageIdentifier, + workspace: WorkspaceDocument, workspacePath: String ) async throws -> LanguageServer { guard let serverBinary = languageConfigs[languageId] else { @@ -184,6 +185,7 @@ final class LSPService: ObservableObject { let server = try await LanguageServer.createServer( for: languageId, with: serverBinary, + workspace: workspace, workspacePath: workspacePath ) languageClients[ClientKey(languageId, workspacePath)] = server @@ -208,7 +210,11 @@ final class LSPService: ObservableObject { if let server = self.languageClients[ClientKey(lspLanguage, workspacePath)] { languageServer = server } else { - languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) + languageServer = try await self.startServer( + for: lspLanguage, + workspace: workspace, + workspacePath: workspacePath + ) } } catch { // swiftlint:disable:next line_length diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift index 79d047acf..ceb61728f 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift @@ -7,10 +7,13 @@ import SwiftUI +/// # Issue Navigator - Sidebar +/// +/// A list that functions as an issue navigator, showing collapsible issues +/// within a project. +/// struct IssueNavigatorView: View { var body: some View { - VStack { - Spacer() - } + IssueNavigatorOutlineView() } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift new file mode 100644 index 000000000..ccaa240c4 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift @@ -0,0 +1,31 @@ +// +// IssueNavigatorOutlineView.swift +// CodeEdit +// +// Created by Abe Malla on 3/16/25. +// + +import SwiftUI +import Combine + +/// Wraps an ``OutlineViewController`` inside a `NSViewControllerRepresentable` +struct IssueNavigatorOutlineView: NSViewControllerRepresentable { + + @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject var editorManager: EditorManager + + @StateObject var prefs: Settings = .shared + + typealias NSViewControllerType = IssueNavigatorViewController + + func makeNSViewController(context: Context) -> IssueNavigatorViewController { + let controller = IssueNavigatorViewController() + controller.workspace = workspace + controller.editor = editorManager.activeEditor + return controller + } + + func updateNSViewController(_ nsViewController: IssueNavigatorViewController, context: Context) { + nsViewController.rowHeight = prefs.preferences.general.projectNavigatorSize.rowHeight + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift new file mode 100644 index 000000000..d7ece97bf --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift @@ -0,0 +1,53 @@ +// +// IssueNavigatorViewController+NSOutlineViewDataSource.swift +// CodeEdit +// +// Created by Abe Malla on 3/16/25. +// + +import AppKit + +extension IssueNavigatorViewController: NSOutlineViewDataSource { + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { + // Always show the project node + return 1 + } + if let node = item as? ProjectIssueNode { + return node.files.count + } + if let node = item as? FileIssueNode { + return node.diagnostics.count + } + return 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { + return workspace?.issueNavigatorViewModel?.filteredRootNode as Any + } + if let node = item as? ProjectIssueNode { + return node.files[index] + } + if let node = item as? FileIssueNode { + return node.diagnostics[index] + } + + fatalError("Unexpected item type in IssueNavigator outlineView") + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + if let node = item as? any IssueNode { + return node.isExpandable + } + return false + } + + func outlineView( + _ outlineView: NSOutlineView, + objectValueFor tableColumn: NSTableColumn?, + byItem item: Any? + ) -> Any? { + return item + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift new file mode 100644 index 000000000..2f21e8971 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -0,0 +1,59 @@ +// +// IssueNavigatorViewController+NSOutlineViewDelegate.swift +// CodeEdit +// +// Created by Abe Malla on 3/16/25. +// + +import AppKit + +extension IssueNavigatorViewController: NSOutlineViewDelegate { + func outlineView( + _ outlineView: NSOutlineView, + shouldShowCellExpansionFor tableColumn: NSTableColumn?, + item: Any + ) -> Bool { + true + } + + func outlineView(_ outlineView: NSOutlineView, shouldShowOutlineCellForItem item: Any) -> Bool { + true + } + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let tableColumn else { return nil } + + let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight) + let cell = StandardTableViewCell(frame: frameRect) + if let node = item as? (any IssueNode) { + cell.configLabel( + label: NSTextField(string: node.name), + isEditable: false + ) + } + return cell + } + + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + if item is DiagnosticIssueNode { + let lines = Double(Settings.shared.preferences.general.issueNavigatorDetail.rawValue) + return rowHeight * lines + } + return rowHeight // This can be changed to 20 to match Xcode's row height. + } + + /// Adds a tooltip to the issue row. + func outlineView( // swiftlint:disable:this function_parameter_count + _ outlineView: NSOutlineView, + toolTipFor cell: NSCell, + rect: NSRectPointer, + tableColumn: NSTableColumn?, + item: Any, + mouseLocation: NSPoint + ) -> String { + if let node = item as? (any IssueNode) { + return node.name + } + return "" + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift new file mode 100644 index 000000000..6b8426d53 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -0,0 +1,91 @@ +// +// IssueNavigatorViewController.swift +// CodeEdit +// +// Created by Abe Malla on 3/15/25. +// + +import OSLog +import AppKit +import SwiftUI + +/// A `NSViewController` that handles the **IssueNavigatorView** in the **NavigatorArea**. +/// +/// Adds a ``outlineView`` inside a ``scrollView`` which shows the issues in a project. +final class IssueNavigatorViewController: NSViewController { + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "IssueNavigatorViewController" + ) + + var scrollView: NSScrollView! + var outlineView: NSOutlineView! + + /// A set of files with their issues expanded + var expandedItems: Set = [] + + weak var workspace: WorkspaceDocument? + weak var editor: Editor? + + var rowHeight: Double = 22 { + willSet { + if newValue != rowHeight { + outlineView.rowHeight = newValue + outlineView.reloadData() + } + } + } + + /// Setup the ``scrollView`` and ``outlineView`` + override func loadView() { + self.scrollView = NSScrollView() + self.scrollView.hasVerticalScroller = true + self.view = scrollView + + self.outlineView = NSOutlineView() + self.outlineView.dataSource = self + self.outlineView.delegate = self + self.outlineView.autosaveExpandedItems = false + self.outlineView.headerView = nil + self.outlineView.allowsMultipleSelection = true + + self.outlineView.setAccessibilityIdentifier("IssueNavigator") + self.outlineView.setAccessibilityLabel("Issue Navigator") + + let column = NSTableColumn(identifier: .init(rawValue: "Cell")) + column.title = "Cell" + outlineView.addTableColumn(column) + + scrollView.documentView = outlineView + scrollView.contentView.automaticallyAdjustsContentInsets = false + scrollView.contentView.contentInsets = .init(top: 10, left: 0, bottom: 0, right: 0) + scrollView.scrollerStyle = .overlay + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + + outlineView.expandItem(outlineView.item(atRow: 0)) + + /// Get autosave expanded items. + for row in 0.. FileIssueNode? in + guard !diagnostics.isEmpty else { return nil } + + let fileName = getFileName(from: uri) + let diagnosticNodes = diagnostics.map { DiagnosticIssueNode(diagnostic: $0, fileUri: uri) } + + // Sort diagnostics by severity + 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 + } + + let fileNode = FileIssueNode(uri: uri, name: fileName, diagnostics: sortedDiagnosticNodes) + fileNode.isExpanded = expandedFileUris.contains(uri) + return fileNode + } + + let sortedFileNodes = fileNodes.sorted { $0.name < $1.name } + rootNode.files = sortedFileNodes + 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 ?? "") + } + + func getAllDiagnostics() -> [Diagnostic] { + return diagnosticsByFile.values.flatMap { $0 } + } + + 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 diagnostics = diagnosticsByFile[uri] else { return nil } + + return diagnostics.first { diagnostic in + let range = 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 + } + } +} + +/// Protocol defining the common interface for nodes in the issue navigator +protocol IssueNode: Identifiable, Hashable { + var id: UUID { get } + var name: String { get } + var icon: Image { get } + var isExpandable: Bool { get } +} + +/// Represents the project (root) node in the issue navigator +class ProjectIssueNode: IssueNode, ObservableObject { + let id: UUID = UUID() + let name: String + @Published var files: [FileIssueNode] + @Published var isExpanded: Bool + + var icon: Image { + Image(systemName: "folder") + } + + var isExpandable: Bool { + !files.isEmpty + } + + var diagnosticsCount: Int { + files.reduce(0) { $0 + $1.diagnostics.count } + } + + var errorCount: Int { + files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .error }.count } + } + + var warningCount: Int { + files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .warning }.count } + } + + init(name: String, files: [FileIssueNode] = [], isExpanded: Bool = true) { + self.name = name + self.files = files + self.isExpanded = isExpanded + } + + static func == (lhs: ProjectIssueNode, rhs: ProjectIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Represents a file node in the issue navigator +class FileIssueNode: IssueNode, ObservableObject { + let id: UUID = UUID() + let uri: DocumentUri + let name: String + @Published var diagnostics: [DiagnosticIssueNode] + @Published var isExpanded: Bool + + // TODO: REPLACE WITH EXISTING CODE + var icon: Image { + // Determine icon based on file extension + let fileExtension = (name as NSString).pathExtension.lowercased() + + switch fileExtension { + case "swift": + return Image(systemName: "swift") + case "js", "javascript": + return Image(systemName: "j.square") + case "html": + return Image(systemName: "h.square") + case "css": + return Image(systemName: "c.square") + case "md", "markdown": + return Image(systemName: "doc.text") + case "json": + return Image(systemName: "curlybraces") + case "txt": + return Image(systemName: "doc.text") + default: + return Image(systemName: "doc") + } + } + + var isExpandable: Bool { + !diagnostics.isEmpty + } + + var errorCount: Int { + diagnostics.filter { $0.diagnostic.severity == .error }.count + } + + var warningCount: Int { + diagnostics.filter { $0.diagnostic.severity == .warning }.count + } + + init(uri: DocumentUri, name: String, diagnostics: [DiagnosticIssueNode] = [], isExpanded: Bool = true) { + self.uri = uri + self.name = name + self.diagnostics = diagnostics + self.isExpanded = isExpanded + } + + static func == (lhs: FileIssueNode, rhs: FileIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Represents a diagnostic node in the issue navigator +class DiagnosticIssueNode: IssueNode, ObservableObject { + let id: UUID = UUID() + let diagnostic: Diagnostic + let fileUri: DocumentUri + + var name: String { + diagnostic.message + } + + var isExpandable: Bool { + false + } + + var icon: Image { + switch diagnostic.severity { + case .error: + return Image(systemName: "exclamationmark.circle.fill") + case .warning: + return Image(systemName: "exclamationmark.triangle.fill") + case .information: + return Image(systemName: "info.circle.fill") + case .hint: + return Image(systemName: "lightbulb.fill") + case nil: + return Image(systemName: "circle.fill") + } + } + + var severityColor: NSColor { + switch diagnostic.severity { + case .error: + return .red + case .warning: + return .yellow + case .information: + return .blue + case .hint: + return .gray + case nil: + return .secondaryLabelColor + } + } + + var locationString: String { + "Line \(diagnostic.range.start.line + 1), Column \(diagnostic.range.start.character + 1)" + } + + init(diagnostic: Diagnostic, fileUri: DocumentUri) { + self.diagnostic = diagnostic + self.fileUri = fileUri + } + + static func == (lhs: DiagnosticIssueNode, rhs: DiagnosticIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// 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 + } +} From 35293743da5b0a9a2163d55e3dd2417cdb95898d Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 1 Apr 2025 20:36:21 -0700 Subject: [PATCH 03/15] Styling updates --- ...ViewController+NSOutlineViewDelegate.swift | 10 +-- .../OutlineView/IssueTableViewCell.swift | 90 +++++++++++++++++++ .../OutlineView/StandardTableViewCell.swift | 1 - .../ViewModels/IssueNavigatorViewModel.swift | 60 +++++++------ 4 files changed, 126 insertions(+), 35 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 2f21e8971..0656bcccc 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -24,13 +24,13 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { guard let tableColumn else { return nil } let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight) - let cell = StandardTableViewCell(frame: frameRect) + if let node = item as? (any IssueNode) { - cell.configLabel( - label: NSTextField(string: node.name), - isEditable: false - ) + let cell = IssueTableViewCell(frame: frameRect, node: node) + return cell } + + let cell = TextTableViewCell(frame: frameRect, startingText: "Unknown item") return cell } diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift index e872ba66b..eb4c2e60d 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift @@ -5,4 +5,94 @@ // Created by Abe Malla on 3/16/25. // +import SwiftUI +import AppKit +class IssueTableViewCell: StandardTableViewCell { + + private var nodeIconView: NSImageView? + private var detailLabel: NSTextField? + + var issueNode: (any IssueNode)? + + /// Initializes the `IssueTableViewCell` with the issue node item + /// - Parameters: + /// - frameRect: The frame of the cell. + /// - node: The issue node the cell represents. + /// - isEditable: Set to true if the user should be able to edit the name (rarely used for issues). + init(frame frameRect: NSRect, node: (any IssueNode)?, isEditable: Bool = false) { + super.init(frame: frameRect, isEditable: isEditable) + self.issueNode = node + + secondaryLabelRightAligned = false + configureForNode(node) + } + + override func configLabel(label: NSTextField, isEditable: Bool) { + super.configLabel(label: label, isEditable: isEditable) + label.lineBreakMode = .byTruncatingTail + } + + override func createIcon() -> NSImageView { + let icon = super.createIcon() + if let diagnosticNode = issueNode as? DiagnosticIssueNode { + icon.contentTintColor = diagnosticNode.severityColor + } + return icon + } + + func configureForNode(_ node: (any IssueNode)?) { + guard let node = node else { return } + + textField?.stringValue = node.name + + if let fileIssueNode = node as? FileIssueNode { + imageView?.image = fileIssueNode.nsIcon + } else if let diagnosticNode = node as? DiagnosticIssueNode { + imageView?.image = diagnosticNode.icon + imageView?.contentTintColor = diagnosticNode.severityColor + } + + if let diagnosticNode = node as? DiagnosticIssueNode { + setupDetailLabel(with: diagnosticNode.locationString) + } else if let projectNode = node as? ProjectIssueNode { + let issuesCount = projectNode.errorCount + projectNode.warningCount + + if issuesCount > 0 { + secondaryLabel?.stringValue = "\(issuesCount) issues" + } + } + } + + private func setupDetailLabel(with text: String) { + detailLabel?.removeFromSuperview() + + let detail = NSTextField(labelWithString: text) + detail.translatesAutoresizingMaskIntoConstraints = false + detail.drawsBackground = false + detail.isBordered = false + detail.font = .systemFont(ofSize: fontSize-2) + detail.textColor = .secondaryLabelColor + + addSubview(detail) + detailLabel = detail + } + + /// Returns the font size for the current row height. Defaults to `13.0` + private var fontSize: Double { + switch self.frame.height { + case 20: return 11 + case 22: return 13 + case 24: return 14 + default: return 13 + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + fatalError("init?(coder: NSCoder) isn't implemented on `IssueTableViewCell`.") + } +} diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift index 3f1becd8d..bad932d15 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift @@ -27,7 +27,6 @@ class StandardTableViewCell: NSTableCellView { init(frame frameRect: NSRect, isEditable: Bool = true) { super.init(frame: frameRect) setupViews(frame: frameRect, isEditable: isEditable) - } // Default init, assumes isEditable to be false diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index dae3c4461..5bd0d1b39 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -162,7 +162,6 @@ class IssueNavigatorViewModel: ObservableObject { protocol IssueNode: Identifiable, Hashable { var id: UUID { get } var name: String { get } - var icon: Image { get } var isExpandable: Bool { get } } @@ -216,29 +215,32 @@ class FileIssueNode: IssueNode, ObservableObject { @Published var diagnostics: [DiagnosticIssueNode] @Published var isExpanded: Bool - // TODO: REPLACE WITH EXISTING CODE - var icon: Image { - // Determine icon based on file extension - let fileExtension = (name as NSString).pathExtension.lowercased() - - switch fileExtension { - case "swift": - return Image(systemName: "swift") - case "js", "javascript": - return Image(systemName: "j.square") - case "html": - return Image(systemName: "h.square") - case "css": - return Image(systemName: "c.square") - case "md", "markdown": - return Image(systemName: "doc.text") - case "json": - return Image(systemName: "curlybraces") - case "txt": - return Image(systemName: "doc.text") - default: - return Image(systemName: "doc") + /// Returns the extension of the file or an empty string if no extension is present. + var type: FileIcon.FileType { + let filename = (uri as NSString).pathExtension + let fileExtension = (filename as NSString).pathExtension.lowercased() + + if !fileExtension.isEmpty { + if let type = FileIcon.FileType(rawValue: fileExtension) { + return type + } } + if let type = FileIcon.FileType(rawValue: filename.lowercased()) { + return type + } + return .txt + } + + /// Return the icon of the file as `Image` + var icon: Image { + return Image(systemName: FileIcon.fileIcon(fileType: type)) + } + + /// Return the icon of the file as `NSImage` + var nsIcon: NSImage { + let systemImage = FileIcon.fileIcon(fileType: type) + return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! } var isExpandable: Bool { @@ -283,18 +285,18 @@ class DiagnosticIssueNode: IssueNode, ObservableObject { false } - var icon: Image { + var icon: NSImage { switch diagnostic.severity { case .error: - return Image(systemName: "exclamationmark.circle.fill") + return NSImage(systemSymbolName: "exclamationmark.octagon.fill", accessibilityDescription: "")! case .warning: - return Image(systemName: "exclamationmark.triangle.fill") + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "")! case .information: - return Image(systemName: "info.circle.fill") + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "")! case .hint: - return Image(systemName: "lightbulb.fill") + return NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: "")! case nil: - return Image(systemName: "circle.fill") + return NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "")! } } From f4919631c9e011a61b4bf53770756b03313555e5 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 1 Apr 2025 21:39:06 -0700 Subject: [PATCH 04/15] Styling updates --- ...ViewController+NSOutlineViewDelegate.swift | 1 + .../OutlineView/IssueTableViewCell.swift | 58 +++++++++---------- .../ViewModels/IssueNavigatorViewModel.swift | 32 +++++----- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 0656bcccc..49fe53141 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -36,6 +36,7 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { if item is DiagnosticIssueNode { + // TODO: DIAGNOSTIC CELLS SHOULD MIGHT SPAN MULTIPLE ROWS, SO SHOW MULTIPLE LINES let lines = Double(Settings.shared.preferences.general.issueNavigatorDetail.rawValue) return rowHeight * lines } diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift index eb4c2e60d..599c2ab9c 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift @@ -5,13 +5,11 @@ // Created by Abe Malla on 3/16/25. // -import SwiftUI import AppKit class IssueTableViewCell: StandardTableViewCell { private var nodeIconView: NSImageView? - private var detailLabel: NSTextField? var issueNode: (any IssueNode)? @@ -24,60 +22,62 @@ class IssueTableViewCell: StandardTableViewCell { super.init(frame: frameRect, isEditable: isEditable) self.issueNode = node - secondaryLabelRightAligned = false configureForNode(node) } override func configLabel(label: NSTextField, isEditable: Bool) { super.configLabel(label: label, isEditable: isEditable) - label.lineBreakMode = .byTruncatingTail - } - override func createIcon() -> NSImageView { - let icon = super.createIcon() - if let diagnosticNode = issueNode as? DiagnosticIssueNode { - icon.contentTintColor = diagnosticNode.severityColor + if issueNode is DiagnosticIssueNode { + label.maximumNumberOfLines = 4 + label.lineBreakMode = .byTruncatingTail + label.cell?.wraps = true + label.cell?.isScrollable = false + label.preferredMaxLayoutWidth = frame.width - iconWidth - 20 + } else { + label.lineBreakMode = .byTruncatingTail } - return icon + } + + override func configSecondaryLabel(secondaryLabel: NSTextField) { + super.configSecondaryLabel(secondaryLabel: secondaryLabel) + secondaryLabel.font = .systemFont(ofSize: fontSize-2, weight: .medium) } func configureForNode(_ node: (any IssueNode)?) { guard let node = node else { return } + secondaryLabelRightAligned = true textField?.stringValue = node.name - if let fileIssueNode = node as? FileIssueNode { + if let projectIssueNode = node as? ProjectIssueNode { + imageView?.image = projectIssueNode.nsIcon + imageView?.contentTintColor = NSColor.folderBlue + } else if let fileIssueNode = node as? FileIssueNode { imageView?.image = fileIssueNode.nsIcon + if Settings.shared.preferences.general.fileIconStyle == .color { + imageView?.contentTintColor = NSColor(fileIssueNode.iconColor) + } else { + imageView?.contentTintColor = NSColor.coolGray + } } else if let diagnosticNode = node as? DiagnosticIssueNode { - imageView?.image = diagnosticNode.icon + imageView?.image = diagnosticNode.nsIcon + .withSymbolConfiguration( + NSImage.SymbolConfiguration(paletteColors: [.white, diagnosticNode.severityColor]) + ) imageView?.contentTintColor = diagnosticNode.severityColor } - if let diagnosticNode = node as? DiagnosticIssueNode { - setupDetailLabel(with: diagnosticNode.locationString) - } else if let projectNode = node as? ProjectIssueNode { + if let projectNode = node as? ProjectIssueNode { let issuesCount = projectNode.errorCount + projectNode.warningCount if issuesCount > 0 { + secondaryLabelRightAligned = false secondaryLabel?.stringValue = "\(issuesCount) issues" } } } - private func setupDetailLabel(with text: String) { - detailLabel?.removeFromSuperview() - - let detail = NSTextField(labelWithString: text) - detail.translatesAutoresizingMaskIntoConstraints = false - detail.drawsBackground = false - detail.isBordered = false - detail.font = .systemFont(ofSize: fontSize-2) - detail.textColor = .secondaryLabelColor - - addSubview(detail) - detailLabel = detail - } - /// Returns the font size for the current row height. Defaults to `13.0` private var fontSize: Double { switch self.frame.height { diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index 5bd0d1b39..f2c3ba5e0 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -163,17 +163,19 @@ protocol IssueNode: Identifiable, Hashable { var id: UUID { get } var name: String { get } var isExpandable: Bool { get } + var nsIcon: NSImage { get } } /// Represents the project (root) node in the issue navigator class ProjectIssueNode: IssueNode, ObservableObject { let id: UUID = UUID() let name: String + @Published var files: [FileIssueNode] @Published var isExpanded: Bool - var icon: Image { - Image(systemName: "folder") + var nsIcon: NSImage { + return NSImage(systemSymbolName: "folder.fill.badge.gearshape", accessibilityDescription: "Root folder")! } var isExpandable: Bool { @@ -212,35 +214,37 @@ class FileIssueNode: IssueNode, ObservableObject { let id: UUID = UUID() let uri: DocumentUri let name: String + @Published var diagnostics: [DiagnosticIssueNode] @Published var isExpanded: Bool /// Returns the extension of the file or an empty string if no extension is present. var type: FileIcon.FileType { - let filename = (uri as NSString).pathExtension - let fileExtension = (filename as NSString).pathExtension.lowercased() - + let fileExtension = (uri as NSString).pathExtension.lowercased() if !fileExtension.isEmpty { if let type = FileIcon.FileType(rawValue: fileExtension) { return type } } - if let type = FileIcon.FileType(rawValue: filename.lowercased()) { - return type - } return .txt } - /// Return the icon of the file as `Image` - var icon: Image { - return Image(systemName: FileIcon.fileIcon(fileType: type)) + /// Returns a `Color` for a specific `fileType` + /// + /// If not specified otherwise this will return `Color.accentColor` + var iconColor: SwiftUI.Color { + FileIcon.iconColor(fileType: type) } /// Return the icon of the file as `NSImage` var nsIcon: NSImage { let systemImage = FileIcon.fileIcon(fileType: type) - return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) - ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! + if let customImage = NSImage.symbol(named: systemImage) { + return customImage + } else { + return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! + } } var isExpandable: Bool { @@ -285,7 +289,7 @@ class DiagnosticIssueNode: IssueNode, ObservableObject { false } - var icon: NSImage { + var nsIcon: NSImage { switch diagnostic.severity { case .error: return NSImage(systemSymbolName: "exclamationmark.octagon.fill", accessibilityDescription: "")! From 20f768b37faf2efe50543968c2bca621704350cd Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 2 Apr 2025 14:59:49 -0700 Subject: [PATCH 05/15] Styling updates --- ...ewController+NSOutlineViewDataSource.swift | 7 +- ...ViewController+NSOutlineViewDelegate.swift | 43 +++- .../OutlineView/IssueTableViewCell.swift | 187 ++++++++++++------ .../OutlineView/StandardTableViewCell.swift | 1 - .../ViewModels/IssueNavigatorViewModel.swift | 4 +- 5 files changed, 169 insertions(+), 73 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift index d7ece97bf..30e391d96 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift @@ -10,8 +10,11 @@ import AppKit extension IssueNavigatorViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { if item == nil { - // Always show the project node - return 1 + // If there are no issues, don't show the project node + if let rootNode = workspace?.issueNavigatorViewModel?.filteredRootNode { + return rootNode.files.isEmpty ? 0 : 1 + } + return 0 } if let node = item as? ProjectIssueNode { return node.files.count diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 49fe53141..3142a0019 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -26,21 +26,44 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight) if let node = item as? (any IssueNode) { - let cell = IssueTableViewCell(frame: frameRect, node: node) - return cell + return IssueTableViewCell(frame: frameRect, node: node) } - - let cell = TextTableViewCell(frame: frameRect, startingText: "Unknown item") - return cell + return TextTableViewCell(frame: frameRect, startingText: "Unknown item") } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { - if item is DiagnosticIssueNode { - // TODO: DIAGNOSTIC CELLS SHOULD MIGHT SPAN MULTIPLE ROWS, SO SHOW MULTIPLE LINES - let lines = Double(Settings.shared.preferences.general.issueNavigatorDetail.rawValue) - return rowHeight * lines + if let diagnosticNode = item as? DiagnosticIssueNode { + let columnWidth = outlineView.tableColumns.first?.width ?? outlineView.frame.width + let indentationLevel = outlineView.level(forItem: item) + let indentationSpace = CGFloat(indentationLevel) * outlineView.indentationPerLevel + let availableWidth = columnWidth - indentationSpace - 24 + + // Create a temporary text field for measurement + let tempView = NSTextField(wrappingLabelWithString: diagnosticNode.name) + tempView.allowsDefaultTighteningForTruncation = false + tempView.cell?.truncatesLastVisibleLine = true + tempView.cell?.wraps = true + tempView.maximumNumberOfLines = Settings.shared.preferences.general.issueNavigatorDetail.rawValue + tempView.preferredMaxLayoutWidth = availableWidth + + let height = tempView.sizeThatFits( + NSSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude) + ).height + return max(height + 8, rowHeight) } - return rowHeight // This can be changed to 20 to match Xcode's row height. + return rowHeight + } + + func outlineViewColumnDidResize(_ notification: Notification) { + // Disable animations temporarily + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + + let indexes = IndexSet(integersIn: 0.. 0 { - secondaryLabelRightAligned = false - secondaryLabel?.stringValue = "\(issuesCount) issues" - } + if node is DiagnosticIssueNode { + let availableWidth = frame.width + label.preferredMaxLayoutWidth = availableWidth } } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + /// Returns the font size for the current row height. Defaults to `13.0` private var fontSize: Double { switch self.frame.height { @@ -87,12 +166,4 @@ class IssueTableViewCell: StandardTableViewCell { default: return 13 } } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - } - - required init?(coder: NSCoder) { - fatalError("init?(coder: NSCoder) isn't implemented on `IssueTableViewCell`.") - } } diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift index bad932d15..edf2afbb5 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift @@ -49,7 +49,6 @@ class StandardTableViewCell: NSTableCellView { // Create the icon let icon = createIcon() configIcon(icon: icon) - addSubview(icon) imageView = icon // add constraints diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index f2c3ba5e0..9d0aee4d8 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -175,7 +175,7 @@ class ProjectIssueNode: IssueNode, ObservableObject { @Published var isExpanded: Bool var nsIcon: NSImage { - return NSImage(systemSymbolName: "folder.fill.badge.gearshape", accessibilityDescription: "Root folder")! + return NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "Root folder")! } var isExpandable: Bool { @@ -282,7 +282,7 @@ class DiagnosticIssueNode: IssueNode, ObservableObject { let fileUri: DocumentUri var name: String { - diagnostic.message + diagnostic.message.trimmingCharacters(in: .newlines) } var isExpandable: Bool { From 9829fa6daffd9ab831cb068de266814560681559 Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 2 Apr 2025 15:32:06 -0700 Subject: [PATCH 06/15] Refactors --- .../Documents/WorkspaceDocument/WorkspaceDocument.swift | 7 +++---- .../ViewModels/IssueNavigatorViewModel.swift | 8 +++++--- .../Pages/GeneralSettings/GeneralSettingsView.swift | 2 -- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 5cefc8876..034ba0049 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -33,10 +33,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var editorManager: EditorManager? = EditorManager() var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel() var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel() - // TODO: GRAB PROJECT NAME FROM ROOT FOLDER - var issueNavigatorViewModel: IssueNavigatorViewModel? = IssueNavigatorViewModel( - projectName: "Test" - ) + var issueNavigatorViewModel: IssueNavigatorViewModel? = IssueNavigatorViewModel() var searchState: SearchState? var openQuicklyViewModel: OpenQuicklyViewModel? var commandsPaletteState: QuickActionsViewModel? @@ -166,6 +163,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) + + issueNavigatorViewModel?.initialize(projectName: displayName) } override func read(from url: URL, ofType typeName: String) throws { diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index 9d0aee4d8..77a058bd5 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -10,13 +10,13 @@ import Foundation import LanguageServerProtocol class IssueNavigatorViewModel: ObservableObject { - @Published var rootNode: ProjectIssueNode + @Published var rootNode: ProjectIssueNode? @Published var filterOptions = IssueFilterOptions() - @Published private(set) var filteredRootNode: ProjectIssueNode + @Published private(set) var filteredRootNode: ProjectIssueNode? private var diagnosticsByFile: [DocumentUri: [Diagnostic]] = [:] - init(projectName: String) { + func initialize(projectName: String) { self.rootNode = ProjectIssueNode(name: projectName) self.filteredRootNode = ProjectIssueNode(name: projectName) } @@ -50,6 +50,7 @@ class IssueNavigatorViewModel: ObservableObject { } private func applyFilter() { + guard let rootNode else { return } let filteredRoot = ProjectIssueNode(name: rootNode.name) // Filter files and diagnostics @@ -75,6 +76,7 @@ class IssueNavigatorViewModel: ObservableObject { /// Rebuilds the tree structure based on current diagnostics private func rebuildTree() { + guard let rootNode else { return } // Keep track of expanded states for files let expandedFileUris = Set(rootNode.files .filter { $0.isExpanded } diff --git a/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift b/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift index 5b6332759..d80038bc3 100644 --- a/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/GeneralSettings/GeneralSettingsView.swift @@ -215,14 +215,12 @@ private extension GeneralSettingsView { } } - // TODO: Implement reflecting Issue Navigator Detail preference and remove disabled modifier var issueNavigatorDetail: some View { Picker("Issue Navigator Detail", selection: $settings.issueNavigatorDetail) { ForEach(SettingsData.NavigatorDetail.allCases, id: \.self) { tag in Text(tag.label).tag(tag) } } - .disabled(true) } // TODO: Implement reset for Don't Ask Me warnings Button and remove disabled modifier From fd46a24053756e19f24574b19b509c87d124ac94 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 5 Apr 2025 10:29:00 -0700 Subject: [PATCH 07/15] Issue navigator coordinator, open file on select, right click options, refactors --- .../ErrorRed.colorset/Contents.json | 38 ++++ .../WarningYellow.colorset/Contents.json | 38 ++++ .../Editor/Models/EditorManager.swift | 2 +- .../OutlineView/IssueNavigatorMenu.swift | 72 ++++++++ .../IssueNavigatorMenuActions.swift | 129 +++++++++++++ .../IssueNavigatorOutlineView.swift | 31 ++++ ...rViewController+NSMenuDelegate.swift.swift | 31 ++++ ...ViewController+NSOutlineViewDelegate.swift | 23 +++ .../IssueNavigatorViewController.swift | 33 ++++ .../OutlineView/IssueTableViewCell.swift | 101 +++++++++++ .../OutlineView/IssueTableViewCell.swift | 169 ------------------ .../OutlineView/StandardTableViewCell.swift | 12 +- ...ViewController+NSOutlineViewDelegate.swift | 18 +- .../ProjectNavigatorViewController.swift | 2 +- .../ViewModels/IssueNavigatorViewModel.swift | 25 ++- 15 files changed, 530 insertions(+), 194 deletions(-) create mode 100644 CodeEdit/Assets.xcassets/Custom Colors/ErrorRed.colorset/Contents.json create mode 100644 CodeEdit/Assets.xcassets/Custom Colors/WarningYellow.colorset/Contents.json create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenu.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenuActions.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSMenuDelegate.swift.swift create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueTableViewCell.swift delete mode 100644 CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift diff --git a/CodeEdit/Assets.xcassets/Custom Colors/ErrorRed.colorset/Contents.json b/CodeEdit/Assets.xcassets/Custom Colors/ErrorRed.colorset/Contents.json new file mode 100644 index 000000000..0bdceb83a --- /dev/null +++ b/CodeEdit/Assets.xcassets/Custom Colors/ErrorRed.colorset/Contents.json @@ -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 + } +} diff --git a/CodeEdit/Assets.xcassets/Custom Colors/WarningYellow.colorset/Contents.json b/CodeEdit/Assets.xcassets/Custom Colors/WarningYellow.colorset/Contents.json new file mode 100644 index 000000000..404ae9236 --- /dev/null +++ b/CodeEdit/Assets.xcassets/Custom Colors/WarningYellow.colorset/Contents.json @@ -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 + } +} diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index 5c60da34b..4626f2ca4 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -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() var cancellable: AnyCancellable? diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenu.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenu.swift new file mode 100644 index 000000000..ac64a345b --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenu.swift @@ -0,0 +1,72 @@ +// +// IssueNavigatorMenu.swift +// CodeEdit +// +// Created by Abe Malla on 4/3/25. +// + +import SwiftUI + +final class IssueNavigatorMenu: NSMenu { + var item: (any IssueNode)? + + /// The workspace, for opening the item + var workspace: WorkspaceDocument? + + /// The `IssueNavigatorViewController` is being called from. + /// By sending it, we can access it's variables and functions. + var sender: IssueNavigatorViewController + + init(_ sender: IssueNavigatorViewController) { + self.sender = sender + super.init(title: "Options") + } + + @available(*, unavailable) + required init(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Creates a `NSMenuItem` depending on the given arguments + /// - Parameters: + /// - title: The title of the menu item + /// - action: A `Selector` or `nil` of the action to perform. + /// - key: A `keyEquivalent` of the menu item. Defaults to an empty `String` + /// - Returns: A `NSMenuItem` which has the target `self` + private func menuItem(_ title: String, action: Selector?, key: String = "") -> NSMenuItem { + let mItem = NSMenuItem(title: title, action: action, keyEquivalent: key) + mItem.target = self + return mItem + } + + /// Configures the menu based on the current selection in the outline view. + /// - Menu items get added depending on the amount of selected items. + private func setupMenu() { + guard item != nil else { return } + + let copy = menuItem("Copy", action: #selector(copyIssue)) + let showInFinder = menuItem("Show in Finder", action: #selector(showInFinder)) + let revealInProjectNavigator = menuItem( + "Reveal in Project Navigator", + action: #selector(revealInProjectNavigator) + ) + let openInTab = menuItem("Open in Tab", action: #selector(openInTab)) + let openWithExternalEditor = menuItem("Open with External Editor", action: #selector(openWithExternalEditor)) + + items = [ + copy, + .separator(), + showInFinder, + revealInProjectNavigator, + .separator(), + openInTab, + openWithExternalEditor, + ] + } + + /// Updates the menu for the selected item and hides it if no item is provided. + override func update() { + removeAllItems() + setupMenu() + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenuActions.swift new file mode 100644 index 000000000..0a7a1f0f2 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorMenuActions.swift @@ -0,0 +1,129 @@ +// +// IssueNavigatorMenuActions.swift +// CodeEdit +// +// Created by Abe Malla on 4/3/25. +// + +import AppKit +import SwiftUI + +extension IssueNavigatorMenu { + /// - Returns: the currently selected `IssueNode` items in the outline view. + func selectedNodes() -> [any IssueNode] { + let selectedItems = sender.outlineView.selectedRowIndexes.compactMap { + sender.outlineView.item(atRow: $0) as? (any IssueNode) + } + + if let menuItem = sender.outlineView.item(atRow: sender.outlineView.clickedRow) as? (any IssueNode) { + if !selectedItems.contains(where: { $0.id == menuItem.id }) { + return [menuItem] + } + } + + return selectedItems + } + + /// Finds the file node that contains a diagnostic node + private func findFileNode(for diagnosticNode: DiagnosticIssueNode) -> FileIssueNode? { + // First try to find it by checking parents in the outline view + if let parent = sender.outlineView.parent(forItem: diagnosticNode) as? FileIssueNode { + return parent + } + + // Fallback: Look for a file with matching URI + for row in 0.. [FileIssueNode] { + let nodes = selectedNodes() + var fileNodes = [FileIssueNode]() + + for node in nodes { + if let fileNode = node as? FileIssueNode { + if !fileNodes.contains(where: { $0.id == fileNode.id }) { + fileNodes.append(fileNode) + } + } else if let diagnosticNode = node as? DiagnosticIssueNode { + if let fileNode = findFileNode(for: diagnosticNode), + !fileNodes.contains(where: { $0.id == fileNode.id }) { + fileNodes.append(fileNode) + } + } + } + + return fileNodes + } + + /// Copies the details of the issue node that was selected + @objc + func copyIssue() { + let textsToCopy = selectedNodes().compactMap { node -> String? in + if let diagnosticNode = node as? DiagnosticIssueNode { + return diagnosticNode.name + } else if let fileNode = node as? FileIssueNode { + return fileNode.name + } else { + return node.name + } + } + + if !textsToCopy.isEmpty { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([textsToCopy.joined(separator: "\n") as NSString]) + } + } + + /// Action that opens **Finder** at the items location. + @objc + func showInFinder() { + let fileURLs = selectedFileNodes().compactMap { URL(string: $0.uri) } + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + + @objc + func revealInProjectNavigator() { + guard let fileNode = selectedFileNodes().first, + let fileURL = URL(string: fileNode.uri), + let workspaceFileManager = workspace?.workspaceFileManager, + let file = workspaceFileManager.getFile(fileURL.path) else { + return + } + workspace?.listenerModel.highlightedFileItem = file + } + + /// Action that opens the item, identical to clicking it. + @objc + func openInTab() { + for fileNode in selectedFileNodes() { + if let fileURL = URL(string: fileNode.uri), + let workspaceFileManager = workspace?.workspaceFileManager, + let file = workspaceFileManager.getFile(fileURL.path) { + workspace?.editorManager?.activeEditor.openTab(file: file) + } + } + } + + /// Action that opens in an external editor + @objc + func openWithExternalEditor() { + let fileURLs = selectedFileNodes().compactMap { URL(string: $0.uri)?.path } + + if !fileURLs.isEmpty { + let process = Process() + process.launchPath = "/usr/bin/open" + process.arguments = fileURLs + try? process.run() + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift index ccaa240c4..b90e62ebf 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift @@ -22,10 +22,41 @@ struct IssueNavigatorOutlineView: NSViewControllerRepresentable { let controller = IssueNavigatorViewController() controller.workspace = workspace controller.editor = editorManager.activeEditor + + context.coordinator.controller = controller + context.coordinator.setupObservers() + return controller } func updateNSViewController(_ nsViewController: IssueNavigatorViewController, context: Context) { nsViewController.rowHeight = prefs.preferences.general.projectNavigatorSize.rowHeight } + + func makeCoordinator() -> Coordinator { + Coordinator(workspace: workspace) + } + + class Coordinator: NSObject { + var cancellables = Set() + weak var workspace: WorkspaceDocument? + weak var controller: IssueNavigatorViewController? + + init(workspace: WorkspaceDocument?) { + self.workspace = workspace + super.init() + } + + func setupObservers() { + guard let viewModel = workspace?.issueNavigatorViewModel else { return } + + viewModel.diagnosticsDidChangePublisher + .sink { [weak self] _ in + DispatchQueue.main.async { + self?.controller?.outlineView.reloadData() + } + } + .store(in: &cancellables) + } + } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSMenuDelegate.swift.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSMenuDelegate.swift.swift new file mode 100644 index 000000000..46fcf70c9 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSMenuDelegate.swift.swift @@ -0,0 +1,31 @@ +// +// IssueNavigatorViewController+NSMenuDelegate.swift.swift +// CodeEdit +// +// Created by Abe Malla on 4/3/25. +// + +import AppKit + +extension IssueNavigatorViewController: NSMenuDelegate { + /// Once a menu gets requested by a `right click` setup the menu + /// + /// If the right click happened outside a row this will result in no menu being shown. + /// - Parameter menu: The menu that got requested + func menuNeedsUpdate(_ menu: NSMenu) { + let row = outlineView.clickedRow + guard let menu = menu as? IssueNavigatorMenu else { return } + + if row == -1 { + menu.item = nil + } else { + if let item = outlineView.item(atRow: row) as? (any IssueNode) { + menu.item = item + menu.workspace = workspace + } else { + menu.item = nil + } + } + menu.update() + } +} diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 3142a0019..182e0a065 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -31,6 +31,29 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { return TextTableViewCell(frame: frameRect, startingText: "Unknown item") } + func outlineViewSelectionDidChange(_ notification: Notification) { + guard let outlineView = notification.object as? NSOutlineView else { return } + + // If multiple rows are selected, do not open any file. + guard outlineView.selectedRowIndexes.count == 1 else { return } + guard shouldSendSelectionUpdate else { return } + + let selectedItem = outlineView.item(atRow: outlineView.selectedRow) + + // Get the file and open it if not already opened + if let fileURL = URL( + string: (selectedItem as? FileIssueNode)?.uri ?? + (selectedItem as? DiagnosticIssueNode)?.fileUri ?? "" + ), !fileURL.path.isEmpty { + shouldSendSelectionUpdate = false + if let file = workspace?.workspaceFileManager?.getFile(fileURL.path), + workspace?.editorManager?.activeEditor.selectedTab?.file != file { + workspace?.editorManager?.activeEditor.openTab(file: file, asTemporary: true) + } + shouldSendSelectionUpdate = true + } + } + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { if let diagnosticNode = item as? DiagnosticIssueNode { let columnWidth = outlineView.tableColumns.first?.width ?? outlineView.frame.width diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index 6b8426d53..1a50806ac 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -36,6 +36,11 @@ final class IssueNavigatorViewController: NSViewController { } } + /// This helps determine whether or not to send an `openTab` when the selection changes. + /// Used b/c the state may update when the selection changes, but we don't necessarily want + /// to open the file a second time. + var shouldSendSelectionUpdate: Bool = true + /// Setup the ``scrollView`` and ``outlineView`` override func loadView() { self.scrollView = NSScrollView() @@ -47,6 +52,9 @@ final class IssueNavigatorViewController: NSViewController { self.outlineView.delegate = self self.outlineView.autosaveExpandedItems = false self.outlineView.headerView = nil + self.outlineView.menu = IssueNavigatorMenu(self) + self.outlineView.menu?.delegate = self + self.outlineView.doubleAction = #selector(onItemDoubleClicked) self.outlineView.allowsMultipleSelection = true self.outlineView.setAccessibilityIdentifier("IssueNavigator") @@ -88,4 +96,29 @@ final class IssueNavigatorViewController: NSViewController { required init?(coder: NSCoder) { fatalError() } + + /// Expand or collapse the folder on double click + @objc + private func onItemDoubleClicked() { + // If there are multiples items selected, don't do anything, just like in Xcode. + guard outlineView.selectedRowIndexes.count == 1 else { return } + + guard let item = outlineView.item(atRow: outlineView.clickedRow) as? (any IssueNode) else { return } + + if let projectNode = item as? ProjectIssueNode { + toggleExpansion(of: projectNode) + } else if let fileNode = item as? FileIssueNode { + toggleExpansion(of: fileNode) + } + } + + /// Toggles the expansion state of an item + @inline(__always) + private func toggleExpansion(of item: Any) { + if outlineView.isItemExpanded(item) { + outlineView.collapseItem(item) + } else { + outlineView.expandItem(item) + } + } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueTableViewCell.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueTableViewCell.swift new file mode 100644 index 000000000..38aad5979 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueTableViewCell.swift @@ -0,0 +1,101 @@ +// +// IssueTableViewCell.swift +// CodeEdit +// +// Created by Abe Malla on 3/16/25. +// + +import AppKit + +final class IssueTableViewCell: StandardTableViewCell { + private var node: (any IssueNode) + + init(frame: CGRect, node: (any IssueNode)) { + self.node = node + super.init(frame: frame) + + // Set the icon based on the node type + if let projectIssueNode = node as? ProjectIssueNode { + imageView?.image = projectIssueNode.nsIcon + imageView?.contentTintColor = NSColor.folderBlue + + let issuesCount = projectIssueNode.errorCount + projectIssueNode.warningCount + let pluralizationKey = issuesCount == 1 ? "issue" : "issues" + secondaryLabel?.stringValue = "\(issuesCount) \(pluralizationKey)" + } else if let fileIssueNode = node as? FileIssueNode { + imageView?.image = fileIssueNode.nsIcon + if Settings.shared.preferences.general.fileIconStyle == .color { + imageView?.contentTintColor = NSColor(fileIssueNode.iconColor) + } else { + imageView?.contentTintColor = NSColor.coolGray + } + } else if let diagnosticNode = node as? DiagnosticIssueNode { + imageView?.image = diagnosticNode.nsIcon.withSymbolConfiguration( + NSImage.SymbolConfiguration(paletteColors: [.white, diagnosticNode.severityColor]) + ) + imageView?.image?.isTemplate = false + } + + textField?.stringValue = node.name + secondaryLabelRightAligned = false + } + + override func createLabel() -> NSTextField { + if let diagnosticNode = node as? DiagnosticIssueNode { + return NSTextField(wrappingLabelWithString: diagnosticNode.name) + } else { + return NSTextField(labelWithString: node.name) + } + } + + override func configLabel(label: NSTextField, isEditable: Bool) { + super.configLabel(label: label, isEditable: false) + + if node is DiagnosticIssueNode { + label.maximumNumberOfLines = Settings.shared.preferences.general.issueNavigatorDetail.rawValue + label.allowsDefaultTighteningForTruncation = false + label.cell?.truncatesLastVisibleLine = true + label.cell?.wraps = true + label.preferredMaxLayoutWidth = frame.width - iconWidth - 10 + } else { + label.lineBreakMode = .byTruncatingTail + } + } + + override func createConstraints(frame frameRect: NSRect) { + super.createConstraints(frame: frameRect) + guard let imageView, + let textField = self.textField, + node is DiagnosticIssueNode + else { return } + + // table views can autosize constraints + // https://developer.apple.com/documentation/appkit/nsoutlineview/autoresizesoutlinecolumn + // https://developer.apple.com/documentation/appkit/nstableview/usesautomaticrowheights + + // For diagnostic nodes, place icon at the top + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + imageView.topAnchor.constraint(equalTo: topAnchor, constant: 4), + imageView.widthAnchor.constraint(equalToConstant: 18), + imageView.heightAnchor.constraint(equalToConstant: 18), + + textField.leadingAnchor + .constraint(equalTo: imageView.trailingAnchor, constant: 6), + textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + textField.topAnchor.constraint(equalTo: topAnchor, constant: 4), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + + if node is DiagnosticIssueNode { + textField?.preferredMaxLayoutWidth = frame.width - iconWidth - 10 + } + } +} diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift deleted file mode 100644 index 3c90dd587..000000000 --- a/CodeEdit/Features/NavigatorArea/OutlineView/IssueTableViewCell.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// IssueTableViewCell.swift -// CodeEdit -// -// Created by Abe Malla on 3/16/25. -// - -import AppKit - -final class IssueTableViewCell: NSTableCellView { - private var label: NSTextField! - private var icon: NSImageView! - private var secondaryLabel: NSTextField? - private var node: (any IssueNode) - - let iconWidth: CGFloat = 22 - - init(frame: CGRect, node: (any IssueNode)) { - self.node = node - super.init(frame: frame) - - // Set up icon - icon = NSImageView(frame: .zero) - icon.translatesAutoresizingMaskIntoConstraints = false - icon.symbolConfiguration = .init(pointSize: 13, weight: .regular, scale: .medium) - - // Set the icon color based on the type of issue node - if let projectIssueNode = node as? ProjectIssueNode { - icon?.image = projectIssueNode.nsIcon - icon?.contentTintColor = NSColor.folderBlue - - let issuesCount = projectIssueNode.errorCount + projectIssueNode.warningCount - let pluralizationKey = issuesCount == 1 ? "issue" : "issues" - createSecondaryLabel(value: "\(issuesCount) \(pluralizationKey)") - } else if let fileIssueNode = node as? FileIssueNode { - icon?.image = fileIssueNode.nsIcon - if Settings.shared.preferences.general.fileIconStyle == .color { - icon?.contentTintColor = NSColor(fileIssueNode.iconColor) - } else { - icon?.contentTintColor = NSColor.coolGray - } - } else if let diagnosticNode = node as? DiagnosticIssueNode { - icon?.image = diagnosticNode.nsIcon - .withSymbolConfiguration( - NSImage.SymbolConfiguration(paletteColors: [.white, diagnosticNode.severityColor]) - ) - icon?.contentTintColor = diagnosticNode.severityColor - } - - self.addSubview(icon) - self.imageView = icon - - createLabel() - setConstraints() - } - - func createLabel() { - if let diagnosticNode = node as? DiagnosticIssueNode { - label = NSTextField(wrappingLabelWithString: diagnosticNode.name) - } else { - label = NSTextField(labelWithString: node.name) - } - - label.translatesAutoresizingMaskIntoConstraints = false - label.drawsBackground = false - label.isBordered = false - label.isEditable = false - label.isSelectable = false - label.layer?.cornerRadius = 10.0 - label.font = .labelFont(ofSize: fontSize) - - if node is DiagnosticIssueNode { - label.maximumNumberOfLines = Settings.shared.preferences.general.issueNavigatorDetail.rawValue - label.allowsDefaultTighteningForTruncation = false - label.cell?.truncatesLastVisibleLine = true - label.cell?.wraps = true - label.preferredMaxLayoutWidth = frame.width - } else { - label.lineBreakMode = .byTruncatingTail - } - - self.addSubview(label) - self.textField = label - } - - func createSecondaryLabel(value: String) { - let secondaryLabel = NSTextField(frame: .zero) - secondaryLabel.translatesAutoresizingMaskIntoConstraints = false - secondaryLabel.drawsBackground = false - secondaryLabel.isBordered = false - secondaryLabel.isEditable = false - secondaryLabel.isSelectable = false - secondaryLabel.layer?.cornerRadius = 10.0 - secondaryLabel.font = .systemFont(ofSize: fontSize) - secondaryLabel.alignment = .center - secondaryLabel.textColor = .secondaryLabelColor - secondaryLabel.stringValue = value - - self.addSubview(secondaryLabel) - self.secondaryLabel = secondaryLabel - } - - func setConstraints() { - if node is DiagnosticIssueNode { - // For diagnostic nodes, place icon at the top - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), - icon.topAnchor.constraint(equalTo: topAnchor, constant: 4), - icon.widthAnchor.constraint(equalToConstant: 16), - icon.heightAnchor.constraint(equalToConstant: 16), - - label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), - label.topAnchor.constraint(equalTo: topAnchor, constant: 4), - label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) - ]) - } else if let secondaryLabel = secondaryLabel { - // Secondary label - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), - icon.centerYAnchor.constraint(equalTo: centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 16), - icon.heightAnchor.constraint(equalToConstant: 16), - - label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), - label.centerYAnchor.constraint(equalTo: centerYAnchor), - - secondaryLabel.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 4), - secondaryLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - secondaryLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8) - ]) - } else { - // All other nodes - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), - icon.centerYAnchor.constraint(equalTo: centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 16), - icon.heightAnchor.constraint(equalToConstant: 16), - - label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - label.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - } - } - - override func layout() { - super.layout() - - if node is DiagnosticIssueNode { - let availableWidth = frame.width - label.preferredMaxLayoutWidth = availableWidth - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Returns the font size for the current row height. Defaults to `13.0` - private var fontSize: Double { - switch self.frame.height { - case 20: return 11 - case 22: return 13 - case 24: return 14 - default: return 13 - } - } -} diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift index edf2afbb5..e7371c7f1 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift @@ -51,14 +51,14 @@ class StandardTableViewCell: NSTableCellView { configIcon(icon: icon) imageView = icon - // add constraints - createConstraints(frame: frameRect) addSubview(label) addSubview(secondaryLabel) addSubview(icon) + createConstraints(frame: frameRect) } // MARK: Create and config stuff + func createLabel() -> NSTextField { return SpecialSelectTextField(frame: .zero) } @@ -122,7 +122,7 @@ class StandardTableViewCell: NSTableCellView { width: iconWidth, height: frame.height ) - // center align the image + // Center align the image if let alignmentRect = imageView.image?.alignmentRect { imageView.frame = NSRect( x: (iconWidth - alignmentRect.width) / 2, @@ -132,11 +132,11 @@ class StandardTableViewCell: NSTableCellView { ) } - // right align the secondary label + // Right align the secondary label if secondaryLabelRightAligned { rightAlignSecondary() } else { - // put the secondary label right after the primary label + // Put the secondary label right after the primary label leftAlignSecondary() } } @@ -147,7 +147,7 @@ class StandardTableViewCell: NSTableCellView { let newSize = secondaryLabel.sizeThatFits( CGSize(width: secondLabelWidth, height: CGFloat.greatestFiniteMagnitude) ) - // somehow, a width of 0 makes it resize properly. + // Somehow, a width of 0 makes it resize properly. secondaryLabel.frame = NSRect( x: frame.width - newSize.width, y: 3.5, diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 4809d1ddd..915e7e277 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -36,10 +36,10 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineViewSelectionDidChange(_ notification: Notification) { guard let outlineView = notification.object as? NSOutlineView else { return } - /// If multiple rows are selected, do not open any file. + // If multiple rows are selected, do not open any file. guard outlineView.selectedRowIndexes.count == 1 else { return } - /// If only one row is selected, proceed as before + // If only one row is selected, proceed as before let selectedIndex = outlineView.selectedRow guard let item = outlineView.item(atRow: selectedIndex) as? CEWorkspaceFile else { return } @@ -58,7 +58,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { } func outlineViewItemDidExpand(_ notification: Notification) { - /// Save expanded items' state to restore when finish filtering. + // Save expanded items' state to restore when finish filtering. guard let workspace else { return } if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile { expandedItems.insert(item) @@ -66,11 +66,11 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { guard let id = workspace.editorManager?.activeEditor.selectedTab?.file.id, let item = workspace.workspaceFileManager?.getFile(id, createIfNotFound: true), - /// update outline selection only if the parent of selected item match with expanded item + // Update outline selection only if the parent of selected item match with expanded item item.parent === notification.userInfo?["NSObject"] as? CEWorkspaceFile else { return } - /// select active file under collapsed folder only if its parent is expanding + // Select active file under collapsed folder only if its parent is expanding if outlineView.isItemExpanded(item.parent) { updateSelection(itemID: item.id) } @@ -143,16 +143,16 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { let visibleRect = scrollView.contentView.visibleRect let visibleRows = outlineView.rows(in: visibleRect) guard !visibleRows.contains(row) else { - /// in case that the selected file is not fully visible (some parts are out of the visible rect), - /// `scrollRowToVisible(_:)` method brings the file where it can be fully visible. + // In case that the selected file is not fully visible (some parts are out of the visible rect), + // `scrollRowToVisible(_:)` method brings the file where it can be fully visible. outlineView.scrollRowToVisible(row) return } let rowRect = outlineView.rect(ofRow: row) let centerY = rowRect.midY - (visibleRect.height / 2) let center = NSPoint(x: 0, y: centerY) - /// `scroll(_:)` method alone doesn't bring the selected file to the center in some cases. - /// calling `scrollRowToVisible(_:)` method before it makes the file reveal in the center more correctly. + // `scroll(_:)` method alone doesn't bring the selected file to the center in some cases. + // Calling `scrollRowToVisible(_:)` method before it makes the file reveal in the center more correctly. outlineView.scrollRowToVisible(row) outlineView.scroll(center) } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index f76f07efc..457504985 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -159,7 +159,7 @@ final class ProjectNavigatorViewController: NSViewController { /// Expand or collapse the folder on double click @objc private func onItemDoubleClicked() { - /// If there are multiples items selected, don't do anything, just like in Xcode. + // If there are multiples items selected, don't do anything, just like in Xcode. guard outlineView.selectedRowIndexes.count == 1 else { return } guard let item = outlineView.item(atRow: outlineView.clickedRow) as? CEWorkspaceFile else { return } diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index 77a058bd5..b674e838c 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -5,6 +5,7 @@ // Created by Abe Malla on 3/15/25. // +import Combine import SwiftUI import Foundation import LanguageServerProtocol @@ -14,6 +15,8 @@ class IssueNavigatorViewModel: ObservableObject { @Published var filterOptions = IssueFilterOptions() @Published private(set) var filteredRootNode: ProjectIssueNode? + let diagnosticsDidChangePublisher = PassthroughSubject() + private var diagnosticsByFile: [DocumentUri: [Diagnostic]] = [:] func initialize(projectName: String) { @@ -32,21 +35,25 @@ class IssueNavigatorViewModel: ObservableObject { } rebuildTree() + diagnosticsDidChangePublisher.send() } func clearDiagnostics() { diagnosticsByFile.removeAll() rebuildTree() + diagnosticsDidChangePublisher.send() } func removeDiagnostics(uri: DocumentUri) { diagnosticsByFile.removeValue(forKey: uri) rebuildTree() + diagnosticsDidChangePublisher.send() } func updateFilter(options: IssueFilterOptions) { self.filterOptions = options applyFilter() + diagnosticsDidChangePublisher.send() } private func applyFilter() { @@ -148,7 +155,6 @@ class IssueNavigatorViewModel: ObservableObject { if line < range.start.line || line > range.end.line { return false } - if line == range.start.line && character < range.start.character { return false } @@ -294,24 +300,27 @@ class DiagnosticIssueNode: IssueNode, ObservableObject { var nsIcon: NSImage { switch diagnostic.severity { case .error: - return NSImage(systemSymbolName: "exclamationmark.octagon.fill", accessibilityDescription: "")! + return NSImage( + systemSymbolName: "xmark.octagon.fill", + accessibilityDescription: "Error" + )! case .warning: - return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "")! + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning")! case .information: - return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "")! + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Information")! case .hint: - return NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: "")! + return NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: "Hint")! case nil: - return NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "")! + return NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Unknown Issue Type")! } } var severityColor: NSColor { switch diagnostic.severity { case .error: - return .red + return .errorRed case .warning: - return .yellow + return .warningYellow case .information: return .blue case .hint: From 5b16bacfb4f4af00e8611f0b26393f563e917152 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 5 Apr 2025 10:47:54 -0700 Subject: [PATCH 08/15] Double click issue nodes to open the file --- .../Features/LSP/Service/LSPService+Events.swift | 4 +++- .../OutlineView/IssueNavigatorViewController.swift | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index d259e84eb..f03fe4dd2 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -45,6 +45,7 @@ extension LSPService { // case let .error(error): // print("Error from EventStream for \(languageClient.languageId.rawValue): \(error)") default: + // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED return } } @@ -77,6 +78,7 @@ extension LSPService { // print("windowWorkDoneProgressCreate: \(params)") // // default: +// // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED // print() // } } @@ -92,7 +94,6 @@ extension LSPService { // case let .windowShowMessage(params): // print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") case let .textDocumentPublishDiagnostics(params): - print("textDocumentPublishDiagnostics: \(params.diagnostics)") languageClient.workspace.issueNavigatorViewModel? .updateDiagnostics(params: params) // case let .telemetryEvent(params): @@ -104,6 +105,7 @@ extension LSPService { // case let .protocolLogTrace(params): // print("protocolLogTrace: \(params)") default: + // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED return } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index 1a50806ac..a38fdbd34 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -109,6 +109,9 @@ final class IssueNavigatorViewController: NSViewController { toggleExpansion(of: projectNode) } else if let fileNode = item as? FileIssueNode { toggleExpansion(of: fileNode) + openFileTab(fileUri: fileNode.uri) + } else if let diagnosticNode = item as? DiagnosticIssueNode { + openFileTab(fileUri: diagnosticNode.fileUri) } } @@ -121,4 +124,14 @@ final class IssueNavigatorViewController: NSViewController { outlineView.expandItem(item) } } + + /// Opens a file as a permanent tab + @inline(__always) + private func openFileTab(fileUri: String) { + guard let fileURL = URL(string: fileUri), + let file = workspace?.workspaceFileManager?.getFile(fileURL.path) else { + return + } + workspace?.editorManager?.activeEditor.openTab(file: file, asTemporary: false) + } } From 14049c13f03d9d1901792f00545f1f859664812b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 7 Jun 2025 18:13:33 -0700 Subject: [PATCH 09/15] Fix expansion state --- .../IssueNavigatorOutlineView.swift | 44 ++++- ...ViewController+NSOutlineViewDelegate.swift | 48 ++++++ .../IssueNavigatorViewController.swift | 79 +++++++-- .../ProjectNavigatorViewController.swift | 4 +- .../ViewModels/IssueNavigatorViewModel.swift | 151 ++++++++++++------ 5 files changed, 266 insertions(+), 60 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift index b90e62ebf..d8fac052e 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift @@ -31,6 +31,13 @@ struct IssueNavigatorOutlineView: NSViewControllerRepresentable { func updateNSViewController(_ nsViewController: IssueNavigatorViewController, context: Context) { nsViewController.rowHeight = prefs.preferences.general.projectNavigatorSize.rowHeight + + // Update the controller reference if needed + if nsViewController.workspace !== workspace { + nsViewController.workspace = workspace + context.coordinator.workspace = workspace + context.coordinator.setupObservers() + } } func makeCoordinator() -> Coordinator { @@ -48,15 +55,48 @@ struct IssueNavigatorOutlineView: NSViewControllerRepresentable { } func setupObservers() { + // Cancel existing subscriptions + cancellables.removeAll() + guard let viewModel = workspace?.issueNavigatorViewModel else { return } + // Listen for diagnostic changes viewModel.diagnosticsDidChangePublisher + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [weak self] _ in - DispatchQueue.main.async { - self?.controller?.outlineView.reloadData() + guard let controller = self?.controller else { return } + + // Save current selection + let selectedRows = controller.outlineView.selectedRowIndexes + + // Reload data + controller.outlineView.reloadData() + + // Restore expansion state after reload + controller.restoreExpandedState() + + // Restore selection if possible + if !selectedRows.isEmpty { + controller.outlineView.selectRowIndexes(selectedRows, byExtendingSelection: false) } } .store(in: &cancellables) + + // Listen for filter changes + viewModel.$filterOptions + .dropFirst() // Skip initial value + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let controller = self?.controller else { return } + + controller.outlineView.reloadData() + controller.restoreExpandedState() + } + .store(in: &cancellables) + } + + deinit { + cancellables.removeAll() } } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 182e0a065..41210b270 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -89,6 +89,54 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { outlineView.layoutSubtreeIfNeeded() } + func outlineViewItemDidExpand(_ notification: Notification) { + if let node = notification.userInfo?["NSObject"] as? (any IssueNode) { + if let fileNode = node as? FileIssueNode { + fileNode.isExpanded = true + expandedItems.insert(fileNode) + workspace?.issueNavigatorViewModel?.setFileExpanded(fileNode.uri, isExpanded: true) + } else if let projectNode = node as? ProjectIssueNode { + projectNode.isExpanded = true + } + } + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + if let node = notification.userInfo?["NSObject"] as? (any IssueNode) { + if let fileNode = node as? FileIssueNode { + fileNode.isExpanded = false + expandedItems.remove(fileNode) + workspace?.issueNavigatorViewModel?.setFileExpanded(fileNode.uri, isExpanded: false) + } else if let projectNode = node as? ProjectIssueNode { + projectNode.isExpanded = false + } + } + } + + func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? { + guard let uri = object as? String else { return nil } + + if let fileNode = workspace?.issueNavigatorViewModel?.getFileNode(for: uri) { + return fileNode + } + + if let rootNode = workspace?.issueNavigatorViewModel?.filteredRootNode, + rootNode.id.uuidString == uri { + return rootNode + } + + return nil + } + + func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? { + if let fileNode = item as? FileIssueNode { + return fileNode.uri + } else if let projectNode = item as? ProjectIssueNode { + return projectNode.id.uuidString + } + return nil + } + /// Adds a tooltip to the issue row. func outlineView( // swiftlint:disable:this function_parameter_count _ outlineView: NSOutlineView, diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index a38fdbd34..b58fb7d79 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -41,6 +41,14 @@ final class IssueNavigatorViewController: NSViewController { /// to open the file a second time. var shouldSendSelectionUpdate: Bool = true + /// Key for storing expansion state in UserDefaults + private var expansionStateKey: String { + guard let workspaceURL = workspace?.workspaceFileManager?.folderUrl else { + return "IssueNavigatorExpansionState" + } + return "IssueNavigatorExpansionState_\(workspaceURL.path.hashValue)" + } + /// Setup the ``scrollView`` and ``outlineView`` override func loadView() { self.scrollView = NSScrollView() @@ -50,7 +58,8 @@ final class IssueNavigatorViewController: NSViewController { self.outlineView = NSOutlineView() self.outlineView.dataSource = self self.outlineView.delegate = self - self.outlineView.autosaveExpandedItems = false + self.outlineView.autosaveExpandedItems = true + self.outlineView.autosaveName = workspace?.workspaceFileManager?.folderUrl.path ?? "" self.outlineView.headerView = nil self.outlineView.menu = IssueNavigatorMenu(self) self.outlineView.menu?.delegate = self @@ -72,15 +81,14 @@ final class IssueNavigatorViewController: NSViewController { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true - outlineView.expandItem(outlineView.item(atRow: 0)) + loadExpansionState() - /// Get autosave expanded items. - for row in 0..() - private var diagnosticsByFile: [DocumentUri: [Diagnostic]] = [:] + // Store file nodes by URI for efficient lookup and to avoid duplication + private var fileNodesByUri: [DocumentUri: FileIssueNode] = [:] + + // Track expansion state separately to persist it + private var expandedFileUris: Set = [] func initialize(projectName: String) { self.rootNode = ProjectIssueNode(name: projectName) @@ -29,9 +33,47 @@ class IssueNavigatorViewModel: ObservableObject { let diagnostics = params.diagnostics if diagnostics.isEmpty { - diagnosticsByFile.removeValue(forKey: uri) + // Remove the file node if no diagnostics + fileNodesByUri.removeValue(forKey: uri) + expandedFileUris.remove(uri) } else { - diagnosticsByFile[uri] = diagnostics + // Get or create file node + let fileNode: FileIssueNode + if let existingNode = fileNodesByUri[uri] { + fileNode = existingNode + // Clear existing diagnostics + 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() @@ -39,13 +81,15 @@ class IssueNavigatorViewModel: ObservableObject { } func clearDiagnostics() { - diagnosticsByFile.removeAll() + fileNodesByUri.removeAll() + expandedFileUris.removeAll() rebuildTree() diagnosticsDidChangePublisher.send() } func removeDiagnostics(uri: DocumentUri) { - diagnosticsByFile.removeValue(forKey: uri) + fileNodesByUri.removeValue(forKey: uri) + expandedFileUris.remove(uri) rebuildTree() diagnosticsDidChangePublisher.send() } @@ -56,9 +100,40 @@ class IssueNavigatorViewModel: ObservableObject { 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 { + return expandedFileUris + } + + /// Restore expansion state from persisted data + func restoreExpandedFileUris(_ uris: Set) { + 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 { @@ -84,38 +159,14 @@ class IssueNavigatorViewModel: ObservableObject { /// Rebuilds the tree structure based on current diagnostics private func rebuildTree() { guard let rootNode else { return } - // Keep track of expanded states for files - let expandedFileUris = Set(rootNode.files - .filter { $0.isExpanded } - .map { $0.uri }) - - // Create file nodes with diagnostics - let fileNodes = diagnosticsByFile.compactMap { (uri, diagnostics) -> FileIssueNode? in - guard !diagnostics.isEmpty else { return nil } - - let fileName = getFileName(from: uri) - let diagnosticNodes = diagnostics.map { DiagnosticIssueNode(diagnostic: $0, fileUri: uri) } - - // Sort diagnostics by severity - 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 - } + let projectExpanded = rootNode.isExpanded + let sortedFileNodes = fileNodesByUri.values + .sorted { $0.name < $1.name } - return severity1 < severity2 - } - - let fileNode = FileIssueNode(uri: uri, name: fileName, diagnostics: sortedDiagnosticNodes) - fileNode.isExpanded = expandedFileUris.contains(uri) - return fileNode - } - - let sortedFileNodes = fileNodes.sorted { $0.name < $1.name } rootNode.files = sortedFileNodes + rootNode.isExpanded = projectExpanded + applyFilter() } @@ -126,11 +177,21 @@ class IssueNavigatorViewModel: ObservableObject { } let components = uri.split(separator: "/") - return String(components.last ?? "") + return String(components.last ?? "Unknown") } func getAllDiagnostics() -> [Diagnostic] { - return diagnosticsByFile.values.flatMap { $0 } + 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] { @@ -146,10 +207,10 @@ class IssueNavigatorViewModel: ObservableObject { } func getDiagnosticAt(uri: DocumentUri, line: Int, character: Int) -> Diagnostic? { - guard let diagnostics = diagnosticsByFile[uri] else { return nil } + guard let fileNode = fileNodesByUri[uri] else { return nil } - return diagnostics.first { diagnostic in - let range = diagnostic.range + 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 { @@ -162,7 +223,7 @@ class IssueNavigatorViewModel: ObservableObject { return false } return true - } + }?.diagnostic } } @@ -175,7 +236,7 @@ protocol IssueNode: Identifiable, Hashable { } /// Represents the project (root) node in the issue navigator -class ProjectIssueNode: IssueNode, ObservableObject { +class ProjectIssueNode: IssueNode, ObservableObject, Equatable { let id: UUID = UUID() let name: String @@ -218,7 +279,7 @@ class ProjectIssueNode: IssueNode, ObservableObject { } /// Represents a file node in the issue navigator -class FileIssueNode: IssueNode, ObservableObject { +class FileIssueNode: IssueNode, ObservableObject, Equatable { let id: UUID = UUID() let uri: DocumentUri let name: String @@ -267,9 +328,9 @@ class FileIssueNode: IssueNode, ObservableObject { diagnostics.filter { $0.diagnostic.severity == .warning }.count } - init(uri: DocumentUri, name: String, diagnostics: [DiagnosticIssueNode] = [], isExpanded: Bool = true) { + init(uri: DocumentUri, name: String? = nil, diagnostics: [DiagnosticIssueNode] = [], isExpanded: Bool = false) { self.uri = uri - self.name = name + self.name = name ?? (URL(string: uri)?.lastPathComponent ?? "Unknown") self.diagnostics = diagnostics self.isExpanded = isExpanded } @@ -284,7 +345,7 @@ class FileIssueNode: IssueNode, ObservableObject { } /// Represents a diagnostic node in the issue navigator -class DiagnosticIssueNode: IssueNode, ObservableObject { +class DiagnosticIssueNode: IssueNode, ObservableObject, Equatable { let id: UUID = UUID() let diagnostic: Diagnostic let fileUri: DocumentUri From 2c8bc06ec78a7724f98e5f491c21e51fb7974730 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 7 Jun 2025 18:17:11 -0700 Subject: [PATCH 10/15] Lint --- .../IssueNavigatorViewController.swift | 8 +- .../OutlineView/IssueNodes.swift | 191 ++++++++++++++++++ .../ViewModels/IssueNavigatorViewModel.swift | 182 ----------------- 3 files changed, 196 insertions(+), 185 deletions(-) create mode 100644 CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNodes.swift diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index b58fb7d79..c3c1a1038 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -159,9 +159,11 @@ final class IssueNavigatorViewController: NSViewController { toggleExpansion(of: fileNode) openFileTab(fileUri: fileNode.uri) } else if let diagnosticNode = item as? DiagnosticIssueNode { - openFileTab(fileUri: diagnosticNode.fileUri, - line: diagnosticNode.diagnostic.range.start.line, - column: diagnosticNode.diagnostic.range.start.character) + openFileTab( + fileUri: diagnosticNode.fileUri, + line: diagnosticNode.diagnostic.range.start.line, + column: diagnosticNode.diagnostic.range.start.character + ) } } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNodes.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNodes.swift new file mode 100644 index 000000000..14430b77d --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNodes.swift @@ -0,0 +1,191 @@ +// +// IssueNodes.swift +// CodeEdit +// +// Created by Abe Malla on 6/7/25. +// + +import SwiftUI +import LanguageServerProtocol + +/// Protocol defining the common interface for nodes in the issue navigator +protocol IssueNode: Identifiable, Hashable { + var id: UUID { get } + var name: String { get } + var isExpandable: Bool { get } + var nsIcon: NSImage { get } +} + +/// Represents the project (root) node in the issue navigator +class ProjectIssueNode: IssueNode, ObservableObject, Equatable { + let id: UUID = UUID() + let name: String + + @Published var files: [FileIssueNode] + @Published var isExpanded: Bool + + var nsIcon: NSImage { + return NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "Root folder")! + } + + var isExpandable: Bool { + !files.isEmpty + } + + var diagnosticsCount: Int { + files.reduce(0) { $0 + $1.diagnostics.count } + } + + var errorCount: Int { + files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .error }.count } + } + + var warningCount: Int { + files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .warning }.count } + } + + init(name: String, files: [FileIssueNode] = [], isExpanded: Bool = true) { + self.name = name + self.files = files + self.isExpanded = isExpanded + } + + static func == (lhs: ProjectIssueNode, rhs: ProjectIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Represents a file node in the issue navigator +class FileIssueNode: IssueNode, ObservableObject, Equatable { + let id: UUID = UUID() + let uri: DocumentUri + let name: String + + @Published var diagnostics: [DiagnosticIssueNode] + @Published var isExpanded: Bool + + /// Returns the extension of the file or an empty string if no extension is present. + var type: FileIcon.FileType { + let fileExtension = (uri as NSString).pathExtension.lowercased() + if !fileExtension.isEmpty { + if let type = FileIcon.FileType(rawValue: fileExtension) { + return type + } + } + return .txt + } + + /// Returns a `Color` for a specific `fileType` + /// + /// If not specified otherwise this will return `Color.accentColor` + var iconColor: SwiftUI.Color { + FileIcon.iconColor(fileType: type) + } + + /// Return the icon of the file as `NSImage` + var nsIcon: NSImage { + let systemImage = FileIcon.fileIcon(fileType: type) + if let customImage = NSImage.symbol(named: systemImage) { + return customImage + } else { + return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! + } + } + + var isExpandable: Bool { + !diagnostics.isEmpty + } + + var errorCount: Int { + diagnostics.filter { $0.diagnostic.severity == .error }.count + } + + var warningCount: Int { + diagnostics.filter { $0.diagnostic.severity == .warning }.count + } + + init(uri: DocumentUri, name: String? = nil, diagnostics: [DiagnosticIssueNode] = [], isExpanded: Bool = false) { + self.uri = uri + self.name = name ?? (URL(string: uri)?.lastPathComponent ?? "Unknown") + self.diagnostics = diagnostics + self.isExpanded = isExpanded + } + + static func == (lhs: FileIssueNode, rhs: FileIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Represents a diagnostic node in the issue navigator +class DiagnosticIssueNode: IssueNode, ObservableObject, Equatable { + let id: UUID = UUID() + let diagnostic: Diagnostic + let fileUri: DocumentUri + + var name: String { + diagnostic.message.trimmingCharacters(in: .newlines) + } + + var isExpandable: Bool { + false + } + + var nsIcon: NSImage { + switch diagnostic.severity { + case .error: + return NSImage( + systemSymbolName: "xmark.octagon.fill", + accessibilityDescription: "Error" + )! + case .warning: + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning")! + case .information: + return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Information")! + case .hint: + return NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: "Hint")! + case nil: + return NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Unknown Issue Type")! + } + } + + var severityColor: NSColor { + switch diagnostic.severity { + case .error: + return .errorRed + case .warning: + return .warningYellow + case .information: + return .blue + case .hint: + return .gray + case nil: + return .secondaryLabelColor + } + } + + var locationString: String { + "Line \(diagnostic.range.start.line + 1), Column \(diagnostic.range.start.character + 1)" + } + + init(diagnostic: Diagnostic, fileUri: DocumentUri) { + self.diagnostic = diagnostic + self.fileUri = fileUri + } + + static func == (lhs: DiagnosticIssueNode, rhs: DiagnosticIssueNode) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift index 33d0d997d..ec69de1c7 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift @@ -227,188 +227,6 @@ class IssueNavigatorViewModel: ObservableObject { } } -/// Protocol defining the common interface for nodes in the issue navigator -protocol IssueNode: Identifiable, Hashable { - var id: UUID { get } - var name: String { get } - var isExpandable: Bool { get } - var nsIcon: NSImage { get } -} - -/// Represents the project (root) node in the issue navigator -class ProjectIssueNode: IssueNode, ObservableObject, Equatable { - let id: UUID = UUID() - let name: String - - @Published var files: [FileIssueNode] - @Published var isExpanded: Bool - - var nsIcon: NSImage { - return NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "Root folder")! - } - - var isExpandable: Bool { - !files.isEmpty - } - - var diagnosticsCount: Int { - files.reduce(0) { $0 + $1.diagnostics.count } - } - - var errorCount: Int { - files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .error }.count } - } - - var warningCount: Int { - files.reduce(0) { $0 + $1.diagnostics.filter { $0.diagnostic.severity == .warning }.count } - } - - init(name: String, files: [FileIssueNode] = [], isExpanded: Bool = true) { - self.name = name - self.files = files - self.isExpanded = isExpanded - } - - static func == (lhs: ProjectIssueNode, rhs: ProjectIssueNode) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -/// Represents a file node in the issue navigator -class FileIssueNode: IssueNode, ObservableObject, Equatable { - let id: UUID = UUID() - let uri: DocumentUri - let name: String - - @Published var diagnostics: [DiagnosticIssueNode] - @Published var isExpanded: Bool - - /// Returns the extension of the file or an empty string if no extension is present. - var type: FileIcon.FileType { - let fileExtension = (uri as NSString).pathExtension.lowercased() - if !fileExtension.isEmpty { - if let type = FileIcon.FileType(rawValue: fileExtension) { - return type - } - } - return .txt - } - - /// Returns a `Color` for a specific `fileType` - /// - /// If not specified otherwise this will return `Color.accentColor` - var iconColor: SwiftUI.Color { - FileIcon.iconColor(fileType: type) - } - - /// Return the icon of the file as `NSImage` - var nsIcon: NSImage { - let systemImage = FileIcon.fileIcon(fileType: type) - if let customImage = NSImage.symbol(named: systemImage) { - return customImage - } else { - return NSImage(systemSymbolName: systemImage, accessibilityDescription: systemImage) - ?? NSImage(systemSymbolName: "doc", accessibilityDescription: "doc")! - } - } - - var isExpandable: Bool { - !diagnostics.isEmpty - } - - var errorCount: Int { - diagnostics.filter { $0.diagnostic.severity == .error }.count - } - - var warningCount: Int { - diagnostics.filter { $0.diagnostic.severity == .warning }.count - } - - init(uri: DocumentUri, name: String? = nil, diagnostics: [DiagnosticIssueNode] = [], isExpanded: Bool = false) { - self.uri = uri - self.name = name ?? (URL(string: uri)?.lastPathComponent ?? "Unknown") - self.diagnostics = diagnostics - self.isExpanded = isExpanded - } - - static func == (lhs: FileIssueNode, rhs: FileIssueNode) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -/// Represents a diagnostic node in the issue navigator -class DiagnosticIssueNode: IssueNode, ObservableObject, Equatable { - let id: UUID = UUID() - let diagnostic: Diagnostic - let fileUri: DocumentUri - - var name: String { - diagnostic.message.trimmingCharacters(in: .newlines) - } - - var isExpandable: Bool { - false - } - - var nsIcon: NSImage { - switch diagnostic.severity { - case .error: - return NSImage( - systemSymbolName: "xmark.octagon.fill", - accessibilityDescription: "Error" - )! - case .warning: - return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Warning")! - case .information: - return NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Information")! - case .hint: - return NSImage(systemSymbolName: "lightbulb.fill", accessibilityDescription: "Hint")! - case nil: - return NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Unknown Issue Type")! - } - } - - var severityColor: NSColor { - switch diagnostic.severity { - case .error: - return .errorRed - case .warning: - return .warningYellow - case .information: - return .blue - case .hint: - return .gray - case nil: - return .secondaryLabelColor - } - } - - var locationString: String { - "Line \(diagnostic.range.start.line + 1), Column \(diagnostic.range.start.character + 1)" - } - - init(diagnostic: Diagnostic, fileUri: DocumentUri) { - self.diagnostic = diagnostic - self.fileUri = fileUri - } - - static func == (lhs: DiagnosticIssueNode, rhs: DiagnosticIssueNode) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - /// Options for filtering diagnostics in the issue navigator struct IssueFilterOptions { var showErrors: Bool = true From 65263a14499246b5dc1f34a4674a8fcd9c591d02 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Jun 2025 19:25:40 -0700 Subject: [PATCH 11/15] Rename to DiagnosticsManager --- .../Documents/WorkspaceDocument/WorkspaceDocument.swift | 4 ++-- CodeEdit/Features/LSP/Service/LSPService+Events.swift | 2 +- .../DiagnosticsManager.swift} | 8 ++------ .../OutlineView/IssueNavigatorOutlineView.swift | 2 +- ...eNavigatorViewController+NSOutlineViewDataSource.swift | 4 ++-- ...sueNavigatorViewController+NSOutlineViewDelegate.swift | 8 ++++---- .../OutlineView/IssueNavigatorViewController.swift | 4 ++-- 7 files changed, 14 insertions(+), 18 deletions(-) rename CodeEdit/Features/NavigatorArea/{ViewModels/IssueNavigatorViewModel.swift => IssueNavigator/DiagnosticsManager.swift} (96%) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 034ba0049..d82b84159 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -33,7 +33,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var editorManager: EditorManager? = EditorManager() var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel() var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel() - var issueNavigatorViewModel: IssueNavigatorViewModel? = IssueNavigatorViewModel() + var diagnosticsManager: DiagnosticsManager? = DiagnosticsManager() var searchState: SearchState? var openQuicklyViewModel: OpenQuicklyViewModel? var commandsPaletteState: QuickActionsViewModel? @@ -164,7 +164,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) - issueNavigatorViewModel?.initialize(projectName: displayName) + diagnosticsManager?.initialize(projectName: displayName) } override func read(from url: URL, ofType typeName: String) throws { diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index f03fe4dd2..304cbb3b1 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -94,7 +94,7 @@ extension LSPService { // case let .windowShowMessage(params): // print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") case let .textDocumentPublishDiagnostics(params): - languageClient.workspace.issueNavigatorViewModel? + languageClient.workspace.diagnosticsManager? .updateDiagnostics(params: params) // case let .telemetryEvent(params): // print("telemetryEvent: \(params)") diff --git a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/DiagnosticsManager.swift similarity index 96% rename from CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift rename to CodeEdit/Features/NavigatorArea/IssueNavigator/DiagnosticsManager.swift index ec69de1c7..e7eb6a534 100644 --- a/CodeEdit/Features/NavigatorArea/ViewModels/IssueNavigatorViewModel.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/DiagnosticsManager.swift @@ -1,5 +1,5 @@ // -// IssueNavigatorViewModel.swift +// DiagnosticsManager.swift // CodeEdit // // Created by Abe Malla on 3/15/25. @@ -10,17 +10,14 @@ import SwiftUI import Foundation import LanguageServerProtocol -class IssueNavigatorViewModel: ObservableObject { +class DiagnosticsManager: ObservableObject { @Published var rootNode: ProjectIssueNode? @Published var filterOptions = IssueFilterOptions() @Published private(set) var filteredRootNode: ProjectIssueNode? let diagnosticsDidChangePublisher = PassthroughSubject() - // Store file nodes by URI for efficient lookup and to avoid duplication private var fileNodesByUri: [DocumentUri: FileIssueNode] = [:] - - // Track expansion state separately to persist it private var expandedFileUris: Set = [] func initialize(projectName: String) { @@ -41,7 +38,6 @@ class IssueNavigatorViewModel: ObservableObject { let fileNode: FileIssueNode if let existingNode = fileNodesByUri[uri] { fileNode = existingNode - // Clear existing diagnostics fileNode.diagnostics.removeAll(keepingCapacity: true) } else { // Create new file node diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift index d8fac052e..4712535f9 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift @@ -58,7 +58,7 @@ struct IssueNavigatorOutlineView: NSViewControllerRepresentable { // Cancel existing subscriptions cancellables.removeAll() - guard let viewModel = workspace?.issueNavigatorViewModel else { return } + guard let viewModel = workspace?.diagnosticsManager else { return } // Listen for diagnostic changes viewModel.diagnosticsDidChangePublisher diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift index 30e391d96..460dbe7dc 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift @@ -11,7 +11,7 @@ extension IssueNavigatorViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { if item == nil { // If there are no issues, don't show the project node - if let rootNode = workspace?.issueNavigatorViewModel?.filteredRootNode { + if let rootNode = workspace?.diagnosticsManager?.filteredRootNode { return rootNode.files.isEmpty ? 0 : 1 } return 0 @@ -27,7 +27,7 @@ extension IssueNavigatorViewController: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { if item == nil { - return workspace?.issueNavigatorViewModel?.filteredRootNode as Any + return workspace?.diagnosticsManager?.filteredRootNode as Any } if let node = item as? ProjectIssueNode { return node.files[index] diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift index 41210b270..78d347be4 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -94,7 +94,7 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { if let fileNode = node as? FileIssueNode { fileNode.isExpanded = true expandedItems.insert(fileNode) - workspace?.issueNavigatorViewModel?.setFileExpanded(fileNode.uri, isExpanded: true) + workspace?.diagnosticsManager?.setFileExpanded(fileNode.uri, isExpanded: true) } else if let projectNode = node as? ProjectIssueNode { projectNode.isExpanded = true } @@ -106,7 +106,7 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { if let fileNode = node as? FileIssueNode { fileNode.isExpanded = false expandedItems.remove(fileNode) - workspace?.issueNavigatorViewModel?.setFileExpanded(fileNode.uri, isExpanded: false) + workspace?.diagnosticsManager?.setFileExpanded(fileNode.uri, isExpanded: false) } else if let projectNode = node as? ProjectIssueNode { projectNode.isExpanded = false } @@ -116,11 +116,11 @@ extension IssueNavigatorViewController: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? { guard let uri = object as? String else { return nil } - if let fileNode = workspace?.issueNavigatorViewModel?.getFileNode(for: uri) { + if let fileNode = workspace?.diagnosticsManager?.getFileNode(for: uri) { return fileNode } - if let rootNode = workspace?.issueNavigatorViewModel?.filteredRootNode, + if let rootNode = workspace?.diagnosticsManager?.filteredRootNode, rootNode.id.uuidString == uri { return rootNode } diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index c3c1a1038..8b50ecc9d 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -108,7 +108,7 @@ final class IssueNavigatorViewController: NSViewController { /// Saves the current expansion state to UserDefaults private func saveExpansionState() { - guard let viewModel = workspace?.issueNavigatorViewModel else { return } + guard let viewModel = workspace?.diagnosticsManager else { return } let expandedUris = viewModel.getExpandedFileUris() let urisArray = Array(expandedUris) @@ -118,7 +118,7 @@ final class IssueNavigatorViewController: NSViewController { /// Loads the expansion state from UserDefaults private func loadExpansionState() { - guard let viewModel = workspace?.issueNavigatorViewModel else { return } + guard let viewModel = workspace?.diagnosticsManager else { return } if let urisArray = UserDefaults.standard.stringArray(forKey: expansionStateKey) { let expandedUris = Set(urisArray) From bdc0dc76481a672b07fae12997c0d6b71d4547f6 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Jun 2025 19:43:05 -0700 Subject: [PATCH 12/15] Move DiagnosticsManager to Diagnostics/ Feature folder --- .../IssueNavigator => Diagnostics}/DiagnosticsManager.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CodeEdit/Features/{NavigatorArea/IssueNavigator => Diagnostics}/DiagnosticsManager.swift (100%) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/DiagnosticsManager.swift b/CodeEdit/Features/Diagnostics/DiagnosticsManager.swift similarity index 100% rename from CodeEdit/Features/NavigatorArea/IssueNavigator/DiagnosticsManager.swift rename to CodeEdit/Features/Diagnostics/DiagnosticsManager.swift From 22be1735b4e6874e112b9eedb4fcc047c7c46c21 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 19 Jun 2025 16:10:31 -0700 Subject: [PATCH 13/15] Fix generic type --- CodeEdit/Features/LSP/Service/LSPService+Events.swift | 6 +++--- .../OutlineView/IssueNavigatorViewController.swift | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index 304cbb3b1..c1061e5c6 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -33,7 +33,7 @@ extension LSPService { private func handleEvent( _ event: ServerEvent, - for languageClient: LanguageServer + for languageClient: LanguageServer ) { // TODO: Handle Events switch event { @@ -52,7 +52,7 @@ extension LSPService { private func handleRequest( _ request: ServerRequest, - _ languageClient: LanguageServer + _ languageClient: LanguageServer ) { // TODO: Handle Requests // switch request { @@ -85,7 +85,7 @@ extension LSPService { private func handleNotification( _ notification: ServerNotification, - _ languageClient: LanguageServer + _ languageClient: LanguageServer ) { // TODO: Handle Notifications switch notification { diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index 8b50ecc9d..36b108b2f 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -168,7 +168,6 @@ final class IssueNavigatorViewController: NSViewController { } /// Toggles the expansion state of an item - @inline(__always) private func toggleExpansion(of item: Any) { if outlineView.isItemExpanded(item) { outlineView.collapseItem(item) @@ -178,7 +177,6 @@ final class IssueNavigatorViewController: NSViewController { } /// Opens a file as a permanent tab, optionally at a specific line and column - @inline(__always) private func openFileTab(fileUri: String, line: Int? = nil, column: Int? = nil) { guard let fileURL = URL(string: fileUri), let file = workspace?.workspaceFileManager?.getFile(fileURL.path) else { From 7ec3b08bbd786d6b93c84bef9bfce0390f6f01a4 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 19 Jun 2025 16:31:33 -0700 Subject: [PATCH 14/15] Move code to viewWillAppear --- .../IssueNavigatorViewController.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift index 36b108b2f..39c0aaa2f 100644 --- a/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -82,14 +82,6 @@ final class IssueNavigatorViewController: NSViewController { scrollView.autohidesScrollers = true loadExpansionState() - - // Expand the project node by default - DispatchQueue.main.async { [weak self] in - if let rootItem = self?.outlineView.item(atRow: 0) { - self?.outlineView.expandItem(rootItem) - } - self?.restoreExpandedState() - } } init() { @@ -186,6 +178,14 @@ final class IssueNavigatorViewController: NSViewController { workspace?.editorManager?.activeEditor.openTab(file: file, asTemporary: false) } + override func viewWillAppear() { + // Expand the project node by default + if let rootItem = self.outlineView.item(atRow: 0) { + self.outlineView.expandItem(rootItem) + } + self.restoreExpandedState() + } + /// Called when view will disappear - save state override func viewWillDisappear() { super.viewWillDisappear() From 82ef01d305e6135baf2424d3c9f22e2984e6bb23 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 19 Jun 2025 19:37:20 -0700 Subject: [PATCH 15/15] Fix tests --- .../Features/LSP/LanguageServer+CodeFileDocument.swift | 3 ++- .../Features/LSP/LanguageServer+DocumentObjects.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index 236f2a721..1f0b1c503 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -62,9 +62,10 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { save: nil ) ) - let server = LanguageServerType( + let server = await LanguageServerType( languageId: .swift, binary: .init(execPath: "", args: [], env: nil), + workspace: WorkspaceDocument(), lspInstance: InitializingServer( server: bufferingConnection, initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: tempTestDir.path()) diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift index 76b2e8cf3..003cea5bd 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -42,9 +42,10 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { var capabilities = ServerCapabilities() capabilities.textDocumentSync = .optionA(.init(openClose: true, change: .full)) capabilities.semanticTokensProvider = .optionA(.init(legend: .init(tokenTypes: [], tokenModifiers: []))) - server = LanguageServerType( + server = await LanguageServerType( languageId: .swift, binary: .init(execPath: "", args: [], env: nil), + workspace: WorkspaceDocument(), lspInstance: InitializingServer( server: BufferingServerConnection(), initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: "/")