Improve Logs

This commit is contained in:
世界
2025-11-26 22:25:50 +08:00
parent c20213ed91
commit 340cdcb16d
3 changed files with 188 additions and 92 deletions
+130 -92
View File
@@ -2,115 +2,153 @@ import Library
import SwiftUI
public struct LogView: View {
@Environment(\.selection) private var selection
@EnvironmentObject private var environments: ExtensionEnvironments
public init() {}
public var body: some View {
LogView0().environmentObject(environments.commandClient)
LogViewContent(commandClient: environments.commandClient)
}
}
private struct LogViewContent: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel: LogViewModel
private let logFont = Font.system(.caption2, design: .monospaced)
init(commandClient: CommandClient) {
_viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient))
}
private struct LogView0: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@EnvironmentObject private var commandClient: CommandClient
@State private var selectedLogLevel: Int?
private let logFont = Font.system(.caption2, design: .monospaced)
private var filteredLogList: [LogEntry] {
let effectiveLevel = selectedLogLevel ?? commandClient.defaultLogLevel
return commandClient.logList.filter { $0.level <= effectiveLevel }
var body: some View {
Group {
if ApplicationLibrary.inPreview {
previewContent
} else if viewModel.isEmpty {
emptyStateContent
} else if viewModel.filteredLogs.isEmpty {
emptyLogsContent
} else {
logScrollView
}
}
#if !os(tvOS)
.toolbar {
ToolbarItemGroup {
if !viewModel.isEmpty {
toolbarButtons
}
}
}
#endif
}
var body: some View {
Group {
if ApplicationLibrary.inPreview {
let logList = [
"(packet-tunnel) log server started",
"INFO[0000] router: loaded geoip database: 250 codes",
"INFO[0000] router: loaded geosite database: 1400 codes",
"INFO[0000] router: updated default interface en0, index 11",
"inbound/tun[0]: started at utun3",
"sing-box started (1.666s)",
]
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()
}
private var previewContent: some View {
let logList = [
"(packet-tunnel) log server started",
"INFO[0000] router: loaded geoip database: 250 codes",
"INFO[0000] router: loaded geosite database: 1400 codes",
"INFO[0000] router: updated default interface en0, index 11",
"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)
.focusEffectDisabled()
.focusSection()
.focusable()
#endif
} else if commandClient.logList.isEmpty {
VStack {
if commandClient.isConnected {
Text("Empty logs")
} else {
Text("Service not started").onAppear {
environments.connect()
}
}
}
} else {
ScrollViewReader { reader in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(filteredLogList) { logEntry in
Text(ANSIColors.parseAnsiString(logEntry.message))
.font(logFont)
#if os(tvOS)
.focusable()
#endif
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
}
}
.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 {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.filteredLogs) { logEntry in
Text(ANSIColors.parseAnsiString(logEntry.message))
.font(logFont)
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
.focusable()
#endif
.onAppear {
if let lastEntry = filteredLogList.last {
reader.scrollTo(lastEntry.id, anchor: .bottom)
}
}
.onChangeCompat(of: filteredLogList.count) { _ in
guard let lastEntry = filteredLogList.last else {
return
}
withAnimation {
reader.scrollTo(lastEntry.id, anchor: .bottom)
}
}
}
}
}
#if !os(tvOS)
.toolbar {
ToolbarItem {
Menu {
Picker("Log Level", selection: $selectedLogLevel) {
Text(NSLocalizedString("Default", comment: "Log level filter default option")).tag(Int?.none)
ForEach(LogLevel.allCases) { level in
Text(level.name).tag(Int?.some(level.rawValue))
}
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
#if os(tvOS)
.focusEffectDisabled()
.focusSection()
#endif
.onAppear {
scrollToLastEntry(reader: reader)
}
.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)
}
}
@ViewBuilder
private var toolbarButtons: some View {
Button(action: viewModel.togglePause) {
Label(
viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"),
systemImage: viewModel.isPaused ? "play.circle" : "pause.circle"
)
}
Menu {
Menu {
Picker("Log Level", selection: $viewModel.selectedLogLevel) {
Text(NSLocalizedString("Default", comment: "Log level filter default option")).tag(Int?.none)
ForEach(LogLevel.allCases) { level in
Text(level.name).tag(Int?.some(level.rawValue))
}
}
} label: {
Label("Log Level", systemImage: "slider.horizontal.3")
}
Divider()
Button(role: .destructive, action: viewModel.clearLogs) {
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
}
@@ -0,0 +1,40 @@
import Combine
import Foundation
import Library
@MainActor
public class LogViewModel: ObservableObject {
@Published public var selectedLogLevel: Int?
@Published public var isPaused = false
@Published public var filteredLogs: [LogEntry] = []
private let commandClient: CommandClient
public var isEmpty: Bool { commandClient.logList.isEmpty }
public var isConnected: Bool { commandClient.isConnected }
public init(commandClient: CommandClient) {
self.commandClient = commandClient
Publishers.CombineLatest3(
commandClient.$logList,
commandClient.$defaultLogLevel,
$selectedLogLevel
)
.map { logList, defaultLogLevel, selectedLogLevel in
let effectiveLevel = selectedLogLevel ?? defaultLogLevel
return logList.filter { $0.level <= effectiveLevel }
}
.receive(on: DispatchQueue.main)
.assign(to: &$filteredLogs)
}
public func togglePause() {
isPaused.toggle()
}
public func clearLogs() {
commandClient.logList.removeAll()
isPaused = false
}
}
+18
View File
@@ -226,6 +226,17 @@
}
}
},
"Clear Logs" : {
"comment" : "Clear all logs",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除日志"
}
}
}
},
"Close" : {
"localizations" : {
"zh-Hans" : {
@@ -964,6 +975,7 @@
}
},
"Log Level" : {
"comment" : "Log level filter",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -1200,6 +1212,9 @@
}
}
},
"Pause" : {
"comment" : "Pause log auto-scroll"
},
"Please grant the permission for **SFMExtension**, then we can continue." : {
"localizations" : {
"zh-Hans" : {
@@ -1323,6 +1338,9 @@
}
}
},
"Resume" : {
"comment" : "Resume log auto-scroll"
},
"Save" : {
"localizations" : {
"zh-Hans" : {