Fix logs
This commit is contained in:
@@ -26,28 +26,71 @@ struct LogTextView: View {
|
|||||||
class LogCoordinator {
|
class LogCoordinator {
|
||||||
var lastLogsCount: Int = 0
|
var lastLogsCount: Int = 0
|
||||||
var lastLog: LogEntry?
|
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
|
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 let lastLog = logs.last, let previousLastLog = self.lastLog {
|
||||||
if lastLog.id == previousLastLog.id {
|
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
|
lastLogsCount = currentCount
|
||||||
lastLog = logs.last
|
lastLog = logs.last
|
||||||
return true
|
lastSearchText = searchText
|
||||||
|
return strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UpdateStrategy {
|
||||||
|
case noUpdate
|
||||||
|
case fullRebuild
|
||||||
|
case incremental(from: Int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String) -> NSAttributedString {
|
private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, baseAttributedString: NSAttributedString? = nil, startIndex: Int = 0) -> NSAttributedString {
|
||||||
let result = NSMutableAttributedString()
|
let result: NSMutableAttributedString
|
||||||
let highlightColor: PlatformColor = .systemYellow
|
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 attributedString = ANSIColors.parseAnsiString(log.message)
|
||||||
let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters))
|
let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters))
|
||||||
|
|
||||||
@@ -72,7 +115,8 @@ class LogCoordinator {
|
|||||||
|
|
||||||
result.append(nsAttributedString)
|
result.append(nsAttributedString)
|
||||||
|
|
||||||
if index < logs.count - 1 {
|
let isLastLog = (startIndex + offset) == (logs.count - 1)
|
||||||
|
if !isLastLog {
|
||||||
result.append(NSAttributedString(string: "\n", attributes: [
|
result.append(NSAttributedString(string: "\n", attributes: [
|
||||||
.foregroundColor: defaultColor,
|
.foregroundColor: defaultColor,
|
||||||
.font: monoFont,
|
.font: monoFont,
|
||||||
@@ -152,8 +196,20 @@ class LogCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ textView: UITextView, context: Context) {
|
func updateUIView(_ textView: UITextView, context: Context) {
|
||||||
guard context.coordinator.shouldUpdate(logs: logs) else { return }
|
let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText)
|
||||||
textView.attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, 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 {
|
func makeCoordinator() -> LogCoordinator {
|
||||||
@@ -200,13 +256,22 @@ class LogCoordinator {
|
|||||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
|
|
||||||
let lastCount = context.coordinator.lastLogsCount
|
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
|
let shouldScroll = shouldAutoScroll && logs.count != lastCount
|
||||||
|
|
||||||
textView.textStorage?.setAttributedString(attributedText)
|
|
||||||
|
|
||||||
if shouldScroll {
|
if shouldScroll {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
textView.scrollToEndOfDocument(nil)
|
textView.scrollToEndOfDocument(nil)
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ private struct LogViewContent: View {
|
|||||||
LogExportView(
|
LogExportView(
|
||||||
showFileExporter: $viewModel.showFileExporter,
|
showFileExporter: $viewModel.showFileExporter,
|
||||||
logFileURL: $viewModel.logFileURL,
|
logFileURL: $viewModel.logFileURL,
|
||||||
alert: $viewModel.alert
|
alert: $viewModel.alert,
|
||||||
|
cleanup: viewModel.cleanupLogFile
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
@@ -214,7 +215,6 @@ private struct LogViewContent: View {
|
|||||||
Label("Save", systemImage: "square.and.arrow.down")
|
Label("Save", systemImage: "square.and.arrow.down")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
Divider()
|
|
||||||
Button(role: .destructive, action: viewModel.clearLogs) {
|
Button(role: .destructive, action: viewModel.clearLogs) {
|
||||||
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
|
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
|
||||||
}
|
}
|
||||||
@@ -244,6 +244,7 @@ private struct LogViewContent: View {
|
|||||||
@Binding var logFileURL: URL?
|
@Binding var logFileURL: URL?
|
||||||
@Binding var alert: Alert?
|
@Binding var alert: Alert?
|
||||||
@State private var showShareSheet = false
|
@State private var showShareSheet = false
|
||||||
|
let cleanup: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
@@ -253,6 +254,8 @@ private struct LogViewContent: View {
|
|||||||
contentType: .plainText,
|
contentType: .plainText,
|
||||||
defaultFilename: "logs.txt"
|
defaultFilename: "logs.txt"
|
||||||
) { result in
|
) { result in
|
||||||
|
cleanup()
|
||||||
|
logFileURL = nil
|
||||||
if case let .failure(error) = result {
|
if case let .failure(error) = result {
|
||||||
alert = Alert(error)
|
alert = Alert(error)
|
||||||
}
|
}
|
||||||
@@ -273,6 +276,7 @@ private struct LogViewContent: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: showShareSheet) { newValue in
|
.onChange(of: showShareSheet) { newValue in
|
||||||
if !newValue {
|
if !newValue {
|
||||||
|
cleanup()
|
||||||
logFileURL = nil
|
logFileURL = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Libbox
|
||||||
import Library
|
import Library
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
@@ -20,6 +21,9 @@ public class LogViewModel: ObservableObject {
|
|||||||
@Published public var logFileURL: URL?
|
@Published public var logFileURL: URL?
|
||||||
|
|
||||||
private let commandClient: CommandClient
|
private let commandClient: CommandClient
|
||||||
|
private var lastProcessedLogCount = 0
|
||||||
|
private var lastEffectiveLevel: Int?
|
||||||
|
private var lastSearchText = ""
|
||||||
|
|
||||||
public var isEmpty: Bool { commandClient.logList.isEmpty }
|
public var isEmpty: Bool { commandClient.logList.isEmpty }
|
||||||
public var isConnected: Bool { commandClient.isConnected }
|
public var isConnected: Bool { commandClient.isConnected }
|
||||||
@@ -36,17 +40,42 @@ public class LogViewModel: ObservableObject {
|
|||||||
$selectedLogLevel,
|
$selectedLogLevel,
|
||||||
debouncedSearchText
|
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)
|
.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<AnyCancellable>()
|
||||||
|
|
||||||
public func togglePause() {
|
public func togglePause() {
|
||||||
isPaused.toggle()
|
isPaused.toggle()
|
||||||
}
|
}
|
||||||
@@ -59,11 +88,23 @@ public class LogViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func clearLogs() {
|
public func clearLogs() {
|
||||||
commandClient.logList.removeAll()
|
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
lastProcessedLogCount = 0
|
||||||
|
lastEffectiveLevel = nil
|
||||||
|
lastSearchText = ""
|
||||||
|
Task.detached {
|
||||||
|
let client = LibboxNewStandaloneCommandClient()
|
||||||
|
try? client?.clearLogs()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#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 {
|
public func getLogsText() -> String {
|
||||||
filteredLogs.map(\.message).joined(separator: "\n")
|
filteredLogs.map(\.message).joined(separator: "\n")
|
||||||
}
|
}
|
||||||
@@ -78,12 +119,16 @@ public class LogViewModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func cleanupLogFile() {
|
||||||
|
guard let url = logFileURL else { return }
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
public func prepareLogFile() {
|
public func prepareLogFile() {
|
||||||
|
cleanupLogFile()
|
||||||
do {
|
do {
|
||||||
let text = getLogsText()
|
let text = getLogsText()
|
||||||
let dateFormatter = DateFormatter()
|
let dateString = Self.dateFormatter.string(from: Date())
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd-HH:mm:ss"
|
|
||||||
let dateString = dateFormatter.string(from: Date())
|
|
||||||
let tempDirectory = FileManager.default.temporaryDirectory
|
let tempDirectory = FileManager.default.temporaryDirectory
|
||||||
let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt")
|
let fileURL = tempDirectory.appendingPathComponent("logs-\(dateString).txt")
|
||||||
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ public class CommandClient: ObservableObject {
|
|||||||
let logEntry = messageList.next()!
|
let logEntry = messageList.next()!
|
||||||
commandClient.logList.append(LogEntry(level: Int(logEntry.level), message: logEntry.message))
|
commandClient.logList.append(LogEntry(level: Int(logEntry.level), message: logEntry.message))
|
||||||
}
|
}
|
||||||
if commandClient.logList.count >= commandClient.logMaxLines {
|
if commandClient.logList.count > commandClient.logMaxLines {
|
||||||
commandClient.logList.removeSubrange(0 ... commandClient.logList.count - commandClient.logMaxLines)
|
let removeCount = commandClient.logList.count - commandClient.logMaxLines
|
||||||
|
commandClient.logList.removeFirst(removeCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user