Merge branch 'main' of https://github.com/microsoft/PowerToys into leilzh/sparse

This commit is contained in:
Leilei Zhang
2025-10-15 10:32:05 +08:00
60 changed files with 2490 additions and 433 deletions

View File

@@ -580,6 +580,7 @@ GETSCREENSAVERRUNNING
GETSECKEY GETSECKEY
GETSTICKYKEYS GETSTICKYKEYS
GETTEXTLENGTH GETTEXTLENGTH
gitmodules
GHND GHND
GMEM GMEM
GNumber GNumber
@@ -916,7 +917,6 @@ luid
LUMA LUMA
lusrmgr lusrmgr
LVal LVal
lvm
LWA LWA
lwin lwin
LZero LZero
@@ -1328,6 +1328,7 @@ PRTL
prvpane prvpane
psapi psapi
pscid pscid
pscustomobject
PSECURITY PSECURITY
psfgao psfgao
psfi psfi
@@ -1964,6 +1965,7 @@ WMI
WMICIM WMICIM
wmimgmt wmimgmt
wmp wmp
wmsg
WMSYSCOMMAND WMSYSCOMMAND
wnd wnd
WNDCLASS WNDCLASS
@@ -1977,6 +1979,7 @@ WORKSPACESEDITOR
WORKSPACESLAUNCHER WORKSPACESLAUNCHER
WORKSPACESSNAPSHOTTOOL WORKSPACESSNAPSHOTTOOL
WORKSPACESWINDOWARRANGER WORKSPACESWINDOWARRANGER
Worktree
wox wox
wparam wparam
wpf wpf

View File

@@ -828,6 +828,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64 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|ARM64.Build.0 = Release|ARM64
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64 {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -3334,6 +3344,7 @@ Global
{3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477} {3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477}
{F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94} {F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94}
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}

View File

@@ -108,7 +108,7 @@ public:
m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT"); m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT");
m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK"); 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(); init_settings();
}; };

View File

@@ -8,6 +8,9 @@
#include <string> #include <string>
#include <LightSwitchSettings.h> #include <LightSwitchSettings.h>
#include <common/utils/gpo.h> #include <common/utils/gpo.h>
#include <logger/logger_settings.h>
#include <logger/logger.h>
#include <utils/logger_helper.h>
SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS g_ServiceStatus = {};
SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr;
@@ -35,6 +38,8 @@ int _tmain(int argc, TCHAR* argv[])
wchar_t serviceName[] = L"LightSwitchService"; wchar_t serviceName[] = L"LightSwitchService";
SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } }; SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } };
LoggerHelpers::init_logger(L"LightSwitch", L"Service", LogSettings::lightSwitchLoggerName);
if (!StartServiceCtrlDispatcherW(table)) if (!StartServiceCtrlDispatcherW(table))
{ {
DWORD err = GetLastError(); DWORD err = GetLastError();
@@ -106,6 +111,7 @@ VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl)
SetServiceStatus(g_StatusHandle, &g_ServiceStatus); SetServiceStatus(g_StatusHandle, &g_ServiceStatus);
// Signal the service to stop // Signal the service to stop
Logger::info(L"[LightSwitchService] Stop requested, signaling worker thread to exit.");
SetEvent(g_ServiceStopEvent); SetEvent(g_ServiceStopEvent);
break; break;
@@ -126,13 +132,21 @@ static void update_sun_times(auto& settings)
int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute;
int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; 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"); Logger::info(L"[LightSwitchService] Updated sun times and saved to config.");
values.add_property(L"lightTime", newLightTime); }
values.add_property(L"darkTime", newDarkTime); catch (const std::exception& e)
values.save_to_settings_file(); {
std::wstring wmsg(e.what(), e.what() + strlen(e.what()));
OutputDebugString(L"[LightSwitchService] Updated sun times and saved to config.\n"); Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg);
}
} }
DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
@@ -142,7 +156,8 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
if (parentPid) if (parentPid)
hParent = OpenProcess(SYNCHRONIZE, FALSE, 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 // Initialize settings system
LightSwitchSettings::instance().InitFileWatcher(); LightSwitchSettings::instance().InitFileWatcher();
@@ -214,19 +229,19 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
update_sun_times(settings); update_sun_times(settings);
g_lastUpdatedDay = st.wDay; 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]; wchar_t msg[160];
swprintf_s(msg, 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.wHour,
st.wMinute, st.wMinute,
settings.lightTime / 60, settings.lightTime / 60,
settings.lightTime % 60, settings.lightTime % 60,
settings.darkTime / 60, settings.darkTime / 60,
settings.darkTime % 60); settings.darkTime % 60);
OutputDebugString(msg); Logger::info(msg);
// --- Manual override check --- // --- Manual override check ---
bool manualOverrideActive = false; bool manualOverrideActive = false;
@@ -242,11 +257,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440) nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440)
{ {
ResetEvent(hManualOverride); ResetEvent(hManualOverride);
OutputDebugString(L"[LightSwitchService] Manual override cleared at boundary\n"); Logger::info(L"[LightSwitchService] Manual override cleared at boundary\n");
} }
else 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; goto sleep_until_next_minute;
} }
} }
@@ -261,10 +276,17 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
msToNextMinute = 50; msToNextMinute = 50;
DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); 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 <20> exiting worker loop.");
break; 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 <20> stopping service.");
break; break;
}
} }
if (hManualOverride) if (hManualOverride)
@@ -282,8 +304,8 @@ int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
wchar_t msg[160]; wchar_t msg[160];
swprintf_s( swprintf_s(
msg, msg,
L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.\n"); L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
OutputDebugString(msg); Logger::info(msg);
return 0; return 0;
} }

View File

