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
+78 -13
View File
@@ -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)
let shouldScroll = shouldAutoScroll && logs.count != lastCount
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 shouldScroll = shouldAutoScroll && logs.count != lastCount
if shouldScroll {
DispatchQueue.main.async {
textView.scrollToEndOfDocument(nil)
+6 -2
View File
@@ -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
}
}
@@ -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,16 +40,41 @@ public class LogViewModel: ObservableObject {
$selectedLogLevel,
debouncedSearchText
)
.map { logList, defaultLogLevel, selectedLogLevel, searchText in
.receive(on: DispatchQueue.main)
.sink { [weak self] logList, defaultLogLevel, selectedLogLevel, searchText in
guard let self = self else { return }
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
return logList.filter { log in
// 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))
}
}
.receive(on: DispatchQueue.main)
.assign(to: &$filteredLogs)
self.lastProcessedLogCount = logList.count
self.lastEffectiveLevel = effectiveLevel
self.lastSearchText = searchText
}
.store(in: &cancellables)
}
private var cancellables = Set<AnyCancellable>()
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)
+3 -2
View File
@@ -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)
}
}
}