This commit is contained in:
世界
2025-11-26 22:25:51 +08:00
parent 68b4142340
commit 2e2fc223bf
4 changed files with 146 additions and 31 deletions
+80 -15
View File
@@ -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)
+6 -2
View File
@@ -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
} }
} }
+57 -12
View File
@@ -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)
+3 -2
View File
@@ -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)
} }
} }
} }