From e7f2f8438ed27ab30da8042e4104a9e99cffc14b Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Tue, 24 Feb 2026 15:58:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactoring=20command=20hi?= =?UTF-8?q?story?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PHP Monitor.xcodeproj/project.pbxproj | 10 ++ .../xcschemes/PHP Monitor EAP.xcscheme | 2 +- phpmon/Common/Monitoring/CommandTracker.swift | 34 ++++--- phpmon/Common/Shell/RealShell.swift | 1 - phpmon/Container/Container.swift | 8 +- .../UI/CommandHistoryRow.swift | 55 +++++++++++ .../UI/CommandHistoryView.swift | 99 ++++++++++--------- .../UI/CommandHistoryWindowController.swift | 4 +- 8 files changed, 145 insertions(+), 68 deletions(-) create mode 100644 phpmon/Modules/Command History/UI/CommandHistoryRow.swift diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index c0d65338..052610d6 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -128,6 +128,10 @@ 037F44232EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; 037F44242EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; 037F44252EDB92EC002EBF75 /* HomebrewWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */; }; + 0381313D2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */; }; + 0381313E2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */; }; + 0381313F2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */; }; + 038131402F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */; }; 0386B0B42ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B52ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; 0386B0B62ED36C3D00CA6795 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386B0B32ED36C3D00CA6795 /* Locked.swift */; }; @@ -1112,6 +1116,7 @@ 037F44172EDB27B7002EBF75 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 037F441C2EDB9195002EBF75 /* ConfigWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigWatchManager.swift; sourceTree = ""; }; 037F44212EDB92EC002EBF75 /* HomebrewWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewWatchManager.swift; sourceTree = ""; }; + 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHistoryRow.swift; sourceTree = ""; }; 0386B0B32ED36C3D00CA6795 /* Locked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locked.swift; sourceTree = ""; }; 0386B0B82ED36DF800CA6795 /* LockedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedTests.swift; sourceTree = ""; }; 038A2B7D2EDDB24400173ACF /* App+UUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+UUID.swift"; sourceTree = ""; }; @@ -2581,6 +2586,7 @@ children = ( E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */, 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */, + 0381313C2F4DF3B400653177 /* CommandHistoryRow.swift */, ); path = UI; sourceTree = ""; @@ -3121,6 +3127,7 @@ C464ADB2275A87CA003FCD53 /* DomainListCellProtocol.swift in Sources */, C4EE188422D3386B00E126E5 /* Constants.swift in Sources */, C4DEB7D427A5D60B00834718 /* Stats.swift in Sources */, + 038131402F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */, 039C29182E8AA314007F5FAB /* TestableWebApi.swift in Sources */, C47015022C46D6910069AAE7 /* NVAlertExtension.swift in Sources */, C4E49DEA28F7643D0026AC4E /* CommandProtocol.swift in Sources */, @@ -3355,6 +3362,7 @@ C471E7DA28F9BA8F0021E251 /* TestableCommand.swift in Sources */, C471E7E528F9BAC20021E251 /* Events.swift in Sources */, C471E7D628F9BA8F0021E251 /* RealFileSystem.swift in Sources */, + 0381313D2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */, C471E81728F9BAE80021E251 /* NSMenuExtension.swift in Sources */, C40D725C2A018ACC0054A067 /* BusyStatus.swift in Sources */, 03DAD3A92EB3B08F003417BD /* DomainListVC+Certs.swift in Sources */, @@ -3417,6 +3425,7 @@ C471E8A728F9BB8F0021E251 /* App+ActivationPolicy.swift in Sources */, C45B914C295607F400F4EC78 /* Service.swift in Sources */, 03DAD3A82EB3B08F003417BD /* DomainListVC+Certs.swift in Sources */, + 0381313F2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */, C471E8A828F9BB8F0021E251 /* App+GlobalHotkey.swift in Sources */, C471E8A928F9BB8F0021E251 /* InterAppHandler.swift in Sources */, 0379C4A02ED71D050035D7EA /* Startup+Launch.swift in Sources */, @@ -3739,6 +3748,7 @@ C4513F962B13E30C001AD760 /* BrewExtensionsObservable.swift in Sources */, C4B97B76275CF08C003F3378 /* AppDelegate+MenuOutlets.swift in Sources */, C4AFC4AF29C4F32F00BF4E0D /* BrewPhpFormula.swift in Sources */, + 0381313E2F4DF3BA00653177 /* CommandHistoryRow.swift in Sources */, C4F780CD25D80B75000DBC97 /* Alert.swift in Sources */, C4415E8E2B0287E90035F520 /* BrewFormulaeObservable.swift in Sources */, C485706D28BF450900539B36 /* NSMenuItemExtension.swift in Sources */, diff --git a/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme b/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme index fd68bb83..fc89920a 100644 --- a/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme +++ b/PHP Monitor.xcodeproj/xcshareddata/xcschemes/PHP Monitor EAP.xcscheme @@ -93,7 +93,7 @@ + isEnabled = "YES"> String { if let completedAt { - let duration = completedAt.timeIntervalSince(startedAt) - - if duration < 0.001 { - let micros = max(1, Int(duration * 1_000_000)) - return "Completed in \(micros) us" - } - - let ms = max(1, Int(duration * 1000)) - return "Completed in \(ms) ms" + return Self.formattedDuration( + completedAt.timeIntervalSince(startedAt), + isCompleted: true + ) } - let ms = max(1, Int(date.timeIntervalSince(startedAt) * 1000)) - return "Running for \(ms) ms" + return Self.formattedDuration( + date.timeIntervalSince(startedAt), + isCompleted: false + ) + } + + private static func formattedDuration( + _ duration: TimeInterval, + isCompleted: Bool + ) -> String { + let prefix = isCompleted ? "Completed in" : "Running for" + + if duration >= 0.3 { + let seconds = String(format: "%.2f", duration) + return "\(prefix) \(seconds) sec" + } + + let ms = max(1, Int(duration * 1000)) + return "\(prefix) \(ms) ms" } } diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index 467cfad7..04aecc3c 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -380,7 +380,6 @@ class RealShell: ShellProtocol, @unchecked Sendable { }) } - func reloadEnvPath() { // Instead of replacing the entire shell instance, we simply re-fetch the PATH self.PATH = RealShell.getPath() diff --git a/phpmon/Container/Container.swift b/phpmon/Container/Container.swift index 88b002d7..915d6aba 100644 --- a/phpmon/Container/Container.swift +++ b/phpmon/Container/Container.swift @@ -16,8 +16,8 @@ class Container: @unchecked Sendable { private(set) var paths: Paths! private(set) var shell: ShellProtocol! private(set) var command: CommandProtocol! - private(set) var webApi: WebApiProtocol! private(set) var commandTracker: CommandTracker! + private(set) var webApi: WebApiProtocol! // Secondary (uses primary instances above) private(set) var preferences: Preferences! @@ -69,10 +69,8 @@ class Container: @unchecked Sendable { self.filesystem = RealFileSystem(container: self) self.paths = Paths(container: self) self.commandTracker = CommandTracker() - let realShell = RealShell(binPath: paths.binPath) - self.shell = TrackedShell(shell: realShell, commandTracker: commandTracker) - let realCommand = RealCommand() - self.command = TrackedCommand(command: realCommand, commandTracker: commandTracker) + self.shell = TrackedShell(shell: RealShell(binPath: paths.binPath), commandTracker: commandTracker) + self.command = TrackedCommand(command: RealCommand(), commandTracker: commandTracker) self.webApi = RealWebApi(container: self) if coreOnly { diff --git a/phpmon/Modules/Command History/UI/CommandHistoryRow.swift b/phpmon/Modules/Command History/UI/CommandHistoryRow.swift new file mode 100644 index 00000000..0050a69f --- /dev/null +++ b/phpmon/Modules/Command History/UI/CommandHistoryRow.swift @@ -0,0 +1,55 @@ +// +// CommandHistoryRow.swift +// PHP Monitor +// +// Created by Nico Verbruggen on 24/02/2026. +// Copyright © 2026 Nico Verbruggen. All rights reserved. +// + +import SwiftUI + +struct CommandHistoryRow: View { + let command: LoggedCommand + let now: Date + let isEvenRow: Bool + let onAppear: () -> Void + let onDisappear: () -> Void + let onCompleted: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if command.isCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .frame(width: 16) + } else { + ProgressView() + .controlSize(.small) + .frame(width: 16) + } + Text(command.command) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + } + + Text(command.durationText(at: now)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 2) + .onAppear(perform: onAppear) + .onDisappear(perform: onDisappear) + .onChange(of: command.isCompleted) { isCompleted in + guard isCompleted else { return } + onCompleted() + } + .listRowSeparator(.hidden) + .listRowBackground( + Color(nsColor: NSColor.alternatingContentBackgroundColors[isEvenRow ? 0 : 1]) + ) + } +} diff --git a/phpmon/Modules/Command History/UI/CommandHistoryView.swift b/phpmon/Modules/Command History/UI/CommandHistoryView.swift index e926b699..483e8622 100644 --- a/phpmon/Modules/Command History/UI/CommandHistoryView.swift +++ b/phpmon/Modules/Command History/UI/CommandHistoryView.swift @@ -5,83 +5,84 @@ // Copyright © 2025 Nico Verbruggen. All rights reserved. // +import AppKit import SwiftUI struct CommandHistoryView: View { + // Provides access to the tracked command history @ObservedObject var commandTracker: CommandTracker + + // Timestamp used to compute duration labels in the list; updated when tick fires @State private var now = Date() - init(commandTracker: CommandTracker? = nil) { - self.commandTracker = commandTracker ?? App.shared.container.commandTracker + // Tracks whether the window view is currently visible + @State private var isWindowVisible = false + + // IDs for visible, active rows; used to avoid ticking when none are on-screen + @State private var visibleCommandIds: Set = [] + + init(commandTracker: CommandTracker) { + self.commandTracker = commandTracker } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("This window displays the last executed (shell) commands. Keep in mind that only the last 200 commands are stored and displayed.") - .font(.system(size: 11)) - .foregroundColor(.secondary) - .padding(.horizontal, 12) - .padding(.top, 10) ScrollViewReader { proxy in List { - if commandTracker.commands.isEmpty { - HStack { - Spacer() - Text("No commands have been tracked yet.") - .font(.system(size: 13)) - .foregroundColor(.secondary) - .padding(30) - Spacer() - } - } else { - ForEach(commandTracker.commands) { command in - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - if command.isCompleted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .frame(width: 16) - } else { - ProgressView() - .controlSize(.small) - .frame(width: 16) - } - Text(command.command) - .font(.system(size: 12, design: .monospaced)) - .lineLimit(2) - } - - Text(command.durationText(at: now)) - .font(.system(size: 11)) - .foregroundColor(.secondary) + ForEach(commandTracker.commands.indices, id: \.self) { index in + let command = commandTracker.commands[index] + let isEvenRow = index.isMultiple(of: 2) + CommandHistoryRow( + command: command, + now: now, + isEvenRow: isEvenRow, + onAppear: { + // Track only visible, active commands to avoid unnecessary ticking + guard !command.isCompleted else { return } + visibleCommandIds.insert(command.id) + }, + onDisappear: { + // Remove from visible set when the row scrolls out + visibleCommandIds.remove(command.id) + }, + onCompleted: { + // Stop ticking for this row once the command completes + visibleCommandIds.remove(command.id) } - Spacer() - } - .padding(.vertical, 2) - .id(command.id) - } + ) + .id(command.id) } } .listStyle(.plain) .onChange(of: commandTracker.commands.count) { _ in + // Scroll to the bottom as new commands come in if let last = commandTracker.commands.last { proxy.scrollTo(last.id, anchor: .bottom) } } } .frame(minWidth: 400, minHeight: 200) - .onChange(of: commandTracker.isActive) { isActive in - guard isActive else { return } + .onAppear { + // Mark the window as visible so duration ticking can start + isWindowVisible = true now = Date() } - .onReceive( - Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() - ) { _ in - if commandTracker.isActive { + .onDisappear { + // Stop ticking when the window disappears + isWindowVisible = false + } + .onReceive(Timer.publish(every: 0.08, on: .main, in: .common).autoconnect()) { _ in + // Only update running commands if the window is visible + there's command IDs that are: + // - visible (in window, based on scroll position) + // - running (so we need to update the timestamp periodically) + if commandTracker.isActive, shouldTick { now = Date() } } } } + + private var shouldTick: Bool { + isWindowVisible && !visibleCommandIds.isEmpty + } } diff --git a/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift b/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift index eb45c62f..384e5f2a 100644 --- a/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift +++ b/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift @@ -26,7 +26,9 @@ class CommandHistoryWindowController: PMWindowController { panel.isFloatingPanel = true panel.hidesOnDeactivate = false panel.delegate = delegate ?? windowController - panel.contentView = NSHostingView(rootView: CommandHistoryView()) + panel.contentView = NSHostingView(rootView: CommandHistoryView( + commandTracker: App.shared.container.commandTracker + )) panel.setContentSize(NSSize(width: 500, height: 300)) windowController.window = panel