Fix toolbar menu closing on log refresh

Use UIKit UIMenu on iOS to prevent SwiftUI view updates from
dismissing open menus. Separate LogDataModel from LogViewModel
to isolate log data changes from UI state.
This commit is contained in:
世界
2025-12-19 23:29:09 +08:00
parent 90a1022ec2
commit 1bbda34a86
2 changed files with 241 additions and 111 deletions
+200 -78
View File
@@ -20,45 +20,217 @@ public struct LogView: View {
} }
private struct LogViewContent: View { private struct LogViewContent: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel: LogViewModel @StateObject private var viewModel: LogViewModel
private let logFont = Font.system(.caption2, design: .monospaced)
init(commandClient: CommandClient) { init(commandClient: CommandClient) {
_viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient)) _viewModel = StateObject(wrappedValue: LogViewModel(commandClient: commandClient))
} }
var body: some View {
LogContentInnerView(dataModel: viewModel.dataModel, viewModel: viewModel)
#if !os(tvOS)
.applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching)
.toolbar {
ToolbarItemGroup {
toolbarButtons
}
}
.alert($viewModel.alert)
.background(
LogExportView(
showFileExporter: Binding(
get: { viewModel.dataModel.showFileExporter },
set: { viewModel.dataModel.showFileExporter = $0 }
),
logFileURL: Binding(
get: { viewModel.dataModel.logFileURL },
set: { viewModel.dataModel.logFileURL = $0 }
),
alert: $viewModel.alert,
cleanup: { viewModel.dataModel.cleanupLogFile() }
)
)
#endif
}
#if !os(tvOS)
@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"),
systemImage: viewModel.isPaused ? "play.circle" : "pause.circle"
)
}
#if canImport(UIKit)
LogMenuButton(viewModel: viewModel)
#else
LogMenuView(viewModel: viewModel)
#endif
}
#endif
}
#if !os(tvOS)
#if canImport(UIKit)
private struct LogMenuButton: UIViewRepresentable {
let viewModel: LogViewModel
@Environment(\.colorScheme) private var colorScheme
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
let config = UIImage.SymbolConfiguration(scale: .large)
button.setImage(UIImage(systemName: "line.3.horizontal.circle", withConfiguration: config), for: .normal)
button.tintColor = colorScheme == .dark ? .white : .black
button.showsMenuAsPrimaryAction = true
button.menu = createMenu()
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
return button
}
func updateUIView(_ uiView: UIButton, context: Context) {
uiView.menu = createMenu()
uiView.tintColor = colorScheme == .dark ? .white : .black
}
private func createMenu() -> UIMenu {
let logLevelActions = [
UIAction(
title: NSLocalizedString("Default", comment: "Log level filter default option"),
state: viewModel.selectedLogLevel == nil ? .on : .off
) { _ in
viewModel.selectedLogLevel = nil
}
] + LogLevel.allCases.map { level in
UIAction(
title: level.name,
state: viewModel.selectedLogLevel == level.rawValue ? .on : .off
) { _ in
viewModel.selectedLogLevel = level.rawValue
}
}
let logLevelMenu = UIMenu(
title: NSLocalizedString("Log Level", comment: ""),
image: UIImage(systemName: "slider.horizontal.3"),
children: logLevelActions
)
let saveActions = [
UIAction(
title: NSLocalizedString("To Clipboard", comment: ""),
image: UIImage(systemName: "doc.on.clipboard")
) { _ in
viewModel.dataModel.copyToClipboard()
},
UIAction(
title: NSLocalizedString("To File", comment: ""),
image: UIImage(systemName: "arrow.down.doc")
) { _ in
viewModel.dataModel.prepareLogFile()
viewModel.dataModel.showFileExporter = true
},
UIAction(
title: NSLocalizedString("Share", comment: ""),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
viewModel.dataModel.prepareLogFile()
},
]
let saveMenu = UIMenu(
title: NSLocalizedString("Save", comment: ""),
image: UIImage(systemName: "square.and.arrow.down"),
children: saveActions
)
let clearAction = UIAction(
title: NSLocalizedString("Clear Logs", comment: "Clear all logs"),
image: UIImage(systemName: "trash"),
attributes: .destructive
) { _ in
viewModel.dataModel.clearLogs()
}
return UIMenu(children: [logLevelMenu, saveMenu, clearAction])
}
}
#endif
#if canImport(AppKit)
private struct LogMenuView: View {
let viewModel: LogViewModel
var body: some View {
Menu {
Picker(selection: Binding(
get: { viewModel.selectedLogLevel },
set: { viewModel.selectedLogLevel = $0 }
)) {
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")
}
Menu {
Button {
viewModel.dataModel.copyToClipboard()
} label: {
Label("To Clipboard", systemImage: "doc.on.clipboard")
}
Button {
viewModel.dataModel.prepareLogFile()
viewModel.dataModel.showFileExporter = true
} label: {
Label("To File", systemImage: "arrow.down.doc")
}
Button {
viewModel.dataModel.prepareLogFile()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
Button(role: .destructive) {
viewModel.dataModel.clearLogs()
} label: {
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
}
#endif
#endif
private struct LogContentInnerView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@ObservedObject var dataModel: LogDataModel
@ObservedObject var viewModel: LogViewModel
private let logFont = Font.system(.caption2, design: .monospaced)
var body: some View { var body: some View {
Group { Group {
if ApplicationLibrary.inPreview { if ApplicationLibrary.inPreview {
previewContent previewContent
} else if viewModel.isEmpty { } else if dataModel.isEmpty {
emptyContent emptyContent
} else if viewModel.visibleLogs.isEmpty { } else if dataModel.visibleLogs.isEmpty {
emptyContent emptyContent
} else { } else {
logScrollView logScrollView
} }
} }
#if !os(tvOS)
.applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching)
.toolbar {
ToolbarItemGroup {
if !viewModel.isEmpty {
toolbarButtons
}
}
}
.alert($viewModel.alert)
.background(
LogExportView(
showFileExporter: $viewModel.showFileExporter,
logFileURL: $viewModel.logFileURL,
alert: $viewModel.alert,
cleanup: viewModel.cleanupLogFile
)
)
#endif
} }
private var previewContent: some View { private var previewContent: some View {
@@ -99,7 +271,7 @@ private struct LogViewContent: View {
@ViewBuilder @ViewBuilder
private var emptyContent: some View { private var emptyContent: some View {
if viewModel.isConnected { if dataModel.isConnected {
Text("Empty logs") Text("Empty logs")
} else { } else {
Text("Service not started").onAppear { Text("Service not started").onAppear {
@@ -113,7 +285,7 @@ private struct LogViewContent: View {
ScrollViewReader { reader in ScrollViewReader { reader in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.visibleLogs) { logEntry in ForEach(dataModel.visibleLogs) { logEntry in
Text(highlightedText(for: logEntry.message)) Text(highlightedText(for: logEntry.message))
.font(logFont) .font(logFont)
.focusable() .focusable()
@@ -127,7 +299,7 @@ private struct LogViewContent: View {
.onAppear { .onAppear {
scrollToLastEntry(reader) scrollToLastEntry(reader)
} }
.onChangeCompat(of: viewModel.visibleLogs.count) { _ in .onChangeCompat(of: dataModel.visibleLogs.count) { _ in
if !viewModel.isPaused { if !viewModel.isPaused {
scrollToLastEntry(reader) scrollToLastEntry(reader)
} }
@@ -135,7 +307,7 @@ private struct LogViewContent: View {
} }
#else #else
LogTextView( LogTextView(
logs: viewModel.visibleLogs, logs: dataModel.visibleLogs,
font: logFont, font: logFont,
shouldAutoScroll: !viewModel.isPaused, shouldAutoScroll: !viewModel.isPaused,
searchText: viewModel.searchText searchText: viewModel.searchText
@@ -166,62 +338,12 @@ private struct LogViewContent: View {
#if os(tvOS) #if os(tvOS)
private func scrollToLastEntry(_ reader: ScrollViewProxy) { private func scrollToLastEntry(_ reader: ScrollViewProxy) {
guard let lastEntry = viewModel.visibleLogs.last else { return } guard let lastEntry = dataModel.visibleLogs.last else { return }
withAnimation { withAnimation {
reader.scrollTo(lastEntry.id, anchor: .bottom) reader.scrollTo(lastEntry.id, anchor: .bottom)
} }
} }
#endif #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"),
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")
}
#if !os(tvOS)
Menu {
Button(action: viewModel.copyToClipboard) {
Label("To Clipboard", systemImage: "doc.on.clipboard")
}
Button(action: {
viewModel.prepareLogFile()
viewModel.showFileExporter = true
}, label: {
Label("To File", systemImage: "arrow.down.doc")
})
Button(action: viewModel.prepareLogFile) {
Label("Share", systemImage: "square.and.arrow.up")
}
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
#endif
Button(role: .destructive, action: viewModel.clearLogs) {
Label(NSLocalizedString("Clear Logs", comment: "Clear all logs"), systemImage: "trash")
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
} }
#if !os(tvOS) #if !os(tvOS)
+41 -33
View File
@@ -10,27 +10,23 @@ import SwiftUI
#endif #endif
@MainActor @MainActor
public class LogViewModel: BaseViewModel { public class LogDataModel: 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] = [] @Published public var filteredLogs: [LogEntry] = []
@Published public var showFileExporter = false @Published public var showFileExporter = false
@Published public var logFileURL: URL? @Published public var logFileURL: URL?
private let commandClient: CommandClient private let commandClient: CommandClient
private weak var viewModel: LogViewModel?
private var lastProcessedLogCount = 0 private var lastProcessedLogCount = 0
private var lastEffectiveLevel: Int? private var lastEffectiveLevel: Int?
private var lastSearchText = "" private var lastSearchText = ""
private var cancellables = Set<AnyCancellable>()
// Performance optimization: limit visible logs to prevent UI lag
private static let maxVisibleLogs = 1000 private static let maxVisibleLogs = 1000
public var isEmpty: Bool { commandClient.logList.isEmpty } public var isEmpty: Bool { commandClient.logList.isEmpty }
public var isConnected: Bool { commandClient.isConnected } public var isConnected: Bool { commandClient.isConnected }
// Only show last N logs for performance, but keep all in filteredLogs for export
public var visibleLogs: [LogEntry] { public var visibleLogs: [LogEntry] {
if filteredLogs.count <= Self.maxVisibleLogs { if filteredLogs.count <= Self.maxVisibleLogs {
return filteredLogs return filteredLogs
@@ -39,17 +35,17 @@ public class LogViewModel: BaseViewModel {
} }
} }
public init(commandClient: CommandClient) { public init(commandClient: CommandClient, viewModel: LogViewModel) {
self.commandClient = commandClient self.commandClient = commandClient
super.init() self.viewModel = viewModel
let debouncedSearchText = $searchText let debouncedSearchText = viewModel.$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
Publishers.CombineLatest4( Publishers.CombineLatest4(
commandClient.$logList, commandClient.$logList,
commandClient.$defaultLogLevel, commandClient.$defaultLogLevel,
$selectedLogLevel, viewModel.$selectedLogLevel,
debouncedSearchText debouncedSearchText
) )
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@@ -57,14 +53,12 @@ public class LogViewModel: BaseViewModel {
guard let self else { return } guard let self else { return }
let effectiveLevel = selectedLogLevel ?? defaultLogLevel let effectiveLevel = selectedLogLevel ?? defaultLogLevel
// Check if we can do incremental filtering
let canIncrement = self.lastProcessedLogCount > 0 && let canIncrement = self.lastProcessedLogCount > 0 &&
logList.count > self.lastProcessedLogCount && logList.count > self.lastProcessedLogCount &&
effectiveLevel == self.lastEffectiveLevel && effectiveLevel == self.lastEffectiveLevel &&
searchText == self.lastSearchText searchText == self.lastSearchText
if canIncrement { if canIncrement {
// Incremental filtering: only filter new logs
let newLogs = logList[self.lastProcessedLogCount...] let newLogs = logList[self.lastProcessedLogCount...]
let newFilteredLogs = newLogs.filter { log in let newFilteredLogs = newLogs.filter { log in
log.level <= effectiveLevel && log.level <= effectiveLevel &&
@@ -72,7 +66,6 @@ public class LogViewModel: BaseViewModel {
} }
self.filteredLogs.append(contentsOf: newFilteredLogs) self.filteredLogs.append(contentsOf: newFilteredLogs)
} else { } else {
// Full refiltering needed
self.filteredLogs = logList.filter { log in self.filteredLogs = logList.filter { log in
log.level <= effectiveLevel && log.level <= effectiveLevel &&
(searchText.isEmpty || log.message.contains(searchText)) (searchText.isEmpty || log.message.contains(searchText))
@@ -86,21 +79,8 @@ public class LogViewModel: BaseViewModel {
.store(in: &cancellables) .store(in: &cancellables)
} }
private var cancellables = Set<AnyCancellable>()
public func togglePause() {
isPaused.toggle()
}
public func toggleSearch() {
isSearching.toggle()
if !isSearching {
searchText = ""
}
}
public func clearLogs() { public func clearLogs() {
isPaused = false viewModel?.isPaused = false
lastProcessedLogCount = 0 lastProcessedLogCount = 0
lastEffectiveLevel = nil lastEffectiveLevel = nil
lastSearchText = "" lastSearchText = ""
@@ -110,6 +90,10 @@ public class LogViewModel: BaseViewModel {
} }
} }
public func getLogsText() -> String {
filteredLogs.map(\.message).joined(separator: "\n")
}
#if !os(tvOS) #if !os(tvOS)
private static let dateFormatter: DateFormatter = { private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -117,10 +101,6 @@ public class LogViewModel: BaseViewModel {
return formatter return formatter
}() }()
public func getLogsText() -> String {
filteredLogs.map(\.message).joined(separator: "\n")
}
public func copyToClipboard() { public func copyToClipboard() {
let text = getLogsText() let text = getLogsText()
#if os(iOS) #if os(iOS)
@@ -146,8 +126,36 @@ public class LogViewModel: BaseViewModel {
try text.write(to: fileURL, atomically: true, encoding: .utf8) try text.write(to: fileURL, atomically: true, encoding: .utf8)
logFileURL = fileURL logFileURL = fileURL
} catch { } catch {
alert = AlertState(error: error) viewModel?.alert = AlertState(error: error)
} }
} }
#endif #endif
} }
@MainActor
public class LogViewModel: BaseViewModel {
@Published public var selectedLogLevel: Int?
@Published public var isPaused = false
@Published public var searchText = ""
@Published public var isSearching = false
public let commandClient: CommandClient
public private(set) var dataModel: LogDataModel!
public init(commandClient: CommandClient) {
self.commandClient = commandClient
super.init()
dataModel = LogDataModel(commandClient: commandClient, viewModel: self)
}
public func togglePause() {
isPaused.toggle()
}
public func toggleSearch() {
isSearching.toggle()
if !isSearching {
searchText = ""
}
}
}