mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-16 11:48:06 +01:00
Merge branch 'main' of https://github.com/microsoft/PowerToys into leilzh/sparse
This commit is contained in:
5
.github/actions/spell-check/expect.txt
vendored
5
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => [];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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) => [];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'))" />
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
tools/build/Delete-Worktree.cmd
Normal file
4
tools/build/Delete-Worktree.cmd
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*
|
||||||
130
tools/build/Delete-Worktree.ps1
Normal file
130
tools/build/Delete-Worktree.ps1
Normal 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
|
||||||
|
}
|
||||||
4
tools/build/New-WorktreeFromBranch.cmd
Normal file
4
tools/build/New-WorktreeFromBranch.cmd
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*
|
||||||
78
tools/build/New-WorktreeFromBranch.ps1
Normal file
78
tools/build/New-WorktreeFromBranch.ps1
Normal 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
|
||||||
|
}
|
||||||
4
tools/build/New-WorktreeFromFork.cmd
Normal file
4
tools/build/New-WorktreeFromFork.cmd
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*
|
||||||
127
tools/build/New-WorktreeFromFork.ps1
Normal file
127
tools/build/New-WorktreeFromFork.ps1
Normal 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
|
||||||
|
}
|
||||||
4
tools/build/New-WorktreeFromIssue.cmd
Normal file
4
tools/build/New-WorktreeFromIssue.cmd
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*
|
||||||
78
tools/build/New-WorktreeFromIssue.ps1
Normal file
78
tools/build/New-WorktreeFromIssue.ps1
Normal 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
|
||||||
|
}
|
||||||
94
tools/build/Worktree-Guidelines.md
Normal file
94
tools/build/Worktree-Guidelines.md
Normal 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 checked‑out branches sharing a single `.git` object store. Benefits:
|
||||||
|
- Fast context switching: no re-clone, no duplicate large binary/object downloads.
|
||||||
|
- Lower disk usage versus multiple full clones.
|
||||||
|
- Keeps each change isolated in its own folder so you can run builds/tests independently.
|
||||||
|
- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean.
|
||||||
|
|
||||||
|
Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations.
|
||||||
|
|
||||||
|
## Scripts Overview
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`<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
151
tools/build/WorktreeLib.ps1
Normal 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" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user