import Combine import Foundation import Libbox import Library import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif @MainActor public class LogDataModel: ObservableObject { @Published public var filteredLogs: [LogEntry] = [] @Published public private(set) var visibleLogs: [LogEntry] = [] @Published public var showFileExporter = false @Published public var logFileURL: URL? private let commandClient: CommandClient private weak var viewModel: LogViewModel? private var pausedLogSnapshot: [LogEntry]? private var lastPaused = false private var lastProcessedLogCount = 0 private var lastEffectiveLevel: Int? private var lastSearchText = "" private var cancellables = Set() private static let maxVisibleLogs = 1000 public var isEmpty: Bool { commandClient.logList.isEmpty } public var isConnected: Bool { commandClient.isConnected } private func updateVisibleLogs() { if filteredLogs.count <= Self.maxVisibleLogs { visibleLogs = filteredLogs } else { visibleLogs = Array(filteredLogs.suffix(Self.maxVisibleLogs)) } } public init(commandClient: CommandClient, viewModel: LogViewModel) { self.commandClient = commandClient self.viewModel = viewModel let debouncedSearchText = viewModel.$searchText .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) Publishers.CombineLatest( Publishers.CombineLatest4( commandClient.$logList, commandClient.$defaultLogLevel, viewModel.$selectedLogLevel, debouncedSearchText ), viewModel.$isPaused ) .receive(on: DispatchQueue.main) .sink { [weak self] combined, isPaused in guard let self else { return } let (logList, defaultLogLevel, selectedLogLevel, searchText) = combined let effectiveLevel = selectedLogLevel ?? defaultLogLevel if isPaused, !self.lastPaused { self.pausedLogSnapshot = logList self.lastProcessedLogCount = 0 } else if !isPaused, self.lastPaused { self.pausedLogSnapshot = nil self.lastProcessedLogCount = 0 } self.lastPaused = isPaused let sourceList = self.pausedLogSnapshot ?? logList if isPaused, effectiveLevel == self.lastEffectiveLevel, searchText == self.lastSearchText, self.lastProcessedLogCount > 0 { return } let canIncrement = self.lastProcessedLogCount > 0 && sourceList.count > self.lastProcessedLogCount && effectiveLevel == self.lastEffectiveLevel && searchText == self.lastSearchText if canIncrement { let newLogs = sourceList[self.lastProcessedLogCount...] let newFilteredLogs = newLogs.filter { log in log.level <= effectiveLevel && (searchText.isEmpty || log.message.contains(searchText)) } self.filteredLogs.append(contentsOf: newFilteredLogs) } else { self.filteredLogs = sourceList.filter { log in log.level <= effectiveLevel && (searchText.isEmpty || log.message.contains(searchText)) } } self.updateVisibleLogs() self.lastProcessedLogCount = sourceList.count self.lastEffectiveLevel = effectiveLevel self.lastSearchText = searchText } .store(in: &cancellables) } public func clearLogs() { viewModel?.isPaused = false pausedLogSnapshot = nil lastPaused = false lastProcessedLogCount = 0 lastEffectiveLevel = nil lastSearchText = "" filteredLogs = [] visibleLogs = [] commandClient.clearLogs() Task.detached { try? LibboxNewStandaloneCommandClient()!.clearLogs() } } public func getLogsText() -> String { filteredLogs.map(\.message).joined(separator: "\n") } #if !os(tvOS) private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HH:mm:ss" return formatter }() public func copyToClipboard() { let text = getLogsText() #if os(iOS) UIPasteboard.general.string = text #elseif os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) #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 dateString = Self.dateFormatter.string(from: Date()) let tempDirectory = FilePath.cacheDirectory let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt") try text.write(to: fileURL, atomically: true, encoding: .utf8) logFileURL = fileURL } catch { viewModel?.alert = AlertState(action: "prepare log file", error: error) } } #endif } @MainActor public class LogViewModel: BaseViewModel { @Published public var selectedLogLevel: Int? @Published public var isPaused = false @Published public var searchText = "" @Published public var isSearching = false public let commandClient: CommandClient public private(set) var dataModel: LogDataModel! public init(commandClient: CommandClient, searchText: String = "") { self.commandClient = commandClient self.searchText = searchText isSearching = !searchText.isEmpty super.init() dataModel = LogDataModel(commandClient: commandClient, viewModel: self) } public func togglePause() { isPaused.toggle() } public func toggleSearch() { isSearching.toggle() if !isSearching { searchText = "" } } }