From 6f5501573a8acad824b448ce2e04e109b3318631 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Tue, 24 Feb 2026 14:43:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Command=20History=20to=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PHP Monitor.xcodeproj/project.pbxproj | 74 ++++++------ phpmon/Common/Command/CommandProtocol.swift | 65 ++++++++++ phpmon/Common/Command/CommandTracker.swift | 112 ++++++++++++++++++ phpmon/Common/Command/RealCommand.swift | 16 +-- phpmon/Common/Command/TestableCommand.swift | 15 +-- phpmon/Common/Core/CommandTracker.swift | 76 ------------ phpmon/Common/Shell/RealShell.swift | 39 +----- phpmon/Common/Shell/ShellProtocol.swift | 62 ++++++++++ phpmon/Common/Shell/TestableShell.swift | 36 +++++- phpmon/Domain/App/AppDelegate.swift | 3 - phpmon/Domain/App/WindowManager.swift | 2 +- phpmon/Domain/Menu/MainMenu.swift | 6 + phpmon/Domain/Menu/StatusMenu+Items.swift | 3 +- .../PreferencesVC+WindowRestore.swift | 10 +- .../UI/ActiveCommandsView.swift | 75 ------------ .../UI/CommandHistoryView.swift | 87 ++++++++++++++ .../UI/CommandHistoryWindowController.swift} | 16 +-- phpmon/en.lproj/Localizable.strings | 1 + 18 files changed, 431 insertions(+), 267 deletions(-) create mode 100644 phpmon/Common/Command/CommandTracker.swift delete mode 100644 phpmon/Common/Core/CommandTracker.swift delete mode 100644 phpmon/Modules/Active Commands/UI/ActiveCommandsView.swift create mode 100644 phpmon/Modules/Command History/UI/CommandHistoryView.swift rename phpmon/Modules/{Active Commands/UI/ActiveCommandsWindowController.swift => Command History/UI/CommandHistoryWindowController.swift} (69%) diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 79097a06..198649fa 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -209,6 +209,9 @@ 03FE39E72E81682800B7B5AC /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E52E81682800B7B5AC /* AppIcon.icon */; }; 03FE39E82E81682800B7B5AC /* AppIconEAP.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E62E81682800B7B5AC /* AppIconEAP.icon */; }; 03FE39EA2E81694500B7B5AC /* AppIconUD.icon in Resources */ = {isa = PBXBuildFile; fileRef = 03FE39E92E81694500B7B5AC /* AppIconUD.icon */; }; + 0A1A6208D3DD2495FBD8569B /* CommandHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */; }; + 25250C95E34D20ED4C91C320 /* CommandHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */; }; + 4181B8F1C0ED930B2C5E5532 /* CommandHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */; }; 5420395926135DC100FB00FA /* PreferencesVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5420395826135DC100FB00FA /* PreferencesVC.swift */; }; 5420395F2613607600FB00FA /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5420395E2613607600FB00FA /* Preferences.swift */; }; 5489625828312FAD004F647A /* CreatedFromFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5489625728312FAD004F647A /* CreatedFromFile.swift */; }; @@ -216,6 +219,7 @@ 54A18D40282A566E000A0D81 /* nginx-secure-proxy-custom-tld.test in Resources */ = {isa = PBXBuildFile; fileRef = 54A18D3F282A566E000A0D81 /* nginx-secure-proxy-custom-tld.test */; }; 54B48B5F275F66AE006D90C5 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B48B5E275F66AE006D90C5 /* Application.swift */; }; 54B48B60275F66AE006D90C5 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B48B5E275F66AE006D90C5 /* Application.swift */; }; + 54D6418DBC6AF23FED489018 /* CommandHistoryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */; }; 54D9E0B227E4F51E003B9AD9 /* HotKeysController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D9E0AC27E4F51E003B9AD9 /* HotKeysController.swift */; }; 54D9E0B327E4F51E003B9AD9 /* HotKeysController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D9E0AC27E4F51E003B9AD9 /* HotKeysController.swift */; }; 54D9E0B427E4F51E003B9AD9 /* Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D9E0AD27E4F51E003B9AD9 /* Key.swift */; }; @@ -235,6 +239,9 @@ 54FCFD2E276C8D67004CE748 /* HotkeyPreferenceView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54FCFD2C276C8D67004CE748 /* HotkeyPreferenceView.xib */; }; 54FCFD30276C8DA4004CE748 /* HotkeyPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54FCFD2F276C8DA4004CE748 /* HotkeyPreferenceView.swift */; }; 54FCFD31276C8DA4004CE748 /* HotkeyPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54FCFD2F276C8DA4004CE748 /* HotkeyPreferenceView.swift */; }; + 5FD883E3DBC963F53E12A17F /* CommandHistoryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */; }; + 719E6363E4F41C8A599FCC99 /* CommandHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */; }; + BB3AF45BED2C82AE27E86107 /* CommandHistoryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */; }; C40175B82903108900763A68 /* ValetInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40175B72903108900763A68 /* ValetInteractor.swift */; }; C40175B92903108900763A68 /* ValetInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40175B72903108900763A68 /* ValetInteractor.swift */; }; C40175BA2903108900763A68 /* ValetInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40175B72903108900763A68 /* ValetInteractor.swift */; }; @@ -321,7 +328,6 @@ C42106682AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42106652AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift */; }; C42106692AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42106652AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift */; }; C422DDAA28A2C49900CEAC97 /* PhpDoctorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDA928A2C49900CEAC97 /* PhpDoctorView.swift */; }; - 719E6363E4F41C8A599FCC99 /* ActiveCommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */; }; C4232EE52612526500158FC6 /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = C4232EE42612526500158FC6 /* Credits.html */; }; C42337A3281F19F000459A48 /* Xdebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42337A2281F19F000459A48 /* Xdebug.swift */; }; C42759672627662800093CAE /* NSMenuExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42759662627662800093CAE /* NSMenuExtension.swift */; }; @@ -637,7 +643,6 @@ C471E86428F9BB650021E251 /* Warning.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699F028A2F3150060FEB8 /* Warning.swift */; }; C471E86528F9BB650021E251 /* WarningManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699EE28A2F2A30060FEB8 /* WarningManager.swift */; }; C471E86628F9BB650021E251 /* PhpDoctorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDAC28A2DAC600CEAC97 /* PhpDoctorWindowController.swift */; }; - DE164E569298309E4224C5D6 /* ActiveCommandsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */; }; C471E86728F9BB650021E251 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE82288F1F9700FC478F /* OnboardingWindowController.swift */; }; C471E86828F9BB650021E251 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4998F092617633900B2526E /* PreferencesWindowController.swift */; }; C471E86928F9BB650021E251 /* PreferencesWindowController+Hotkey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE7F288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift */; }; @@ -658,7 +663,6 @@ C471E87E28F9BB650021E251 /* PresetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C463E37F284930EE00422731 /* PresetHelper.swift */; }; C471E87F28F9BB650021E251 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4297F7928970D59004C4630 /* WarningView.swift */; }; C471E88028F9BB650021E251 /* PhpDoctorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDA928A2C49900CEAC97 /* PhpDoctorView.swift */; }; - 25250C95E34D20ED4C91C320 /* ActiveCommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */; }; C471E88128F9BB650021E251 /* NoWarningsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41C708C28AA7F7900E8D498 /* NoWarningsView.swift */; }; C471E88228F9BB650021E251 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E9D2BF2878B336008FFDAD /* OnboardingView.swift */; }; C471E88328F9BB650021E251 /* VersionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BF2850BD2A007400F1 /* VersionPopoverView.swift */; }; @@ -720,7 +724,6 @@ C471E8C728F9BB8F0021E251 /* Warning.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699F028A2F3150060FEB8 /* Warning.swift */; }; C471E8C828F9BB8F0021E251 /* WarningManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699EE28A2F2A30060FEB8 /* WarningManager.swift */; }; C471E8C928F9BB8F0021E251 /* PhpDoctorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDAC28A2DAC600CEAC97 /* PhpDoctorWindowController.swift */; }; - BB3AF45BED2C82AE27E86107 /* ActiveCommandsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */; }; C471E8CA28F9BB8F0021E251 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE82288F1F9700FC478F /* OnboardingWindowController.swift */; }; C471E8CB28F9BB8F0021E251 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4998F092617633900B2526E /* PreferencesWindowController.swift */; }; C471E8CC28F9BB8F0021E251 /* PreferencesWindowController+Hotkey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE7F288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift */; }; @@ -741,7 +744,6 @@ C471E8E128F9BB8F0021E251 /* PresetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C463E37F284930EE00422731 /* PresetHelper.swift */; }; C471E8E228F9BB8F0021E251 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4297F7928970D59004C4630 /* WarningView.swift */; }; C471E8E328F9BB8F0021E251 /* PhpDoctorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDA928A2C49900CEAC97 /* PhpDoctorView.swift */; }; - 0A1A6208D3DD2495FBD8569B /* ActiveCommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */; }; C471E8E428F9BB8F0021E251 /* NoWarningsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41C708C28AA7F7900E8D498 /* NoWarningsView.swift */; }; C471E8E528F9BB8F0021E251 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E9D2BF2878B336008FFDAD /* OnboardingView.swift */; }; C471E8E628F9BB8F0021E251 /* VersionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BF2850BD2A007400F1 /* VersionPopoverView.swift */; }; @@ -775,9 +777,7 @@ C485706D28BF450900539B36 /* NSMenuItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40508B028ADAB44008FAC1F /* NSMenuItemExtension.swift */; }; C485706E28BF451C00539B36 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4FACE82288F1F9700FC478F /* OnboardingWindowController.swift */; }; C485706F28BF452300539B36 /* PhpDoctorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDAC28A2DAC600CEAC97 /* PhpDoctorWindowController.swift */; }; - 54D6418DBC6AF23FED489018 /* ActiveCommandsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */; }; C485707028BF452300539B36 /* PhpDoctorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDAC28A2DAC600CEAC97 /* PhpDoctorWindowController.swift */; }; - 5FD883E3DBC963F53E12A17F /* ActiveCommandsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */; }; C485707128BF452E00539B36 /* WarningManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699EE28A2F2A30060FEB8 /* WarningManager.swift */; }; C485707228BF453800539B36 /* SwiftUIHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BD2850B86C007400F1 /* SwiftUIHelper.swift */; }; C485707328BF454300539B36 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E9D2BF2878B336008FFDAD /* OnboardingView.swift */; }; @@ -788,7 +788,6 @@ C485707828BF456300539B36 /* Warning.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47699F028A2F3150060FEB8 /* Warning.swift */; }; C485707928BF456C00539B36 /* ArrayExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EB53E628553117006F9937 /* ArrayExtension.swift */; }; C485707A28BF457800539B36 /* PhpDoctorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C422DDA928A2C49900CEAC97 /* PhpDoctorView.swift */; }; - 4181B8F1C0ED930B2C5E5532 /* ActiveCommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */; }; C485707B28BF458900539B36 /* VersionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44264BF2850BD2A007400F1 /* VersionPopoverView.swift */; }; C485707C28BF459500539B36 /* NoWarningsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41C708C28AA7F7900E8D498 /* NoWarningsView.swift */; }; C485707D28BF45A200539B36 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4297F7928970D59004C4630 /* WarningView.swift */; }; @@ -1030,6 +1029,7 @@ C4FD87A829AB9ABD0002D701 /* PhpConfigChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43FDBE829A932B0003D85EC /* PhpConfigChecker.swift */; }; C4FD87A929AB9ABD0002D701 /* PhpConfigChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43FDBE829A932B0003D85EC /* PhpConfigChecker.swift */; }; C4FD87AA29AB9ABD0002D701 /* PhpConfigChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43FDBE829A932B0003D85EC /* PhpConfigChecker.swift */; }; + DE164E569298309E4224C5D6 /* CommandHistoryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1131,6 +1131,7 @@ 03FE39E52E81682800B7B5AC /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 03FE39E62E81682800B7B5AC /* AppIconEAP.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIconEAP.icon; sourceTree = ""; }; 03FE39E92E81694500B7B5AC /* AppIconUD.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIconUD.icon; sourceTree = ""; }; + 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHistoryView.swift; sourceTree = ""; }; 5420395826135DC100FB00FA /* PreferencesVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesVC.swift; sourceTree = ""; }; 5420395E2613607600FB00FA /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 5489625728312FAD004F647A /* CreatedFromFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatedFromFile.swift; sourceTree = ""; }; @@ -1198,8 +1199,6 @@ C42106652AFA9FF400DF3732 /* PhpVersionManagerView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PhpVersionManagerView+Actions.swift"; sourceTree = ""; }; C422DDA928A2C49900CEAC97 /* PhpDoctorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpDoctorView.swift; sourceTree = ""; }; C422DDAC28A2DAC600CEAC97 /* PhpDoctorWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpDoctorWindowController.swift; sourceTree = ""; }; - E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCommandsWindowController.swift; sourceTree = ""; }; - 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCommandsView.swift; sourceTree = ""; }; C4232EE42612526500158FC6 /* Credits.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = Credits.html; sourceTree = ""; }; C42337A2281F19F000459A48 /* Xdebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xdebug.swift; sourceTree = ""; }; C42759662627662800093CAE /* NSMenuExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSMenuExtension.swift; sourceTree = ""; }; @@ -1393,6 +1392,7 @@ C4FC8D412A49816300FBBD16 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; C4FC8D432A49816C00FBBD16 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; C4FC8D442A4981BC00FBBD16 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHistoryWindowController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1725,7 +1725,6 @@ C40C7F2F27722E8D00DDDCDC /* Logger.swift */, C417DC73277614690015E6EE /* Helpers.swift */, C4CB6E64292C362C002E9027 /* Homebrew.swift */, - 031A80DB2F4CF1690016F7DD /* CommandTracker.swift */, ); path = Core; sourceTree = ""; @@ -1975,23 +1974,6 @@ path = Data; sourceTree = ""; }; - F0F42C1C96504D46C75AD6F8 /* UI */ = { - isa = PBXGroup; - children = ( - E51118AFDD02AA1B7D8C9278 /* ActiveCommandsWindowController.swift */, - 31503E15DADA980998F0F5A2 /* ActiveCommandsView.swift */, - ); - path = UI; - sourceTree = ""; - }; - E1E4FBD76E71C469A65AC4F8 /* Active Commands */ = { - isa = PBXGroup; - children = ( - F0F42C1C96504D46C75AD6F8 /* UI */, - ); - path = "Active Commands"; - sourceTree = ""; - }; C44DFA832A6706A200B98ED5 /* Modules */ = { isa = PBXGroup; children = ( @@ -1999,7 +1981,7 @@ C464ADAA275A7A25003FCD53 /* Domain List */, C44DFA7A2A6703FD00B98ED5 /* PHP Config Editor */, C4297F7828970D4E004C4630 /* PHP Doctor */, - E1E4FBD76E71C469A65AC4F8 /* Active Commands */, + E1E4FBD76E71C469A65AC4F8 /* Command History */, C43931C329C4BD510069165B /* PHP Version Manager */, C4292D512B023F37004F0D2A /* PHP Extension Manager */, ); @@ -2470,6 +2452,7 @@ C4B5853D2770FE3900DA4FBE /* RealCommand.swift */, C4E49DEC28F764A00026AC4E /* TestableCommand.swift */, C4E49DE928F7643D0026AC4E /* CommandProtocol.swift */, + 031A80DB2F4CF1690016F7DD /* CommandTracker.swift */, ); path = Command; sourceTree = ""; @@ -2566,6 +2549,23 @@ path = Extensions; sourceTree = ""; }; + E1E4FBD76E71C469A65AC4F8 /* Command History */ = { + isa = PBXGroup; + children = ( + F0F42C1C96504D46C75AD6F8 /* UI */, + ); + path = "Command History"; + sourceTree = ""; + }; + F0F42C1C96504D46C75AD6F8 /* UI */ = { + isa = PBXGroup; + children = ( + E51118AFDD02AA1B7D8C9278 /* CommandHistoryWindowController.swift */, + 31503E15DADA980998F0F5A2 /* CommandHistoryView.swift */, + ); + path = UI; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -3024,7 +3024,7 @@ 03D846352EB64E39006EFE3C /* CrashReporter.swift in Sources */, C42759672627662800093CAE /* NSMenuExtension.swift in Sources */, C422DDAA28A2C49900CEAC97 /* PhpDoctorView.swift in Sources */, - 719E6363E4F41C8A599FCC99 /* ActiveCommandsView.swift in Sources */, + 719E6363E4F41C8A599FCC99 /* CommandHistoryView.swift in Sources */, 031A80DE2F4CF1690016F7DD /* CommandTracker.swift in Sources */, C469E6FE294CF7B200A82AB2 /* FakeValetProxy.swift in Sources */, C464ADAF275A7A69003FCD53 /* DomainListVC.swift in Sources */, @@ -3063,7 +3063,7 @@ 031D74842F46225C00D4FF48 /* SelectDomainTypeView.swift in Sources */, C4D5CFCA27E0F9CD00035329 /* NginxConfigurationFile.swift in Sources */, C485707028BF452300539B36 /* PhpDoctorWindowController.swift in Sources */, - 5FD883E3DBC963F53E12A17F /* ActiveCommandsWindowController.swift in Sources */, + 5FD883E3DBC963F53E12A17F /* CommandHistoryWindowController.swift in Sources */, C4CE3BBA27B31F670086CA49 /* ComposerWindow.swift in Sources */, C40D725A2A018ACC0054A067 /* BusyStatus.swift in Sources */, 031F24802EA1071A00CFB8D9 /* Container+Fake.swift in Sources */, @@ -3203,7 +3203,7 @@ C45B9150295608E300F4EC78 /* ValetServicesManager.swift in Sources */, C471E86528F9BB650021E251 /* WarningManager.swift in Sources */, C471E86628F9BB650021E251 /* PhpDoctorWindowController.swift in Sources */, - DE164E569298309E4224C5D6 /* ActiveCommandsWindowController.swift in Sources */, + DE164E569298309E4224C5D6 /* CommandHistoryWindowController.swift in Sources */, C471E86728F9BB650021E251 /* OnboardingWindowController.swift in Sources */, C471E86828F9BB650021E251 /* PreferencesWindowController.swift in Sources */, C471E86928F9BB650021E251 /* PreferencesWindowController+Hotkey.swift in Sources */, @@ -3236,7 +3236,7 @@ C471E87E28F9BB650021E251 /* PresetHelper.swift in Sources */, C471E87F28F9BB650021E251 /* WarningView.swift in Sources */, C471E88028F9BB650021E251 /* PhpDoctorView.swift in Sources */, - 25250C95E34D20ED4C91C320 /* ActiveCommandsView.swift in Sources */, + 25250C95E34D20ED4C91C320 /* CommandHistoryView.swift in Sources */, C471E88128F9BB650021E251 /* NoWarningsView.swift in Sources */, C471E88228F9BB650021E251 /* OnboardingView.swift in Sources */, C471E88328F9BB650021E251 /* VersionPopoverView.swift in Sources */, @@ -3440,7 +3440,7 @@ C471E8C828F9BB8F0021E251 /* WarningManager.swift in Sources */, C46DC7A72C7B5BCA00F19D17 /* Favorites.swift in Sources */, C471E8C928F9BB8F0021E251 /* PhpDoctorWindowController.swift in Sources */, - BB3AF45BED2C82AE27E86107 /* ActiveCommandsWindowController.swift in Sources */, + BB3AF45BED2C82AE27E86107 /* CommandHistoryWindowController.swift in Sources */, C41ADCEB2970CCC700120423 /* FSNotifier.swift in Sources */, C471E8CA28F9BB8F0021E251 /* OnboardingWindowController.swift in Sources */, C456A0C92AA614BD0080144F /* PhpPreference.swift in Sources */, @@ -3479,7 +3479,7 @@ 031D747D2F46225600D4FF48 /* SimpleButton.swift in Sources */, C471E8E228F9BB8F0021E251 /* WarningView.swift in Sources */, C471E8E328F9BB8F0021E251 /* PhpDoctorView.swift in Sources */, - 0A1A6208D3DD2495FBD8569B /* ActiveCommandsView.swift in Sources */, + 0A1A6208D3DD2495FBD8569B /* CommandHistoryView.swift in Sources */, C471E8E428F9BB8F0021E251 /* NoWarningsView.swift in Sources */, C471E8E528F9BB8F0021E251 /* OnboardingView.swift in Sources */, C4B79EBF29CA38DB00A483EE /* BrewCommand.swift in Sources */, @@ -3641,7 +3641,7 @@ C41ADCE92970CCC700120423 /* FSNotifier.swift in Sources */, C40C7F2927721FF600DDDCDC /* Valet+Alerts.swift in Sources */, C485707A28BF457800539B36 /* PhpDoctorView.swift in Sources */, - 4181B8F1C0ED930B2C5E5532 /* ActiveCommandsView.swift in Sources */, + 4181B8F1C0ED930B2C5E5532 /* CommandHistoryView.swift in Sources */, C4C0E8E827F88B41002D32A9 /* DomainScanner.swift in Sources */, C449B4F027EE7FB800C47E8A /* DomainListTLSCell.swift in Sources */, 037F44162EDB0AAA002EBF75 /* FSNotifierTest.swift in Sources */, @@ -3826,7 +3826,7 @@ C449B4F327EE7FC600C47E8A /* DomainListTypeCell.swift in Sources */, C48D6C71279CD2AC00F26D7E /* VersionNumber.swift in Sources */, C485706F28BF452300539B36 /* PhpDoctorWindowController.swift in Sources */, - 54D6418DBC6AF23FED489018 /* ActiveCommandsWindowController.swift in Sources */, + 54D6418DBC6AF23FED489018 /* CommandHistoryWindowController.swift in Sources */, C46FA9892822EFDC00D78807 /* PhpConfigurationFile.swift in Sources */, C43931CB29C4C03F0069165B /* Brew.swift in Sources */, 0392CDEC2EB25371009176DA /* SecurePopoverView.swift in Sources */, diff --git a/phpmon/Common/Command/CommandProtocol.swift b/phpmon/Common/Command/CommandProtocol.swift index f30795f6..e784b8e6 100644 --- a/phpmon/Common/Command/CommandProtocol.swift +++ b/phpmon/Common/Command/CommandProtocol.swift @@ -9,6 +9,20 @@ import Foundation protocol CommandProtocol { + /** + Immediately executes a command, without tracking. + + - Parameter path: The path of the command or program to invoke. + - Parameter arguments: A list of arguments that are passed on. + - Parameter trimNewlines: Removes empty new line output. + - Parameter withStandardError: Outputs standard error output to the same string output as well. + */ + func executeRaw( + path: String, + arguments: [String], + trimNewlines: Bool, + withStandardError: Bool + ) -> String /** Immediately executes a command. @@ -39,3 +53,54 @@ protocol CommandProtocol { ) -> String } + +extension CommandProtocol { + func execute( + path: String, + arguments: [String], + trimNewlines: Bool, + withStandardError: Bool + ) -> String { + executeRaw( + path: path, + arguments: arguments, + trimNewlines: trimNewlines, + withStandardError: withStandardError + ) + } + + func execute( + path: String, + arguments: [String], + trimNewlines: Bool + ) -> String { + execute( + path: path, + arguments: arguments, + trimNewlines: trimNewlines, + withStandardError: false + ) + } + +} + +protocol TrackedCommandProtocol: CommandProtocol, CommandTrackingProvider {} + +extension TrackedCommandProtocol { + func execute( + path: String, + arguments: [String], + trimNewlines: Bool, + withStandardError: Bool + ) -> String { + let commandDescription = "\(path) \(arguments.joined(separator: " "))" + return trackedCommand(description: commandDescription) { + executeRaw( + path: path, + arguments: arguments, + trimNewlines: trimNewlines, + withStandardError: withStandardError + ) + } + } +} diff --git a/phpmon/Common/Command/CommandTracker.swift b/phpmon/Common/Command/CommandTracker.swift new file mode 100644 index 00000000..942737e5 --- /dev/null +++ b/phpmon/Common/Command/CommandTracker.swift @@ -0,0 +1,112 @@ +// +// CommandTracker.swift +// PHP Monitor +// +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import Foundation +@preconcurrency import Dispatch + +@MainActor +class CommandTracker: ObservableObject { + nonisolated init() {} + + private let maxStoredCommands = 200 + @Published private(set) var commands: [TrackedCommand] = [] + + var activeCommands: [TrackedCommand] { + commands.filter { !$0.isCompleted } + } + + var isActive: Bool { + !activeCommands.isEmpty + } + + @discardableResult + func track(_ command: String, id: UUID = UUID()) -> UUID { + let tracked = TrackedCommand(id: id, command: command, startedAt: Date()) + commands.append(tracked) + if commands.count > maxStoredCommands { + commands.removeFirst(commands.count - maxStoredCommands) + } + return tracked.id + } + + func complete(_ id: UUID) { + if let index = commands.firstIndex(where: { $0.id == id }) { + commands[index].completedAt = Date() + } + } + + nonisolated func trackFromAnyThread(_ command: String) -> UUID { + let id = UUID() + Task { @MainActor in + self.track(command, id: id) + } + return id + } + + nonisolated func completeFromAnyThread(_ id: UUID) { + Task { @MainActor in + self.complete(id) + } + } +} + +// MARK: - Tracked Command + +struct TrackedCommand: Identifiable { + let id: UUID + let command: String + let startedAt: Date + var completedAt: Date? + + var isCompleted: Bool { + completedAt != nil + } + + func durationText(at date: Date = Date()) -> 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) μs" + } + + let ms = max(1, Int(duration * 1000)) + return "Completed in \(ms) ms" + } + + let ms = max(1, Int(date.timeIntervalSince(startedAt) * 1000)) + return "Running for \(ms) ms" + } +} + +// MARK: - Command Tracking + +protocol CommandTrackingProvider { + var commandTracker: CommandTracker { get } +} + +extension CommandTrackingProvider { + func trackedCommand(description: String, _ work: () -> T) -> T { + let trackingId = commandTracker.trackFromAnyThread(description) + defer { + commandTracker.completeFromAnyThread(trackingId) + } + return work() + } + + func trackedCommandAsync( + description: String, + _ work: () async throws -> T + ) async rethrows -> T { + let trackingId = commandTracker.trackFromAnyThread(description) + defer { + commandTracker.completeFromAnyThread(trackingId) + } + return try await work() + } +} diff --git a/phpmon/Common/Command/RealCommand.swift b/phpmon/Common/Command/RealCommand.swift index 3b75eea5..2139f185 100644 --- a/phpmon/Common/Command/RealCommand.swift +++ b/phpmon/Common/Command/RealCommand.swift @@ -7,29 +7,19 @@ import Cocoa -public class RealCommand: CommandProtocol { - private let commandTracker: CommandTracker +public class RealCommand: TrackedCommandProtocol { + let commandTracker: CommandTracker init(commandTracker: CommandTracker) { self.commandTracker = commandTracker } - public func execute( + public func executeRaw( path: String, arguments: [String], trimNewlines: Bool, withStandardError: Bool ) -> String { - let tracker = self.commandTracker - let commandDescription = "\(path) \(arguments.joined(separator: " "))" - var trackingId: UUID? - DispatchQueue.main.async { trackingId = tracker.track(commandDescription) } - defer { - DispatchQueue.main.async { - if let id = trackingId { tracker.complete(id) } - } - } - let task = Process() var output = "" diff --git a/phpmon/Common/Command/TestableCommand.swift b/phpmon/Common/Command/TestableCommand.swift index ccf2615b..b4b61e4a 100644 --- a/phpmon/Common/Command/TestableCommand.swift +++ b/phpmon/Common/Command/TestableCommand.swift @@ -15,15 +15,12 @@ class TestableCommand: CommandProtocol { var commands: [String: String] - func execute(path: String, arguments: [String]) -> String { - self.execute(path: path, arguments: arguments, trimNewlines: false) - } - - public func execute(path: String, arguments: [String], trimNewlines: Bool, withStandardError: Bool) -> String { - self.execute(path: path, arguments: arguments, trimNewlines: trimNewlines) - } - - public func execute(path: String, arguments: [String], trimNewlines: Bool) -> String { + public func executeRaw( + path: String, + arguments: [String], + trimNewlines: Bool, + withStandardError: Bool + ) -> String { let concatenatedCommand = "\(path) \(arguments.joined(separator: " "))" assert(commands.keys.contains(concatenatedCommand), "Command `\(concatenatedCommand)` not found") return self.commands[concatenatedCommand]! diff --git a/phpmon/Common/Core/CommandTracker.swift b/phpmon/Common/Core/CommandTracker.swift deleted file mode 100644 index 842aca87..00000000 --- a/phpmon/Common/Core/CommandTracker.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// CommandTracker.swift -// PHP Monitor -// -// Copyright © 2025 Nico Verbruggen. All rights reserved. -// - -import Foundation - -struct TrackedCommand: Identifiable { - let id: UUID - let command: String - let startedAt: Date - var completedAt: Date? - - var isCompleted: Bool { - completedAt != nil - } - - var durationText: String { - let end = completedAt ?? Date() - let ms = Int(end.timeIntervalSince(startedAt) * 1000) - if isCompleted { - return "Completed in \(ms) ms" - } else { - return "Running for \(ms) ms" - } - } -} - -@MainActor -class CommandTracker: ObservableObject { - nonisolated init() {} - - @Published private(set) var commands: [TrackedCommand] = [] - - var activeCommands: [TrackedCommand] { - commands.filter { !$0.isCompleted } - } - - var isActive: Bool { - !activeCommands.isEmpty - } - - var loggingEnabled: Bool = true - - @discardableResult - func track(_ command: String) -> UUID { - let tracked = TrackedCommand(id: UUID(), command: command, startedAt: Date()) - commands.append(tracked) - if loggingEnabled { - logActiveCommands("TRACK") - } - return tracked.id - } - - func complete(_ id: UUID) { - if let index = commands.firstIndex(where: { $0.id == id }) { - commands[index].completedAt = Date() - } - if loggingEnabled { - logActiveCommands("COMPLETE") - } - } - - private func logActiveCommands(_ label: String) { - if activeCommands.isEmpty { - Log.info("[CommandTracker] [\(label)] No active commands.") - } else { - let list = activeCommands - .map { " - \($0.command) (started: \($0.startedAt))" } - .joined(separator: "\n") - Log.info("[CommandTracker] [\(label)] Active commands:\n\(list)") - } - } -} diff --git a/phpmon/Common/Shell/RealShell.swift b/phpmon/Common/Shell/RealShell.swift index ac8255fc..6eca8964 100644 --- a/phpmon/Common/Shell/RealShell.swift +++ b/phpmon/Common/Shell/RealShell.swift @@ -9,7 +9,7 @@ import Foundation @preconcurrency import Dispatch -class RealShell: ShellProtocol, @unchecked Sendable { +class RealShell: TrackedShellProtocol, @unchecked Sendable { init(binPath: String, commandTracker: CommandTracker) { self.binPath = binPath self.commandTracker = commandTracker @@ -17,7 +17,7 @@ class RealShell: ShellProtocol, @unchecked Sendable { self._exports = [:] } - private let commandTracker: CommandTracker + let commandTracker: CommandTracker private(set) var binPath: String /** @@ -157,16 +157,7 @@ class RealShell: ShellProtocol, @unchecked Sendable { // MARK: - Shellable Protocol - func sync(_ command: String) -> ShellOutput { - let tracker = self.commandTracker - var trackingId: UUID? - DispatchQueue.main.async { trackingId = tracker.track(command) } - defer { - DispatchQueue.main.async { - if let id = trackingId { tracker.complete(id) } - } - } - + func syncRaw(_ command: String) -> ShellOutput { let process = getShellProcess(for: command) let outputPipe = Pipe() @@ -197,13 +188,7 @@ class RealShell: ShellProtocol, @unchecked Sendable { } @discardableResult - func pipe(_ command: String) async -> ShellOutput { - let trackingId = await commandTracker.track(command) - defer { - let tracker = self.commandTracker - DispatchQueue.main.async { tracker.complete(trackingId) } - } - + func pipeRaw(_ command: String) async -> ShellOutput { let process = getShellProcess(for: command) let outputPipe = Pipe() @@ -239,13 +224,7 @@ class RealShell: ShellProtocol, @unchecked Sendable { } @discardableResult - func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { - let trackingId = await commandTracker.track(command) - defer { - let tracker = self.commandTracker - DispatchQueue.main.async { tracker.complete(trackingId) } - } - + func pipeRaw(_ command: String, timeout: TimeInterval) async -> ShellOutput { let process = getShellProcess(for: command) let outputPipe = Pipe() @@ -311,17 +290,11 @@ class RealShell: ShellProtocol, @unchecked Sendable { } @discardableResult - func attach( + func attachRaw( _ command: String, didReceiveOutput: @escaping (String, ShellStream) -> Void, withTimeout timeout: TimeInterval = 5.0 ) async throws -> (Process, ShellOutput) { - let trackingId = await commandTracker.track(command) - defer { - let tracker = self.commandTracker - DispatchQueue.main.async { tracker.complete(trackingId) } - } - let process = getShellProcess(for: command) let outputPipe = Pipe(), errorPipe = Pipe() diff --git a/phpmon/Common/Shell/ShellProtocol.swift b/phpmon/Common/Shell/ShellProtocol.swift index 459fee69..c273e017 100644 --- a/phpmon/Common/Shell/ShellProtocol.swift +++ b/phpmon/Common/Shell/ShellProtocol.swift @@ -14,6 +14,19 @@ protocol ShellProtocol { */ var PATH: String { get } + /** + Run a command synchronously without tracking. Use with caution! + + Common usage: + ``` + let output = Shell.sync("php -v") + ``` + + @return The shell output. If the command times out, returns empty output. + */ + @discardableResult + func syncRaw(_ command: String) -> ShellOutput + /** Run a command synchronously. Use with caution! @@ -37,6 +50,9 @@ protocol ShellProtocol { @return The shell output. If the command times out, returns empty output. */ + @discardableResult + func pipeRaw(_ command: String) async -> ShellOutput + @discardableResult func pipe(_ command: String) async -> ShellOutput @@ -49,6 +65,9 @@ protocol ShellProtocol { @return The shell output. If the command times out, returns empty output. */ + @discardableResult + func pipeRaw(_ command: String, timeout: TimeInterval) async -> ShellOutput + @discardableResult func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput @@ -63,6 +82,13 @@ protocol ShellProtocol { @return A tuple, containing the `Process` and `ShellOutput` objects. */ + @discardableResult + func attachRaw( + _ command: String, + didReceiveOutput: @escaping (String, ShellStream) -> Void, + withTimeout timeout: TimeInterval + ) async throws -> (Process, ShellOutput) + @discardableResult func attach( _ command: String, @@ -76,6 +102,42 @@ protocol ShellProtocol { func reloadEnvPath() } +protocol TrackedShellProtocol: ShellProtocol, CommandTrackingProvider {} + +extension TrackedShellProtocol { + @discardableResult + func sync(_ command: String) -> ShellOutput { + trackedCommand(description: command) { + syncRaw(command) + } + } + + @discardableResult + func pipe(_ command: String) async -> ShellOutput { + await trackedCommandAsync(description: command) { + await pipeRaw(command) + } + } + + @discardableResult + func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { + await trackedCommandAsync(description: command) { + await pipeRaw(command, timeout: timeout) + } + } + + @discardableResult + func attach( + _ command: String, + didReceiveOutput: @escaping (String, ShellStream) -> Void, + withTimeout timeout: TimeInterval + ) async throws -> (Process, ShellOutput) { + try await trackedCommandAsync(description: command) { + try await attachRaw(command, didReceiveOutput: didReceiveOutput, withTimeout: timeout) + } + } +} + enum ShellStream: Codable { case stdOut, stdErr, stdIn } diff --git a/phpmon/Common/Shell/TestableShell.swift b/phpmon/Common/Shell/TestableShell.swift index 0e63819c..0a8dc927 100644 --- a/phpmon/Common/Shell/TestableShell.swift +++ b/phpmon/Common/Shell/TestableShell.swift @@ -20,7 +20,7 @@ public class TestableShell: ShellProtocol { var expectations: [String: BatchFakeShellOutput] = [:] @discardableResult - func sync(_ command: String) -> ShellOutput { + func syncRaw(_ command: String) -> ShellOutput { // This assertion will only fire during test builds assert(expectations.keys.contains(command), "No response declared for command: \(command)") @@ -32,18 +32,18 @@ public class TestableShell: ShellProtocol { } @discardableResult - func pipe(_ command: String) async -> ShellOutput { - return await pipe(command, timeout: 60) + func pipeRaw(_ command: String) async -> ShellOutput { + await pipeRaw(command, timeout: 60) } @discardableResult - func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { - let (_, output) = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: timeout) + func pipeRaw(_ command: String, timeout: TimeInterval) async -> ShellOutput { + let (_, output) = try! await self.attachRaw(command, didReceiveOutput: { _, _ in }, withTimeout: timeout) return output } @discardableResult - func attach( + func attachRaw( _ command: String, didReceiveOutput: @escaping (String, ShellStream) -> Void, withTimeout timeout: TimeInterval @@ -72,6 +72,30 @@ public class TestableShell: ShellProtocol { func reloadEnvPath() { // does nothing } + + @discardableResult + func sync(_ command: String) -> ShellOutput { + syncRaw(command) + } + + @discardableResult + func pipe(_ command: String) async -> ShellOutput { + await pipeRaw(command) + } + + @discardableResult + func pipe(_ command: String, timeout: TimeInterval) async -> ShellOutput { + await pipeRaw(command, timeout: timeout) + } + + @discardableResult + func attach( + _ command: String, + didReceiveOutput: @escaping (String, ShellStream) -> Void, + withTimeout timeout: TimeInterval + ) async throws -> (Process, ShellOutput) { + try await attachRaw(command, didReceiveOutput: didReceiveOutput, withTimeout: timeout) + } } struct FakeShellOutput: Codable { diff --git a/phpmon/Domain/App/AppDelegate.swift b/phpmon/Domain/App/AppDelegate.swift index 49bae2e9..20821ea9 100644 --- a/phpmon/Domain/App/AppDelegate.swift +++ b/phpmon/Domain/App/AppDelegate.swift @@ -119,9 +119,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele // Start with the regular busy icon MainMenu.shared.setStatusBar(image: NSImage.statusBarIcon) - // Show the active commands debug window - ActiveCommandsWindowController.show() - Task { // Make sure the menu performs its initial checks await Startup.check(App.shared.container) } diff --git a/phpmon/Domain/App/WindowManager.swift b/phpmon/Domain/App/WindowManager.swift index 3f56c0db..c3f12f6b 100644 --- a/phpmon/Domain/App/WindowManager.swift +++ b/phpmon/Domain/App/WindowManager.swift @@ -15,7 +15,7 @@ typealias PhpConfigManagerWC = PhpConfigManagerWindowController typealias PhpDoctorWC = PhpDoctorWindowController typealias PhpVersionManagerWC = PhpVersionManagerWindowController typealias PhpExtensionManagerWC = PhpExtensionManagerWindowController -typealias ActiveCommandsWC = ActiveCommandsWindowController +typealias CommandHistoryWC = CommandHistoryWindowController let WindowManager = WindowCoordinator.shared diff --git a/phpmon/Domain/Menu/MainMenu.swift b/phpmon/Domain/Menu/MainMenu.swift index fb61efc1..a246e222 100644 --- a/phpmon/Domain/Menu/MainMenu.swift +++ b/phpmon/Domain/Menu/MainMenu.swift @@ -138,6 +138,12 @@ class MainMenu: NSObject, NSWindowDelegate, NSMenuDelegate, PhpSwitcherDelegate } } + @objc func showCommandHistory() { + Task { @MainActor in + CommandHistoryWindowController.show() + } + } + @objc func showIncompatiblePhpVersionsAlert() { Task { @MainActor in NVAlert().withInformation( diff --git a/phpmon/Domain/Menu/StatusMenu+Items.swift b/phpmon/Domain/Menu/StatusMenu+Items.swift index 7f7c408e..b0c1e773 100644 --- a/phpmon/Domain/Menu/StatusMenu+Items.swift +++ b/phpmon/Domain/Menu/StatusMenu+Items.swift @@ -325,7 +325,8 @@ extension StatusMenu { // FIRST AID HeaderView.asMenuItem(text: "mi_first_aid".localized), NSMenuItem(title: "mi_view_onboarding".localized, action: #selector(MainMenu.showWelcomeTour)), - NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings)) + NSMenuItem(title: "mi_fa_php_doctor".localized, action: #selector(MainMenu.openWarnings)), + NSMenuItem(title: "mi_view_command_history".localized, action: #selector(MainMenu.showCommandHistory)) ] if Valet.installed { diff --git a/phpmon/Domain/Preferences/PreferencesVC+WindowRestore.swift b/phpmon/Domain/Preferences/PreferencesVC+WindowRestore.swift index db600840..4ff6467d 100644 --- a/phpmon/Domain/Preferences/PreferencesVC+WindowRestore.swift +++ b/phpmon/Domain/Preferences/PreferencesVC+WindowRestore.swift @@ -47,10 +47,10 @@ extension GenericPreferenceVC { name: windowName, frame: WindowManager.window(for: PhpExtensionManagerWC.self)?.frame ) - case "ActiveCommands": + case "CommandHistory", "ActiveCommands": return WindowSnapshot( name: windowName, - frame: WindowManager.window(for: ActiveCommandsWC.self)?.frame + frame: WindowManager.window(for: CommandHistoryWC.self)?.frame ) default: return nil @@ -79,9 +79,9 @@ extension GenericPreferenceVC { case "PhpExtensionManager": PhpExtensionManagerWindowController.show() applyFrame(snapshot.frame, for: PhpExtensionManagerWC.self) - case "ActiveCommands": - ActiveCommandsWindowController.show() - applyFrame(snapshot.frame, for: ActiveCommandsWC.self) + case "CommandHistory", "ActiveCommands": + CommandHistoryWindowController.show() + applyFrame(snapshot.frame, for: CommandHistoryWC.self) default: continue } diff --git a/phpmon/Modules/Active Commands/UI/ActiveCommandsView.swift b/phpmon/Modules/Active Commands/UI/ActiveCommandsView.swift deleted file mode 100644 index a9797987..00000000 --- a/phpmon/Modules/Active Commands/UI/ActiveCommandsView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ActiveCommandsView.swift -// PHP Monitor -// -// Copyright © 2025 Nico Verbruggen. All rights reserved. -// - -import SwiftUI - -struct ActiveCommandsView: View { - @ObservedObject var commandTracker: CommandTracker - @State private var tick = false - - let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() - - init(commandTracker: CommandTracker? = nil) { - self.commandTracker = commandTracker ?? App.shared.container.commandTracker - } - - var body: some View { - 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(spacing: 10) { - if command.isCompleted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .frame(width: 16) - } else { - ProgressView() - .controlSize(.small) - .frame(width: 16) - } - VStack(alignment: .leading, spacing: 4) { - Text(command.command) - .font(.system(size: 12, design: .monospaced)) - .lineLimit(2) - // tick forces re-evaluation every 200ms - let _ = tick - Text(command.durationText) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - Spacer() - } - .padding(.vertical, 2) - .id(command.id) - } - } - } - .listStyle(.plain) - .onChange(of: commandTracker.commands.count) { _ in - if let last = commandTracker.commands.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - .frame(minWidth: 400, minHeight: 200) - .onReceive(timer) { _ in - if commandTracker.commands.contains(where: { !$0.isCompleted }) { - tick.toggle() - } - } - } -} diff --git a/phpmon/Modules/Command History/UI/CommandHistoryView.swift b/phpmon/Modules/Command History/UI/CommandHistoryView.swift new file mode 100644 index 00000000..9ed47f37 --- /dev/null +++ b/phpmon/Modules/Command History/UI/CommandHistoryView.swift @@ -0,0 +1,87 @@ +// +// CommandHistoryView.swift +// PHP Monitor +// +// Copyright © 2025 Nico Verbruggen. All rights reserved. +// + +import SwiftUI + +struct CommandHistoryView: View { + @ObservedObject var commandTracker: CommandTracker + @State private var now = Date() + + init(commandTracker: CommandTracker? = nil) { + self.commandTracker = commandTracker ?? App.shared.container.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) + } + Spacer() + } + .padding(.vertical, 2) + .id(command.id) + } + } + } + .listStyle(.plain) + .onChange(of: commandTracker.commands.count) { _ 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 } + now = Date() + } + .onReceive( + Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() + ) { _ in + if commandTracker.isActive { + now = Date() + } + } + } + } +} diff --git a/phpmon/Modules/Active Commands/UI/ActiveCommandsWindowController.swift b/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift similarity index 69% rename from phpmon/Modules/Active Commands/UI/ActiveCommandsWindowController.swift rename to phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift index 393c067a..eb45c62f 100644 --- a/phpmon/Modules/Active Commands/UI/ActiveCommandsWindowController.swift +++ b/phpmon/Modules/Command History/UI/CommandHistoryWindowController.swift @@ -1,5 +1,5 @@ // -// ActiveCommandsWindowController.swift +// CommandHistoryWindowController.swift // PHP Monitor // // Copyright © 2025 Nico Verbruggen. All rights reserved. @@ -8,12 +8,12 @@ import Cocoa import SwiftUI -class ActiveCommandsWindowController: PMWindowController { +class CommandHistoryWindowController: PMWindowController { // MARK: - Window Identifier override var windowName: String { - return "ActiveCommands" + return "CommandHistory" } public static func create(delegate: NSWindowDelegate?) { @@ -21,12 +21,12 @@ class ActiveCommandsWindowController: PMWindowController { let panel = NSPanel() panel.styleMask = [.titled, .closable, .miniaturizable, .resizable, .utilityWindow] - panel.title = "Active Commands" + panel.title = "Command History" panel.titlebarAppearsTransparent = true panel.isFloatingPanel = true panel.hidesOnDeactivate = false panel.delegate = delegate ?? windowController - panel.contentView = NSHostingView(rootView: ActiveCommandsView()) + panel.contentView = NSHostingView(rootView: CommandHistoryView()) panel.setContentSize(NSSize(width: 500, height: 300)) windowController.window = panel @@ -35,12 +35,12 @@ class ActiveCommandsWindowController: PMWindowController { } public static func show(delegate: NSWindowDelegate? = nil) { - if !WindowManager.hasController(for: ActiveCommandsWC.self) { + if !WindowManager.hasController(for: CommandHistoryWC.self) { Self.create(delegate: delegate) } - WindowManager.show(ActiveCommandsWC.self) - WindowManager.withWindow(for: ActiveCommandsWC.self) { window in + WindowManager.show(CommandHistoryWC.self) + WindowManager.withWindow(for: CommandHistoryWC.self) { window in window.setCenterPosition(offsetY: 70) } } diff --git a/phpmon/en.lproj/Localizable.strings b/phpmon/en.lproj/Localizable.strings index 88d47b29..b03cdf02 100644 --- a/phpmon/en.lproj/Localizable.strings +++ b/phpmon/en.lproj/Localizable.strings @@ -84,6 +84,7 @@ "mi_set_up_presets" = "Learn more about presets..."; "mi_view_onboarding" = "Open Welcome Tour..."; +"mi_view_command_history" = "Open Command History..."; "mi_xdebug_available_modes" = "Available Modes"; "mi_xdebug_actions" = "Actions";