From 2bf9cde27cf3afcbe1f46539a5fa6736623f8c3b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:31:21 -0500 Subject: [PATCH 1/7] Upgrade Output Utility, Language Server Output --- .../InternalDevelopmentInspectorView.swift | 1 + .../InternalDevelopmentOutputView.swift | 54 +++++ .../LSP/LanguageServer/LanguageServer.swift | 3 + .../LanguageServerFileMap.swift | 1 + .../LSP/Service/LSPService+Events.swift | 84 ++++---- .../Features/LSP/Service/LSPService.swift | 2 +- .../ExtensionUtilityAreaOutputSource.swift | 44 ++++ .../InternalDevelopmentOutputSource.swift | 44 ++++ .../Sources/LanguageServerLogContainer.swift | 62 ++++++ .../Model/UtilityAreaLogLevel.swift | 52 +++++ .../Model/UtilityAreaOutputSource.swift | 23 ++ .../OutputUtility/UtilityAreaOutputView.swift | 90 -------- .../View/UtilityAreaOutputView.swift | 202 ++++++++++++++++++ .../UtilityArea/Views/OSLogType+Color.swift | 26 --- .../WindowCommands/CodeEditCommands.swift | 22 +- .../Extensions/Date/Date+Formatted.swift | 10 + 16 files changed, 553 insertions(+), 167 deletions(-) create mode 100644 CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift delete mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift delete mode 100644 CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift index 0906bbcbfb..df1750159a 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -11,6 +11,7 @@ struct InternalDevelopmentInspectorView: View { var body: some View { Form { InternalDevelopmentNotificationsView() + InternalDevelopmentOutputView() } } } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift new file mode 100644 index 0000000000..6f86baee25 --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift @@ -0,0 +1,54 @@ +// +// InternalDevelopmentOutputView.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct InternalDevelopmentOutputView: View { + var body: some View { + Section("Output Utility") { + Button("Error Log") { + pushLog(.error) + } + Button("Warning Log") { + pushLog(.warning) + } + Button("Info Log") { + pushLog(.info) + } + Button("Debug Log") { + pushLog(.debug) + } + } + + } + + func pushLog(_ level: UtilityAreaLogLevel) { + InternalDevelopmentOutputSource.shared.pushLog( + .init( + message: randomString(), + subsystem: "internal.development", + category: "Logs", + level: level + ) + ) + } + + func randomString() -> String { + let strings = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce molestie, dui et consectetur" + + "porttitor, orci lectus fermentum augue, eu faucibus lectus nisl id velit. Suspendisse in mi nunc. Aliquam" + + "non dolor eu eros mollis euismod. Praesent mollis mauris at ex dapibus ornare. Ut imperdiet" + + "finibus lacus ut aliquam. Vivamus semper, mauris in condimentum volutpat, quam erat eleifend ligula," + + "nec tincidunt sem ante et ex. Sed dui magna, placerat quis orci at, bibendum molestie massa. Maecenas" + + "velit nunc, vehicula eu venenatis vel, tincidunt id purus. Morbi eu dignissim arcu, sed ornare odio." + + "Nam vestibulum tempus nibh id finibus.").split(separator: " ") + let count = Int.random(in: 0..<25) + return (0.. { /// The configuration options this server supports. var serverCapabilities: ServerCapabilities + var logContainer: LanguageServerLogContainer + /// An instance of a language server, that may or may not be initialized private(set) var lspInstance: InitializingServer /// The path to the root of the project @@ -58,6 +60,7 @@ class LanguageServer { self.serverCapabilities = serverCapabilities self.rootPath = rootPath self.openFiles = LanguageServerFileMap() + self.logContainer = LanguageServerLogContainer(language: languageId) self.logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer.\(languageId.rawValue)" diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index fd71a06b7a..3c4ff4d4de 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -8,6 +8,7 @@ import Foundation import LanguageServerProtocol + /// Tracks data associated with files and language servers. class LanguageServerFileMap { typealias HighlightProviderType = SemanticTokenHighlightProvider diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index b4baa73bb9..41d61f72ea 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -5,6 +5,7 @@ // Created by Abe Malla on 6/1/24. // +import Foundation import LanguageClient import LanguageServerProtocol @@ -32,64 +33,67 @@ extension LSPService { } private func handleEvent(_ event: ServerEvent, for key: ClientKey) { - // TODO: Handle Events -// switch event { -// case let .request(id, request): -// print("Request ID: \(id) for \(key.languageId.rawValue)") -// handleRequest(request) -// case let .notification(notification): -// handleNotification(notification) -// case let .error(error): -// print("Error from EventStream for \(key.languageId.rawValue): \(error)") -// } + guard let client = languageClient(for: key.languageId, workspacePath: key.workspacePath) else { + return + } + + switch event { + case let .request(_, request): + handleRequest(request, client: client) + case let .notification(notification): + handleNotification(notification, client: client) + case let .error(error): + logger.warning("Error from server \(key.languageId.rawValue, privacy: .public): \(error)") + } } - private func handleRequest(_ request: ServerRequest) { + private func handleRequest(_ request: ServerRequest, client: LanguageServerType) { // TODO: Handle Requests -// switch request { -// case let .workspaceConfiguration(params, _): -// print("workspaceConfiguration: \(params)") -// case let .workspaceFolders(handler): -// print("workspaceFolders: \(String(describing: handler))") -// case let .workspaceApplyEdit(params, _): -// print("workspaceApplyEdit: \(params)") + switch request { + // case let .workspaceConfiguration(params, _): + // print("workspaceConfiguration: \(params)") + // case let .workspaceFolders(handler): + // print("workspaceFolders: \(String(describing: handler))") + // case let .workspaceApplyEdit(params, _): + // print("workspaceApplyEdit: \(params)") // case let .clientRegisterCapability(params, _): // print("clientRegisterCapability: \(params)") // case let .clientUnregisterCapability(params, _): // print("clientUnregisterCapability: \(params)") -// case let .workspaceCodeLensRefresh(handler): -// print("workspaceCodeLensRefresh: \(String(describing: handler))") + // case let .workspaceCodeLensRefresh(handler): + // print("workspaceCodeLensRefresh: \(String(describing: handler))") // case let .workspaceSemanticTokenRefresh(handler): -// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") -// case let .windowShowMessageRequest(params, _): -// print("windowShowMessageRequest: \(params)") -// case let .windowShowDocument(params, _): -// print("windowShowDocument: \(params)") -// case let .windowWorkDoneProgressCreate(params, _): -// print("windowWorkDoneProgressCreate: \(params)") -// -// default: -// print() -// } +// print("Refresh semantic tokens!", handler) + // case let .windowShowMessageRequest(params, _): + // print("windowShowMessageRequest: \(params)") + // case let .windowShowDocument(params, _): + // print("windowShowDocument: \(params)") + // case let .windowWorkDoneProgressCreate(params, _): + // print("windowWorkDoneProgressCreate: \(params)") + default: + return + } } - private func handleNotification(_ notification: ServerNotification) { + private func handleNotification(_ notification: ServerNotification, client: LanguageServerType) { // TODO: Handle Notifications -// switch notification { -// case let .windowLogMessage(params): -// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") + switch notification { + case let .windowLogMessage(message): + client.logContainer.appendLog(message) // 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)") // case let .telemetryEvent(params): // print("telemetryEvent: \(params)") -// case let .protocolCancelRequest(params): -// print("protocolCancelRequest: \(params)") + // case let .protocolCancelRequest(params): + // print("protocolCancelRequest: \(params)") // case let .protocolProgress(params): // 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 f927dd9441..2062479b45 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -212,7 +212,7 @@ final class LSPService: ObservableObject { /// - Parameter document: The code document that was opened. func openDocument(_ document: CodeFileDocument) { guard let workspace = document.findWorkspace(), - let workspacePath = workspace.fileURL?.absoluteURL.path(), + let workspacePath = workspace.fileURL?.absolutePath, let lspLanguage = document.getLanguage().lspLanguage else { return } diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift new file mode 100644 index 0000000000..ebfe6457db --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift @@ -0,0 +1,44 @@ +// +// ExtensionUtilityAreaOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LogStream + +extension LogMessage: @retroactive Identifiable, UtilityAreaOutputMessage { + public var id: String { + "\(date.timeIntervalSince1970)" + process + (subsystem ?? "") + (category ?? "") + } + + var level: UtilityAreaLogLevel { + switch type { + case .fault, .error: + .error + case .info, .default: + .info + case .debug: + .debug + default: + .info + } + } +} + +struct ExtensionUtilityAreaOutputSource: UtilityAreaOutputSource { + var id: String { + "extension_output" + extensionInfo.id + } + + let extensionInfo: ExtensionInfo + + func cachedMessages() -> [LogMessage] { + [] + } + + func streamMessages() -> AsyncStream { + LogStream.logs(for: extensionInfo.pid, flags: [.info, .historical, .processOnly]) + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift new file mode 100644 index 0000000000..0f489b8920 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift @@ -0,0 +1,44 @@ +// +// InternalDevelopmentOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import Foundation + +class InternalDevelopmentOutputSource: UtilityAreaOutputSource { + static let shared = InternalDevelopmentOutputSource() + + struct Message: UtilityAreaOutputMessage { + var id: UUID = UUID() + + var message: String + var date: Date = Date() + var subsystem: String? + var category: String? + var level: UtilityAreaLogLevel + } + + var id: UUID = UUID() + private var logs: [Message] = [] + private(set) var streamContinuation: AsyncStream.Continuation + private var stream: AsyncStream + + init() { + (stream, streamContinuation) = AsyncStream.makeStream() + } + + func pushLog(_ log: Message) { + logs.append(log) + streamContinuation.yield(log) + } + + func cachedMessages() -> [Message] { + logs + } + + func streamMessages() -> AsyncStream { + stream + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift new file mode 100644 index 0000000000..77399baf79 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift @@ -0,0 +1,62 @@ +// +// LanguageServerLogContainer.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LanguageServerProtocol + +class LanguageServerLogContainer: UtilityAreaOutputSource { + struct LanguageServerMessage: UtilityAreaOutputMessage { + let log: LogMessageParams + var id: UUID = UUID() + + var message: String { + log.message + } + + var level: UtilityAreaLogLevel { + switch log.type { + case .error: + .error + case .warning: + .warning + case .info: + .info + case .log: + .debug + } + } + + var date: Date = Date() + var subsystem: String? + var category: String? + } + + let id: String + + private var streamContinuation: AsyncStream.Continuation + private var stream: AsyncStream + private(set) var logs: [LanguageServerMessage] = [] + + init(language: LanguageIdentifier) { + id = language.rawValue + (stream, streamContinuation) = AsyncStream.makeStream() + } + + func appendLog(_ log: LogMessageParams) { + let message = LanguageServerMessage(log: log) + logs.append(message) + streamContinuation.yield(message) + } + + func cachedMessages() -> [LanguageServerMessage] { + logs + } + + func streamMessages() -> AsyncStream { + stream + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift new file mode 100644 index 0000000000..9a04194384 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift @@ -0,0 +1,52 @@ +// +// UtilityAreaLogLevel.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +enum UtilityAreaLogLevel { + case error + case warning + case info + case debug + + var iconName: String { + switch self { + case .error: + "exclamationmark.3" + case .warning: + "exclamationmark.2" + case .info: + "info" + case .debug: + "stethoscope" + } + } + + var color: Color { + switch self { + case .error: + return Color(red: 202.0/255.0, green: 27.0/255.0, blue: 0) + case .warning: + return Color(red: 255.0/255.0, green: 186.0/255.0, blue: 0) + case .info: + return .cyan + case .debug: + return .coolGray + } + } + + var backgroundColor: Color { + switch self { + case .error: + color.opacity(0.1) + case .warning: + color.opacity(0.2) + case .info, .debug: + .clear + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift new file mode 100644 index 0000000000..2ef76aa8a9 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift @@ -0,0 +1,23 @@ +// +// UtilityAreaOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LogStream + +protocol UtilityAreaOutputMessage: Identifiable { + var message: String { get } + var date: Date { get } + var subsystem: String? { get } + var category: String? { get } + var level: UtilityAreaLogLevel { get } +} + +protocol UtilityAreaOutputSource: Identifiable { + associatedtype Message: UtilityAreaOutputMessage + func cachedMessages() -> [Message] + func streamMessages() -> AsyncStream +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift b/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift deleted file mode 100644 index 5c76724374..0000000000 --- a/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// UtilityAreaOutputView.swift -// CodeEdit -// -// Created by Austin Condiff on 5/25/23. -// - -import SwiftUI -import LogStream - -struct UtilityAreaOutputView: View { - @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - - @ObservedObject var extensionManager = ExtensionManager.shared - - @State var output: [LogMessage] = [] - - @State private var filterText = "" - - @State var selectedOutputSource: ExtensionInfo? - - var filteredOutput: [LogMessage] { - output.filter { item in - return filterText == "" ? true : item.message.contains(filterText) - } - } - - var body: some View { - UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in - Group { - if selectedOutputSource == nil { - Text("No output") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(maxHeight: .infinity) - } else { - if let ext = selectedOutputSource { - ScrollView { - VStack(alignment: .leading) { - ForEach(filteredOutput, id: \.self) { item in - HStack { - Text(item.message) - .fontWeight(.semibold) - .fontDesign(.monospaced) - .foregroundColor(item.type.color) - Spacer() - } - .padding(.leading, 2) - } - } - .padding(5) - .frame(maxWidth: .infinity) - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .task(id: ext.pid) { - output = [] - for await item in LogStream.logs(for: ext.pid, flags: [.info, .historical, .processOnly]) { - output.append(item) - } - } - } - } - } - .paneToolbar { - Picker("Output Source", selection: $selectedOutputSource) { - Text("All Sources") - .tag(nil as ExtensionInfo?) - ForEach(extensionManager.extensions) { - Text($0.name) - .tag($0 as ExtensionInfo?) - } - } - .buttonStyle(.borderless) - .labelsHidden() - .controlSize(.small) - Spacer() - UtilityAreaFilterTextField(title: "Filter", text: $filterText) - .frame(maxWidth: 175) - Button { - output = [] - } label: { - Image(systemName: "trash") - } - } - } - } -} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift new file mode 100644 index 0000000000..afdf68d546 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift @@ -0,0 +1,202 @@ +// +// UtilityAreaOutputView.swift +// CodeEdit +// +// Created by Austin Condiff on 5/25/23. +// + +import SwiftUI +import LogStream + +struct UtilityAreaOutputView: View { + enum Sources: Hashable { + case extensions(ExtensionUtilityAreaOutputSource) + case languageServer(LanguageServerLogContainer) + case devOutput + + public static func == (_ lhs: Sources, _ rhs: Sources) -> Bool { + switch (lhs, rhs) { + case let (.extensions(lhs), .extensions(rhs)): + return lhs.id == rhs.id + case let (.languageServer(lhs), .languageServer(rhs)): + return lhs.id == rhs.id + case (.devOutput, .devOutput): + return true + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .extensions(let source): + hasher.combine(0) + hasher.combine(source.id) + case .languageServer(let source): + hasher.combine(1) + hasher.combine(source.id) + case .devOutput: + hasher.combine(2) + } + } + } + + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + + @EnvironmentObject private var workspace: WorkspaceDocument + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + @ObservedObject var extensionManager = ExtensionManager.shared + @Service var lspService: LSPService + + @State private var filterText: String = "" + @State private var selectedSource: Sources? + + var languageServerClients: [LSPService.LanguageServerType] { + lspService.languageClients.compactMap { (key: LSPService.ClientKey, value: LSPService.LanguageServerType) in + if key.workspacePath == workspace.fileURL?.absolutePath { + return value + } + return nil + } + } + + var body: some View { + UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in + Group { + if let selectedSource { + switch selectedSource { + case .extensions(let source): + OutputView(source: source, filterText: $filterText) + case .languageServer(let source): + OutputView(source: source, filterText: $filterText) + case .devOutput: + OutputView(source: InternalDevelopmentOutputSource.shared, filterText: $filterText) + } + } else { + Text("No output") + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(maxHeight: .infinity) + } + } + .paneToolbar { + Picker("Output Source", selection: $selectedSource) { + if selectedSource == nil { + Text("No Selection") + .tag(nil as Sources?) + } + + if extensionManager.extensions.isEmpty { + Text("No Extensions") + } + ForEach(extensionManager.extensions) { extensionInfo in + Text(extensionInfo.name) + .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) + } + Divider() + + if languageServerClients.isEmpty { + Text("No Language Servers") + } + ForEach(languageServerClients, id: \.languageId) { server in + Text(server.languageId.rawValue) + .tag(Sources.languageServer(server.logContainer)) + } + + if showInternalDevelopmentInspector { + Divider() + Text("Development Output") + .tag(Sources.devOutput) + } + } + .buttonStyle(.borderless) + .labelsHidden() + .controlSize(.small) + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) +// Button { +// output = [] +// } label: { +// Image(systemName: "trash") +// } + } + } + } + + struct OutputView: View { + let source: Source + + @State var output: [Source.Message] = [] + @Binding var filterText: String + + var filteredOutput: [Source.Message] { + if filterText.isEmpty { + return output + } + return output.filter { item in + return filterText == "" ? true : item.message.contains(filterText) + } + } + + var body: some View { + List(filteredOutput.reversed()) { item in + VStack(spacing: 2) { + HStack(spacing: 0) { + Text(item.message) + .fontDesign(.monospaced) + .font(.system(size: 12, weight: .regular).monospaced()) + Spacer(minLength: 0) + } + HStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: item.level.iconName) + .foregroundColor(.white) + .font(.system(size: 7, weight: .semibold)) + .frame(width: 12, height: 12) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(item.level.color) + .aspectRatio(1.0, contentMode: .fit) + ) + Text(item.date.logFormatted()) + .fontWeight(.medium) + } + if let subsystem = item.subsystem { + HStack(spacing: 2) { + Image(systemName: "gearshape.2") + .font(.system(size: 8, weight: .regular)) + Text(subsystem) + } + } + if let category = item.category { + HStack(spacing: 2) { + Image(systemName: "square.grid.3x3") + .font(.system(size: 8, weight: .regular)) + Text(category) + } + } + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .font(.system(size: 9, weight: .semibold).monospaced()) + } + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + .listRowBackground(item.level.backgroundColor) + } + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .task(id: source.id) { + output = source.cachedMessages() + for await item in source.streamMessages() { + output.append(item) + } + } + } + } +} diff --git a/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift b/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift deleted file mode 100644 index 94535886df..0000000000 --- a/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// OSLogType+Color.swift -// CodeEdit -// -// Created by Wouter Hennen on 22/05/2023. -// - -import OSLog -import SwiftUI - -extension OSLogType { - var color: Color { - switch self { - case .error: - return .orange - case .debug, .default: - return .primary - case .fault: - return .red - case .info: - return .cyan - default: - return .green - } - } -} diff --git a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift index e3e9d0925a..5e2d664134 100644 --- a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift +++ b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift @@ -12,16 +12,18 @@ struct CodeEditCommands: Commands { private var sourceControlIsEnabled var body: some Commands { - MainCommands() - FileCommands() - ViewCommands() - FindCommands() - NavigateCommands() - TasksCommands() - if sourceControlIsEnabled { SourceControlCommands() } - EditorCommands() - ExtensionCommands() - WindowCommands() + Group { // SwiftUI limits to 9 items in an initializer, so we have to group every 9 items. + MainCommands() + FileCommands() + ViewCommands() + FindCommands() + NavigateCommands() + TasksCommands() + if sourceControlIsEnabled { SourceControlCommands() } + EditorCommands() + ExtensionCommands() + WindowCommands() + } HelpCommands() } } diff --git a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift b/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift index 0d1f2d1056..54ada62530 100644 --- a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift +++ b/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift @@ -36,4 +36,14 @@ extension Date { return formatter.string(from: self) } + + static var logFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSSS" + return formatter + }() + + func logFormatted() -> String { + Self.logFormatter.string(from: self) + } } From 61a5e496ada10c49a193b31f98d89138d84bef37 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:31:36 -0500 Subject: [PATCH 2/7] fix:lint --- CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index 3c4ff4d4de..fd71a06b7a 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -8,7 +8,6 @@ import Foundation import LanguageServerProtocol - /// Tracks data associated with files and language servers. class LanguageServerFileMap { typealias HighlightProviderType = SemanticTokenHighlightProvider From d26598f494cbf87d7ecb96d5f24df583a3df6446 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:08:05 -0500 Subject: [PATCH 3/7] Don't Buffer LSP Logs in The Stream --- .../Model/Sources/LanguageServerLogContainer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift index 77399baf79..60e29c0d89 100644 --- a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift @@ -43,7 +43,9 @@ class LanguageServerLogContainer: UtilityAreaOutputSource { init(language: LanguageIdentifier) { id = language.rawValue - (stream, streamContinuation) = AsyncStream.makeStream() + (stream, streamContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(0) + ) } func appendLog(_ log: LogMessageParams) { From 66197d7f009842de9e807a0294f8ef6651056018 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:08:16 -0500 Subject: [PATCH 4/7] Publish languageClients --- CodeEdit/Features/LSP/Service/LSPService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 2062479b45..559413ec29 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -115,7 +115,7 @@ final class LSPService: ObservableObject { } /// Holds the active language clients - var languageClients: [ClientKey: LanguageServerType] = [:] + @Published var languageClients: [ClientKey: LanguageServerType] = [:] /// Holds the language server configurations for all the installed language servers var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] /// Holds all the event listeners for each active language client From 24c0e990a809b83f1aad5c4c6231b220cfd8191b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:17:26 -0500 Subject: [PATCH 5/7] Make output picker live update --- .../View/UtilityAreaOutputLogList.swift | 101 +++++++++++ .../View/UtilityAreaOutputSourcePicker.swift | 83 +++++++++ .../View/UtilityAreaOutputView.swift | 164 ++++-------------- 3 files changed, 215 insertions(+), 133 deletions(-) create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift create mode 100644 CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift new file mode 100644 index 0000000000..ae606b3b11 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift @@ -0,0 +1,101 @@ +// +// UtilityAreaOutputLogList.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct UtilityAreaOutputLogList: View { + let source: Source + + @State var output: [Source.Message] = [] + @Binding var filterText: String + var toolbar: () -> Toolbar + + init(source: Source, filterText: Binding, @ViewBuilder toolbar: @escaping () -> Toolbar) { + self.source = source + self._filterText = filterText + self.toolbar = toolbar + } + + var filteredOutput: [Source.Message] { + if filterText.isEmpty { + return output + } + return output.filter { item in + return filterText == "" ? true : item.message.contains(filterText) + } + } + + var body: some View { + List(filteredOutput.reversed()) { item in + VStack(spacing: 2) { + HStack(spacing: 0) { + Text(item.message) + .fontDesign(.monospaced) + .font(.system(size: 12, weight: .regular).monospaced()) + Spacer(minLength: 0) + } + HStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: item.level.iconName) + .foregroundColor(.white) + .font(.system(size: 7, weight: .semibold)) + .frame(width: 12, height: 12) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(item.level.color) + .aspectRatio(1.0, contentMode: .fit) + ) + Text(item.date.logFormatted()) + .fontWeight(.medium) + } + if let subsystem = item.subsystem { + HStack(spacing: 2) { + Image(systemName: "gearshape.2") + .font(.system(size: 8, weight: .regular)) + Text(subsystem) + } + } + if let category = item.category { + HStack(spacing: 2) { + Image(systemName: "square.grid.3x3") + .font(.system(size: 8, weight: .regular)) + Text(category) + } + } + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .font(.system(size: 9, weight: .semibold).monospaced()) + } + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + .listRowBackground(item.level.backgroundColor) + } + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .task(id: source.id) { + output = source.cachedMessages() + for await item in source.streamMessages() { + output.append(item) + } + } + .paneToolbar { + toolbar() + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) + Button { + output.removeAll(keepingCapacity: true) + } label: { + Image(systemName: "trash") + } + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift new file mode 100644 index 0000000000..c38c4f79ac --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift @@ -0,0 +1,83 @@ +// +// UtilityAreaOutputSourcePicker.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct UtilityAreaOutputSourcePicker: View { + typealias Sources = UtilityAreaOutputView.Sources + + @EnvironmentObject private var workspace: WorkspaceDocument + + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + + @Binding var selectedSource: Sources? + + @ObservedObject var extensionManager = ExtensionManager.shared + + @Service var lspService: LSPService + @State private var updater: UUID = UUID() + @State private var languageServerClients: [LSPService.LanguageServerType] = [] + + var body: some View { + Picker("Output Source", selection: $selectedSource) { + if languageServerClients.isEmpty { + Text("No Language Servers") + } else { + ForEach(languageServerClients, id: \.languageId) { server in + Text(server.languageId.rawValue) + .tag(Sources.languageServer(server.logContainer)) + } + } + + Divider() + + if extensionManager.extensions.isEmpty { + Text("No Extensions") + } else { + ForEach(extensionManager.extensions) { extensionInfo in + Text(extensionInfo.name) + .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) + } + } + + if showInternalDevelopmentInspector { + Divider() + Text("Development Output") + .tag(Sources.devOutput) + } + } + .id(updater) + .buttonStyle(.borderless) + .labelsHidden() + .controlSize(.small) + .onAppear { + updateLanguageServers(lspService.languageClients) + } + .onReceive(lspService.$languageClients) { clients in + updateLanguageServers(clients) + } + .onReceive(extensionManager.$extensions) { _ in + updater = UUID() + } + } + + func updateLanguageServers(_ clients: [LSPService.ClientKey: LSPService.LanguageServerType]) { + languageServerClients = clients + .compactMap { (key, value) in + if key.workspacePath == workspace.fileURL?.absolutePath { + return value + } + return nil + } + .sorted(by: { $0.languageId.rawValue < $1.languageId.rawValue }) + if selectedSource == nil, let client = languageServerClients.first { + selectedSource = Sources.languageServer(client.logContainer) + } + updater = UUID() + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift index afdf68d546..94bd5a5a7c 100644 --- a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift @@ -14,6 +14,17 @@ struct UtilityAreaOutputView: View { case languageServer(LanguageServerLogContainer) case devOutput + var title: String { + switch self { + case .extensions(let source): + "Extension - \(source.extensionInfo.name)" + case .languageServer(let source): + "Language Server - \(source.id)" + case .devOutput: + "Internal Development Output" + } + } + public static func == (_ lhs: Sources, _ rhs: Sources) -> Bool { switch (lhs, rhs) { case let (.extensions(lhs), .extensions(rhs)): @@ -41,160 +52,47 @@ struct UtilityAreaOutputView: View { } } - @AppSettings(\.developerSettings.showInternalDevelopmentInspector) - var showInternalDevelopmentInspector - - @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - @ObservedObject var extensionManager = ExtensionManager.shared - @Service var lspService: LSPService @State private var filterText: String = "" @State private var selectedSource: Sources? - var languageServerClients: [LSPService.LanguageServerType] { - lspService.languageClients.compactMap { (key: LSPService.ClientKey, value: LSPService.LanguageServerType) in - if key.workspacePath == workspace.fileURL?.absolutePath { - return value - } - return nil - } - } - var body: some View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in Group { if let selectedSource { switch selectedSource { case .extensions(let source): - OutputView(source: source, filterText: $filterText) + UtilityAreaOutputLogList(source: source, filterText: $filterText) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } case .languageServer(let source): - OutputView(source: source, filterText: $filterText) + UtilityAreaOutputLogList(source: source, filterText: $filterText) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } case .devOutput: - OutputView(source: InternalDevelopmentOutputSource.shared, filterText: $filterText) + UtilityAreaOutputLogList( + source: InternalDevelopmentOutputSource.shared, + filterText: $filterText + ) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } } } else { Text("No output") .font(.system(size: 16)) .foregroundColor(.secondary) .frame(maxHeight: .infinity) - } - } - .paneToolbar { - Picker("Output Source", selection: $selectedSource) { - if selectedSource == nil { - Text("No Selection") - .tag(nil as Sources?) - } - - if extensionManager.extensions.isEmpty { - Text("No Extensions") - } - ForEach(extensionManager.extensions) { extensionInfo in - Text(extensionInfo.name) - .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) - } - Divider() - - if languageServerClients.isEmpty { - Text("No Language Servers") - } - ForEach(languageServerClients, id: \.languageId) { server in - Text(server.languageId.rawValue) - .tag(Sources.languageServer(server.logContainer)) - } - - if showInternalDevelopmentInspector { - Divider() - Text("Development Output") - .tag(Sources.devOutput) - } - } - .buttonStyle(.borderless) - .labelsHidden() - .controlSize(.small) - Spacer() - UtilityAreaFilterTextField(title: "Filter", text: $filterText) - .frame(maxWidth: 175) -// Button { -// output = [] -// } label: { -// Image(systemName: "trash") -// } - } - } - } - - struct OutputView: View { - let source: Source - - @State var output: [Source.Message] = [] - @Binding var filterText: String - - var filteredOutput: [Source.Message] { - if filterText.isEmpty { - return output - } - return output.filter { item in - return filterText == "" ? true : item.message.contains(filterText) - } - } - - var body: some View { - List(filteredOutput.reversed()) { item in - VStack(spacing: 2) { - HStack(spacing: 0) { - Text(item.message) - .fontDesign(.monospaced) - .font(.system(size: 12, weight: .regular).monospaced()) - Spacer(minLength: 0) - } - HStack(spacing: 6) { - HStack(spacing: 4) { - Image(systemName: item.level.iconName) - .foregroundColor(.white) - .font(.system(size: 7, weight: .semibold)) - .frame(width: 12, height: 12) - .background( - RoundedRectangle(cornerRadius: 2) - .fill(item.level.color) - .aspectRatio(1.0, contentMode: .fit) - ) - Text(item.date.logFormatted()) - .fontWeight(.medium) - } - if let subsystem = item.subsystem { - HStack(spacing: 2) { - Image(systemName: "gearshape.2") - .font(.system(size: 8, weight: .regular)) - Text(subsystem) + .paneToolbar { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) + Button { } label: { + Image(systemName: "trash") } + .disabled(true) } - if let category = item.category { - HStack(spacing: 2) { - Image(systemName: "square.grid.3x3") - .font(.system(size: 8, weight: .regular)) - Text(category) - } - } - Spacer(minLength: 0) - } - .foregroundStyle(.secondary) - .font(.system(size: 9, weight: .semibold).monospaced()) - } - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - .listRowBackground(item.level.backgroundColor) - } - .listStyle(.plain) - .listRowInsets(EdgeInsets()) - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .task(id: source.id) { - output = source.cachedMessages() - for await item in source.streamMessages() { - output.append(item) } } } From 9040a1a559cad767c5077c2e2c7b4846c6b70839 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:18:01 -0500 Subject: [PATCH 6/7] Clean up picker title --- .../OutputUtility/View/UtilityAreaOutputSourcePicker.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift index c38c4f79ac..57796a2d4e 100644 --- a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift @@ -29,7 +29,7 @@ struct UtilityAreaOutputSourcePicker: View { Text("No Language Servers") } else { ForEach(languageServerClients, id: \.languageId) { server in - Text(server.languageId.rawValue) + Text(Sources.languageServer(server.logContainer).title) .tag(Sources.languageServer(server.logContainer)) } } @@ -40,14 +40,14 @@ struct UtilityAreaOutputSourcePicker: View { Text("No Extensions") } else { ForEach(extensionManager.extensions) { extensionInfo in - Text(extensionInfo.name) + Text(Sources.extensions(.init(extensionInfo: extensionInfo)).title) .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) } } if showInternalDevelopmentInspector { Divider() - Text("Development Output") + Text(Sources.devOutput.title) .tag(Sources.devOutput) } } From 2dcbfc3c873f32d0557fdf29164143d11d780d19 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:23:21 -0500 Subject: [PATCH 7/7] Clean Up Picker --- .../OutputUtility/View/UtilityAreaOutputSourcePicker.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift index 57796a2d4e..4d65d39d80 100644 --- a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift @@ -25,6 +25,13 @@ struct UtilityAreaOutputSourcePicker: View { var body: some View { Picker("Output Source", selection: $selectedSource) { + if selectedSource == nil { + Text("No Selected Output Source") + .italic() + .tag(Sources?.none) + Divider() + } + if languageServerClients.isEmpty { Text("No Language Servers") } else {