Search for logs
This commit is contained in:
@@ -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
|
||||||
@@ -25,14 +25,15 @@ private struct LogViewContent: View {
|
|||||||
if ApplicationLibrary.inPreview {
|
if ApplicationLibrary.inPreview {
|
||||||
previewContent
|
previewContent
|
||||||
} else if viewModel.isEmpty {
|
} else if viewModel.isEmpty {
|
||||||
emptyStateContent
|
emptyContent
|
||||||
} else if viewModel.filteredLogs.isEmpty {
|
} else if viewModel.filteredLogs.isEmpty {
|
||||||
emptyLogsContent
|
emptyContent
|
||||||
} else {
|
} else {
|
||||||
logScrollView
|
logScrollView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
.applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup {
|
ToolbarItemGroup {
|
||||||
if !viewModel.isEmpty {
|
if !viewModel.isEmpty {
|
||||||
@@ -52,27 +53,35 @@ private struct LogViewContent: View {
|
|||||||
"inbound/tun[0]: started at utun3",
|
"inbound/tun[0]: started at utun3",
|
||||||
"sing-box started (1.666s)",
|
"sing-box started (1.666s)",
|
||||||
]
|
]
|
||||||
|
#if os(tvOS)
|
||||||
return ScrollView {
|
return ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(logList.indices, id: \.self) { index in
|
ForEach(logList.indices, id: \.self) { index in
|
||||||
Text(ANSIColors.parseAnsiString(logList[index]))
|
Text(ANSIColors.parseAnsiString(logList[index]))
|
||||||
.font(logFont)
|
.font(logFont)
|
||||||
#if os(tvOS)
|
|
||||||
.focusable()
|
.focusable()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
|
||||||
.focusEffectDisabled()
|
.focusEffectDisabled()
|
||||||
.focusSection()
|
.focusSection()
|
||||||
|
#else
|
||||||
|
let previewLogs = logList.enumerated().map { _, message in
|
||||||
|
LogEntry(level: 4, message: message)
|
||||||
|
}
|
||||||
|
return LogTextView(
|
||||||
|
logs: previewLogs,
|
||||||
|
font: logFont,
|
||||||
|
shouldAutoScroll: false,
|
||||||
|
searchText: ""
|
||||||
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyStateContent: some View {
|
@ViewBuilder
|
||||||
VStack {
|
private var emptyContent: some View {
|
||||||
if viewModel.isConnected {
|
if viewModel.isConnected {
|
||||||
Text("Empty logs")
|
Text("Empty logs")
|
||||||
} else {
|
} else {
|
||||||
@@ -81,51 +90,79 @@ private struct LogViewContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyLogsContent: some View {
|
|
||||||
Text("Empty logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var logScrollView: some View {
|
private var logScrollView: some View {
|
||||||
|
#if os(tvOS)
|
||||||
ScrollViewReader { reader in
|
ScrollViewReader { reader in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(viewModel.filteredLogs) { logEntry in
|
ForEach(viewModel.filteredLogs) { logEntry in
|
||||||
Text(ANSIColors.parseAnsiString(logEntry.message))
|
Text(highlightedText(for: logEntry.message))
|
||||||
.font(logFont)
|
.font(logFont)
|
||||||
#if os(tvOS)
|
|
||||||
.focusable()
|
.focusable()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
|
||||||
.focusEffectDisabled()
|
.focusEffectDisabled()
|
||||||
.focusSection()
|
.focusSection()
|
||||||
#endif
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToLastEntry(reader: reader)
|
scrollToLastEntry(reader)
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: viewModel.filteredLogs.count) { _ in
|
.onChangeCompat(of: viewModel.filteredLogs.count) { _ in
|
||||||
if !viewModel.isPaused {
|
if !viewModel.isPaused {
|
||||||
scrollToLastEntry(reader: reader)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToLastEntry(reader: ScrollViewProxy) {
|
return attributedString
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private func scrollToLastEntry(_ reader: ScrollViewProxy) {
|
||||||
guard let lastEntry = viewModel.filteredLogs.last else { return }
|
guard let lastEntry = viewModel.filteredLogs.last else { return }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
reader.scrollTo(lastEntry.id, anchor: .bottom)
|
reader.scrollTo(lastEntry.id, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var toolbarButtons: some View {
|
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) {
|
Button(action: viewModel.togglePause) {
|
||||||
Label(
|
Label(
|
||||||
viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"),
|
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 {
|
public class LogViewModel: ObservableObject {
|
||||||
@Published public var selectedLogLevel: Int?
|
@Published public var selectedLogLevel: Int?
|
||||||
@Published public var isPaused = false
|
@Published public var isPaused = false
|
||||||
|
@Published public var searchText = ""
|
||||||
|
@Published public var isSearching = false
|
||||||
@Published public var filteredLogs: [LogEntry] = []
|
@Published public var filteredLogs: [LogEntry] = []
|
||||||
|
|
||||||
private let commandClient: CommandClient
|
private let commandClient: CommandClient
|
||||||
@@ -16,14 +18,21 @@ public class LogViewModel: ObservableObject {
|
|||||||
public init(commandClient: CommandClient) {
|
public init(commandClient: CommandClient) {
|
||||||
self.commandClient = commandClient
|
self.commandClient = commandClient
|
||||||
|
|
||||||
Publishers.CombineLatest3(
|
let debouncedSearchText = $searchText
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
|
||||||
|
Publishers.CombineLatest4(
|
||||||
commandClient.$logList,
|
commandClient.$logList,
|
||||||
commandClient.$defaultLogLevel,
|
commandClient.$defaultLogLevel,
|
||||||
$selectedLogLevel
|
$selectedLogLevel,
|
||||||
|
debouncedSearchText
|
||||||
)
|
)
|
||||||
.map { logList, defaultLogLevel, selectedLogLevel in
|
.map { logList, defaultLogLevel, selectedLogLevel, searchText in
|
||||||
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
|
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)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$filteredLogs)
|
.assign(to: &$filteredLogs)
|
||||||
@@ -33,6 +42,13 @@ public class LogViewModel: ObservableObject {
|
|||||||
isPaused.toggle()
|
isPaused.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func toggleSearch() {
|
||||||
|
isSearching.toggle()
|
||||||
|
if !isSearching {
|
||||||
|
searchText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func clearLogs() {
|
public func clearLogs() {
|
||||||
commandClient.logList.removeAll()
|
commandClient.logList.removeAll()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ public struct LogEntry: Identifiable {
|
|||||||
public let id = UUID()
|
public let id = UUID()
|
||||||
public let level: Int
|
public let level: Int
|
||||||
public let message: String
|
public let message: String
|
||||||
|
|
||||||
|
public init(level: Int, message: String) {
|
||||||
|
self.level = level
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LogLevel: Int, CaseIterable, Identifiable {
|
public enum LogLevel: Int, CaseIterable, Identifiable {
|
||||||
|
|||||||
@@ -1350,6 +1350,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Search" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select Device" : {
|
"Select Device" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
Reference in New Issue
Block a user