Search for logs

This commit is contained in:
世界
2025-11-26 22:25:50 +08:00
parent 340cdcb16d
commit 26aa7c4410
5 changed files with 359 additions and 64 deletions
@@ -0,0 +1,218 @@
import Foundation
import Library
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
struct LogTextView: View {
let logs: [LogEntry]
let font: Font
let shouldAutoScroll: Bool
let searchText: String
var body: some View {
#if os(iOS)
LogTextViewIOS(logs: logs, font: font, shouldAutoScroll: shouldAutoScroll, searchText: searchText)
#elseif os(macOS)
LogTextViewMacOS(logs: logs, font: font, shouldAutoScroll: shouldAutoScroll, searchText: searchText)
#endif
}
}
class LogCoordinator {
var lastLogsCount: Int = 0
var lastLog: LogEntry?
func shouldUpdate(logs: [LogEntry]) -> Bool {
let currentCount = logs.count
if currentCount == lastLogsCount, currentCount > 0 {
if let lastLog = logs.last, let previousLastLog = self.lastLog {
if lastLog.id == previousLastLog.id {
return false
}
}
}
lastLogsCount = currentCount
lastLog = logs.last
return true
}
}
#if os(iOS) || os(macOS)
private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String) -> NSAttributedString {
let result = NSMutableAttributedString()
let highlightColor: PlatformColor = .systemYellow
for (index, log) in logs.enumerated() {
let attributedString = ANSIColors.parseAnsiString(log.message)
let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters))
for run in attributedString.runs {
let range = NSRange(run.range, in: attributedString)
let color = run.foregroundColor.map { PlatformColor($0) } ?? defaultColor
nsAttributedString.addAttribute(.foregroundColor, value: color, range: range)
nsAttributedString.addAttribute(.font, value: monoFont, range: range)
}
if !searchText.isEmpty {
let fullString = nsAttributedString.string
var searchRange = fullString.startIndex ..< fullString.endIndex
while let range = fullString.range(of: searchText, range: searchRange) {
let nsRange = NSRange(range, in: fullString)
nsAttributedString.addAttribute(.backgroundColor, value: highlightColor, range: nsRange)
searchRange = range.upperBound ..< fullString.endIndex
}
}
result.append(nsAttributedString)
if index < logs.count - 1 {
result.append(NSAttributedString(string: "\n", attributes: [
.foregroundColor: defaultColor,
.font: monoFont,
]))
}
}
return result
}
#if os(iOS)
private typealias PlatformFont = UIFont
private typealias PlatformColor = UIColor
#elseif os(macOS)
private typealias PlatformFont = NSFont
private typealias PlatformColor = NSColor
#endif
#endif
#if os(iOS)
struct LogTextViewIOS: View {
let logs: [LogEntry]
let font: Font
let shouldAutoScroll: Bool
let searchText: String
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LogUITextView(logs: logs, searchText: searchText)
.font(font)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.id("logContent")
}
.onAppear {
if shouldAutoScroll {
scrollToBottom(proxy: proxy)
}
}
.onChangeCompat(of: logs.count) { _ in
if shouldAutoScroll {
scrollToBottom(proxy: proxy)
}
}
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
proxy.scrollTo("logContent", anchor: .bottom)
}
}
}
struct LogUITextView: UIViewRepresentable {
let logs: [LogEntry]
let searchText: String
private static let monoFont = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)
private static let defaultColor = UIColor.label
func makeUIView(context _: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.isScrollEnabled = false
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.font = Self.monoFont
textView.textColor = Self.defaultColor
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
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)
}
func makeCoordinator() -> LogCoordinator {
LogCoordinator()
}
}
#endif
#if os(macOS)
struct LogTextViewMacOS: NSViewRepresentable {
let logs: [LogEntry]
let font: Font
let shouldAutoScroll: Bool
let searchText: String
private static let monoFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
private static let defaultColor = NSColor.labelColor
func makeNSView(context _: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
let textView = NSTextView()
textView.isEditable = false
textView.isSelectable = true
textView.drawsBackground = false
textView.textContainerInset = NSSize(width: 16, height: 16)
textView.font = Self.monoFont
textView.textColor = Self.defaultColor
textView.autoresizingMask = [.width]
if let textContainer = textView.textContainer {
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude)
}
scrollView.documentView = textView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
let lastCount = context.coordinator.lastLogsCount
guard context.coordinator.shouldUpdate(logs: logs) else { return }
let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText)
let shouldScroll = shouldAutoScroll && logs.count != lastCount
textView.textStorage?.setAttributedString(attributedText)
if shouldScroll {
DispatchQueue.main.async {
textView.scrollToEndOfDocument(nil)
}
}
}
func makeCoordinator() -> LogCoordinator {
LogCoordinator()
}
}
#endif
+113 -60
View File
@@ -25,14 +25,15 @@ private struct LogViewContent: View {
if ApplicationLibrary.inPreview {
previewContent
} else if viewModel.isEmpty {
emptyStateContent
emptyContent
} else if viewModel.filteredLogs.isEmpty {
emptyLogsContent
emptyContent
} else {
logScrollView
}
}
#if !os(tvOS)
.applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching)
.toolbar {
ToolbarItemGroup {
if !viewModel.isEmpty {
@@ -52,80 +53,116 @@ private struct LogViewContent: View {
"inbound/tun[0]: started at utun3",
"sing-box started (1.666s)",
]
return ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(logList.indices, id: \.self) { index in
Text(ANSIColors.parseAnsiString(logList[index]))
.font(logFont)
#if os(tvOS)
.focusable()
#endif
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
#endif
}
private var emptyStateContent: some View {
VStack {
if viewModel.isConnected {
Text("Empty logs")
} else {
Text("Service not started").onAppear {
environments.connect()
}
}
}
}
private var emptyLogsContent: some View {
Text("Empty logs")
}
private var logScrollView: some View {
ScrollViewReader { reader in
ScrollView {
return ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.filteredLogs) { logEntry in
Text(ANSIColors.parseAnsiString(logEntry.message))
ForEach(logList.indices, id: \.self) { index in
Text(ANSIColors.parseAnsiString(logList[index]))
.font(logFont)
#if os(tvOS)
.focusable()
#endif
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
#endif
.onAppear {
scrollToLastEntry(reader: reader)
#else
let previewLogs = logList.enumerated().map { _, message in
LogEntry(level: 4, message: message)
}
.onChangeCompat(of: viewModel.filteredLogs.count) { _ in
if !viewModel.isPaused {
scrollToLastEntry(reader: reader)
}
}
}
}
private func scrollToLastEntry(reader: ScrollViewProxy) {
guard let lastEntry = viewModel.filteredLogs.last else { return }
withAnimation {
reader.scrollTo(lastEntry.id, anchor: .bottom)
}
return LogTextView(
logs: previewLogs,
font: logFont,
shouldAutoScroll: false,
searchText: ""
)
#endif
}
@ViewBuilder
private var emptyContent: some View {
if viewModel.isConnected {
Text("Empty logs")
} else {
Text("Service not started").onAppear {
environments.connect()
}
}
}
private var logScrollView: some View {
#if os(tvOS)
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.filteredLogs) { logEntry in
Text(highlightedText(for: logEntry.message))
.font(logFont)
.focusable()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
.focusEffectDisabled()
.focusSection()
.onAppear {
scrollToLastEntry(reader)
}
.onChangeCompat(of: viewModel.filteredLogs.count) { _ in
if !viewModel.isPaused {
scrollToLastEntry(reader)
}
}
}
#else
LogTextView(
logs: viewModel.filteredLogs,
font: logFont,
shouldAutoScroll: !viewModel.isPaused,
searchText: viewModel.searchText
)
#endif
}
#if os(tvOS)
private func highlightedText(for message: String) -> AttributedString {
var attributedString = ANSIColors.parseAnsiString(message)
if !viewModel.searchText.isEmpty {
let searchText = viewModel.searchText
let messageString = String(attributedString.characters)
var searchRange = messageString.startIndex ..< messageString.endIndex
while let range = messageString.range(of: searchText, range: searchRange) {
if let attributedRange = Range<AttributedString.Index>(range, in: attributedString) {
attributedString[attributedRange].backgroundColor = .yellow
}
searchRange = range.upperBound ..< messageString.endIndex
}
}
return attributedString
}
#endif
#if os(tvOS)
private func scrollToLastEntry(_ reader: ScrollViewProxy) {
guard let lastEntry = viewModel.filteredLogs.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"),
@@ -152,3 +189,19 @@ private struct LogViewContent: View {
}
}
}
#if !os(tvOS)
private extension View {
func applySearchable(text: Binding<String>, isSearching: Binding<Bool>, shouldShow: Bool) -> some View {
if #available(iOS 17.0, macOS 14.0, *) {
if shouldShow {
return AnyView(searchable(text: text, isPresented: isSearching))
} else {
return AnyView(self)
}
} else {
return AnyView(searchable(text: text))
}
}
}
#endif
@@ -6,6 +6,8 @@ import Library
public class LogViewModel: ObservableObject {
@Published public var selectedLogLevel: Int?
@Published public var isPaused = false
@Published public var searchText = ""
@Published public var isSearching = false
@Published public var filteredLogs: [LogEntry] = []
private let commandClient: CommandClient
@@ -16,14 +18,21 @@ public class LogViewModel: ObservableObject {
public init(commandClient: CommandClient) {
self.commandClient = commandClient
Publishers.CombineLatest3(
let debouncedSearchText = $searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
Publishers.CombineLatest4(
commandClient.$logList,
commandClient.$defaultLogLevel,
$selectedLogLevel
$selectedLogLevel,
debouncedSearchText
)
.map { logList, defaultLogLevel, selectedLogLevel in
.map { logList, defaultLogLevel, selectedLogLevel, searchText in
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
return logList.filter { $0.level <= effectiveLevel }
return logList.filter { log in
log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText))
}
}
.receive(on: DispatchQueue.main)
.assign(to: &$filteredLogs)
@@ -33,6 +42,13 @@ public class LogViewModel: ObservableObject {
isPaused.toggle()
}
public func toggleSearch() {
isSearching.toggle()
if !isSearching {
searchText = ""
}
}
public func clearLogs() {
commandClient.logList.removeAll()
isPaused = false
+5
View File
@@ -5,6 +5,11 @@ public struct LogEntry: Identifiable {
public let id = UUID()
public let level: Int
public let message: String
public init(level: Int, message: String) {
self.level = level
self.message = message
}
}
public enum LogLevel: Int, CaseIterable, Identifiable {
+3
View File
@@ -1350,6 +1350,9 @@
}
}
}
},
"Search" : {
},
"Select Device" : {
"localizations" : {