@@ -28,19 +28,6 @@
<ProjectName>LightSwitchService</ProjectName> <ProjectName>LightSwitchService</ProjectName>
</PropertyGroup> </PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType> <ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries> <UseDebugLibraries>true</UseDebugLibraries>
@@ -54,84 +41,25 @@
<WholeProgramOptimization>true</WholeProgramOptimization> <WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet> <CharacterSet>Unicode</CharacterSet>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup> <PropertyGroup>
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir> <OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
<TargetName>PowerToys.LightSwitchService</TargetName> <TargetName>PowerToys.LightSwitchService</TargetName>
</PropertyGroup> </PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup> <ItemDefinitionGroup>
<ClCompile> <ClCompile>
<WarningLevel>Level3</WarningLevel> <WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck> <SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
<PrecompiledHeader>NotUsing</PrecompiledHeader> <PrecompiledHeader>NotUsing</PrecompiledHeader>
<PreprocessorDefinitions>%(PreprocessorDefinitions)</PreprocessorDefinitions>
<AdditionalIncludeDirectories> <AdditionalIncludeDirectories>
./../; ./../;
..\..\..\common\Telemetry;
..\..\..\common; ..\..\..\common;
..\..\..\common\logger;
..\..\..\common\utils;
..\..\..\common\SettingsAPI;
..\..\..\common\Telemetry;
..\..\..\; ..\..\..\;
..\..\..\..\deps\spdlog\include; ..\..\..\..\deps\spdlog\include;
./; ./;
@@ -145,8 +73,27 @@
</Link> </Link>
</ItemDefinitionGroup> </ItemDefinitionGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> <ClCompile Include="LightSwitchService.cpp" />
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> <ClCompile Include="LightSwitchSettings.cpp" />
<ClCompile Include="SettingsConstants.cpp" />
<ClCompile Include="ThemeHelper.cpp" />
<ClCompile Include="ThemeScheduler.cpp" />
<ClCompile Include="WinHookEventIDs.cpp" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="LightSwitchService.rc" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="LightSwitchSettings.h" />
<ClInclude Include="SettingsConstants.h" />
<ClInclude Include="SettingsObserver.h" />
<ClInclude Include="ThemeHelper.h" />
<ClInclude Include="ThemeScheduler.h" />
<ClInclude Include="WinHookEventIDs.h" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj">
<Project>{4aed67b6-55fd-486f-b917-e543dee2cb3c}</Project>
</ProjectReference> </ProjectReference>
<ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj">
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
@@ -158,62 +105,10 @@
<Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project>
</ProjectReference> </ProjectReference>
</ItemGroup> </ItemGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="LightSwitchService.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="LightSwitchSettings.cpp" />
<ClCompile Include="SettingsConstants.cpp" />
<ClCompile Include="ThemeHelper.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="ThemeScheduler.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="WinHookEventIDs.cpp" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="LightSwitchService.rc" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="LightSwitchSettings.h" />
<ClInclude Include="SettingsConstants.h" />
<ClInclude Include="SettingsObserver.h" />
<ClInclude Include="ThemeHelper.h" />
<ClInclude Include="ThemeScheduler.h">
<ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">false</ExcludedFromBuild>
</ClInclude>
<ClInclude Include="WinHookEventIDs.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<Import Project="..\..\..\..\deps\spdlog.props" /> <Import Project="..\..\..\..\deps\spdlog.props" />
<ImportGroup Label="ExtensionTargets"> <ImportGroup Label="ExtensionTargets">
<Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" />
</ImportGroup> </ImportGroup>
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>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}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project> </Project>

View File

@@ -24,15 +24,6 @@
<ClCompile Include="ThemeHelper.cpp"> <ClCompile Include="ThemeHelper.cpp">
<Filter>Source Files</Filter> <Filter>Source Files</Filter>
</ClCompile> </ClCompile>
<ClCompile Include="..\..\..\common\SettingsAPI\settings_helpers.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\..\..\common\SettingsAPI\settings_objects.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\..\..\common\SettingsAPI\FileWatcher.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="LightSwitchSettings.cpp"> <ClCompile Include="LightSwitchSettings.cpp">
<Filter>Source Files</Filter> <Filter>Source Files</Filter>
</ClCompile> </ClCompile>
@@ -43,9 +34,6 @@
<Filter>Source Files</Filter> <Filter>Source Files</Filter>
</ClCompile> </ClCompile>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ClInclude Include="ThemeScheduler.h"> <ClInclude Include="ThemeScheduler.h">
<Filter>Header Files</Filter> <Filter>Header Files</Filter>
@@ -69,4 +57,9 @@
<ItemGroup> <ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" /> <Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ResourceCompile Include="LightSwitchService.rc">
<Filter>Resource Files</Filter>
</ResourceCompile>
</ItemGroup>
</Project> </Project>

View File

@@ -2,6 +2,13 @@
// //
#include "resource.h" #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 #define APSTUDIO_READONLY_SYMBOLS
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
// //
@@ -68,8 +75,8 @@ APPICON ICON "appicon.ico"
// //
VS_VERSION_INFO VERSIONINFO VS_VERSION_INFO VERSIONINFO
FILEVERSION 9,10,0,0 FILEVERSION FILE_VERSION
PRODUCTVERSION 9,10,0,0 PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK 0x3fL FILEFLAGSMASK 0x3fL
#ifdef _DEBUG #ifdef _DEBUG
FILEFLAGS 0x1L FILEFLAGS 0x1L
@@ -84,14 +91,14 @@ BEGIN
BEGIN BEGIN
BLOCK "040904b0" BLOCK "040904b0"
BEGIN BEGIN
VALUE "CompanyName", "Microsoft Corporation" VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", "Sysinternals Screen Magnifier" VALUE "FileDescription", FILE_DESCRIPTION
VALUE "FileVersion", "9.10" VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", "ZoomIt" VALUE "InternalName", INTERNAL_NAME
VALUE "LegalCopyright", "Copyright (C) Microsoft Corporation. All rights reserved." VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", "PowerToys.ZoomIt.exe" VALUE "OriginalFilename", ORIGINAL_FILENAME
VALUE "ProductName", "PowerToys Sysinternals ZoomIt" VALUE "ProductName", ZOOMIT_PRODUCT_NAME
VALUE "ProductVersion", "9.10" VALUE "ProductVersion", PRODUCT_VERSION_STRING
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
@@ -114,8 +121,8 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
BEGIN BEGIN
DEFPUSHBUTTON "OK",IDOK,166,306,50,14 DEFPUSHBUTTON "OK",IDOK,166,306,50,14
PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14
LTEXT "ZoomIt v9.01",IDC_VERSION,42,7,73,10 LTEXT "ZoomIt v9.10",IDC_VERSION,42,7,73,10
LTEXT "Copyright <EFBFBD> 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8
CONTROL "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",IDC_LINK, CONTROL "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",IDC_LINK,
"SysLink",WS_TABSTOP,42,26,150,9 "SysLink",WS_TABSTOP,42,26,150,9
ICON "APPICON",IDC_STATIC,12,9,20,20 ICON "APPICON",IDC_STATIC,12,9,20,20

View File

@@ -281,21 +281,7 @@ namespace Awake.Core
TimeSpan remainingTime = expireAt - DateTimeOffset.Now; TimeSpan remainingTime = expireAt - DateTimeOffset.Now;
Observable.Timer(remainingTime).Subscribe( Observable.Timer(remainingTime).Subscribe(
_ => _ => HandleTimerCompletion("expirable"),
{
Logger.LogInfo("Completed expirable keep-awake.");
CancelExistingThread();
if (IsUsingPowerToysConfig)
{
SetPassiveKeepAwake();
}
else
{
Logger.LogInfo("Exiting after expirable keep awake.");
CompleteExit(Environment.ExitCode);
}
},
_tokenSource.Token); _tokenSource.Token);
} }
@@ -348,49 +334,46 @@ namespace Awake.Core
SetModeShellIcon(); SetModeShellIcon();
ulong desiredDuration = (ulong)seconds * 1000; var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds);
ulong targetDuration = Math.Min(desiredDuration, uint.MaxValue - 1) / 1000;
if (desiredDuration > uint.MaxValue) Observable.Interval(TimeSpan.FromSeconds(1))
{ .Select(_ => targetExpiryTime - DateTimeOffset.Now)
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"); .TakeWhile(remaining => remaining.TotalSeconds > 0)
} .Subscribe(
remainingTimeSpan =>
IObservable<long> timerObservable = Observable.Timer(TimeSpan.FromSeconds(targetDuration));
IObservable<long> intervalObservable = Observable.Interval(TimeSpan.FromSeconds(1)).TakeUntil(timerObservable);
IObservable<long> combinedObservable = Observable.CombineLatest(intervalObservable, timerObservable.StartWith(0), (elapsedSeconds, _) => elapsedSeconds + 1);
combinedObservable.Subscribe(
elapsedSeconds =>
{
TimeRemaining = (uint)targetDuration - (uint)elapsedSeconds;
if (TimeRemaining >= 0)
{ {
TimeRemaining = (uint)remainingTimeSpan.TotalSeconds;
TrayHelper.SetShellIcon( TrayHelper.SetShellIcon(
TrayHelper.WindowHandle, 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, TrayHelper.TimedIcon,
TrayIconAction.Update); TrayIconAction.Update);
} },
}, _ => HandleTimerCompletion("timed"),
() => _tokenSource.Token);
{ }
Logger.LogInfo("Completed timed thread.");
CancelExistingThread();
if (IsUsingPowerToysConfig) /// <summary>
{ /// Handles the common logic that should execute when a keep-awake timer completes. Resets
// If we're using PowerToys settings, we need to make sure that /// the application state to Passive if configured; otherwise it exits.
// we just switch over the Passive Keep-Awake. /// </summary>
SetPassiveKeepAwake(); private static void HandleTimerCompletion(string timerType)
} {
else Logger.LogInfo($"Completed {timerType} keep-awake.");
{ CancelExistingThread();
Logger.LogInfo("Exiting after timed keep-awake.");
CompleteExit(Environment.ExitCode); if (IsUsingPowerToysConfig)
} {
}, // If running under PowerToys settings, just revert to the default Passive state.
_tokenSource.Token); SetPassiveKeepAwake();
}
else
{
// If running as a standalone process, exit cleanly.
Logger.LogInfo($"Exiting after {timerType} keep-awake.");
CompleteExit(Environment.ExitCode);
}
} }
/// <summary> /// <summary>

View File

@@ -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;
}
/// <summary>
/// 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.
/// </summary>
public static string Unquote(string? text)
{
return string.IsNullOrWhiteSpace(text) ? (text ?? string.Empty) : text.Trim().Trim('"');
}
/// <summary>
/// Quick heuristic to determine if the string looks like a Windows file path (UNC or drive-letter based).
/// </summary>
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] == ':';
}
/// <summary>
/// Validates path syntax without performing any I/O by using Path.GetFullPath.
/// </summary>
public static bool HasValidPathSyntax(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
_ = Path.GetFullPath(path);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Checks if a string represents a valid Windows file path (local or network)
/// using fast syntax validation only. Reuses LooksLikeFilePath and HasValidPathSyntax.
/// </summary>
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;
}
}
}

View File

@@ -12,4 +12,8 @@ MonitorFromWindow
SHOW_WINDOW_CMD SHOW_WINDOW_CMD
ShellExecuteEx ShellExecuteEx
SEE_MASK_INVOKEIDLIST SEE_MASK_INVOKEIDLIST
GetFileAttributes
FILE_FLAGS_AND_ATTRIBUTES
INVALID_FILE_ATTRIBUTES

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Input;
using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels; namespace Microsoft.CmdPal.Core.ViewModels;
@@ -11,6 +13,13 @@ public partial class DetailsLinkViewModel(
IDetailsElement _detailsElement, IDetailsElement _detailsElement,
WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context) WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context)
{ {
private static readonly string[] _initProperties = [
nameof(Text),
nameof(Link),
nameof(IsLink),
nameof(IsText),
nameof(NavigateCommand)];
private readonly ExtensionObject<IDetailsLink> _dataModel = private readonly ExtensionObject<IDetailsLink> _dataModel =
new(_detailsElement.Data as IDetailsLink); new(_detailsElement.Data as IDetailsLink);
@@ -22,6 +31,8 @@ public partial class DetailsLinkViewModel(
public bool IsText => !IsLink; public bool IsText => !IsLink;
public RelayCommand? NavigateCommand { get; private set; }
public override void InitializeProperties() public override void InitializeProperties()
{ {
base.InitializeProperties(); base.InitializeProperties();
@@ -38,9 +49,18 @@ public partial class DetailsLinkViewModel(
Text = Link.ToString(); Text = Link.ToString();
} }
UpdateProperty(nameof(Text)); if (Link is not null)
UpdateProperty(nameof(Link)); {
UpdateProperty(nameof(IsLink)); // Custom command to open a link in the default browser or app,
UpdateProperty(nameof(IsText)); // 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);
} }
} }

View File

@@ -265,6 +265,9 @@ public partial class ShellViewModel : ObservableObject,
throw new NotSupportedException(); throw new NotSupportedException();
} }
// Clear command bar, ViewModel initialization can already set new commands if it wants to
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
// Kick off async loading of our ViewModel // Kick off async loading of our ViewModel
LoadPageViewModelAsync(pageViewModel, navigationToken) LoadPageViewModelAsync(pageViewModel, navigationToken)
.ContinueWith( .ContinueWith(
@@ -275,9 +278,6 @@ public partial class ShellViewModel : ObservableObject,
{ {
newCts.Dispose(); newCts.Dispose();
} }
// When we're done loading the page, then update the command bar to match
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
}, },
navigationToken, navigationToken,
TaskContinuationOptions.None, TaskContinuationOptions.None,

View File

@@ -10,6 +10,8 @@ using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps; 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.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
@@ -36,6 +38,7 @@ public partial class MainListPage : DynamicListPage,
private List<Scored<IListItem>>? _filteredItems; private List<Scored<IListItem>>? _filteredItems;
private List<Scored<IListItem>>? _filteredApps; private List<Scored<IListItem>>? _filteredApps;
private List<Scored<IListItem>>? _fallbackItems; private List<Scored<IListItem>>? _fallbackItems;
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
private bool _includeApps; private bool _includeApps;
private bool _filteredItemsIncludesApps; private bool _filteredItemsIncludesApps;
private int _appResultLimit = 10; private int _appResultLimit = 10;
@@ -160,7 +163,7 @@ public partial class MainListPage : DynamicListPage,
{ {
lock (_tlcManager.TopLevelCommands) lock (_tlcManager.TopLevelCommands)
{ {
List<Scored<IListItem>> limitedApps = new List<Scored<IListItem>>(); var limitedApps = new List<Scored<IListItem>>();
// Fuzzy matching can produce a lot of results, so we want to limit the // 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. // 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<Scored<IListItem>>() var items = Enumerable.Empty<Scored<IListItem>>()
.Concat(_filteredItems is not null ? _filteredItems : []) .Concat(_filteredItems is not null ? _filteredItems : [])
.Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
.Concat(limitedApps) .Concat(limitedApps)
.OrderByDescending(o => o.Score) .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) public override void UpdateSearchText(string oldSearch, string newSearch)
{ {
var timer = new Stopwatch(); var timer = new Stopwatch();
@@ -216,8 +228,7 @@ public partial class MainListPage : DynamicListPage,
lock (_tlcManager.TopLevelCommands) lock (_tlcManager.TopLevelCommands)
{ {
_filteredItemsIncludesApps = _includeApps; _filteredItemsIncludesApps = _includeApps;
_filteredItems = null; ClearResults();
_filteredApps = null;
} }
} }
@@ -233,7 +244,36 @@ public partial class MainListPage : DynamicListPage,
var commands = _tlcManager.TopLevelCommands; var commands = _tlcManager.TopLevelCommands;
lock (commands) lock (commands)
{ {
UpdateFallbacks(SearchText, commands.ToImmutableArray(), token); if (token.IsCancellationRequested)
{
return;
}
// prefilter fallbacks
var specialFallbacks = new List<TopLevelViewModel>(_specialFallbacks.Length);
var commonFallbacks = new List<TopLevelViewModel>();
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) if (token.IsCancellationRequested)
{ {
@@ -244,9 +284,7 @@ public partial class MainListPage : DynamicListPage,
if (string.IsNullOrEmpty(newSearch)) if (string.IsNullOrEmpty(newSearch))
{ {
_filteredItemsIncludesApps = _includeApps; _filteredItemsIncludesApps = _includeApps;
_filteredItems = null; ClearResults();
_filteredApps = null;
_fallbackItems = null;
RaiseItemsChanged(commands.Count); RaiseItemsChanged(commands.Count);
return; return;
} }
@@ -255,17 +293,13 @@ public partial class MainListPage : DynamicListPage,
// re-use previous results. Reset _filteredItems, and keep er moving. // re-use previous results. Reset _filteredItems, and keep er moving.
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)) if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
{ {
_filteredItems = null; ClearResults();
_filteredApps = null;
_fallbackItems = null;
} }
// If the internal state has changed, reset _filteredItems to reset the list. // If the internal state has changed, reset _filteredItems to reset the list.
if (_filteredItemsIncludesApps != _includeApps) if (_filteredItemsIncludesApps != _includeApps)
{ {
_filteredItems = null; ClearResults();
_filteredApps = null;
_fallbackItems = null;
} }
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
@@ -273,9 +307,9 @@ public partial class MainListPage : DynamicListPage,
return; return;
} }
IEnumerable<IListItem> newFilteredItems = Enumerable.Empty<IListItem>(); var newFilteredItems = Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newFallbacks = Enumerable.Empty<IListItem>(); var newFallbacks = Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newApps = Enumerable.Empty<IListItem>(); var newApps = Enumerable.Empty<IListItem>();
if (_filteredItems is not null) if (_filteredItems is not null)
{ {
@@ -311,15 +345,12 @@ public partial class MainListPage : DynamicListPage,
// with a list of all our commands & apps. // with a list of all our commands & apps.
if (!newFilteredItems.Any() && !newApps.Any()) if (!newFilteredItems.Any() && !newApps.Any())
{ {
// We're going to start over with our fallbacks newFilteredItems = commands.Where(s => !s.IsFallback);
newFallbacks = Enumerable.Empty<IListItem>();
newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId));
// Fallbacks are always included in the list, even if they // Fallbacks are always included in the list, even if they
// don't match the search text. But we don't want to // don't match the search text. But we don't want to
// consider them when filtering the list. // consider them when filtering the list.
newFallbacks = commands.Where(s => s.IsFallback && !_specialFallbacks.Contains(s.CommandProviderId)); newFallbacks = commonFallbacks;
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
{ {
@@ -330,7 +361,20 @@ public partial class MainListPage : DynamicListPage,
if (_includeApps) 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) if (token.IsCancellationRequested)
@@ -339,8 +383,25 @@ public partial class MainListPage : DynamicListPage,
} }
} }
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands!;
Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
// Produce a list of everything that matches the current filter. // Produce a list of everything that matches the current filter.
_filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, ScoreTopLevelItem)]; _filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, scoreItem)];
if (token.IsCancellationRequested)
{
return;
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && _specialFallbacks.Contains(s.CommandProviderId));
if (token.IsCancellationRequested)
{
return;
}
_scoredFallbackItems = ListHelpers.FilterListWithScores<IListItem>(newFallbacksForScoring ?? [], SearchText, scoreItem);
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
{ {
@@ -358,7 +419,7 @@ public partial class MainListPage : DynamicListPage,
// Produce a list of filtered apps with the appropriate limit // Produce a list of filtered apps with the appropriate limit
if (newApps.Any()) if (newApps.Any())
{ {
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, ScoreTopLevelItem); var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
{ {
@@ -425,7 +486,7 @@ public partial class MainListPage : DynamicListPage,
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the // Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't // fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first. // _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; var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
@@ -501,10 +562,9 @@ public partial class MainListPage : DynamicListPage,
// here we add the recent command weight boost // here we add the recent command weight boost
// //
// Otherwise something like `x` will still match everything you've run before // Otherwise something like `x` will still match everything you've run before
var finalScore = matchSomething; var finalScore = matchSomething * 10;
if (matchSomething > 0) if (matchSomething > 0)
{ {
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands;
var recentWeightBoost = history.GetCommandHistoryWeight(id); var recentWeightBoost = history.GetCommandHistoryWeight(id);
finalScore += recentWeightBoost; finalScore += recentWeightBoost;
} }
@@ -521,7 +581,7 @@ public partial class MainListPage : DynamicListPage,
AppStateModel.SaveState(state); AppStateModel.SaveState(state);
} }
private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
{ {
if (topLevelOrAppItem is TopLevelViewModel topLevel) if (topLevelOrAppItem is TopLevelViewModel topLevel)
{ {

View File

@@ -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")]

View File

@@ -7,7 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels; namespace Microsoft.CmdPal.UI.ViewModels;
public partial class RecentCommandsManager : ObservableObject public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager
{ {
[JsonInclude] [JsonInclude]
internal List<HistoryItem> History { get; set; } = []; internal List<HistoryItem> History { get; set; } = [];
@@ -80,3 +80,10 @@ public partial class RecentCommandsManager : ObservableObject
} }
} }
} }
public interface IRecentCommandsManager
{
int GetCommandHistoryWeight(string commandId);
void AddHistoryItem(string commandId);
}

View File

@@ -14,7 +14,6 @@
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI" xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
PreviewKeyDown="UserControl_PreviewKeyDown" PreviewKeyDown="UserControl_PreviewKeyDown"
mc:Ignorable="d"> mc:Ignorable="d">
@@ -22,7 +21,7 @@
<ResourceDictionary> <ResourceDictionary>
<cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" /> <cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Thickness x:Key="DefaultContextMenuItemPadding">12,8,12,8</Thickness>
<cmdpalUI:ContextItemTemplateSelector <cmdpalUI:ContextItemTemplateSelector
x:Key="ContextItemTemplateSelector" x:Key="ContextItemTemplateSelector"
Critical="{StaticResource CriticalContextMenuViewModelTemplate}" Critical="{StaticResource CriticalContextMenuViewModelTemplate}"
@@ -31,7 +30,7 @@
<!-- Template for context items in the context item menu --> <!-- Template for context items in the context item menu -->
<DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel"> <DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"> <Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="32" /> <ColumnDefinition Width="32" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -71,7 +70,7 @@
<!-- Template for context items flagged as critical --> <!-- Template for context items flagged as critical -->
<DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel"> <DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"> <Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="32" /> <ColumnDefinition Width="32" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -114,7 +113,7 @@
<DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel"> <DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
<Rectangle <Rectangle
Height="1" Height="1"
Margin="-16,-12,-12,-12" Margin="0,2,0,2"
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" /> Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
</DataTemplate> </DataTemplate>
</ResourceDictionary> </ResourceDictionary>
@@ -125,35 +124,39 @@
<RowDefinition /> <RowDefinition />
<RowDefinition /> <RowDefinition />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<ListView
<StackPanel x:Name="CommandsPanel"> x:Name="CommandsDropdown"
<ListView MinWidth="248"
x:Name="CommandsDropdown" Margin="0,4,0,2"
MinWidth="248" IsItemClickEnabled="True"
IsItemClickEnabled="True" ItemClick="CommandsDropdown_ItemClick"
ItemClick="CommandsDropdown_ItemClick" ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}" ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown" SelectionMode="Single">
SelectionMode="Single"> <ListView.ItemContainerStyle>
<ListView.ItemContainerStyle> <Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem"> <Setter Property="MinHeight" Value="0" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="Padding" Value="0" />
<Setter Property="Padding" Value="12,8" /> </Style>
</Style> </ListView.ItemContainerStyle>
</ListView.ItemContainerStyle> <ListView.ItemContainerTransitions>
<ListView.ItemContainerTransitions> <TransitionCollection />
<TransitionCollection /> </ListView.ItemContainerTransitions>
</ListView.ItemContainerTransitions> </ListView>
</ListView> <Border BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}" BorderThickness="0,0,0,1" />
</StackPanel>
<TextBox <TextBox
x:Name="ContextFilterBox" x:Name="ContextFilterBox"
x:Uid="ContextFilterBox" x:Uid="ContextFilterBox"
Margin="4" Margin="0"
Padding="10,7,6,8"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8, 8, 0, 0"
IsTextScaleFactorEnabled="True" IsTextScaleFactorEnabled="True"
KeyDown="ContextFilterBox_KeyDown" KeyDown="ContextFilterBox_KeyDown"
PreviewKeyDown="ContextFilterBox_PreviewKeyDown" PreviewKeyDown="ContextFilterBox_PreviewKeyDown"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="ContextFilterBox_TextChanged" /> TextChanged="ContextFilterBox_TextChanged" />
<VisualStateManager.VisualStateGroups> <VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ContextMenuOrder"> <VisualStateGroup x:Name="ContextMenuOrder">
@@ -162,9 +165,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" /> <ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" />
</VisualState.StateTriggers> </VisualState.StateTriggers>
<VisualState.Setters> <VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="1" /> <Setter Target="CommandsDropdown.(Grid.Row)" Value="1" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="0" /> <Setter Target="ContextFilterBox.(Grid.Row)" Value="0" />
<Setter Target="CommandsDropdown.Margin" Value="0, 0, 0, 4" /> <Setter Target="CommandsDropdown.Margin" Value="0, 3, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="8, 8, 0, 0" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-1" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="FilterOnBottom"> <VisualState x:Name="FilterOnBottom">
@@ -172,9 +177,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" /> <ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" />
</VisualState.StateTriggers> </VisualState.StateTriggers>
<VisualState.Setters> <VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="0" /> <Setter Target="CommandsDropdown.(Grid.Row)" Value="0" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="1" /> <Setter Target="ContextFilterBox.(Grid.Row)" Value="1" />
<Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 0" /> <Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="0, 0, 8, 8" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-2" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>

View File

@@ -78,6 +78,12 @@ public sealed partial class ContentPage : Page,
WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this); WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this);
// Clean-up event listeners // Clean-up event listeners
if (e.NavigationMode != NavigationMode.New)
{
ViewModel?.SafeCleanup();
CleanupHelper.Cleanup(this);
}
ViewModel = null; ViewModel = null;
} }

View File

@@ -108,6 +108,7 @@
Visibility="{x:Bind IsText, Mode=OneWay}" /> Visibility="{x:Bind IsText, Mode=OneWay}" />
<HyperlinkButton <HyperlinkButton
Padding="0" Padding="0"
Command="{x:Bind NavigateCommand, Mode=OneWay}"
NavigateUri="{x:Bind Link, Mode=OneWay}" NavigateUri="{x:Bind Link, Mode=OneWay}"
Visibility="{x:Bind IsLink, Mode=OneWay}"> Visibility="{x:Bind IsLink, Mode=OneWay}">
<TextBlock Text="{x:Bind Text, Mode=OneWay}" TextWrapping="Wrap" /> <TextBlock Text="{x:Bind Text, Mode=OneWay}" TextWrapping="Wrap" />

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Microsoft.CmdPal.UI.ViewModels.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="MSTest" />
<PackageReference Include="WyHash" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<string>? 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<string>
{
"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<object, IPropChangedEventArgs>? 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<ListItemMock> 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<ListItemMock>
{
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<ListItemMock>
{
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<ListItemMock>().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<ListItemMock>
{
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<ListItemMock>().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<ListItemMock> CreateMockHistoryItems()
{
var items = new List<ListItemMock>
{
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<ListItemMock>? items = null)
{
var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse<ListItemMock>().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<ScoredItem> TieScoresToMatches(List<ListItemMock> items, List<int> 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<ScoredItem> GetMatches(IEnumerable<ScoredItem> scoredItems)
{
var matches = scoredItems
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ToList();
return matches;
}
private static IEnumerable<ScoredItem> GetMatches(List<ListItemMock> items, List<int> 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);
}
}
}

View File

@@ -13,7 +13,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Apps.Programs; 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"); private static readonly Tag _appTag = new("App");

View File

@@ -103,7 +103,8 @@ public class UWPApplication : IUWPApplication
new CommandContextItem( new CommandContextItem(
new OpenFileCommand(Location) new OpenFileCommand(Location)
{ {
Name = Resources.open_containing_folder, Icon = new("\uE838"),
Name = Resources.open_location,
}) })
{ {
RequestedShortcut = KeyChords.OpenFileLocation, RequestedShortcut = KeyChords.OpenFileLocation,

View File

@@ -207,7 +207,10 @@ public class Win32Program : IProgram
}); });
commands.Add(new CommandContextItem( commands.Add(new CommandContextItem(
new OpenFileCommand(ParentDirectory)) new ShowFileInFolderCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath)
{
Name = Resources.open_location,
})
{ {
RequestedShortcut = KeyChords.OpenFileLocation, RequestedShortcut = KeyChords.OpenFileLocation,
}); });

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
// class via a tool like ResGen or Visual Studio. // class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen // To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project. // 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.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources { internal class Resources {
@@ -241,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Open location. /// Looks up a localized string similar to Open file location.
/// </summary> /// </summary>
internal static string open_location { internal static string open_location {
get { get {

View File

@@ -161,7 +161,7 @@
<value>File</value> <value>File</value>
</data> </data>
<data name="open_location" xml:space="preserve"> <data name="open_location" xml:space="preserve">
<value>Open location</value> <value>Open file location</value>
</data> </data>
<data name="copy_path" xml:space="preserve"> <data name="copy_path" xml:space="preserve">
<value>Copy path</value> <value>Copy path</value>
@@ -237,4 +237,4 @@
<data name="limit_none" xml:space="preserve"> <data name="limit_none" xml:space="preserve">
<value>Unlimited</value> <value>Unlimited</value>
</data> </data>
</root> </root>

View File

@@ -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;
/// <summary>
/// Abstraction for providers that can extract metadata and offer actions for a clipboard context.
/// </summary>
internal interface IClipboardMetadataProvider
{
/// <summary>
/// Gets the section title to show in the UI for this provider's metadata.
/// </summary>
string SectionTitle { get; }
/// <summary>
/// Returns true if this provider can produce metadata for the given item.
/// </summary>
bool CanHandle(ClipboardItem item);
/// <summary>
/// Returns metadata elements for the UI. Caller decides section grouping.
/// </summary>
IEnumerable<DetailsElement> GetDetails(ClipboardItem item);
/// <summary>
/// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication.
/// </summary>
IEnumerable<ProviderAction> GetActions(ClipboardItem item);
}

View File

@@ -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);

View File

@@ -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
{
/// <summary>
/// Reads image metadata from a RandomAccessStreamReference without decoding pixels.
/// Returns oriented dimensions (EXIF rotation applied).
/// </summary>
public static async Task<ImageMetadata> 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;
}
}
}

View File

@@ -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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> GetActions(ClipboardItem item) => [];
}

View File

@@ -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,
}

View File

@@ -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;
/// <summary>
/// Represents an action exposed by a metadata provider.
/// </summary>
/// <param name="Id">Unique identifier for de-duplication (case-insensitive).</param>
/// <param name="Action">The actual context menu item to be shown.</param>
internal readonly record struct ProviderAction(string Id, CommandContextItem Action);

View File

@@ -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;
/// <summary>
/// Utility for formatting byte sizes to a human-readable string.
/// </summary>
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);
}
}

View File

@@ -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;
/// <summary>
/// Detects when text content is a valid existing file or directory path and exposes basic metadata.
/// </summary>
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<DetailsElement> GetDetails(ClipboardItem item)
{
ArgumentNullException.ThrowIfNull(item);
var result = new List<DetailsElement>();
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<ProviderAction> 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);
}
}
}

View File

@@ -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}";
}
}

View File

@@ -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, "(?<!\r)\n").Count;
var crCount = Regex.Matches(text, "\r(?!\n)").Count;
var endingTypes = (crlfCount > 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();
}

View File

@@ -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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> GetActions(ClipboardItem item) => [];
}

View File

@@ -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;
/// <summary>
/// Detects web links in text and shows normalized URL and key parts.
/// </summary>
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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> 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);
}
}

View File

@@ -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;
/// <summary>
/// Well-known action id constants used to de-duplicate provider actions.
/// </summary>
internal static class WellKnownActionIds
{
public const string Open = "open";
public const string OpenLocation = "openLocation";
public const string CopyPath = "copyPath";
public const string OpenConsole = "openConsole";
}

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.IO; using Microsoft.CmdPal.Core.Common.Helpers;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.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) // Check if it's a valid file path (local or network)
if (IsValidFilePath(url)) if (PathHelper.IsValidFilePath(url))
{ {
return true; return true;
} }
@@ -78,7 +78,7 @@ internal static class UrlHelper
url = url.Trim(); url = url.Trim();
// If it's a valid file path, convert to file:// URI // 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 try
{ {
@@ -105,40 +105,4 @@ internal static class UrlHelper
return url; return url;
} }
/// <summary>
/// Checks if a string represents a valid file path (local or network)
/// </summary>
/// <param name="path">The string to check</param>
/// <returns>True if the string is a valid file path, false otherwise</returns>
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;
}
}
} }

View File

@@ -10,6 +10,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -9,6 +9,7 @@ using System.Linq;
using Microsoft.CmdPal.Common.Commands; using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Commands;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,13 +17,20 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models;
internal sealed partial class ClipboardListItem : ListItem 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 SettingsManager _settingsManager;
private readonly ClipboardItem _item; private readonly ClipboardItem _item;
private readonly CommandContextItem _deleteContextMenuItem; private readonly CommandContextItem _deleteContextMenuItem;
private readonly CommandContextItem? _pasteCommand; private readonly CommandContextItem? _pasteCommand;
private readonly CommandContextItem? _copyCommand; private readonly CommandContextItem? _copyCommand;
private readonly CommandContextItem? _openUrlCommand;
private readonly Lazy<Details> _lazyDetails; private readonly Lazy<Details> _lazyDetails;
public override IDetails? Details public override IDetails? Details
@@ -73,26 +81,11 @@ internal sealed partial class ClipboardListItem : ListItem
_pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager));
_copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); _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 else
{ {
_pasteCommand = null; _pasteCommand = null;
_copyCommand = null; _copyCommand = null;
_openUrlCommand = null;
} }
RefreshCommands(); RefreshCommands();
@@ -163,27 +156,74 @@ internal sealed partial class ClipboardListItem : ListItem
commands.Add(firstCommand); commands.Add(firstCommand);
} }
if (_openUrlCommand != null) var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var temp = new List<IContextItem>();
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(new Separator());
commands.Add(_deleteContextMenuItem); commands.Add(_deleteContextMenuItem);
return commands.ToArray(); return [.. commands];
} }
private Details CreateDetails() private Details CreateDetails()
{ {
IDetailsElement[] metadata = List<IDetailsElement> metadata = [];
[
new DetailsElement foreach (var provider in MetadataProviders)
{
if (provider.CanHandle(_item))
{ {
Key = "Copied on", var details = provider.GetDetails(_item);
Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), 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) if (_item.IsImage)
{ {
@@ -193,7 +233,7 @@ internal sealed partial class ClipboardListItem : ListItem
{ {
Title = _item.GetDataType(), Title = _item.GetDataType(),
HeroImage = heroImage, HeroImage = heroImage,
Metadata = metadata, Metadata = [.. metadata],
}; };
} }
@@ -203,7 +243,7 @@ internal sealed partial class ClipboardListItem : ListItem
{ {
Title = _item.GetDataType(), Title = _item.GetDataType(),
Body = $"```text\n{_item.Content}\n```", Body = $"```text\n{_item.Content}\n```",
Metadata = metadata, Metadata = [.. metadata],
}; };
} }

View File

@@ -16,7 +16,6 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
private readonly Action<string>? _addToHistory; private readonly Action<string>? _addToHistory;
private readonly ITelemetryService _telemetryService; private readonly ITelemetryService _telemetryService;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentUpdateTask;
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService) public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService)
: base( : base(
@@ -40,44 +39,22 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
try try
{ {
// Save the latest update task DoUpdateQuery(query, cancellationToken);
_currentUpdateTask = DoUpdateQueryAsync(query, cancellationToken);
}
catch (OperationCanceledException)
{
// DO NOTHING HERE
return;
} }
catch (Exception) catch (Exception)
{ {
// Handle other exceptions // Handle other exceptions
return; return;
} }
// Await the task to ensure only the latest one gets processed
_ = ProcessUpdateResultsAsync(_currentUpdateTask);
} }
private async Task ProcessUpdateResultsAsync(Task updateTask) private void DoUpdateQuery(string query, CancellationToken cancellationToken)
{
try
{
await updateTask;
}
catch (OperationCanceledException)
{
// Handle cancellation gracefully
}
catch (Exception)
{
// Handle other exceptions
}
}
private async Task DoUpdateQueryAsync(string query, CancellationToken cancellationToken)
{ {
// Check for cancellation at the start // Check for cancellation at the start
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
var searchText = query.Trim(); var searchText = query.Trim();
Expand(ref searchText); Expand(ref searchText);
@@ -105,22 +82,8 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var timeoutToken = combinedCts.Token; var timeoutToken = combinedCts.Token;
// Use Task.Run with timeout for file system operations exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath, cancellationToken);
var fileSystemTask = Task.Run( pathIsDir = Directory.Exists(exe);
() =>
{
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;
} }
catch (TimeoutException) catch (TimeoutException)
{ {
@@ -139,7 +102,10 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
} }
// Check for cancellation before updating UI properties // Check for cancellation before updating UI properties
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
if (exeExists) if (exeExists)
{ {
@@ -172,7 +138,10 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
} }
// Final cancellation check // Final cancellation check
cancellationToken.ThrowIfCancellationRequested(); if (cancellationToken.IsCancellationRequested)
{
return;
}
} }
public void Dispose() public void Dispose()

View File

@@ -324,7 +324,7 @@ internal sealed class Window
// Correct the process data if the window belongs to a uwp app hosted by 'ApplicationFrameHost.exe' // 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.) // (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(() => new Task(() =>
{ {

View File

@@ -23,7 +23,7 @@ internal sealed class WindowProcess
/// <summary> /// <summary>
/// An indicator if the window belongs to an 'Universal Windows Platform (UWP)' process /// An indicator if the window belongs to an 'Universal Windows Platform (UWP)' process
/// </summary> /// </summary>
private readonly bool _isUwpAppFrameHost; private bool _isUwpAppFrameHost;
/// <summary> /// <summary>
/// Gets the id of the process /// Gets the id of the process
@@ -126,6 +126,14 @@ internal sealed class WindowProcess
get; private set; get; private set;
} }
/// <summary>
/// Gets the type of the process (UWP app, packaged Win32 app, unpackaged Win32 app, ...).
/// </summary>
internal ProcessPackagingInfo ProcessType
{
get; private set;
}
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="WindowProcess"/> class. /// Initializes a new instance of the <see cref="WindowProcess"/> class.
/// </summary> /// </summary>
@@ -134,13 +142,10 @@ internal sealed class WindowProcess
/// <param name="name">New process name.</param> /// <param name="name">New process name.</param>
internal WindowProcess(uint pid, uint tid, string name) internal WindowProcess(uint pid, uint tid, string name)
{ {
ProcessType = ProcessPackagingInfo.Empty;
UpdateProcessInfo(pid, tid, name); UpdateProcessInfo(pid, tid, name);
ProcessType = ProcessPackagingInspector.Inspect((int)pid);
_isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase);
} }
public ProcessPackagingInfo ProcessType { get; private set; }
/// <summary> /// <summary>
/// Updates the process information of the <see cref="WindowProcess"/> instance. /// Updates the process information of the <see cref="WindowProcess"/> instance.
/// </summary> /// </summary>
@@ -156,6 +161,10 @@ internal sealed class WindowProcess
// Process can be elevated only if process id is not 0 (Dummy value on error) // Process can be elevated only if process id is not 0 (Dummy value on error)
IsFullAccessDenied = (pid != 0) ? TestProcessAccessUsingAllAccessFlag(pid) : false; IsFullAccessDenied = (pid != 0) ? TestProcessAccessUsingAllAccessFlag(pid) : false;
// Update process type
ProcessType = ProcessPackagingInspector.Inspect((int)pid);
_isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase);
} }
/// <summary> /// <summary>

View File

@@ -11,4 +11,13 @@ internal sealed record ProcessPackagingInfo(
bool IsAppContainer, bool IsAppContainer,
string? PackageFullName, string? PackageFullName,
int? LastError int? LastError
); )
{
public static ProcessPackagingInfo Empty { get; } = new(
Pid: 0,
Kind: ProcessPackagingKind.Unknown,
HasPackageIdentity: false,
IsAppContainer: false,
PackageFullName: null,
LastError: null);
}

View File

@@ -11,6 +11,7 @@
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" />
<PropertyGroup Label="Globals"> <PropertyGroup Label="Globals">
<CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTOptimized>true</CppWinRTOptimized>
@@ -214,7 +215,8 @@
<ImportGroup Label="ExtensionTargets"> <ImportGroup Label="ExtensionTargets">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets')" />
@@ -235,6 +237,8 @@
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets'))" />

View File

@@ -3,14 +3,17 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Settings.UI.Library namespace Settings.UI.Library
{ {
public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable, IHotkeyConfig
{ {
public const string ModuleName = "LightSwitch"; public const string ModuleName = "LightSwitch";
@@ -24,6 +27,21 @@ namespace Settings.UI.Library
[JsonPropertyName("properties")] [JsonPropertyName("properties")]
public LightSwitchProperties Properties { get; set; } public LightSwitchProperties Properties { get; set; }
public HotkeyAccessor[] GetAllHotkeyAccessors()
{
var hotkeyAccessors = new List<HotkeyAccessor>
{
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() public object Clone()
{ {
return new LightSwitchSettings() return new LightSwitchSettings()
@@ -41,6 +59,7 @@ namespace Settings.UI.Library
SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value), SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value),
Latitude = new StringProperty(Properties.Latitude.Value), Latitude = new StringProperty(Properties.Latitude.Value),
Longitude = new StringProperty(Properties.Longitude.Value), Longitude = new StringProperty(Properties.Longitude.Value),
ToggleThemeHotkey = new KeyboardKeysProperty(Properties.ToggleThemeHotkey.Value),
}, },
}; };
} }

View File

@@ -73,6 +73,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
this.InitializeComponent(); this.InitializeComponent();
this.Loaded += LightSwitchPage_Loaded; this.Loaded += LightSwitchPage_Loaded;
this.Loaded += (s, e) => ViewModel.OnPageLoaded();
} }
private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e)

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@@ -20,8 +21,10 @@ using Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.ViewModels namespace Microsoft.PowerToys.Settings.UI.ViewModels
{ {
public partial class LightSwitchViewModel : Observable public partial class LightSwitchViewModel : PageViewModelBase
{ {
protected override string ModuleName => LightSwitchSettings.ModuleName;
private Func<string, int> SendConfigMSG { get; } private Func<string, int> SendConfigMSG { get; }
public ObservableCollection<SearchLocation> SearchLocations { get; } = new(); public ObservableCollection<SearchLocation> SearchLocations { get; } = new();
@@ -35,14 +38,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ForceDarkCommand = new RelayCommand(ForceDarkNow); ForceDarkCommand = new RelayCommand(ForceDarkNow);
AvailableScheduleModes = new ObservableCollection<string> AvailableScheduleModes = new ObservableCollection<string>
{ {
"FixedHours", "FixedHours",
"SunsetToSunrise", "SunsetToSunrise",
}; };
_toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value; _toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value;
} }
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
{
var hotkeysDict = new Dictionary<string, HotkeySettings[]>
{
[ModuleName] = [ToggleThemeActivationShortcut],
};
return hotkeysDict;
}
private void ForceLightNow() private void ForceLightNow()
{ {
Logger.LogInfo("Sending custom action: forceLight"); Logger.LogInfo("Sending custom action: forceLight");
@@ -395,22 +408,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public HotkeySettings ToggleThemeActivationShortcut public HotkeySettings ToggleThemeActivationShortcut
{ {
get => _toggleThemeHotkey; get => ModuleSettings.Properties.ToggleThemeHotkey.Value;
set set
{ {
if (value != _toggleThemeHotkey) if (value != ModuleSettings.Properties.ToggleThemeHotkey.Value)
{ {
if (value == null) if (value == null)
{ {
_toggleThemeHotkey = LightSwitchProperties.DefaultToggleThemeHotkey; ModuleSettings.Properties.ToggleThemeHotkey.Value = LightSwitchProperties.DefaultToggleThemeHotkey;
} }
else else
{ {
_toggleThemeHotkey = value; ModuleSettings.Properties.ToggleThemeHotkey.Value = value;
} }
_moduleSettings.Properties.ToggleThemeHotkey.Value = _toggleThemeHotkey;
NotifyPropertyChanged(); NotifyPropertyChanged();
SendConfigMSG( SendConfigMSG(
@@ -418,7 +430,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}", "{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
LightSwitchSettings.ModuleName, LightSwitchSettings.ModuleName,
JsonSerializer.Serialize(_moduleSettings, (System.Text.Json.Serialization.Metadata.JsonTypeInfo<LightSwitchSettings>)SourceGenerationContextContext.Default.LightSwitchSettings))); JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings)));
} }
} }
} }

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*

View File

@@ -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 <path> -Recurse -Force
git branch -D <branch>
git remote remove <remote>
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 '^(?<r>[^/]+)/'){ $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 <path> -Recurse -Force'
Info ' git branch -D <branch> (if you also want to drop local branch)'
Info ' git remote remove <remote> (if orphan fork remote remains)'
Info ' git worktree prune'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*

View File

@@ -0,0 +1,78 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree for an existing local or remote (origin) branch.
.DESCRIPTION
Normalizes origin/<name> to <name>. 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/<name> 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 <branch>
git worktree add ../RepoName-XX <branch>
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/<name> to <name>
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 ../<Repo>-XX <branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*

View File

@@ -0,0 +1,127 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree from a branch in a personal fork: <ForkUser>:<ForkBranch>.
.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-<user>-<sanitized-branch> or custom alias), and delegates worktree creation/reuse
to shared helpers in WorktreeLib.
.PARAMETER Spec
Fork spec in the form <ForkUser>:<ForkBranch>.
.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-<user>-<sanitized-branch>.
.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/<user>/<repo>.git
git fetch fork-temp
git branch --track fork-<user>-<branch> fork-temp/<branch>
git worktree add ../Repo-XX fork-<user>-<branch>
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 <ForkUser>:<ForkBranch>, 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 <remote> <local>:<remoteBranch>' }
} 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-<user>-<branch> temp-fork/$ForkBranch"
Info ' git worktree add ../<Repo>-XX fork-<user>-<branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*

View File

@@ -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/<number> or issue/<number>-<slug> (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/<num>-<slug> <base>
git worktree add ../Repo-XX issue/<num>-<slug>
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 ../<Repo>-XX $branch"
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -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 checkedout 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 (`<User>:<branch>` 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/<number>-<slug>`. |
| `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-<user>-<sanitized-branch>`.
- Issue branches: `issue/<number>` or `issue/<number>-<slug>`.
## 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 <remote> <branch>`.
| 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 <name> origin/<name>` 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.

151
tools/build/WorktreeLib.ps1 Normal file
View File

@@ -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 '^(?<remote>[^/]+)/.+$') { 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 <path>'
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" }
}