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/Diagnostics/DiagnosticsManager.swift b/CodeEdit/Features/Diagnostics/DiagnosticsManager.swift new file mode 100644 index 000000000..e7eb6a534 --- /dev/null +++ b/CodeEdit/Features/Diagnostics/DiagnosticsManager.swift @@ -0,0 +1,253 @@ +// +// DiagnosticsManager.swift +// CodeEdit +// +// Created by Abe Malla on 3/15/25. +// + +import Combine +import SwiftUI +import Foundation +import LanguageServerProtocol + +class DiagnosticsManager: ObservableObject { + @Published var rootNode: ProjectIssueNode? + @Published var filterOptions = IssueFilterOptions() + @Published private(set) var filteredRootNode: ProjectIssueNode? + + let diagnosticsDidChangePublisher = PassthroughSubject() + + private var fileNodesByUri: [DocumentUri: FileIssueNode] = [:] + private var expandedFileUris: Set = [] + + func initialize(projectName: String) { + self.rootNode = ProjectIssueNode(name: projectName) + self.filteredRootNode = ProjectIssueNode(name: projectName) + } + + func updateDiagnostics(params: PublishDiagnosticsParams) { + let uri = params.uri + let diagnostics = params.diagnostics + + if diagnostics.isEmpty { + // Remove the file node if no diagnostics + fileNodesByUri.removeValue(forKey: uri) + expandedFileUris.remove(uri) + } else { + // Get or create file node + let fileNode: FileIssueNode + if let existingNode = fileNodesByUri[uri] { + fileNode = existingNode + fileNode.diagnostics.removeAll(keepingCapacity: true) + } else { + // Create new file node + let fileName = getFileName(from: uri) + fileNode = FileIssueNode(uri: uri, name: fileName) + fileNodesByUri[uri] = fileNode + } + + // Convert diagnostics to diagnostic nodes and add to file node + let diagnosticNodes = diagnostics.map { diagnostic in + DiagnosticIssueNode(diagnostic: diagnostic, fileUri: uri) + } + + // Sort diagnostics by severity and line number + let sortedDiagnosticNodes = diagnosticNodes.sorted { node1, node2 in + let severity1 = node1.diagnostic.severity?.rawValue ?? Int.max + let severity2 = node2.diagnostic.severity?.rawValue ?? Int.max + + if severity1 == severity2 { + // If same severity, sort by line number + return node1.diagnostic.range.start.line < node2.diagnostic.range.start.line + } + + return severity1 < severity2 + } + + fileNode.diagnostics = sortedDiagnosticNodes + + // Restore expansion state if it was previously expanded + if expandedFileUris.contains(uri) { + fileNode.isExpanded = true + } + } + + rebuildTree() + diagnosticsDidChangePublisher.send() + } + + func clearDiagnostics() { + fileNodesByUri.removeAll() + expandedFileUris.removeAll() + rebuildTree() + diagnosticsDidChangePublisher.send() + } + + func removeDiagnostics(uri: DocumentUri) { + fileNodesByUri.removeValue(forKey: uri) + expandedFileUris.remove(uri) + rebuildTree() + diagnosticsDidChangePublisher.send() + } + + func updateFilter(options: IssueFilterOptions) { + self.filterOptions = options + applyFilter() + diagnosticsDidChangePublisher.send() + } + + /// Save expansion state for a file + func setFileExpanded(_ uri: DocumentUri, isExpanded: Bool) { + if isExpanded { + expandedFileUris.insert(uri) + } else { + expandedFileUris.remove(uri) + } + + if let fileNode = fileNodesByUri[uri] { + fileNode.isExpanded = isExpanded + } + } + + /// Get all expanded file URIs for persistence + func getExpandedFileUris() -> Set { + 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 { + let filteredDiagnostics = fileNode.diagnostics.filter { + filterOptions.shouldShow(diagnostic: $0.diagnostic) + } + + if !filteredDiagnostics.isEmpty { + let filteredFileNode = FileIssueNode( + uri: fileNode.uri, + name: fileNode.name, + diagnostics: filteredDiagnostics, + isExpanded: fileNode.isExpanded + ) + filteredRoot.files.append(filteredFileNode) + } + } + + filteredRoot.files.sort { $0.name < $1.name } + filteredRootNode = filteredRoot + } + + /// Rebuilds the tree structure based on current diagnostics + private func rebuildTree() { + guard let rootNode else { return } + + let projectExpanded = rootNode.isExpanded + let sortedFileNodes = fileNodesByUri.values + .sorted { $0.name < $1.name } + + rootNode.files = sortedFileNodes + rootNode.isExpanded = projectExpanded + + applyFilter() + } + + /// Extracts file name from document URI + private func getFileName(from uri: DocumentUri) -> String { + if let url = URL(string: uri) { + return url.lastPathComponent + } + + let components = uri.split(separator: "/") + return String(components.last ?? "Unknown") + } + + func getAllDiagnostics() -> [Diagnostic] { + return fileNodesByUri.values.flatMap { fileNode in + fileNode.diagnostics.map { $0.diagnostic } + } + } + + func getDiagnostics(for uri: DocumentUri) -> [Diagnostic]? { + return fileNodesByUri[uri]?.diagnostics.map { $0.diagnostic } + } + + func getFileNode(for uri: DocumentUri) -> FileIssueNode? { + return fileNodesByUri[uri] + } + + func getDiagnosticCountBySeverity() -> [DiagnosticSeverity?: Int] { + let allDiagnostics = getAllDiagnostics() + var countBySeverity: [DiagnosticSeverity?: Int] = [:] + + for severity in DiagnosticSeverity.allCases { + countBySeverity[severity] = allDiagnostics.filter { $0.severity == severity }.count + } + + countBySeverity[nil] = allDiagnostics.filter { $0.severity == nil }.count + return countBySeverity + } + + func getDiagnosticAt(uri: DocumentUri, line: Int, character: Int) -> Diagnostic? { + guard let fileNode = fileNodesByUri[uri] else { return nil } + + return fileNode.diagnostics.first { diagnosticNode in + let range = diagnosticNode.diagnostic.range + + // Check if position is within the diagnostic range + if line < range.start.line || line > range.end.line { + return false + } + if line == range.start.line && character < range.start.character { + return false + } + if line == range.end.line && character > range.end.character { + return false + } + return true + }?.diagnostic + } +} + +/// Options for filtering diagnostics in the issue navigator +struct IssueFilterOptions { + var showErrors: Bool = true + var showWarnings: Bool = true + var showInformation: Bool = true + var showHints: Bool = true + var searchText: String = "" + + func shouldShow(diagnostic: Diagnostic) -> Bool { + if let severity = diagnostic.severity { + switch severity { + case .error: + guard showErrors else { return false } + case .warning: + guard showWarnings else { return false } + case .information: + guard showInformation else { return false } + case .hint: + guard showHints else { return false } + } + } + + if !searchText.isEmpty { + return diagnostic.message.lowercased().contains(searchText.lowercased()) + } + return true + } +} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index a47fdbba8..21ffeabd6 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -35,6 +35,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var editorManager: EditorManager? = EditorManager() var statusBarViewModel: StatusBarViewModel? = StatusBarViewModel() var utilityAreaModel: UtilityAreaViewModel? = UtilityAreaViewModel() + var diagnosticsManager: DiagnosticsManager? = DiagnosticsManager() var searchState: SearchState? var openQuicklyViewModel: OpenQuicklyViewModel? var commandsPaletteState: QuickActionsViewModel? @@ -164,6 +165,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) + + diagnosticsManager?.initialize(projectName: displayName) } override func read(from url: URL, ofType typeName: String) throws { 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/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index a7c48bb25..240f8d922 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -24,6 +24,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 @@ -44,12 +46,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 @@ -74,6 +78,7 @@ class LanguageServer { static func createServer( for languageId: LanguageIdentifier, with binary: LanguageServerBinary, + workspace: WorkspaceDocument, workspacePath: String ) async throws -> LanguageServer { let executionParams = Process.ExecutionParameters( @@ -90,6 +95,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..c1061e5c6 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,29 @@ 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: + // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED + return + } } - private func handleRequest(_ request: ServerRequest) { + private func handleRequest( + _ request: ServerRequest, + _ languageClient: LanguageServer + ) { // TODO: Handle Requests // switch request { // case let .workspaceConfiguration(params, _): @@ -69,19 +78,24 @@ extension LSPService { // print("windowWorkDoneProgressCreate: \(params)") // // default: +// // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED // print() // } } - 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): + languageClient.workspace.diagnosticsManager? + .updateDiagnostics(params: params) // case let .telemetryEvent(params): // print("telemetryEvent: \(params)") // case let .protocolCancelRequest(params): @@ -90,6 +104,9 @@ extension LSPService { // print("protocolProgress: \(params)") // case let .protocolLogTrace(params): // print("protocolLogTrace: \(params)") -// } + default: + // TODO: REMOVE THIS DEFAULT WHEN THE REST ARE IMPLEMENTED + return + } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index df74fb139..43ccc31a0 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -181,6 +181,7 @@ final class LSPService: ObservableObject { /// - Returns: The new language server. func startServer( for languageId: LanguageIdentifier, + workspace: WorkspaceDocument, workspacePath: String ) async throws -> LanguageServerType { guard let serverBinary = languageConfigs[languageId] else { @@ -192,6 +193,7 @@ final class LSPService: ObservableObject { let server = try await LanguageServerType.createServer( for: languageId, with: serverBinary, + workspace: workspace, workspacePath: workspacePath ) languageClients[ClientKey(languageId, workspacePath)] = server @@ -218,7 +220,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 new file mode 100644 index 000000000..ceb61728f --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/IssueNavigatorView.swift @@ -0,0 +1,19 @@ +// +// IssueNavigatorView.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +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 { + IssueNavigatorOutlineView() + } +} 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 new file mode 100644 index 000000000..4712535f9 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorOutlineView.swift @@ -0,0 +1,102 @@ +// +// 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 + + context.coordinator.controller = controller + context.coordinator.setupObservers() + + return controller + } + + 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 { + 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() { + // Cancel existing subscriptions + cancellables.removeAll() + + guard let viewModel = workspace?.diagnosticsManager else { return } + + // Listen for diagnostic changes + viewModel.diagnosticsDidChangePublisher + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + 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+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+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift new file mode 100644 index 000000000..460dbe7dc --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDataSource.swift @@ -0,0 +1,56 @@ +// +// 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 { + // If there are no issues, don't show the project node + if let rootNode = workspace?.diagnosticsManager?.filteredRootNode { + return rootNode.files.isEmpty ? 0 : 1 + } + return 0 + } + 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?.diagnosticsManager?.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..78d347be4 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController+NSOutlineViewDelegate.swift @@ -0,0 +1,154 @@ +// +// 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) + + if let node = item as? (any IssueNode) { + return IssueTableViewCell(frame: frameRect, node: node) + } + 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 + 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 + } + + func outlineViewColumnDidResize(_ notification: Notification) { + // Disable animations temporarily + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + + let indexes = IndexSet(integersIn: 0.. Any? { + guard let uri = object as? String else { return nil } + + if let fileNode = workspace?.diagnosticsManager?.getFileNode(for: uri) { + return fileNode + } + + if let rootNode = workspace?.diagnosticsManager?.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, + 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..39c0aaa2f --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/IssueNavigator/OutlineView/IssueNavigatorViewController.swift @@ -0,0 +1,194 @@ +// +// 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() + } + } + } + + /// 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 + + /// 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() + self.scrollView.hasVerticalScroller = true + self.view = scrollView + + self.outlineView = NSOutlineView() + self.outlineView.dataSource = self + self.outlineView.delegate = self + 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 + self.outlineView.doubleAction = #selector(onItemDoubleClicked) + 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 + + loadExpansionState() + } + + init() { + super.init(nibName: nil, bundle: nil) + } + + deinit { + saveExpansionState() + outlineView?.removeFromSuperview() + scrollView?.removeFromSuperview() + } + + required init?(coder: NSCoder) { + fatalError() + } + + /// Saves the current expansion state to UserDefaults + private func saveExpansionState() { + guard let viewModel = workspace?.diagnosticsManager else { return } + + let expandedUris = viewModel.getExpandedFileUris() + let urisArray = Array(expandedUris) + + UserDefaults.standard.set(urisArray, forKey: expansionStateKey) + } + + /// Loads the expansion state from UserDefaults + private func loadExpansionState() { + guard let viewModel = workspace?.diagnosticsManager else { return } + + if let urisArray = UserDefaults.standard.stringArray(forKey: expansionStateKey) { + let expandedUris = Set(urisArray) + viewModel.restoreExpandedFileUris(expandedUris) + } + } + + /// Restores the expanded state of items based on their model state + public func restoreExpandedState() { + // Expand root if it should be expanded + if let rootItem = outlineView.item(atRow: 0) as? ProjectIssueNode, + rootItem.isExpanded { + outlineView.expandItem(rootItem) + } + + // Expand file nodes based on their expansion state + for row in 0.. 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/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/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/OutlineView/StandardTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift index 3f1becd8d..e7371c7f1 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 @@ -50,17 +49,16 @@ class StandardTableViewCell: NSTableCellView { // Create the icon let icon = createIcon() configIcon(icon: icon) - addSubview(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) } @@ -124,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, @@ -134,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() } } @@ -149,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 9256c3e3e..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) } @@ -97,8 +97,8 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { /// Finds and selects an ``Item`` from an array of ``Item`` and their `children` based on the `id`. /// - Parameters: - /// - id: the id of the item item - /// - collection: the array to search for + /// - id: The id of the item. + /// - collection: The array to search for. /// - forcesReveal: The boolean to indicates whether or not it should force to reveal the selected file. func select(by id: EditorTabID, forcesReveal: Bool) { guard case .codeEditor(let path) = 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 f68170535..7484687cf 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -107,7 +107,7 @@ final class ProjectNavigatorViewController: NSViewController { outlineView.expandItem(outlineView.item(atRow: 0)) - /// Get autosave expanded items. + // Get autosave expanded items. for row in 0..