diff --git a/ApplicationLibrary/Views/Log/LogTextView.swift b/ApplicationLibrary/Views/Log/LogTextView.swift index e96664c..7524e15 100644 --- a/ApplicationLibrary/Views/Log/LogTextView.swift +++ b/ApplicationLibrary/Views/Log/LogTextView.swift @@ -26,28 +26,71 @@ struct LogTextView: View { class LogCoordinator { var lastLogsCount: Int = 0 var lastLog: LogEntry? + var lastSearchText: String = "" + var cachedAttributedString: NSAttributedString? - func shouldUpdate(logs: [LogEntry]) -> Bool { + func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy { let currentCount = logs.count - if currentCount == lastLogsCount, currentCount > 0 { + let searchChanged = searchText != lastSearchText + + // Check if nothing changed + if currentCount == lastLogsCount, currentCount > 0, !searchChanged { if let lastLog = logs.last, let previousLastLog = self.lastLog { if lastLog.id == previousLastLog.id { - return false + return .noUpdate } } } + + // Determine update strategy + let strategy: UpdateStrategy + if currentCount == 0 || searchChanged || lastLogsCount > currentCount { + // Full rebuild needed + strategy = .fullRebuild + cachedAttributedString = nil + } else if currentCount > lastLogsCount { + // Incremental update possible + strategy = .incremental(from: lastLogsCount) + } else { + // Same count but different last log (shouldn't happen normally) + strategy = .fullRebuild + cachedAttributedString = nil + } + lastLogsCount = currentCount lastLog = logs.last - return true + lastSearchText = searchText + return strategy + } + + enum UpdateStrategy { + case noUpdate + case fullRebuild + case incremental(from: Int) } } #if os(iOS) || os(macOS) - private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String) -> NSAttributedString { - let result = NSMutableAttributedString() + private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, baseAttributedString: NSAttributedString? = nil, startIndex: Int = 0) -> NSAttributedString { + let result: NSMutableAttributedString let highlightColor: PlatformColor = .systemYellow - for (index, log) in logs.enumerated() { + if let base = baseAttributedString { + result = NSMutableAttributedString(attributedString: base) + // Add newline separator if appending to existing content + if result.length > 0 { + result.append(NSAttributedString(string: "\n", attributes: [ + .foregroundColor: defaultColor, + .font: monoFont, + ])) + } + } else { + result = NSMutableAttributedString() + } + + let logsToProcess = logs[startIndex...] + + for (offset, log) in logsToProcess.enumerated() { let attributedString = ANSIColors.parseAnsiString(log.message) let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters)) @@ -72,7 +115,8 @@ class LogCoordinator { result.append(nsAttributedString) - if index < logs.count - 1 { + let isLastLog = (startIndex + offset) == (logs.count - 1) + if !isLastLog { result.append(NSAttributedString(string: "\n", attributes: [ .foregroundColor: defaultColor, .font: monoFont, @@ -152,8 +196,20 @@ class LogCoordinator { } func updateUIView(_ textView: UITextView, context: Context) { - guard context.coordinator.shouldUpdate(logs: logs) else { return } - textView.attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) + let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + + switch updateStrategy { + case .noUpdate: + return + case .fullRebuild: + let attributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) + context.coordinator.cachedAttributedString = attributedString + textView.attributedText = attributedString + case let .incremental(from: startIndex): + let attributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: context.coordinator.cachedAttributedString, startIndex: startIndex) + context.coordinator.cachedAttributedString = attributedString + textView.attributedText = attributedString + } } func makeCoordinator() -> LogCoordinator { @@ -200,13 +256,22 @@ class LogCoordinator { guard let textView = scrollView.documentView as? NSTextView else { return } let lastCount = context.coordinator.lastLogsCount - guard context.coordinator.shouldUpdate(logs: logs) else { return } + let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + + switch updateStrategy { + case .noUpdate: + return + case .fullRebuild: + let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) + context.coordinator.cachedAttributedString = attributedText + textView.textStorage?.setAttributedString(attributedText) + case let .incremental(from: startIndex): + let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: context.coordinator.cachedAttributedString, startIndex: startIndex) + context.coordinator.cachedAttributedString = attributedText + textView.textStorage?.setAttributedString(attributedText) + } - let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) let shouldScroll = shouldAutoScroll && logs.count != lastCount - - textView.textStorage?.setAttributedString(attributedText) - if shouldScroll { DispatchQueue.main.async { textView.scrollToEndOfDocument(nil) diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 82abbdc..61d681a 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -54,7 +54,8 @@ private struct LogViewContent: View { LogExportView( showFileExporter: $viewModel.showFileExporter, logFileURL: $viewModel.logFileURL, - alert: $viewModel.alert + alert: $viewModel.alert, + cleanup: viewModel.cleanupLogFile ) ) #endif @@ -214,7 +215,6 @@ private struct LogViewContent: View { Label("Save", systemImage: "square.and.arrow.down") } #endif - Divider() Button(role: .destructive, action: viewModel.clearLogs) { Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash") } @@ -244,6 +244,7 @@ private struct LogViewContent: View { @Binding var logFileURL: URL? @Binding var alert: Alert? @State private var showShareSheet = false + let cleanup: () -> Void var body: some View { Color.clear @@ -253,6 +254,8 @@ private struct LogViewContent: View { contentType: .plainText, defaultFilename: "logs.txt" ) { result in + cleanup() + logFileURL = nil if case let .failure(error) = result { alert = Alert(error) } @@ -273,6 +276,7 @@ private struct LogViewContent: View { } .onChange(of: showShareSheet) { newValue in if !newValue { + cleanup() logFileURL = nil } } diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 4135406..6599e08 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import Libbox import Library import SwiftUI #if canImport(UIKit) @@ -20,6 +21,9 @@ public class LogViewModel: ObservableObject { @Published public var logFileURL: URL? private let commandClient: CommandClient + private var lastProcessedLogCount = 0 + private var lastEffectiveLevel: Int? + private var lastSearchText = "" public var isEmpty: Bool { commandClient.logList.isEmpty } public var isConnected: Bool { commandClient.isConnected } @@ -36,17 +40,42 @@ public class LogViewModel: ObservableObject { $selectedLogLevel, debouncedSearchText ) - .map { logList, defaultLogLevel, selectedLogLevel, searchText in - let effectiveLevel = selectedLogLevel ?? defaultLogLevel - return logList.filter { log in - log.level <= effectiveLevel && - (searchText.isEmpty || log.message.contains(searchText)) - } - } .receive(on: DispatchQueue.main) - .assign(to: &$filteredLogs) + .sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in + guard let self = self else { return } + let effectiveLevel = selectedLogLevel ?? defaultLogLevel + + // Check if we can do incremental filtering + let canIncrement = self.lastProcessedLogCount > 0 && + logList.count > self.lastProcessedLogCount && + effectiveLevel == self.lastEffectiveLevel && + searchText == self.lastSearchText + + if canIncrement { + // Incremental filtering: only filter new logs + let newLogs = logList[self.lastProcessedLogCount...] + let newFilteredLogs = newLogs.filter { log in + log.level <= effectiveLevel && + (searchText.isEmpty || log.message.contains(searchText)) + } + self.filteredLogs.append(contentsOf: newFilteredLogs) + } else { + // Full refiltering needed + self.filteredLogs = logList.filter { log in + log.level <= effectiveLevel && + (searchText.isEmpty || log.message.contains(searchText)) + } + } + + self.lastProcessedLogCount = logList.count + self.lastEffectiveLevel = effectiveLevel + self.lastSearchText = searchText + } + .store(in: &cancellables) } + private var cancellables = Set() + public func togglePause() { isPaused.toggle() } @@ -59,11 +88,23 @@ public class LogViewModel: ObservableObject { } public func clearLogs() { - commandClient.logList.removeAll() isPaused = false + lastProcessedLogCount = 0 + lastEffectiveLevel = nil + lastSearchText = "" + Task.detached { + let client = LibboxNewStandaloneCommandClient() + try? client?.clearLogs() + } } #if !os(tvOS) + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HH:mm:ss" + return formatter + }() + public func getLogsText() -> String { filteredLogs.map(\.message).joined(separator: "\n") } @@ -78,12 +119,16 @@ public class LogViewModel: ObservableObject { #endif } + public func cleanupLogFile() { + guard let url = logFileURL else { return } + try? FileManager.default.removeItem(at: url) + } + public func prepareLogFile() { + cleanupLogFile() do { let text = getLogsText() - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd-HH:mm:ss" - let dateString = dateFormatter.string(from: Date()) + let dateString = Self.dateFormatter.string(from: Date()) let tempDirectory = FileManager.default.temporaryDirectory let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt") try text.write(to: fileURL, atomically: true, encoding: .utf8) diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 97f41c7..d3f0867 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -222,8 +222,9 @@ public class CommandClient: ObservableObject { let logEntry = messageList.next()! commandClient.logList.append(LogEntry(level: Int(logEntry.level), message: logEntry.message)) } - if commandClient.logList.count >= commandClient.logMaxLines { - commandClient.logList.removeSubrange(0 ... commandClient.logList.count - commandClient.logMaxLines) + if commandClient.logList.count > commandClient.logMaxLines { + let removeCount = commandClient.logList.count - commandClient.logMaxLines + commandClient.logList.removeFirst(removeCount) } } }