diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 204f9ac..416e95c 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -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") } } } diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift new file mode 100644 index 0000000..cac536f --- /dev/null +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -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 + } +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 99520cb..f6297f5 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : {