diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index c075373..de9e9c0 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -20,45 +20,217 @@ public struct LogView: View { } private struct LogViewContent: View { - @EnvironmentObject private var environments: ExtensionEnvironments @StateObject private var viewModel: LogViewModel - private let logFont = Font.system(.caption2, design: .monospaced) init(commandClient: CommandClient) { _viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient)) } + var body: some View { + LogContentInnerView(dataModel: viewModel.dataModel, viewModel: viewModel) + #if !os(tvOS) + .applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching) + .toolbar { + ToolbarItemGroup { + toolbarButtons + } + } + .alert($viewModel.alert) + .background( + LogExportView( + showFileExporter: Binding( + get: { viewModel.dataModel.showFileExporter }, + set: { viewModel.dataModel.showFileExporter = $0 } + ), + logFileURL: Binding( + get: { viewModel.dataModel.logFileURL }, + set: { viewModel.dataModel.logFileURL = $0 } + ), + alert: $viewModel.alert, + cleanup: { viewModel.dataModel.cleanupLogFile() } + ) + ) + #endif + } + + #if !os(tvOS) + @ViewBuilder + private var toolbarButtons: some View { + if #available(iOS 17.0, macOS 14.0, *) { + Button(action: viewModel.toggleSearch) { + Label("Search", systemImage: "magnifyingglass") + } + } + Button(action: viewModel.togglePause) { + Label( + viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"), + systemImage: viewModel.isPaused ? "play.circle" : "pause.circle" + ) + } + #if canImport(UIKit) + LogMenuButton(viewModel: viewModel) + #else + LogMenuView(viewModel: viewModel) + #endif + } + #endif +} + +#if !os(tvOS) + #if canImport(UIKit) + private struct LogMenuButton: UIViewRepresentable { + let viewModel: LogViewModel + @Environment(\.colorScheme) private var colorScheme + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .system) + let config = UIImage.SymbolConfiguration(scale: .large) + button.setImage(UIImage(systemName: "line.3.horizontal.circle", withConfiguration: config), for: .normal) + button.tintColor = colorScheme == .dark ? .white : .black + button.showsMenuAsPrimaryAction = true + button.menu = createMenu() + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + return button + } + + func updateUIView(_ uiView: UIButton, context: Context) { + uiView.menu = createMenu() + uiView.tintColor = colorScheme == .dark ? .white : .black + } + + private func createMenu() -> UIMenu { + let logLevelActions = [ + UIAction( + title: NSLocalizedString("Default", comment: "Log level filter default option"), + state: viewModel.selectedLogLevel == nil ? .on : .off + ) { _ in + viewModel.selectedLogLevel = nil + } + ] + LogLevel.allCases.map { level in + UIAction( + title: level.name, + state: viewModel.selectedLogLevel == level.rawValue ? .on : .off + ) { _ in + viewModel.selectedLogLevel = level.rawValue + } + } + + let logLevelMenu = UIMenu( + title: NSLocalizedString("Log Level", comment: ""), + image: UIImage(systemName: "slider.horizontal.3"), + children: logLevelActions + ) + + let saveActions = [ + UIAction( + title: NSLocalizedString("To Clipboard", comment: ""), + image: UIImage(systemName: "doc.on.clipboard") + ) { _ in + viewModel.dataModel.copyToClipboard() + }, + UIAction( + title: NSLocalizedString("To File", comment: ""), + image: UIImage(systemName: "arrow.down.doc") + ) { _ in + viewModel.dataModel.prepareLogFile() + viewModel.dataModel.showFileExporter = true + }, + UIAction( + title: NSLocalizedString("Share", comment: ""), + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in + viewModel.dataModel.prepareLogFile() + }, + ] + + let saveMenu = UIMenu( + title: NSLocalizedString("Save", comment: ""), + image: UIImage(systemName: "square.and.arrow.down"), + children: saveActions + ) + + let clearAction = UIAction( + title: NSLocalizedString("Clear Logs", comment: "Clear all logs"), + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { _ in + viewModel.dataModel.clearLogs() + } + + return UIMenu(children: [logLevelMenu, saveMenu, clearAction]) + } + } + #endif + + #if canImport(AppKit) + private struct LogMenuView: View { + let viewModel: LogViewModel + + var body: some View { + Menu { + Picker(selection: Binding( + get: { viewModel.selectedLogLevel }, + set: { viewModel.selectedLogLevel = $0 } + )) { + Text(NSLocalizedString("Default", comment: "Log level filter default option")).tag(Int?.none) + ForEach(LogLevel.allCases) { level in + Text(level.name).tag(Int?.some(level.rawValue)) + } + } label: { + Label("Log Level", systemImage: "slider.horizontal.3") + } + Menu { + Button { + viewModel.dataModel.copyToClipboard() + } label: { + Label("To Clipboard", systemImage: "doc.on.clipboard") + } + Button { + viewModel.dataModel.prepareLogFile() + viewModel.dataModel.showFileExporter = true + } label: { + Label("To File", systemImage: "arrow.down.doc") + } + Button { + viewModel.dataModel.prepareLogFile() + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } label: { + Label("Save", systemImage: "square.and.arrow.down") + } + Button(role: .destructive) { + viewModel.dataModel.clearLogs() + } label: { + Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash") + } + } label: { + Label("Filter", systemImage: "line.3.horizontal.circle") + } + } + } + #endif +#endif + +private struct LogContentInnerView: View { + @EnvironmentObject private var environments: ExtensionEnvironments + @ObservedObject var dataModel: LogDataModel + @ObservedObject var viewModel: LogViewModel + private let logFont = Font.system(.caption2, design: .monospaced) + var body: some View { Group { if ApplicationLibrary.inPreview { previewContent - } else if viewModel.isEmpty { + } else if dataModel.isEmpty { emptyContent - } else if viewModel.visibleLogs.isEmpty { + } else if dataModel.visibleLogs.isEmpty { emptyContent } else { logScrollView } } - #if !os(tvOS) - .applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching) - .toolbar { - ToolbarItemGroup { - if !viewModel.isEmpty { - toolbarButtons - } - } - } - .alert($viewModel.alert) - .background( - LogExportView( - showFileExporter: $viewModel.showFileExporter, - logFileURL: $viewModel.logFileURL, - alert: $viewModel.alert, - cleanup: viewModel.cleanupLogFile - ) - ) - #endif } private var previewContent: some View { @@ -99,7 +271,7 @@ private struct LogViewContent: View { @ViewBuilder private var emptyContent: some View { - if viewModel.isConnected { + if dataModel.isConnected { Text("Empty logs") } else { Text("Service not started").onAppear { @@ -113,7 +285,7 @@ private struct LogViewContent: View { ScrollViewReader { reader in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.visibleLogs) { logEntry in + ForEach(dataModel.visibleLogs) { logEntry in Text(highlightedText(for: logEntry.message)) .font(logFont) .focusable() @@ -127,7 +299,7 @@ private struct LogViewContent: View { .onAppear { scrollToLastEntry(reader) } - .onChangeCompat(of: viewModel.visibleLogs.count) { _ in + .onChangeCompat(of: dataModel.visibleLogs.count) { _ in if !viewModel.isPaused { scrollToLastEntry(reader) } @@ -135,7 +307,7 @@ private struct LogViewContent: View { } #else LogTextView( - logs: viewModel.visibleLogs, + logs: dataModel.visibleLogs, font: logFont, shouldAutoScroll: !viewModel.isPaused, searchText: viewModel.searchText @@ -166,62 +338,12 @@ private struct LogViewContent: View { #if os(tvOS) private func scrollToLastEntry(_ reader: ScrollViewProxy) { - guard let lastEntry = viewModel.visibleLogs.last else { return } + guard let lastEntry = dataModel.visibleLogs.last else { return } withAnimation { reader.scrollTo(lastEntry.id, anchor: .bottom) } } #endif - - @ViewBuilder - private var toolbarButtons: some View { - if #available(iOS 17.0, macOS 14.0, *) { - Button(action: viewModel.toggleSearch) { - Label("Search", systemImage: "magnifyingglass") - } - } - Button(action: viewModel.togglePause) { - Label( - viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"), - systemImage: viewModel.isPaused ? "play.circle" : "pause.circle" - ) - } - Menu { - Menu { - Picker("Log Level", selection: $viewModel.selectedLogLevel) { - Text(NSLocalizedString("Default", comment: "Log level filter default option")).tag(Int?.none) - ForEach(LogLevel.allCases) { level in - Text(level.name).tag(Int?.some(level.rawValue)) - } - } - } label: { - Label("Log Level", systemImage: "slider.horizontal.3") - } - #if !os(tvOS) - Menu { - Button(action: viewModel.copyToClipboard) { - Label("To Clipboard", systemImage: "doc.on.clipboard") - } - Button(action: { - viewModel.prepareLogFile() - viewModel.showFileExporter = true - }, label: { - Label("To File", systemImage: "arrow.down.doc") - }) - Button(action: viewModel.prepareLogFile) { - Label("Share", systemImage: "square.and.arrow.up") - } - } label: { - Label("Save", systemImage: "square.and.arrow.down") - } - #endif - Button(role: .destructive, action: viewModel.clearLogs) { - Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash") - } - } label: { - Label("Filter", systemImage: "line.3.horizontal.circle") - } - } } #if !os(tvOS) diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index cce1550..380eeb8 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -10,27 +10,23 @@ import SwiftUI #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 class LogDataModel: ObservableObject { @Published public var filteredLogs: [LogEntry] = [] @Published public var showFileExporter = false @Published public var logFileURL: URL? private let commandClient: CommandClient + private weak var viewModel: LogViewModel? private var lastProcessedLogCount = 0 private var lastEffectiveLevel: Int? private var lastSearchText = "" + private var cancellables = Set() - // Performance optimization: limit visible logs to prevent UI lag private static let maxVisibleLogs = 1000 public var isEmpty: Bool { commandClient.logList.isEmpty } public var isConnected: Bool { commandClient.isConnected } - // Only show last N logs for performance, but keep all in filteredLogs for export public var visibleLogs: [LogEntry] { if filteredLogs.count <= Self.maxVisibleLogs { return filteredLogs @@ -39,17 +35,17 @@ public class LogViewModel: BaseViewModel { } } - public init(commandClient: CommandClient) { + public init(commandClient: CommandClient, viewModel: LogViewModel) { self.commandClient = commandClient - super.init() + self.viewModel = viewModel - let debouncedSearchText = $searchText + let debouncedSearchText = viewModel.$searchText .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) Publishers.CombineLatest4( commandClient.$logList, commandClient.$defaultLogLevel, - $selectedLogLevel, + viewModel.$selectedLogLevel, debouncedSearchText ) .receive(on: DispatchQueue.main) @@ -57,14 +53,12 @@ public class LogViewModel: BaseViewModel { guard let 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 && @@ -72,7 +66,6 @@ public class LogViewModel: BaseViewModel { } self.filteredLogs.append(contentsOf: newFilteredLogs) } else { - // Full refiltering needed self.filteredLogs = logList.filter { log in log.level <= effectiveLevel && (searchText.isEmpty || log.message.contains(searchText)) @@ -86,21 +79,8 @@ public class LogViewModel: BaseViewModel { .store(in: &cancellables) } - private var cancellables = Set() - - public func togglePause() { - isPaused.toggle() - } - - public func toggleSearch() { - isSearching.toggle() - if !isSearching { - searchText = "" - } - } - public func clearLogs() { - isPaused = false + viewModel?.isPaused = false lastProcessedLogCount = 0 lastEffectiveLevel = nil lastSearchText = "" @@ -110,6 +90,10 @@ public class LogViewModel: BaseViewModel { } } + public func getLogsText() -> String { + filteredLogs.map(\.message).joined(separator: "\n") + } + #if !os(tvOS) private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -117,10 +101,6 @@ public class LogViewModel: BaseViewModel { return formatter }() - public func getLogsText() -> String { - filteredLogs.map(\.message).joined(separator: "\n") - } - public func copyToClipboard() { let text = getLogsText() #if os(iOS) @@ -146,8 +126,36 @@ public class LogViewModel: BaseViewModel { try text.write(to: fileURL, atomically: true, encoding: .utf8) logFileURL = fileURL } catch { - alert = AlertState(error: error) + viewModel?.alert = AlertState(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) { + self.commandClient = commandClient + super.init() + dataModel = LogDataModel(commandClient: commandClient, viewModel: self) + } + + public func togglePause() { + isPaused.toggle() + } + + public func toggleSearch() { + isSearching.toggle() + if !isSearching { + searchText = "" + } + } +}