diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c89141a8c5..86bb9fc1d4 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -580,6 +580,7 @@ GETSCREENSAVERRUNNING GETSECKEY GETSTICKYKEYS GETTEXTLENGTH +gitmodules GHND GMEM GNumber @@ -916,7 +917,6 @@ luid LUMA lusrmgr LVal -lvm LWA lwin LZero @@ -1328,6 +1328,7 @@ PRTL prvpane psapi pscid +pscustomobject PSECURITY psfgao psfi @@ -1964,6 +1965,7 @@ WMI WMICIM wmimgmt wmp +wmsg WMSYSCOMMAND wnd WNDCLASS @@ -1977,6 +1979,7 @@ WORKSPACESEDITOR WORKSPACESLAUNCHER WORKSPACESSNAPSHOTTOOL WORKSPACESWINDOWARRANGER +Worktree wox wparam wpf diff --git a/PowerToys.sln b/PowerToys.sln index ece51b958c..f4f2e1bd0f 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -828,6 +828,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -3006,6 +3008,14 @@ Global {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.Build.0 = Debug|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.ActiveCfg = Release|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.Build.0 = Release|ARM64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.ActiveCfg = Release|x64 + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3334,6 +3344,7 @@ Global {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp index be3afb170d..4e95555a5a 100644 --- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -108,7 +108,7 @@ public: m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT"); m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK"); - m_manual_override_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); init_settings(); }; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 168ee092e7..15c268fc84 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; @@ -35,6 +38,8 @@ int _tmain(int argc, TCHAR* argv[]) wchar_t serviceName[] = L"LightSwitchService"; SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } }; + LoggerHelpers::init_logger(L"LightSwitch", L"Service", LogSettings::lightSwitchLoggerName); + if (!StartServiceCtrlDispatcherW(table)) { DWORD err = GetLastError(); @@ -106,6 +111,7 @@ VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl) SetServiceStatus(g_StatusHandle, &g_ServiceStatus); // Signal the service to stop + Logger::info(L"[LightSwitchService] Stop requested, signaling worker thread to exit."); SetEvent(g_ServiceStopEvent); break; @@ -126,13 +132,21 @@ static void update_sun_times(auto& settings) int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; + try + { + auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + values.add_property(L"lightTime", newLightTime); + values.add_property(L"darkTime", newDarkTime); + values.save_to_settings_file(); - auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); - values.add_property(L"lightTime", newLightTime); - values.add_property(L"darkTime", newDarkTime); - values.save_to_settings_file(); - - OutputDebugString(L"[LightSwitchService] Updated sun times and saved to config.\n"); + Logger::info(L"[LightSwitchService] Updated sun times and saved to config."); + } + catch (const std::exception& e) + { + std::wstring wmsg(e.what(), e.what() + strlen(e.what())); + Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg); + } + } DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) @@ -142,7 +156,8 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) if (parentPid) hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid); - OutputDebugString(L"[LightSwitchService] Worker thread starting...\n"); + Logger::info(L"[LightSwitchService] Worker thread starting..."); + Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid); // Initialize settings system LightSwitchSettings::instance().InitFileWatcher(); @@ -214,19 +229,19 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) update_sun_times(settings); g_lastUpdatedDay = st.wDay; - OutputDebugString(L"[LightSwitchService] Recalculated sun times at new day boundary.\n"); + Logger::info(L"[LightSwitchService] Recalculated sun times at new day boundary."); } wchar_t msg[160]; swprintf_s(msg, - L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d\n", + L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d", st.wHour, st.wMinute, settings.lightTime / 60, settings.lightTime % 60, settings.darkTime / 60, settings.darkTime % 60); - OutputDebugString(msg); + Logger::info(msg); // --- Manual override check --- bool manualOverrideActive = false; @@ -242,11 +257,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440) { ResetEvent(hManualOverride); - OutputDebugString(L"[LightSwitchService] Manual override cleared at boundary\n"); + Logger::info(L"[LightSwitchService] Manual override cleared at boundary\n"); } else { - OutputDebugString(L"[LightSwitchService] Skipping schedule due to manual override\n"); + Logger::info(L"[LightSwitchService] Skipping schedule due to manual override\n"); goto sleep_until_next_minute; } } @@ -261,10 +276,17 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) msToNextMinute = 50; DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); - if (wait == WAIT_OBJECT_0) // stop event + if (wait == WAIT_OBJECT_0) + { + Logger::info(L"[LightSwitchService] Stop event triggered — exiting worker loop."); break; - if (hParent && wait == WAIT_OBJECT_0 + 1) // parent exited + } + if (hParent && wait == WAIT_OBJECT_0 + 1) // parent process exited + { + Logger::info(L"[LightSwitchService] Parent process exited — stopping service."); break; + } + } if (hManualOverride) @@ -282,8 +304,8 @@ int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) wchar_t msg[160]; swprintf_s( msg, - L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.\n"); - OutputDebugString(msg); + L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); + Logger::info(msg); return 0; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc index 82dbcb263a..c1914c5a5e 100644 Binary files a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc and b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index 2151d0b5b6..b082250f61 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -28,19 +28,6 @@ LightSwitchService - - Application - true - v143 - Unicode - - - Application - false - v143 - true - Unicode - Application true @@ -54,84 +41,25 @@ true Unicode - - Application - true - v143 - Unicode - - - Application - false - v143 - true - Unicode - - - - - - - - - - - - - - - - - - - - - - - - ..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\ PowerToys.LightSwitchService - - - Level3 - true - _DEBUG;%(PreprocessorDefinitions) - true - - - Windows - true - - - - - Level3 - true - true - true - NDEBUG;%(PreprocessorDefinitions) - true - - - Windows - true - - Level3 true - %(PreprocessorDefinitions) true NotUsing + %(PreprocessorDefinitions) ./../; - ..\..\..\common\Telemetry; ..\..\..\common; + ..\..\..\common\logger; + ..\..\..\common\utils; + ..\..\..\common\SettingsAPI; + ..\..\..\common\Telemetry; ..\..\..\; ..\..\..\..\deps\spdlog\include; ./; @@ -145,8 +73,27 @@ - - {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + + + + + + + + + + + + + + + + + + + {4aed67b6-55fd-486f-b917-e543dee2cb3c} {6955446d-23f7-4023-9bb3-8657f904af99} @@ -158,62 +105,10 @@ {8f021b46-362b-485c-bfba-ccf83e820cbd} - - - Level3 - true - true - true - NDEBUG;%(PreprocessorDefinitions) - true - - - Windows - true - - - - - NotUsing - - - - - NotUsing - - - NotUsing - - - - - - - - - - - - - - - - false - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index a244dfc075..f5aa05afc3 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -24,15 +24,6 @@ Source Files - - Source Files - - - Source Files - - - Source Files - Source Files @@ -43,9 +34,6 @@ Source Files - - - Header Files @@ -69,4 +57,9 @@ + + + Resource Files + + \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index 8b8cab11eb..382c93208e 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -2,6 +2,13 @@ // #include "resource.h" +// version.h and branding.h are different in the Sysinternals repository, +// keep the includes as such, here. +// From $(MSBuildThisFileDirectory)..\..\..\common\version +#include "version.h" +// From $(MSBuildThisFileDirectory)PowerToys +#include "branding.h" + #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // @@ -68,8 +75,8 @@ APPICON ICON "appicon.ico" // VS_VERSION_INFO VERSIONINFO - FILEVERSION 9,10,0,0 - PRODUCTVERSION 9,10,0,0 + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -84,14 +91,14 @@ BEGIN BEGIN BLOCK "040904b0" BEGIN - VALUE "CompanyName", "Microsoft Corporation" - VALUE "FileDescription", "Sysinternals Screen Magnifier" - VALUE "FileVersion", "9.10" - VALUE "InternalName", "ZoomIt" - VALUE "LegalCopyright", "Copyright (C) Microsoft Corporation. All rights reserved." - VALUE "OriginalFilename", "PowerToys.ZoomIt.exe" - VALUE "ProductName", "PowerToys Sysinternals ZoomIt" - VALUE "ProductVersion", "9.10" + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", FILE_DESCRIPTION + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", INTERNAL_NAME + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", ORIGINAL_FILENAME + VALUE "ProductName", ZOOMIT_PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING END END BLOCK "VarFileInfo" @@ -114,8 +121,8 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0 BEGIN DEFPUSHBUTTON "OK",IDOK,166,306,50,14 PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 - LTEXT "ZoomIt v9.01",IDC_VERSION,42,7,73,10 - LTEXT "Copyright � 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 + LTEXT "ZoomIt v9.10",IDC_VERSION,42,7,73,10 + LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 CONTROL "Sysinternals - www.sysinternals.com",IDC_LINK, "SysLink",WS_TABSTOP,42,26,150,9 ICON "APPICON",IDC_STATIC,12,9,20,20 diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs index df4ac87581..ad4c417b31 100644 --- a/src/modules/awake/Awake/Core/Manager.cs +++ b/src/modules/awake/Awake/Core/Manager.cs @@ -281,21 +281,7 @@ namespace Awake.Core TimeSpan remainingTime = expireAt - DateTimeOffset.Now; Observable.Timer(remainingTime).Subscribe( - _ => - { - Logger.LogInfo("Completed expirable keep-awake."); - CancelExistingThread(); - - if (IsUsingPowerToysConfig) - { - SetPassiveKeepAwake(); - } - else - { - Logger.LogInfo("Exiting after expirable keep awake."); - CompleteExit(Environment.ExitCode); - } - }, + _ => HandleTimerCompletion("expirable"), _tokenSource.Token); } @@ -348,49 +334,46 @@ namespace Awake.Core SetModeShellIcon(); - ulong desiredDuration = (ulong)seconds * 1000; - ulong targetDuration = Math.Min(desiredDuration, uint.MaxValue - 1) / 1000; + var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds); - if (desiredDuration > uint.MaxValue) - { - Logger.LogInfo($"The desired interval of {seconds} seconds ({desiredDuration}ms) exceeds the limit. Defaulting to maximum possible value: {targetDuration} seconds. Read more about existing limits in the official documentation: https://aka.ms/powertoys/awake"); - } - - IObservable timerObservable = Observable.Timer(TimeSpan.FromSeconds(targetDuration)); - IObservable intervalObservable = Observable.Interval(TimeSpan.FromSeconds(1)).TakeUntil(timerObservable); - IObservable combinedObservable = Observable.CombineLatest(intervalObservable, timerObservable.StartWith(0), (elapsedSeconds, _) => elapsedSeconds + 1); - - combinedObservable.Subscribe( - elapsedSeconds => - { - TimeRemaining = (uint)targetDuration - (uint)elapsedSeconds; - if (TimeRemaining >= 0) + Observable.Interval(TimeSpan.FromSeconds(1)) + .Select(_ => targetExpiryTime - DateTimeOffset.Now) + .TakeWhile(remaining => remaining.TotalSeconds > 0) + .Subscribe( + remainingTimeSpan => { + TimeRemaining = (uint)remainingTimeSpan.TotalSeconds; + TrayHelper.SetShellIcon( TrayHelper.WindowHandle, - $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}][{TimeSpan.FromSeconds(TimeRemaining).ToHumanReadableString()}]", + $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}][{remainingTimeSpan.ToHumanReadableString()}]", TrayHelper.TimedIcon, TrayIconAction.Update); - } - }, - () => - { - Logger.LogInfo("Completed timed thread."); - CancelExistingThread(); + }, + _ => HandleTimerCompletion("timed"), + _tokenSource.Token); + } - if (IsUsingPowerToysConfig) - { - // If we're using PowerToys settings, we need to make sure that - // we just switch over the Passive Keep-Awake. - SetPassiveKeepAwake(); - } - else - { - Logger.LogInfo("Exiting after timed keep-awake."); - CompleteExit(Environment.ExitCode); - } - }, - _tokenSource.Token); + /// + /// Handles the common logic that should execute when a keep-awake timer completes. Resets + /// the application state to Passive if configured; otherwise it exits. + /// + private static void HandleTimerCompletion(string timerType) + { + Logger.LogInfo($"Completed {timerType} keep-awake."); + CancelExistingThread(); + + if (IsUsingPowerToysConfig) + { + // If running under PowerToys settings, just revert to the default Passive state. + SetPassiveKeepAwake(); + } + else + { + // If running as a standalone process, exit cleanly. + Logger.LogInfo($"Exiting after {timerType} keep-awake."); + CompleteExit(Environment.ExitCode); + } } /// diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs new file mode 100644 index 0000000000..75cfcac444 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +public static class PathHelper +{ + public static bool Exists(string path, out bool isDirectory) + { + isDirectory = false; + if (string.IsNullOrEmpty(path)) + { + return false; + } + + string? fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + + var result = ExistsCore(fullPath, out isDirectory); + if (result && IsDirectorySeparator(fullPath[^1])) + { + // Some sys-calls remove all trailing slashes and may give false positives for existing files. + // We want to make sure that if the path ends in a trailing slash, it's truly a directory. + return isDirectory; + } + + return result; + } + + /// + /// Normalize potential local/UNC file path text input: trim whitespace and surrounding quotes. + /// Windows file paths cannot contain quotes, but user input can include them. + /// + public static string Unquote(string? text) + { + return string.IsNullOrWhiteSpace(text) ? (text ?? string.Empty) : text.Trim().Trim('"'); + } + + /// + /// Quick heuristic to determine if the string looks like a Windows file path (UNC or drive-letter based). + /// + public static bool LooksLikeFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + // UNC path + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + // Win32 File Namespaces \\?\ + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + return IsSlow(path[4..]); + } + + // Basic UNC path validation: \\server\share or \\server\share\path + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + + return parts.Length >= 2; // At minimum: server and share + } + + // Drive letter path (e.g., C:\ or C:) + return path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'; + } + + /// + /// Validates path syntax without performing any I/O by using Path.GetFullPath. + /// + public static bool HasValidPathSyntax(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + _ = Path.GetFullPath(path); + return true; + } + catch + { + return false; + } + } + + /// + /// Checks if a string represents a valid Windows file path (local or network) + /// using fast syntax validation only. Reuses LooksLikeFilePath and HasValidPathSyntax. + /// + public static bool IsValidFilePath(string? path) + { + return LooksLikeFilePath(path) && HasValidPathSyntax(path); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + private static bool ExistsCore(string fullPath, out bool isDirectory) + { + var attributes = PInvoke.GetFileAttributes(fullPath); + var result = attributes != PInvoke.INVALID_FILE_ATTRIBUTES; + isDirectory = result && (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; + return result; + } + + public static bool IsSlow(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + try + { + var root = Path.GetPathRoot(path); + if (!string.IsNullOrEmpty(root)) + { + if (root.Length > 2 && char.IsLetter(root[0]) && root[1] == ':') + { + return new DriveInfo(root).DriveType is not (DriveType.Fixed or DriveType.Ram); + } + else if (root.StartsWith(@"\\", StringComparison.Ordinal)) + { + return !root.StartsWith(@"\\?\", StringComparison.Ordinal) || IsSlow(root[4..]); + } + } + + return false; + } + catch + { + return false; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt index 61e89b68c4..03318381a6 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt @@ -12,4 +12,8 @@ MonitorFromWindow SHOW_WINDOW_CMD ShellExecuteEx -SEE_MASK_INVOKEIDLIST \ No newline at end of file +SEE_MASK_INVOKEIDLIST + +GetFileAttributes +FILE_FLAGS_AND_ATTRIBUTES +INVALID_FILE_ATTRIBUTES \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs index 427fcd170e..81fec6e363 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs @@ -2,8 +2,10 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Mvvm.Input; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; @@ -11,6 +13,13 @@ public partial class DetailsLinkViewModel( IDetailsElement _detailsElement, WeakReference context) : DetailsElementViewModel(_detailsElement, context) { + private static readonly string[] _initProperties = [ + nameof(Text), + nameof(Link), + nameof(IsLink), + nameof(IsText), + nameof(NavigateCommand)]; + private readonly ExtensionObject _dataModel = new(_detailsElement.Data as IDetailsLink); @@ -22,6 +31,8 @@ public partial class DetailsLinkViewModel( public bool IsText => !IsLink; + public RelayCommand? NavigateCommand { get; private set; } + public override void InitializeProperties() { base.InitializeProperties(); @@ -38,9 +49,18 @@ public partial class DetailsLinkViewModel( Text = Link.ToString(); } - UpdateProperty(nameof(Text)); - UpdateProperty(nameof(Link)); - UpdateProperty(nameof(IsLink)); - UpdateProperty(nameof(IsText)); + if (Link is not null) + { + // Custom command to open a link in the default browser or app, + // depending on the link type. + // Binding Link to a Hyperlink(Button).NavigateUri works only for + // certain URI schemes (e.g., http, https) and cannot open file: + // scheme URIs or local files. + NavigateCommand = new RelayCommand( + () => ShellHelpers.OpenInShell(Link.ToString()), + () => Link is not null); + } + + UpdateProperty(_initProperties); } } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs index 40afae6f9c..41db974f5b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -265,6 +265,9 @@ public partial class ShellViewModel : ObservableObject, throw new NotSupportedException(); } + // Clear command bar, ViewModel initialization can already set new commands if it wants to + OnUIThread(() => WeakReferenceMessenger.Default.Send(new(null))); + // Kick off async loading of our ViewModel LoadPageViewModelAsync(pageViewModel, navigationToken) .ContinueWith( @@ -275,9 +278,6 @@ public partial class ShellViewModel : ObservableObject, { newCts.Dispose(); } - - // When we're done loading the page, then update the command bar to match - WeakReferenceMessenger.Default.Send(new(null)); }, navigationToken, TaskContinuationOptions.None, diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index bd767ba0f1..ef7c879d36 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -10,6 +10,8 @@ using ManagedCommon; using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.Apps; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.State; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CommandPalette.Extensions; @@ -36,6 +38,7 @@ public partial class MainListPage : DynamicListPage, private List>? _filteredItems; private List>? _filteredApps; private List>? _fallbackItems; + private IEnumerable>? _scoredFallbackItems; private bool _includeApps; private bool _filteredItemsIncludesApps; private int _appResultLimit = 10; @@ -160,7 +163,7 @@ public partial class MainListPage : DynamicListPage, { lock (_tlcManager.TopLevelCommands) { - List> limitedApps = new List>(); + var limitedApps = new List>(); // Fuzzy matching can produce a lot of results, so we want to limit the // number of apps we show at once if it's a large set. @@ -171,6 +174,7 @@ public partial class MainListPage : DynamicListPage, var items = Enumerable.Empty>() .Concat(_filteredItems is not null ? _filteredItems : []) + .Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : []) .Concat(limitedApps) .OrderByDescending(o => o.Score) @@ -184,6 +188,14 @@ public partial class MainListPage : DynamicListPage, } } + private void ClearResults() + { + _filteredItems = null; + _filteredApps = null; + _fallbackItems = null; + _scoredFallbackItems = null; + } + public override void UpdateSearchText(string oldSearch, string newSearch) { var timer = new Stopwatch(); @@ -216,8 +228,7 @@ public partial class MainListPage : DynamicListPage, lock (_tlcManager.TopLevelCommands) { _filteredItemsIncludesApps = _includeApps; - _filteredItems = null; - _filteredApps = null; + ClearResults(); } } @@ -233,7 +244,36 @@ public partial class MainListPage : DynamicListPage, var commands = _tlcManager.TopLevelCommands; lock (commands) { - UpdateFallbacks(SearchText, commands.ToImmutableArray(), token); + if (token.IsCancellationRequested) + { + return; + } + + // prefilter fallbacks + var specialFallbacks = new List(_specialFallbacks.Length); + var commonFallbacks = new List(); + + foreach (var s in commands) + { + if (!s.IsFallback) + { + continue; + } + + if (_specialFallbacks.Contains(s.CommandProviderId)) + { + specialFallbacks.Add(s); + } + else + { + commonFallbacks.Add(s); + } + } + + // start update of fallbacks; update special fallbacks separately, + // so they can finish faster + UpdateFallbacks(SearchText, specialFallbacks, token); + UpdateFallbacks(SearchText, commonFallbacks, token); if (token.IsCancellationRequested) { @@ -244,9 +284,7 @@ public partial class MainListPage : DynamicListPage, if (string.IsNullOrEmpty(newSearch)) { _filteredItemsIncludesApps = _includeApps; - _filteredItems = null; - _filteredApps = null; - _fallbackItems = null; + ClearResults(); RaiseItemsChanged(commands.Count); return; } @@ -255,17 +293,13 @@ public partial class MainListPage : DynamicListPage, // re-use previous results. Reset _filteredItems, and keep er moving. if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)) { - _filteredItems = null; - _filteredApps = null; - _fallbackItems = null; + ClearResults(); } // If the internal state has changed, reset _filteredItems to reset the list. if (_filteredItemsIncludesApps != _includeApps) { - _filteredItems = null; - _filteredApps = null; - _fallbackItems = null; + ClearResults(); } if (token.IsCancellationRequested) @@ -273,9 +307,9 @@ public partial class MainListPage : DynamicListPage, return; } - IEnumerable newFilteredItems = Enumerable.Empty(); - IEnumerable newFallbacks = Enumerable.Empty(); - IEnumerable newApps = Enumerable.Empty(); + var newFilteredItems = Enumerable.Empty(); + var newFallbacks = Enumerable.Empty(); + var newApps = Enumerable.Empty(); if (_filteredItems is not null) { @@ -311,15 +345,12 @@ public partial class MainListPage : DynamicListPage, // with a list of all our commands & apps. if (!newFilteredItems.Any() && !newApps.Any()) { - // We're going to start over with our fallbacks - newFallbacks = Enumerable.Empty(); - - newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId)); + newFilteredItems = commands.Where(s => !s.IsFallback); // Fallbacks are always included in the list, even if they // don't match the search text. But we don't want to // consider them when filtering the list. - newFallbacks = commands.Where(s => s.IsFallback && !_specialFallbacks.Contains(s.CommandProviderId)); + newFallbacks = commonFallbacks; if (token.IsCancellationRequested) { @@ -330,7 +361,20 @@ public partial class MainListPage : DynamicListPage, if (_includeApps) { - newApps = AllAppsCommandProvider.Page.GetItems().ToList(); + var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList(); + + // We need to remove pinned apps from allNewApps so they don't show twice. + var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers(); + + if (pinnedApps.Length > 0) + { + newApps = allNewApps.Where(w => + pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0); + } + else + { + newApps = allNewApps; + } } if (token.IsCancellationRequested) @@ -339,8 +383,25 @@ public partial class MainListPage : DynamicListPage, } } + var history = _serviceProvider.GetService()!.RecentCommands!; + Func scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); }; + // Produce a list of everything that matches the current filter. - _filteredItems = [.. ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, ScoreTopLevelItem)]; + _filteredItems = [.. ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, scoreItem)]; + + if (token.IsCancellationRequested) + { + return; + } + + IEnumerable newFallbacksForScoring = commands.Where(s => s.IsFallback && _specialFallbacks.Contains(s.CommandProviderId)); + + if (token.IsCancellationRequested) + { + return; + } + + _scoredFallbackItems = ListHelpers.FilterListWithScores(newFallbacksForScoring ?? [], SearchText, scoreItem); if (token.IsCancellationRequested) { @@ -358,7 +419,7 @@ public partial class MainListPage : DynamicListPage, // Produce a list of filtered apps with the appropriate limit if (newApps.Any()) { - var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, ScoreTopLevelItem); + var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, scoreItem); if (token.IsCancellationRequested) { @@ -425,7 +486,7 @@ public partial class MainListPage : DynamicListPage, // Almost verbatim ListHelpers.ScoreListItem, but also accounting for the // fact that we want fallback handlers down-weighted, so that they don't // _always_ show up first. - private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem) + internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history) { var title = topLevelOrAppItem.Title; if (string.IsNullOrWhiteSpace(title)) @@ -501,10 +562,9 @@ public partial class MainListPage : DynamicListPage, // here we add the recent command weight boost // // Otherwise something like `x` will still match everything you've run before - var finalScore = matchSomething; + var finalScore = matchSomething * 10; if (matchSomething > 0) { - var history = _serviceProvider.GetService()!.RecentCommands; var recentWeightBoost = history.GetCommandHistoryWeight(id); finalScore += recentWeightBoost; } @@ -521,7 +581,7 @@ public partial class MainListPage : DynamicListPage, AppStateModel.SaveState(state); } - private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) + private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) { if (topLevelOrAppItem is TopLevelViewModel topLevel) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..24c453d64c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.UI.ViewModels.UnitTests")] diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs index 9135c9588a..c12d189445 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs @@ -7,7 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class RecentCommandsManager : ObservableObject +public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager { [JsonInclude] internal List History { get; set; } = []; @@ -80,3 +80,10 @@ public partial class RecentCommandsManager : ObservableObject } } } + +public interface IRecentCommandsManager +{ + int GetCommandHistoryWeight(string commandId); + + void AddHistoryItem(string commandId); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 8e44d0f420..2c81e8dc37 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -14,7 +14,6 @@ xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" - Background="Transparent" PreviewKeyDown="UserControl_PreviewKeyDown" mc:Ignorable="d"> @@ -22,7 +21,7 @@ - + 12,8,12,8 - + @@ -71,7 +70,7 @@ - + @@ -114,7 +113,7 @@ @@ -125,35 +124,39 @@ - - - - - - - - - - - + + + + + + + + + @@ -162,9 +165,11 @@ - + - + + + @@ -172,9 +177,11 @@ - + - + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs index e9ab57cfa5..c022d82b34 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs @@ -78,6 +78,12 @@ public sealed partial class ContentPage : Page, WeakReferenceMessenger.Default.Unregister(this); // Clean-up event listeners + if (e.NavigationMode != NavigationMode.New) + { + ViewModel?.SafeCleanup(); + CleanupHelper.Cleanup(this); + } + ViewModel = null; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index 597072241a..fe1a29dd97 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -108,6 +108,7 @@ Visibility="{x:Bind IsText, Mode=OneWay}" /> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj new file mode 100644 index 0000000000..9282141355 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + + + false + true + Microsoft.CmdPal.UI.ViewModels.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + enable + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs new file mode 100644 index 0000000000..78ead1588e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.UI.ViewModels.MainPage; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; +using WyHash; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class RecentCommandsTests : CommandPaletteUnitTestBase +{ + private static RecentCommandsManager CreateHistory(IList? commandIds = null) + { + var history = new RecentCommandsManager(); + if (commandIds != null) + { + foreach (var item in commandIds) + { + history.AddHistoryItem(item); + } + } + + return history; + } + + private static RecentCommandsManager CreateBasicHistoryService() + { + var commonCommands = new List + { + "com.microsoft.cmdpal.shell", + "com.microsoft.cmdpal.windowwalker", + "Visual Studio 2022 Preview_6533433915015224980", + "com.microsoft.cmdpal.reload", + "com.microsoft.cmdpal.shell", + }; + + return CreateHistory(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var history = CreateHistory(); + + // Act + history.AddHistoryItem("com.microsoft.cmdpal.shell"); + + // Assert + Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0); + } + + [TestMethod] + public void ValidateHistoryWeighting() + { + // Setup + var history = CreateBasicHistoryService(); + + // Act + var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell"); + var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker"); + var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980"); + var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload"); + var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command"); + + // Assert + Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses"); + Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency"); + Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight"); + Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency"); + Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight"); + } + + private sealed partial record ListItemMock( + string Title, + string? Subtitle = "", + string? GivenId = "", + string? ProviderId = "") : IListItem + { + public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId; + + public IDetails Details => throw new System.NotImplementedException(); + + public string Section => throw new System.NotImplementedException(); + + public ITag[] Tags => throw new System.NotImplementedException(); + + public string TextToSuggest => throw new System.NotImplementedException(); + + public ICommand Command => new NoOpCommand() { Id = Id }; + + public IIconInfo Icon => throw new System.NotImplementedException(); + + public IContextItem[] MoreCommands => throw new System.NotImplementedException(); + +#pragma warning disable CS0067 + public event TypedEventHandler? PropChanged; +#pragma warning restore CS0067 + + private string GenerateId() + { + // Use WyHash64 to generate stable ID hashes. + // manually seeding with 0, so that the hash is stable across launches + var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0); + return $"{ProviderId}{result}"; + } + } + + private static RecentCommandsManager CreateHistory(IList items) + { + var history = new RecentCommandsManager(); + foreach (var item in items) + { + history.AddHistoryItem(item.Id); + } + + return history; + } + + [TestMethod] + public void ValidateMocksWork() + { + // Setup + var items = new List + { + new("Command A", "Subtitle A", "idA", "providerA"), + new("Command B", "Subtitle B", GivenId: "idB"), + new("Command C", "Subtitle C", ProviderId: "providerC"), + new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses + }; + + // Act + var history = CreateHistory(items); + + // Assert + foreach (var item in items) + { + var weight = history.GetCommandHistoryWeight(item.Id); + Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero."); + } + + // Check that the duplicate item has a higher weight due to increased uses + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID + Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses."); + Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses."); + Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands"); + } + + [TestMethod] + public void ValidateHistoryBuckets() + { + // Setup + // (these will be checked in reverse order, so that A is the most recent) + var items = new List + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1 + new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1 + new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1 + new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1 + new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1 + new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1 + new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1 + new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1 + new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2 + new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2 + new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2 + new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2 + }; + + for (var i = items.Count; i <= 50; i++) + { + items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}")); + } + + // Act + var history = CreateHistory(items.Reverse().ToList()); + + // Assert + // First three items should be in the top bucket + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight("idC"); + + Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands"); + Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands"); + + // Next eight items (3-10 inclusive) should be in the second bucket + var weightD = history.GetCommandHistoryWeight("idD"); + var weightE = history.GetCommandHistoryWeight("idE"); + var weightF = history.GetCommandHistoryWeight("idF"); + var weightG = history.GetCommandHistoryWeight("idG"); + var weightH = history.GetCommandHistoryWeight("idH"); + var weightI = history.GetCommandHistoryWeight("idI"); + var weightJ = history.GetCommandHistoryWeight("idJ"); + var weightK = history.GetCommandHistoryWeight("idK"); + + Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands"); + Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands"); + Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands"); + Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands"); + Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands"); + Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands"); + Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands"); + + // Items up to the 15th should be in the third bucket + var weightL = history.GetCommandHistoryWeight("idL"); + var weightM = history.GetCommandHistoryWeight("idM"); + var weightN = history.GetCommandHistoryWeight("idN"); + var weightO = history.GetCommandHistoryWeight("idO"); + var weight15 = history.GetCommandHistoryWeight("id15"); + Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands"); + Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands"); + Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands"); + Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands"); + + // Items after that should be in the lowest buckets + var weight0 = history.GetCommandHistoryWeight(items[0].Id); + var weight3 = history.GetCommandHistoryWeight(items[3].Id); + var weight11 = history.GetCommandHistoryWeight(items[11].Id); + var weight16 = history.GetCommandHistoryWeight("id16"); + var weight20 = history.GetCommandHistoryWeight("id20"); + var weight30 = history.GetCommandHistoryWeight("id30"); + var weight40 = history.GetCommandHistoryWeight("id40"); + var weight49 = history.GetCommandHistoryWeight("id49"); + + Assert.IsTrue(weight0 > weight3); + Assert.IsTrue(weight3 > weight11); + Assert.IsTrue(weight11 > weight16); + + Assert.AreEqual(weight16, weight20); + Assert.AreEqual(weight20, weight30); + Assert.IsTrue(weight30 > weight40); + Assert.AreEqual(weight40, weight49); + + // The 50th item has fallen out of the list now + var weight50 = history.GetCommandHistoryWeight("id50"); + Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list"); + } + + [TestMethod] + public void ValidateSimpleScoring() + { + // Setup + var items = new List + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + }; + + var history = CreateHistory(items.Reverse().ToList()); + + var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history); + var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history); + var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history); + + // Assert + // All of these equally match the query, and they're all in the same bucket, + // so they should all have the same score. + Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score"); + Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score"); + } + + private static List CreateMockHistoryItems() + { + var items = new List + { + new("Visual Studio 2022"), // #0 -> bucket 0 + new("Visual Studio Code"), // #1 -> bucket 0 + new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0 + new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1 + new("Windows Settings"), // #4 -> bucket 1 + new("Command Prompt"), // #5 -> bucket 1 + new("Terminal Canary"), // #6 -> bucket 1 + }; + return items; + } + + private static RecentCommandsManager CreateMockHistoryService(List? items = null) + { + var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse().ToList()); + return history; + } + + private sealed record ScoredItem(ListItemMock Item, int Score) + { + public string Title => Item.Title; + + public override string ToString() => $"[{Score}]{Title}"; + } + + private static IEnumerable TieScoresToMatches(List items, List scores) + { + if (items.Count != scores.Count) + { + throw new ArgumentException("Items and scores must have the same number of elements"); + } + + for (var i = 0; i < items.Count; i++) + { + yield return new ScoredItem(items[i], scores[i]); + } + } + + private static IEnumerable GetMatches(IEnumerable scoredItems) + { + var matches = scoredItems + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .ToList(); + + return matches; + } + + private static IEnumerable GetMatches(List items, List scores) + { + return GetMatches(TieScoresToMatches(items, scores)); + } + + [TestMethod] + public void ValidateScoredWeightingSimple() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList(); + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); + Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items"); + for (var i = 0; i < unweightedScores.Count; i++) + { + var unweighted = unweightedScores[i]; + var weighted = weightedScores[i]; + var item = items[i]; + if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase)) + { + Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})"); + } + else + { + Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.AreEqual(unweighted, weighted); + } + } + + var unweightedMatches = GetMatches(items, unweightedScores).ToList(); + Assert.AreEqual(4, unweightedMatches.Count); + Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match"); + Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match"); + Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title); + Assert.AreEqual("Run commands", unweightedMatches[3].Title); + + // Even after weighting for 1 use, Command Prompt should still be the top match. + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + Assert.AreEqual("Command Prompt", weightedMatches[0].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title); + Assert.AreEqual("Terminal Canary", weightedMatches[2].Title); + Assert.AreEqual("Run commands", weightedMatches[3].Title); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanHistory() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanUsage() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + // Add extra uses of VS Code to try and push it above Terminal + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(items[1].Id); + } + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateUsageEventuallyHelps() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + + // We're gonna run this test and keep adding more uses of VS Code till + // it breaks past Command Prompt + var vsCodeId = items[1].Id; + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(vsCodeId); + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + + var expectedCmdIndex = i < 5 ? 0 : 1; + var expectedCodeIndex = i < 5 ? 1 : 0; + Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index 34f6c9c9c5..d97bde7037 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -13,7 +13,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Apps.Programs; -internal sealed partial class AppListItem : ListItem +public sealed partial class AppListItem : ListItem { private static readonly Tag _appTag = new("App"); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 7485716f1b..37698d972d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -103,7 +103,8 @@ public class UWPApplication : IUWPApplication new CommandContextItem( new OpenFileCommand(Location) { - Name = Resources.open_containing_folder, + Icon = new("\uE838"), + Name = Resources.open_location, }) { RequestedShortcut = KeyChords.OpenFileLocation, diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs index a45f05cdf3..9b89afc425 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs @@ -207,7 +207,10 @@ public class Win32Program : IProgram }); commands.Add(new CommandContextItem( - new OpenFileCommand(ParentDirectory)) + new ShowFileInFolderCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath) + { + Name = Resources.open_location, + }) { RequestedShortcut = KeyChords.OpenFileLocation, }); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs index d00d51a728..15cc4d338e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -241,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// - /// Looks up a localized string similar to Open location. + /// Looks up a localized string similar to Open file location. /// internal static string open_location { get { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx index 4191efddd5..dae13838e6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx @@ -161,7 +161,7 @@ File - Open location + Open file location Copy path @@ -237,4 +237,4 @@ Unlimited - + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs new file mode 100644 index 0000000000..9b73ade32b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Abstraction for providers that can extract metadata and offer actions for a clipboard context. +/// +internal interface IClipboardMetadataProvider +{ + /// + /// Gets the section title to show in the UI for this provider's metadata. + /// + string SectionTitle { get; } + + /// + /// Returns true if this provider can produce metadata for the given item. + /// + bool CanHandle(ClipboardItem item); + + /// + /// Returns metadata elements for the UI. Caller decides section grouping. + /// + IEnumerable GetDetails(ClipboardItem item); + + /// + /// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication. + /// + IEnumerable GetActions(ClipboardItem item); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs new file mode 100644 index 0000000000..429f6341f3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record ImageMetadata( + uint Width, + uint Height, + double DpiX, + double DpiY, + ulong? StorageSize); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs new file mode 100644 index 0000000000..e69a7d3d9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal static class ImageMetadataAnalyzer +{ + /// + /// Reads image metadata from a RandomAccessStreamReference without decoding pixels. + /// Returns oriented dimensions (EXIF rotation applied). + /// + public static async Task GetAsync(RandomAccessStreamReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + + using IRandomAccessStream ras = await reference.OpenReadAsync().AsTask().ConfigureAwait(false); + var sizeBytes = TryGetSize(ras); + + // BitmapDecoder does not decode pixel data unless you ask it to, + // so this is fast and memory-friendly. + var decoder = await BitmapDecoder.CreateAsync(ras).AsTask().ConfigureAwait(false); + + // OrientedPixelWidth/Height account for EXIF orientation + var width = decoder.OrientedPixelWidth; + var height = decoder.OrientedPixelHeight; + + return new ImageMetadata( + Width: width, + Height: height, + DpiX: decoder.DpiX, + DpiY: decoder.DpiY, + StorageSize: sizeBytes); + } + + private static ulong? TryGetSize(IRandomAccessStream s) + { + try + { + // On file-backed streams this is accurate. + // On some URI/virtual streams this may be unsupported or 0. + var size = s.Size; + return size == 0 ? (ulong?)0 : size; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs new file mode 100644 index 0000000000..09a3f33f2e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class ImageMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Image metadata"; + + public bool CanHandle(ClipboardItem item) => item.IsImage; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || item.ImageData is null) + { + return result; + } + + try + { + var metadata = ImageMetadataAnalyzer.GetAsync(item.ImageData).GetAwaiter().GetResult(); + + result.Add(new DetailsElement + { + Key = "Dimensions", + Data = new DetailsLink($"{metadata.Width} x {metadata.Height}"), + }); + result.Add(new DetailsElement + { + Key = "DPI", + Data = new DetailsLink($"{metadata.DpiX:0.###} x {metadata.DpiY:0.###}"), + }); + + if (metadata.StorageSize != null) + { + result.Add(new DetailsElement + { + Key = "Storage size", + Data = new DetailsLink(SizeFormatter.FormatSize(metadata.StorageSize.Value)), + }); + } + } + catch (Exception ex) + { + Logger.LogDebug("Failed to retrieve image metadata:" + ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs new file mode 100644 index 0000000000..1274d1ace9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal enum LineEndingType +{ + None, + Windows, // \r\n (CRLF) + Unix, // \n (LF) + Mac, // \r (CR) + Mixed, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs new file mode 100644 index 0000000000..1827fa8744 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Represents an action exposed by a metadata provider. +/// +/// Unique identifier for de-duplication (case-insensitive). +/// The actual context menu item to be shown. +internal readonly record struct ProviderAction(string Id, CommandContextItem Action); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs new file mode 100644 index 0000000000..a08ab32bc2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Utility for formatting byte sizes to a human-readable string. +/// +internal static class SizeFormatter +{ + private const long KB = 1024; + private const long MB = 1024 * KB; + private const long GB = 1024 * MB; + + public static string FormatSize(long bytes) + { + return bytes switch + { + >= GB => string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", (double)bytes / GB), + >= MB => string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", (double)bytes / MB), + >= KB => string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", (double)bytes / KB), + _ => string.Format(CultureInfo.CurrentCulture, "{0} B", bytes), + }; + } + + public static string FormatSize(ulong bytes) + { + // Use double for division to avoid overflow; thresholds mirror long version + if (bytes >= (ulong)GB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", bytes / (double)GB); + } + + if (bytes >= (ulong)MB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", bytes / (double)MB); + } + + if (bytes >= (ulong)KB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", bytes / (double)KB); + } + + return string.Format(CultureInfo.CurrentCulture, "{0} B", bytes); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs new file mode 100644 index 0000000000..a51444a3af --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects when text content is a valid existing file or directory path and exposes basic metadata. +/// +internal sealed class TextFileSystemMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "File"; + + public bool CanHandle(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + var text = PathHelper.Unquote(item.Content); + return PathHelper.IsValidFilePath(text); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(Path.GetFileName(path)) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(path), path) }); + return result; + } + + try + { + if (!isDirectory) + { + var fi = new FileInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(fi.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(fi.FullName), fi.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink(fi.Extension) }); + result.Add(new DetailsElement { Key = "Size", Data = new DetailsLink(SizeFormatter.FormatSize(fi.Length)) }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(fi.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(fi.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + else + { + var di = new DirectoryInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(di.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(di.FullName), di.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink("Folder") }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(di.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(di.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to retrieve file system metadata.", ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + yield break; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + // One anything + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + yield break; + } + + if (!isDirectory) + { + // Open file + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + // Show in folder (select) + var show = new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = WellKnownKeyChords.OpenFileLocation }; + yield return new ProviderAction(WellKnownActionIds.OpenLocation, show); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + + // Open in console at file location + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromFile(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + } + else + { + // Open folder + var openFolder = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, openFolder); + + // Open in console + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromDirectory(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs new file mode 100644 index 0000000000..726a15c37e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record TextMetadata +{ + public int CharacterCount { get; init; } + + public int WordCount { get; init; } + + public int SentenceCount { get; init; } + + public int LineCount { get; init; } + + public int ParagraphCount { get; init; } + + public LineEndingType LineEnding { get; init; } + + public override string ToString() + { + return $"Characters: {CharacterCount}, Words: {WordCount}, Sentences: {SentenceCount}, Lines: {LineCount}, Paragraphs: {ParagraphCount}, Line Ending: {LineEnding}"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs new file mode 100644 index 0000000000..83992f6428 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal partial class TextMetadataAnalyzer +{ + public TextMetadata Analyze(string input) + { + ArgumentNullException.ThrowIfNull(input); + + return new TextMetadata + { + CharacterCount = input.Length, + WordCount = CountWords(input), + SentenceCount = CountSentences(input), + LineCount = CountLines(input), + ParagraphCount = CountParagraphs(input), + LineEnding = DetectLineEnding(input), + }; + } + + private LineEndingType DetectLineEnding(string text) + { + var crlfCount = Regex.Matches(text, "\r\n").Count; + var lfCount = Regex.Matches(text, "(? 0 ? 1 : 0) + (lfCount > 0 ? 1 : 0) + (crCount > 0 ? 1 : 0); + + if (endingTypes > 1) + { + return LineEndingType.Mixed; + } + + if (crlfCount > 0) + { + return LineEndingType.Windows; + } + + if (lfCount > 0) + { + return LineEndingType.Unix; + } + + if (crCount > 0) + { + return LineEndingType.Mac; + } + + return LineEndingType.None; + } + + private int CountLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return text.Count(c => c == '\n') + 1; + } + + private int CountParagraphs(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var paragraphs = ParagraphsRegex() + .Split(text) + .Count(static p => !string.IsNullOrWhiteSpace(p)); + + return paragraphs > 0 ? paragraphs : 1; + } + + private int CountWords(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return Regex.Matches(text, @"\b\w+\b").Count; + } + + private int CountSentences(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var matches = SentencesRegex().Matches(text); + return matches.Count > 0 ? matches.Count : (text.Trim().Length > 0 ? 1 : 0); + } + + [GeneratedRegex(@"(\r?\n){2,}")] + private static partial Regex ParagraphsRegex(); + + [GeneratedRegex(@"[.!?]+(?=\s|$)")] + private static partial Regex SentencesRegex(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs new file mode 100644 index 0000000000..86e2a32270 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class TextMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Text statistics"; + + public bool CanHandle(ClipboardItem item) => item.IsText; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || string.IsNullOrEmpty(item.Content)) + { + return result; + } + + var r = new TextMetadataAnalyzer().Analyze(item.Content); + + result.Add(new DetailsElement + { + Key = "Characters", + Data = new DetailsLink(r.CharacterCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Words", + Data = new DetailsLink(r.WordCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Sentences", + Data = new DetailsLink(r.SentenceCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Lines", + Data = new DetailsLink(r.LineCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Paragraphs", + Data = new DetailsLink(r.ParagraphCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Line Ending", + Data = new DetailsLink(r.LineEnding.ToString()), + }); + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs new file mode 100644 index 0000000000..0a2afc3e01 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects web links in text and shows normalized URL and key parts. +/// +internal sealed class WebLinkMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Link"; + + public bool CanHandle(ClipboardItem item) + { + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + if (!UrlHelper.IsValidUrl(item.Content)) + { + return false; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return false; + } + + // Exclude file: scheme; it's handled by TextFileSystemMetadataProvider + return !uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + try + { + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return result; + } + + // Skip file: at runtime as well (defensive) + if (uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + result.Add(new DetailsElement { Key = "URL", Data = new DetailsLink(normalized) }); + result.Add(new DetailsElement { Key = "Host", Data = new DetailsLink(uri.Host) }); + + if (!uri.IsDefaultPort) + { + result.Add(new DetailsElement { Key = "Port", Data = new DetailsLink(uri.Port.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") + { + result.Add(new DetailsElement { Key = "Path", Data = new DetailsLink(uri.AbsolutePath) }); + } + + if (!string.IsNullOrEmpty(uri.Query)) + { + var q = uri.Query; + var count = q.Count(static c => c == '&') + (q.Length > 1 ? 1 : 0); + result.Add(new DetailsElement { Key = "Query params", Data = new DetailsLink(count.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + result.Add(new DetailsElement { Key = "Fragment", Data = new DetailsLink(uri.Fragment) }); + } + } + catch + { + // ignore malformed inputs + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + if (!CanHandle(item)) + { + yield break; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content!); + + var open = new CommandContextItem(new OpenUrlCommand(normalized)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs new file mode 100644 index 0000000000..7fa2a74aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Well-known action id constants used to de-duplicate provider actions. +/// +internal static class WellKnownActionIds +{ + public const string Open = "open"; + public const string OpenLocation = "openLocation"; + public const string CopyPath = "copyPath"; + public const string OpenConsole = "openConsole"; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs index 60e7851761..fe160e4c1b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System; -using System.IO; +using Microsoft.CmdPal.Core.Common.Helpers; namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; @@ -31,7 +31,7 @@ internal static class UrlHelper } // Check if it's a valid file path (local or network) - if (IsValidFilePath(url)) + if (PathHelper.IsValidFilePath(url)) { return true; } @@ -78,7 +78,7 @@ internal static class UrlHelper url = url.Trim(); // If it's a valid file path, convert to file:// URI - if (IsValidFilePath(url) && !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + if (!url.StartsWith("file://", StringComparison.OrdinalIgnoreCase) && PathHelper.IsValidFilePath(url)) { try { @@ -105,40 +105,4 @@ internal static class UrlHelper return url; } - - /// - /// Checks if a string represents a valid file path (local or network) - /// - /// The string to check - /// True if the string is a valid file path, false otherwise - private static bool IsValidFilePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - try - { - // Check for UNC paths (network paths starting with \\) - if (path.StartsWith(@"\\", StringComparison.Ordinal)) - { - // Basic UNC path validation: \\server\share or \\server\share\path - var parts = path.Substring(2).Split('\\', StringSplitOptions.RemoveEmptyEntries); - return parts.Length >= 2; // At minimum: server and share - } - - // Check for drive letters (C:\ or C:) - if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') - { - return true; - } - - return false; - } - catch - { - return false; - } - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj index e3d17fb500..b0c0617c34 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj @@ -10,6 +10,7 @@ enable + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs index 9b5aae6f7d..865d8f6b91 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -9,6 +9,7 @@ using System.Linq; using Microsoft.CmdPal.Common.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -16,13 +17,20 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; internal sealed partial class ClipboardListItem : ListItem { + private static readonly IClipboardMetadataProvider[] MetadataProviders = + [ + new ImageMetadataProvider(), + new TextFileSystemMetadataProvider(), + new WebLinkMetadataProvider(), + new TextMetadataProvider(), + ]; + private readonly SettingsManager _settingsManager; private readonly ClipboardItem _item; private readonly CommandContextItem _deleteContextMenuItem; private readonly CommandContextItem? _pasteCommand; private readonly CommandContextItem? _copyCommand; - private readonly CommandContextItem? _openUrlCommand; private readonly Lazy
_lazyDetails; public override IDetails? Details @@ -73,26 +81,11 @@ internal sealed partial class ClipboardListItem : ListItem _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); - - // Check if the text content is a valid URL and add OpenUrl command - if (UrlHelper.IsValidUrl(_item.Content ?? string.Empty)) - { - var normalizedUrl = UrlHelper.NormalizeUrl(_item.Content ?? string.Empty); - _openUrlCommand = new CommandContextItem(new OpenUrlCommand(normalizedUrl)) - { - RequestedShortcut = KeyChords.OpenUrl, - }; - } - else - { - _openUrlCommand = null; - } } else { _pasteCommand = null; _copyCommand = null; - _openUrlCommand = null; } RefreshCommands(); @@ -163,27 +156,74 @@ internal sealed partial class ClipboardListItem : ListItem commands.Add(firstCommand); } - if (_openUrlCommand != null) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var temp = new List(); + foreach (var provider in MetadataProviders) { - commands.Add(_openUrlCommand); + if (!provider.CanHandle(_item)) + { + continue; + } + + foreach (var action in provider.GetActions(_item)) + { + if (string.IsNullOrEmpty(action.Id) || !seen.Add(action.Id)) + { + continue; + } + + temp.Add(action.Action); + } + } + + if (temp.Count > 0) + { + if (commands.Count > 0) + { + commands.Add(new Separator()); + } + + commands.AddRange(temp); } commands.Add(new Separator()); commands.Add(_deleteContextMenuItem); - return commands.ToArray(); + return [.. commands]; } private Details CreateDetails() { - IDetailsElement[] metadata = - [ - new DetailsElement + List metadata = []; + + foreach (var provider in MetadataProviders) + { + if (provider.CanHandle(_item)) { - Key = "Copied on", - Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + var details = provider.GetDetails(_item); + if (details.Any()) + { + metadata.Add(new DetailsElement + { + Key = provider.SectionTitle, + Data = new DetailsSeparator(), + }); + + metadata.AddRange(details); + } } - ]; + } + + metadata.Add(new DetailsElement + { + Key = "General", + Data = new DetailsSeparator(), + }); + metadata.Add(new DetailsElement + { + Key = "Copied", + Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + }); if (_item.IsImage) { @@ -193,7 +233,7 @@ internal sealed partial class ClipboardListItem : ListItem { Title = _item.GetDataType(), HeroImage = heroImage, - Metadata = metadata, + Metadata = [.. metadata], }; } @@ -203,7 +243,7 @@ internal sealed partial class ClipboardListItem : ListItem { Title = _item.GetDataType(), Body = $"```text\n{_item.Content}\n```", - Metadata = metadata, + Metadata = [.. metadata], }; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs index 2e4cac7b16..ab557ba258 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -16,7 +16,6 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos private readonly Action? _addToHistory; private readonly ITelemetryService _telemetryService; private CancellationTokenSource? _cancellationTokenSource; - private Task? _currentUpdateTask; public FallbackExecuteItem(SettingsManager settings, Action? addToHistory, ITelemetryService telemetryService) : base( @@ -40,44 +39,22 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos try { - // Save the latest update task - _currentUpdateTask = DoUpdateQueryAsync(query, cancellationToken); - } - catch (OperationCanceledException) - { - // DO NOTHING HERE - return; + DoUpdateQuery(query, cancellationToken); } catch (Exception) { // Handle other exceptions return; } - - // Await the task to ensure only the latest one gets processed - _ = ProcessUpdateResultsAsync(_currentUpdateTask); } - private async Task ProcessUpdateResultsAsync(Task updateTask) - { - try - { - await updateTask; - } - catch (OperationCanceledException) - { - // Handle cancellation gracefully - } - catch (Exception) - { - // Handle other exceptions - } - } - - private async Task DoUpdateQueryAsync(string query, CancellationToken cancellationToken) + private void DoUpdateQuery(string query, CancellationToken cancellationToken) { // Check for cancellation at the start - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } var searchText = query.Trim(); Expand(ref searchText); @@ -105,22 +82,8 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); var timeoutToken = combinedCts.Token; - // Use Task.Run with timeout for file system operations - var fileSystemTask = Task.Run( - () => - { - exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); - pathIsDir = Directory.Exists(exe); - }, - CancellationToken.None); - - // Wait for either completion or timeout - await fileSystemTask.WaitAsync(timeoutToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Main cancellation token was cancelled, re-throw - throw; + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath, cancellationToken); + pathIsDir = Directory.Exists(exe); } catch (TimeoutException) { @@ -139,7 +102,10 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } // Check for cancellation before updating UI properties - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (exeExists) { @@ -172,7 +138,10 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos } // Final cancellation check - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } } public void Dispose() diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs index aebf9a77fd..1dc43600c2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs @@ -324,7 +324,7 @@ internal sealed class Window // Correct the process data if the window belongs to a uwp app hosted by 'ApplicationFrameHost.exe' // (This only works if the window isn't minimized. For minimized windows the required child window isn't assigned.) - if (string.Equals(_handlesToProcessCache[hWindow].Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase)) + if (_handlesToProcessCache[hWindow].IsUwpAppFrameHost) { new Task(() => { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs index af3730cada..2dfbbcf429 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs @@ -23,7 +23,7 @@ internal sealed class WindowProcess /// /// An indicator if the window belongs to an 'Universal Windows Platform (UWP)' process /// - private readonly bool _isUwpAppFrameHost; + private bool _isUwpAppFrameHost; /// /// Gets the id of the process @@ -126,6 +126,14 @@ internal sealed class WindowProcess get; private set; } + /// + /// Gets the type of the process (UWP app, packaged Win32 app, unpackaged Win32 app, ...). + /// + internal ProcessPackagingInfo ProcessType + { + get; private set; + } + /// /// Initializes a new instance of the class. /// @@ -134,13 +142,10 @@ internal sealed class WindowProcess /// New process name. internal WindowProcess(uint pid, uint tid, string name) { + ProcessType = ProcessPackagingInfo.Empty; UpdateProcessInfo(pid, tid, name); - ProcessType = ProcessPackagingInspector.Inspect((int)pid); - _isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase); } - public ProcessPackagingInfo ProcessType { get; private set; } - /// /// Updates the process information of the instance. /// @@ -156,6 +161,10 @@ internal sealed class WindowProcess // Process can be elevated only if process id is not 0 (Dummy value on error) IsFullAccessDenied = (pid != 0) ? TestProcessAccessUsingAllAccessFlag(pid) : false; + + // Update process type + ProcessType = ProcessPackagingInspector.Inspect((int)pid); + _isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase); } /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs index f1d3c5e09d..1a1321a9d6 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs @@ -11,4 +11,13 @@ internal sealed record ProcessPackagingInfo( bool IsAppContainer, string? PackageFullName, int? LastError -); +) +{ + public static ProcessPackagingInfo Empty { get; } = new( + Pid: 0, + Kind: ProcessPackagingKind.Unknown, + HasPackageIdentity: false, + IsAppContainer: false, + PackageFullName: null, + LastError: null); +} diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index e6b8618099..df105e5815 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -11,6 +11,7 @@ + true @@ -214,7 +215,8 @@ - + + @@ -235,6 +237,8 @@ + + diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs index 3dac744762..b4eae2d1ba 100644 --- a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -3,14 +3,17 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Reflection; using System.Text.Json.Serialization; +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Settings.UI.Library { - public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable + public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable, IHotkeyConfig { public const string ModuleName = "LightSwitch"; @@ -24,6 +27,21 @@ namespace Settings.UI.Library [JsonPropertyName("properties")] public LightSwitchProperties Properties { get; set; } + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ToggleThemeHotkey.Value, + value => Properties.ToggleThemeHotkey.Value = value ?? LightSwitchProperties.DefaultToggleThemeHotkey, + "LightSwitch_ThemeToggle_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + public ModuleType GetModuleType() => ModuleType.LightSwitch; + public object Clone() { return new LightSwitchSettings() @@ -41,6 +59,7 @@ namespace Settings.UI.Library SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value), Latitude = new StringProperty(Properties.Latitude.Value), Longitude = new StringProperty(Properties.Longitude.Value), + ToggleThemeHotkey = new KeyboardKeysProperty(Properties.ToggleThemeHotkey.Value), }, }; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs index a3c8486b8f..d970700484 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -73,6 +73,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views this.InitializeComponent(); this.Loaded += LightSwitchPage_Loaded; + this.Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs index e5262f93d7..010bd68935 100644 --- a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; @@ -20,8 +21,10 @@ using Settings.UI.Library.Helpers; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class LightSwitchViewModel : Observable + public partial class LightSwitchViewModel : PageViewModelBase { + protected override string ModuleName => LightSwitchSettings.ModuleName; + private Func SendConfigMSG { get; } public ObservableCollection SearchLocations { get; } = new(); @@ -35,14 +38,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels ForceDarkCommand = new RelayCommand(ForceDarkNow); AvailableScheduleModes = new ObservableCollection - { - "FixedHours", - "SunsetToSunrise", - }; + { + "FixedHours", + "SunsetToSunrise", + }; _toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value; } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ToggleThemeActivationShortcut], + }; + + return hotkeysDict; + } + private void ForceLightNow() { Logger.LogInfo("Sending custom action: forceLight"); @@ -395,22 +408,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public HotkeySettings ToggleThemeActivationShortcut { - get => _toggleThemeHotkey; + get => ModuleSettings.Properties.ToggleThemeHotkey.Value; set { - if (value != _toggleThemeHotkey) + if (value != ModuleSettings.Properties.ToggleThemeHotkey.Value) { if (value == null) { - _toggleThemeHotkey = LightSwitchProperties.DefaultToggleThemeHotkey; + ModuleSettings.Properties.ToggleThemeHotkey.Value = LightSwitchProperties.DefaultToggleThemeHotkey; } else { - _toggleThemeHotkey = value; + ModuleSettings.Properties.ToggleThemeHotkey.Value = value; } - _moduleSettings.Properties.ToggleThemeHotkey.Value = _toggleThemeHotkey; NotifyPropertyChanged(); SendConfigMSG( @@ -418,7 +430,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels CultureInfo.InvariantCulture, "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", LightSwitchSettings.ModuleName, - JsonSerializer.Serialize(_moduleSettings, (System.Text.Json.Serialization.Metadata.JsonTypeInfo)SourceGenerationContextContext.Default.LightSwitchSettings))); + JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings))); } } } diff --git a/tools/build/Delete-Worktree.cmd b/tools/build/Delete-Worktree.cmd new file mode 100644 index 0000000000..edf14bb537 --- /dev/null +++ b/tools/build/Delete-Worktree.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %* diff --git a/tools/build/Delete-Worktree.ps1 b/tools/build/Delete-Worktree.ps1 new file mode 100644 index 0000000000..68f7c218d4 --- /dev/null +++ b/tools/build/Delete-Worktree.ps1 @@ -0,0 +1,130 @@ +<#! +.SYNOPSIS + Remove a git worktree (and optionally its local branch and orphan fork remote). + +.DESCRIPTION + Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository + root is never removed. Optionally discards local changes with -Force. Deletes associated branch + unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking + branches, that remote is removed unless -KeepRemote. + +.PARAMETER Pattern + Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed + and no deletion occurs. + +.PARAMETER Force + Discard uncommitted changes and attempt aggressive cleanup on failure. + +.PARAMETER KeepBranch + Preserve the local branch (only remove the worktree directory entry). + +.PARAMETER KeepRemote + Preserve any orphan fork remote even if no branches still track it. + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern feature/login + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch + +.NOTES + Manual recovery: + git worktree list --porcelain + git worktree prune + Remove-Item -LiteralPath -Recurse -Force + git branch -D + git remote remove + git worktree prune +#> + +param( + [string] $Pattern, + [switch] $Force, + [switch] $KeepBranch, + [switch] $KeepRemote, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } +try { + $repoRoot = Get-RepoRoot + $entries = Get-WorktreeEntries + if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' } + $hasWildcard = $Pattern -match '[\*\?]' + $matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" } + $found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) } + if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" } + if ($found.Count -gt 1) { + Warn 'Pattern matches multiple worktrees:' + $found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) } + return + } + $target = $found | Select-Object -First 1 + $branch = $target.Branch + $folder = $target.Path + if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' } + try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {} + $primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath + if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' } + $status = git -C $folder status --porcelain 2>$null + if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" } + if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' } + if ($Force -and $status) { + Warn '[Force] Discarding local changes' + git -C $folder reset --hard HEAD | Out-Null + git -C $folder clean -fdx | Out-Null + } + if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder } + if ($LASTEXITCODE -ne 0) { + $exit1 = $LASTEXITCODE + $errMsg = "git worktree remove failed (exit $exit1)" + if ($Force) { + Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).' + try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {} + try { git -C $folder clean -dfx 2>$null | Out-Null } catch {} + try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {} + if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } } + git worktree prune 2>$null | Out-Null + if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." } + } else { + throw "$errMsg. Rerun with -Force to attempt aggressive cleanup." + } + } + # Determine upstream before potentially deleting branch + $upRemote = Get-BranchUpstreamRemote -Branch $branch + $looksForkName = $branch -like 'fork-*' + + if (-not $KeepBranch) { + git branch -D $branch 2>$null | Out-Null + if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') { + $otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null | + Where-Object { $_ -and ($_ -notmatch "^$branch\|") } | + ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?[^/]+)/'){ $parts[0],$Matches.r } } | + Where-Object { $_[1] -eq $upRemote } + if (-not $otherTracking) { + Warn "Removing orphan remote '$upRemote' (no more tracking branches)" + git remote remove $upRemote 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." } + } else { Info "Remote '$upRemote' retained (other branches still track it)." } + } elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) { + Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.' + } + } + + Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' } + Show-WorktreeExecutionSummary -CurrentBranch $branch +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual cleanup guidelines:' + Info ' git worktree list --porcelain' + Info ' git worktree prune' + Info ' # If still present:' + Info ' Remove-Item -LiteralPath -Recurse -Force' + Info ' git branch -D (if you also want to drop local branch)' + Info ' git remote remove (if orphan fork remote remains)' + Info ' git worktree prune' + exit 1 +} diff --git a/tools/build/New-WorktreeFromBranch.cmd b/tools/build/New-WorktreeFromBranch.cmd new file mode 100644 index 0000000000..a1c2b9a624 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %* diff --git a/tools/build/New-WorktreeFromBranch.ps1 b/tools/build/New-WorktreeFromBranch.ps1 new file mode 100644 index 0000000000..d299e1a879 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for an existing local or remote (origin) branch. + +.DESCRIPTION + Normalizes origin/ to . If the branch does not exist locally (and -NoFetch is not + provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree + bound to the branch; otherwise creates a new one adjacent to the repository root. + +.PARAMETER Branch + Branch name (local or origin/ form) to materialize as a worktree. + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.PARAMETER NoFetch + Skip fetch if branch missing locally; script will error instead of creating it. + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch feature/login + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch + +.NOTES + Manual recovery: + git fetch origin && git checkout + git worktree add ../RepoName-XX + code ../RepoName-XX --profile Default +#> + +param( + [string] $Branch, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $NoFetch, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" + +if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +# Normalize origin/ to +if ($Branch -match '^(origin|upstream|main|master)/.+') { + if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] } +} + +try { + git show-ref --verify --quiet "refs/heads/$Branch" + if ($LASTEXITCODE -ne 0) { + if (-not $NoFetch) { + Warn "Local branch '$Branch' not found; attempting remote fetch..." + git fetch --all --prune 2>$null | Out-Null + $remoteRef = "origin/$Branch" + git show-ref --verify --quiet "refs/remotes/$remoteRef" + if ($LASTEXITCODE -eq 0) { + git branch --track $Branch $remoteRef 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" } + Info "Created local tracking branch '$Branch' from $remoteRef." + } else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." } + } else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." } + } + + New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info ' git fetch origin' + Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)" + Info ' git worktree add ../-XX ' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromFork.cmd b/tools/build/New-WorktreeFromFork.cmd new file mode 100644 index 0000000000..be8bc05c0f --- /dev/null +++ b/tools/build/New-WorktreeFromFork.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %* diff --git a/tools/build/New-WorktreeFromFork.ps1 b/tools/build/New-WorktreeFromFork.ps1 new file mode 100644 index 0000000000..ccd26631e4 --- /dev/null +++ b/tools/build/New-WorktreeFromFork.ps1 @@ -0,0 +1,127 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree from a branch in a personal fork: :. + +.DESCRIPTION + Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified. + Fetches only the target branch (fallback full fetch once if needed), creates a local tracking + branch (fork-- or custom alias), and delegates worktree creation/reuse + to shared helpers in WorktreeLib. + +.PARAMETER Spec + Fork spec in the form :. + +.PARAMETER ForkRepo + Repository name in the fork (default: PowerToys). + +.PARAMETER RemoteName + Desired remote name; if left as 'fork' a unique suffix will be generated. + +.PARAMETER BranchAlias + Optional local branch name override; defaults to fork--. + +.PARAMETER VSCodeProfile + VS Code profile to pass through to worktree opening (Default profile by default). + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash + +.NOTES + Manual equivalent if this script fails: + git remote add fork-temp https://github.com//.git + git fetch fork-temp + git branch --track fork-- fork-temp/ + git worktree add ../Repo-XX fork-- + code ../Repo-XX +#> +param( + [string] $Spec, + [string] $ForkRepo = 'PowerToys', + [string] $RemoteName = 'fork', + [string] $BranchAlias, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) + +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { throw 'Not inside a git repository.' } + +# Parse spec +if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be :, got '$Spec'" } +$ForkUser,$ForkBranch = $Spec.Split(':',2) + +$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git" + +# Auto-suffix remote name if user left default 'fork' +$allRemotes = @(git remote 2>$null) +if ($RemoteName -eq 'fork') { + $chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + do { + $suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] }) + $candidate = "fork-$suffix" + } while ($allRemotes -contains $candidate) + $RemoteName = $candidate + Info "Assigned unique remote name: $RemoteName" +} + +$existing = $allRemotes | Where-Object { $_ -eq $RemoteName } +if (-not $existing) { + Info "Adding remote $RemoteName -> $forkUrl" + git remote add $RemoteName $forkUrl | Out-Null +} else { + $currentUrl = git remote get-url $RemoteName 2>$null + if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." } +} + +## Note: Verbose fetch & stale lock auto-clean removed for simplicity. + +try { + Info "Fetching branch '$ForkBranch' from $RemoteName..." + & git fetch $RemoteName $ForkBranch 1>$null 2>$null + $fetchExit = $LASTEXITCODE + if ($fetchExit -ne 0) { + # Retry full fetch silently once (covers servers not supporting branch-only fetch syntax) + & git fetch $RemoteName 1>$null 2>$null + $fetchExit = $LASTEXITCODE + } + if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." } + + $remoteRef = "refs/remotes/$RemoteName/$ForkBranch" + git show-ref --verify --quiet $remoteRef + if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" } + + $sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-') + if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" } + + git show-ref --verify --quiet "refs/heads/$localBranch" + if ($LASTEXITCODE -ne 0) { + Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch" + git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" } + } else { Info "Local branch $localBranch already exists." } + + New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile + # Ensure upstream so future 'git push' works + Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path + Warn "Remote $RemoteName ready (URL: $forkUrl)" + $hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null + if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u :' } +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git remote add temp-fork $forkUrl" + Info " git fetch temp-fork" + Info " git branch --track fork-- temp-fork/$ForkBranch" + Info ' git worktree add ../-XX fork--' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromIssue.cmd b/tools/build/New-WorktreeFromIssue.cmd new file mode 100644 index 0000000000..6aba21652c --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %* diff --git a/tools/build/New-WorktreeFromIssue.ps1 b/tools/build/New-WorktreeFromIssue.ps1 new file mode 100644 index 0000000000..c5523fcd13 --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for a new issue branch derived from a base ref. + +.DESCRIPTION + Composes a branch name as issue/ or issue/- (slug from optional -Title). + If the branch does not already exist, it is created from -Base (default origin/main). Then a + worktree is created or reused. + +.PARAMETER Number + Issue number used to construct the branch name. + +.PARAMETER Title + Optional descriptive title; slug into the branch name. + +.PARAMETER Base + Base ref to branch from (default origin/main). + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop + +.NOTES + Manual recovery: + git fetch origin + git checkout -b issue/- + git worktree add ../Repo-XX issue/- + code ../Repo-XX +#> + +param( + [int] $Number, + [string] $Title, + [string] $Base = 'origin/main', + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +$scriptPath = $MyInvocation.MyCommand.Path +if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return } + +# Compose branch name +if ($Title) { + $slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-' + $branch = "issue/$Number-$slug" +} else { + $branch = "issue/$Number" +} + +try { + # Create branch if missing + git show-ref --verify --quiet "refs/heads/$branch" + if ($LASTEXITCODE -ne 0) { + Info "Creating branch $branch from $Base" + git branch $branch $Base 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" } + } else { + Info "Branch $branch already exists locally." + } + + New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git fetch origin" + Info " git checkout -b $branch $Base (if branch missing)" + Info " git worktree add ../-XX $branch" + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/Worktree-Guidelines.md b/tools/build/Worktree-Guidelines.md new file mode 100644 index 0000000000..bccd80ab9f --- /dev/null +++ b/tools/build/Worktree-Guidelines.md @@ -0,0 +1,94 @@ +# PowerToys Worktree Helper Scripts + +This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time. + +## Why worktree? +Git worktree let you have several checked‑out branches sharing a single `.git` object store. Benefits: +- Fast context switching: no re-clone, no duplicate large binary/object downloads. +- Lower disk usage versus multiple full clones. +- Keeps each change isolated in its own folder so you can run builds/tests independently. +- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean. + +Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations. + +## Scripts Overview +| Script | Purpose | +|--------|---------| +| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`:` spec). Adds a temporary unique remote (e.g. `fork-abc12`). | +| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. | +| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/-`. | +| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. | +| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. | + +## Typical Flows +### 1. Create from a fork branch +``` +./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak +``` +Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root. + +### 2. Create from an existing or remote branch +``` +./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui +``` +Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree. + +### 3. Start a new issue branch +``` +./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" +``` +Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree. + +### 4. Delete a worktree when done +``` +./Delete-Worktree.ps1 -Pattern feature/perf-tweak +``` +If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote. + +## After Creating a Worktree +Inside the new worktree directory: +1. Run the minimal build bootstrap in VSCode terminal: +``` +tools\build\build-essentials.cmd +``` +2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise. +3. Make changes, commit, push. +4. Finally delete the worktree when done. + +## Naming & Locations +- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions. +- Fork-based branches get local names `fork--`. +- Issue branches: `issue/` or `issue/-`. + +## Scenarios Covered / Limitations +Covered scenarios: +1. From a fork branch (personal fork on GitHub). +2. From an existing local or origin remote branch. +3. Creating a new branch for an issue. + +Not covered (manual steps needed): +- Creating from a non-origin upstream other than a fork (add remote manually then use branch script). +- Batch creation of multiple worktree in one command. +- Automatic rebase / sync of many worktree at once (do that manually or script separately). + +## Best Practices +- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone. +- Delete stale worktree early; each adds file watchers & potential incremental build churn. +- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction. +- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree. + +## Troubleshooting +| Symptom | Hint | +|---------|------| +| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch `. +| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry. +| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate. +| Local branch missing for remote | Use `git branch --track origin/` then re-run the branch script. + +## Security & Safety Notes +- Scripts avoid force-deleting unless you pass `-Force` (Delete script). +- No network credentials are stored; they rely on your existing Git credential helper. +- Always review a new fork remote URL before pushing. + +--- +Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change. diff --git a/tools/build/WorktreeLib.ps1 b/tools/build/WorktreeLib.ps1 new file mode 100644 index 0000000000..01883115d1 --- /dev/null +++ b/tools/build/WorktreeLib.ps1 @@ -0,0 +1,151 @@ +# WorktreeLib.ps1 - shared helpers + +function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan } +function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow } +function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red } + +function Get-RepoRoot { + $root = git rev-parse --show-toplevel 2>$null + if (-not $root) { throw 'Not inside a git repository.' } + return $root +} + +function Get-WorktreeBasePath { + param([string]$RepoRoot) + # Always use parent of repo root (folder that contains the main repo directory) + $parent = Split-Path -Parent $RepoRoot + if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" } + return (Resolve-Path $parent).ProviderPath +} + +function Get-ShortHashFromString { + param([Parameter(Mandatory)][string]$Text) + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $digest = $md5.ComputeHash($bytes) + return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') }) + } finally { $md5.Dispose() } +} + +function Initialize-SubmodulesIfAny { + param([string]$RepoRoot,[string]$WorktreePath) + $hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules') + if ($hasGitmodules) { + git -C $WorktreePath submodule sync --recursive | Out-Null + git -C $WorktreePath submodule update --init --recursive | Out-Null + return $true + } + return $false +} + +function New-WorktreeForExistingBranch { + param( + [Parameter(Mandatory)][string] $Branch, + [Parameter(Mandatory)][string] $VSCodeProfile + ) + $repoRoot = Get-RepoRoot + git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." } + + # Detect existing worktree for this branch + $entries = Get-WorktreeEntries + $match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1 + if ($match) { + Info "Reusing existing worktree for '$Branch': $($match.Path)" + code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null + return + } + + $safeBranch = ($Branch -replace '[\\/:*?"<>|]','-') + $hash = Get-ShortHashFromString -Text $safeBranch + $folderName = "$(Split-Path -Leaf $repoRoot)-$hash" + $base = Get-WorktreeBasePath -RepoRoot $repoRoot + $folder = Join-Path $base $folderName + git worktree add $folder $Branch + $inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder + code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null + Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' } +} + +function Get-WorktreeEntries { + # Returns objects with Path and Branch (branch without refs/heads/ prefix) + $lines = git worktree list --porcelain 2>$null + if (-not $lines) { return @() } + $entries = @(); $current=@{} + foreach($l in $lines){ + if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue } + if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] } + elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() } + } + if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) } + return ($entries | Sort-Object Path,Branch -Unique) +} + +function Get-BranchUpstreamRemote { + param([Parameter(Mandatory)][string]$Branch) + # Returns remote name if branch has an upstream, else $null + $ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null } + if ($ref -match '^(?[^/]+)/.+$') { return $Matches.remote } + return $null +} + +function Show-IssueFarmCommonFooter { + Info '--- Common Manual Steps ---' + Info 'List worktree: git worktree list --porcelain' + Info 'List branches: git branch -vv' + Info 'List remotes: git remote -v' + Info 'Prune worktree: git worktree prune' + Info 'Remove worktree dir: Remove-Item -Recurse -Force ' + Info 'Reset branch: git reset --hard HEAD' +} + +function Show-WorktreeExecutionSummary { + param( + [string]$CurrentBranch, + [string]$WorktreePath + ) + Info '--- Summary ---' + if ($CurrentBranch) { Info "Branch: $CurrentBranch" } + if ($WorktreePath) { Info "Worktree path: $WorktreePath" } + $entries = Get-WorktreeEntries + if ($entries.Count -gt 0) { + Info 'Existing worktrees:' + $entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) } + } + Info 'Remotes:' + git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" } +} + +function Show-FileEmbeddedHelp { + param([string]$ScriptPath) + if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" } + $content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop + $inBlock=$false + foreach($line in $content){ + if ($line -match '^<#!') { $inBlock=$true; continue } + if ($line -match '#>$') { break } + if ($inBlock) { Write-Host $line } + } + Show-IssueFarmCommonFooter +} + +function Set-BranchUpstream { + param( + [Parameter(Mandatory)][string]$LocalBranch, + [Parameter(Mandatory)][string]$RemoteName, + [Parameter(Mandatory)][string]$RemoteBranchPath + ) + $current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null + if (-not $current) { + Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath" + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } + return + } + if ($current -ne "$RemoteName/$RemoteBranchPath") { + Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..." + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' } + } else { Info "Upstream already: $current" } +}