mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-19 02:28:05 +01:00
Compare commits
32 Commits
jay/ls-ui-
...
user/yeela
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd9698073 | ||
|
|
cd5b76c988 | ||
|
|
f44addb29c | ||
|
|
1e3429dd3a | ||
|
|
075bbb46cb | ||
|
|
4aa27316fb | ||
|
|
f55c49e15b | ||
|
|
b06cd9f896 | ||
|
|
3e0d62d101 | ||
|
|
b89237ff94 | ||
|
|
df972447d4 | ||
|
|
668820cf2c | ||
|
|
ca4e8b2986 | ||
|
|
0472e7dc78 | ||
|
|
1f18088afe | ||
|
|
494901b52d | ||
|
|
3e213165a8 | ||
|
|
e04e6a11d1 | ||
|
|
14ff4dbc8c | ||
|
|
a8eb17d21a | ||
|
|
0d5220561d | ||
|
|
ccc31c13ae | ||
|
|
233ca4c05b | ||
|
|
f42d6dbc3d | ||
|
|
466a94eb40 | ||
|
|
26ec8c6bd5 | ||
|
|
8a218860d4 | ||
|
|
e748f31593 | ||
|
|
b6944b432c | ||
|
|
8ce4b635cf | ||
|
|
87af08630a | ||
|
|
55f0bcc441 |
2
.github/actions/spell-check/allow/names.txt
vendored
2
.github/actions/spell-check/allow/names.txt
vendored
@@ -29,8 +29,6 @@ shortcutguide
|
||||
|
||||
# 8LWXpg is user name but user folder causes a flag
|
||||
LWXpg
|
||||
# 0x6f677548 is user name but user folder causes a flag
|
||||
x6f677548
|
||||
Adoumie
|
||||
Advaith
|
||||
alekhyareddy
|
||||
|
||||
4
.github/actions/spell-check/excludes.txt
vendored
4
.github/actions/spell-check/excludes.txt
vendored
@@ -121,6 +121,10 @@
|
||||
^src/modules/MouseWithoutBorders/App/Helper/.*\.resx$
|
||||
^src/modules/MouseWithoutBorders/ModuleInterface/generateSecurityDescriptor\.h$
|
||||
^src/modules/peek/Peek.Common/NativeMethods\.txt$
|
||||
^src/modules/peek/Peek.UITests/TestAssets/4\.qoi$
|
||||
^src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2\.txt$
|
||||
^src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase\.txt$
|
||||
^src/modules/powerrename/PowerRenameUITest/testItems/testCase1\.txt$
|
||||
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
|
||||
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
|
||||
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
|
||||
|
||||
54
.github/actions/spell-check/expect.txt
vendored
54
.github/actions/spell-check/expect.txt
vendored
@@ -26,8 +26,6 @@ ADMINS
|
||||
adml
|
||||
admx
|
||||
advancedpaste
|
||||
advancedpasteui
|
||||
advancedpasteuishortcut
|
||||
advapi
|
||||
advfirewall
|
||||
AFeature
|
||||
@@ -45,7 +43,6 @@ ALLINPUT
|
||||
Allman
|
||||
Allmodule
|
||||
ALLOWUNDO
|
||||
allpc
|
||||
ALLVIEW
|
||||
ALPHATYPE
|
||||
AModifier
|
||||
@@ -136,7 +133,6 @@ bla
|
||||
BLACKFRAME
|
||||
BLENDFUNCTION
|
||||
Blockquotes
|
||||
blogs
|
||||
Blt
|
||||
BLURBEHIND
|
||||
BLURREGION
|
||||
@@ -368,6 +364,7 @@ desktopshorcutinstalled
|
||||
DESKTOPVERTRES
|
||||
devblogs
|
||||
devdocs
|
||||
devenv
|
||||
devmgmt
|
||||
DEVMODE
|
||||
DEVMODEW
|
||||
@@ -511,7 +508,6 @@ FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
FARPROC
|
||||
fesf
|
||||
fff
|
||||
FFFF
|
||||
FILEEXPLORER
|
||||
fileexploreraddons
|
||||
@@ -584,6 +580,7 @@ GETSCREENSAVERRUNNING
|
||||
GETSECKEY
|
||||
GETSTICKYKEYS
|
||||
GETTEXTLENGTH
|
||||
gitmodules
|
||||
GHND
|
||||
GMEM
|
||||
GNumber
|
||||
@@ -671,11 +668,7 @@ Hostx
|
||||
hotfixes
|
||||
hotkeycontrol
|
||||
HOTKEYF
|
||||
hotkeylockmachine
|
||||
hotkeyreconnect
|
||||
hotkeys
|
||||
hotkeyswitch
|
||||
hotkeytoggleeasymouse
|
||||
hotlight
|
||||
hotspot
|
||||
HPAINTBUFFER
|
||||
@@ -734,8 +727,6 @@ IMAGERESIZERCONTEXTMENU
|
||||
IMAGERESIZEREXT
|
||||
imageresizerinput
|
||||
imageresizersettings
|
||||
imagetotext
|
||||
imagetotextshortcut
|
||||
imagingdevices
|
||||
ime
|
||||
imgflip
|
||||
@@ -826,6 +817,7 @@ killrunner
|
||||
kmph
|
||||
kvp
|
||||
Kybd
|
||||
LARGEICON
|
||||
lastcodeanalysissucceeded
|
||||
LASTEXITCODE
|
||||
LAYOUTRTL
|
||||
@@ -854,6 +846,7 @@ linkid
|
||||
LINKOVERLAY
|
||||
LINQTo
|
||||
listview
|
||||
LIVEDRAW
|
||||
LIVEZOOM
|
||||
LLKH
|
||||
llkhf
|
||||
@@ -865,7 +858,6 @@ localappdata
|
||||
localpackage
|
||||
LOCALSYSTEM
|
||||
LOCATIONCHANGE
|
||||
LOCKMACHINE
|
||||
LOCKTYPE
|
||||
LOGFONT
|
||||
LOGFONTW
|
||||
@@ -874,7 +866,6 @@ LOGMSG
|
||||
LOGPIXELSX
|
||||
LOGPIXELSY
|
||||
lng
|
||||
LOn
|
||||
lon
|
||||
longdate
|
||||
LONGNAMES
|
||||
@@ -926,12 +917,10 @@ luid
|
||||
LUMA
|
||||
lusrmgr
|
||||
LVal
|
||||
lvm
|
||||
LWA
|
||||
lwin
|
||||
LZero
|
||||
MAGTRANSFORM
|
||||
MAJORMINOR
|
||||
MAKEINTRESOURCE
|
||||
MAKEINTRESOURCEA
|
||||
MAKEINTRESOURCEW
|
||||
@@ -956,7 +945,6 @@ MDL
|
||||
mdtext
|
||||
mdtxt
|
||||
mdwn
|
||||
measuretool
|
||||
meme
|
||||
memicmp
|
||||
MENUITEMINFO
|
||||
@@ -1006,7 +994,6 @@ MOUSEHWHEEL
|
||||
MOUSEINPUT
|
||||
mousejump
|
||||
mousepointer
|
||||
mousepointercrosshairs
|
||||
mouseutils
|
||||
MOVESIZEEND
|
||||
MOVESIZESTART
|
||||
@@ -1051,7 +1038,6 @@ MWBEx
|
||||
MYICON
|
||||
NAMECHANGE
|
||||
namespaceanddescendants
|
||||
Namotion
|
||||
nao
|
||||
NCACTIVATE
|
||||
ncc
|
||||
@@ -1089,7 +1075,6 @@ NEWPLUSSHELLEXTENSIONWIN
|
||||
newrow
|
||||
nicksnettravels
|
||||
NIF
|
||||
NJson
|
||||
NLog
|
||||
NLSTEXT
|
||||
NMAKE
|
||||
@@ -1209,23 +1194,13 @@ PACL
|
||||
PAINTSTRUCT
|
||||
PALETTEWINDOW
|
||||
PARENTNOTIFY
|
||||
PARENTRELATIVE
|
||||
PARENTRELATIVEEDITING
|
||||
PARENTRELATIVEFORADDRESSBAR
|
||||
PARENTRELATIVEFORUI
|
||||
PARENTRELATIVEPARSING
|
||||
parray
|
||||
PARTIALCONFIRMATIONDIALOGTITLE
|
||||
pasteashtmlfile
|
||||
pasteashtmlfileshortcut
|
||||
pasteasjson
|
||||
pasteasjsonshortcut
|
||||
pasteasmarkdown
|
||||
pasteasmarkdownshortcut
|
||||
pasteasplaintext
|
||||
pasteasplaintextshortcut
|
||||
pasteaspngfile
|
||||
pasteaspngfileshortcut
|
||||
pasteastxtfile
|
||||
pasteastxtfileshortcut
|
||||
PATCOPY
|
||||
PATHMUSTEXIST
|
||||
PATINVERT
|
||||
@@ -1233,6 +1208,7 @@ PATPAINT
|
||||
pbc
|
||||
pbi
|
||||
PBlob
|
||||
pbrush
|
||||
pcb
|
||||
pcch
|
||||
pcelt
|
||||
@@ -1266,6 +1242,7 @@ pgp
|
||||
pguid
|
||||
phbm
|
||||
phbmp
|
||||
phicon
|
||||
phwnd
|
||||
pici
|
||||
pidl
|
||||
@@ -1274,6 +1251,7 @@ pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
PKBDLLHOOKSTRUCT
|
||||
pkgfamily
|
||||
plib
|
||||
ploc
|
||||
ploca
|
||||
@@ -1293,7 +1271,6 @@ Pomodoro
|
||||
Popups
|
||||
POPUPWINDOW
|
||||
POSITIONITEM
|
||||
powerocr
|
||||
POWERRENAMECONTEXTMENU
|
||||
powerrenameinput
|
||||
POWERRENAMETEST
|
||||
@@ -1344,7 +1321,6 @@ PRODUCTVERSION
|
||||
Progman
|
||||
programdata
|
||||
projectname
|
||||
projitems
|
||||
PROPERTYKEY
|
||||
Propset
|
||||
PROPVARIANT
|
||||
@@ -1352,6 +1328,7 @@ PRTL
|
||||
prvpane
|
||||
psapi
|
||||
pscid
|
||||
pscustomobject
|
||||
PSECURITY
|
||||
psfgao
|
||||
psfi
|
||||
@@ -1437,7 +1414,6 @@ Removelnk
|
||||
renamable
|
||||
RENAMEONCOLLISION
|
||||
reparented
|
||||
reparenthotkey
|
||||
reparenting
|
||||
reportfileaccesses
|
||||
requery
|
||||
@@ -1463,7 +1439,6 @@ RIDEV
|
||||
RIGHTSCROLLBAR
|
||||
riid
|
||||
RKey
|
||||
Rns
|
||||
RNumber
|
||||
rop
|
||||
ROUNDSMALL
|
||||
@@ -1687,7 +1662,6 @@ STYLECHANGED
|
||||
STYLECHANGING
|
||||
subkeys
|
||||
sublang
|
||||
Subdomain
|
||||
SUBMODULEUPDATE
|
||||
subresource
|
||||
Superbar
|
||||
@@ -1760,7 +1734,6 @@ THICKFRAME
|
||||
THEMECHANGED
|
||||
THISCOMPONENT
|
||||
throughs
|
||||
thumbnailhotkey
|
||||
TILEDWINDOW
|
||||
TILLSON
|
||||
timedate
|
||||
@@ -1776,9 +1749,7 @@ tlbimp
|
||||
tlc
|
||||
tmain
|
||||
TNP
|
||||
TOGGLEEASYMOUSE
|
||||
Toolhelp
|
||||
toolkitconverters
|
||||
toolwindow
|
||||
TOPDOWNDIB
|
||||
TOUCHEVENTF
|
||||
@@ -1790,11 +1761,9 @@ tracelogging
|
||||
tracerpt
|
||||
trackbar
|
||||
trafficmanager
|
||||
transcodetomp
|
||||
transicc
|
||||
TRAYMOUSEMESSAGE
|
||||
triaging
|
||||
Tru
|
||||
trl
|
||||
trx
|
||||
tsa
|
||||
@@ -1830,7 +1799,6 @@ ULONGLONG
|
||||
ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
undefining
|
||||
UNDNAME
|
||||
UNICODETEXT
|
||||
unins
|
||||
@@ -1997,6 +1965,7 @@ WMI
|
||||
WMICIM
|
||||
wmimgmt
|
||||
wmp
|
||||
wmsg
|
||||
WMSYSCOMMAND
|
||||
wnd
|
||||
WNDCLASS
|
||||
@@ -2010,6 +1979,7 @@ WORKSPACESEDITOR
|
||||
WORKSPACESLAUNCHER
|
||||
WORKSPACESSNAPSHOTTOOL
|
||||
WORKSPACESWINDOWARRANGER
|
||||
Worktree
|
||||
wox
|
||||
wparam
|
||||
wpf
|
||||
|
||||
9
.github/actions/spell-check/patterns.txt
vendored
9
.github/actions/spell-check/patterns.txt
vendored
@@ -1,5 +1,10 @@
|
||||
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
|
||||
|
||||
# marker to ignore all code on line
|
||||
^.*/\* #no-spell-check-line \*/.*$
|
||||
# marker for ignoring a comment to the end of the line
|
||||
// #no-spell-check.*$
|
||||
|
||||
# Gaelic
|
||||
Gàidhlig
|
||||
|
||||
@@ -264,3 +269,7 @@ St&yle
|
||||
# This matches a relative clause where the relative pronoun "that" is omitted.
|
||||
# Example: "Gets or sets the window the TitleBar should configure."
|
||||
\bthe\s+\w+\s+the\b
|
||||
|
||||
# Usernames with numbers
|
||||
# 0x6f677548 is user name but user folder causes a flag
|
||||
\bx6f677548\b
|
||||
|
||||
@@ -258,6 +258,7 @@ jobs:
|
||||
-restore -graph
|
||||
/p:RestorePackagesConfig=true
|
||||
/p:CIBuild=true
|
||||
/p:BuildInParallel=true
|
||||
/bl:$(LogOutputDirectory)\build-0-main.binlog
|
||||
${{ parameters.additionalBuildOptions }}
|
||||
$(MSBuildCacheParameters)
|
||||
@@ -277,7 +278,7 @@ jobs:
|
||||
condition: ne(variables['BuildPlatform'], 'x64')
|
||||
inputs:
|
||||
solution: src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj
|
||||
msbuildArgs: /t:Build /m /restore
|
||||
msbuildArgs: /t:Build /m /restore /p:BuildInParallel=true
|
||||
platform: x64
|
||||
configuration: $(BuildConfiguration)
|
||||
msbuildArchitecture: x64
|
||||
@@ -323,6 +324,7 @@ jobs:
|
||||
-restore -graph
|
||||
/p:RestorePackagesConfig=true
|
||||
/p:CIBuild=true
|
||||
/p:BuildInParallel=true
|
||||
/bl:$(LogOutputDirectory)\build-bug-report.binlog
|
||||
${{ parameters.additionalBuildOptions }}
|
||||
$(MSBuildCacheParameters)
|
||||
@@ -344,6 +346,7 @@ jobs:
|
||||
-restore -graph
|
||||
/p:RestorePackagesConfig=true
|
||||
/p:CIBuild=true
|
||||
/p:BuildInParallel=true
|
||||
/bl:$(LogOutputDirectory)\build-styles-report.binlog
|
||||
${{ parameters.additionalBuildOptions }}
|
||||
$(MSBuildCacheParameters)
|
||||
@@ -365,7 +368,7 @@ jobs:
|
||||
msbuildArgs: >-
|
||||
/target:Publish
|
||||
/graph
|
||||
/p:Configuration=$(BuildConfiguration);Platform=$(BuildPlatform);AppxBundle=Never
|
||||
/p:Configuration=$(BuildConfiguration);Platform=$(BuildPlatform);AppxBundle=Never;BuildInParallel=true
|
||||
/p:VCRTForwarders-IncludeDebugCRT=false
|
||||
/p:PowerToysRoot=$(Build.SourcesDirectory)
|
||||
/p:PublishProfile=InstallationPublishProfile.pubxml
|
||||
|
||||
@@ -90,6 +90,7 @@ jobs:
|
||||
/p:RestorePackagesConfig=true
|
||||
/p:BuildProjectReferences=true
|
||||
/p:CIBuild=true
|
||||
/p:BuildInParallel=true
|
||||
/bl:$(LogOutputDirectory)\build-all-uitests.binlog
|
||||
$(NUGET_RESTORE_MSBUILD_ARGS)
|
||||
platform: $(BuildPlatform)
|
||||
@@ -111,6 +112,7 @@ jobs:
|
||||
/p:RestorePackagesConfig=true
|
||||
/p:BuildProjectReferences=true
|
||||
/p:CIBuild=true
|
||||
/p:BuildInParallel=true
|
||||
/bl:$(LogOutputDirectory)\build-${{ module }}.binlog
|
||||
$(NUGET_RESTORE_MSBUILD_ARGS)
|
||||
platform: $(BuildPlatform)
|
||||
|
||||
@@ -44,6 +44,9 @@ foreach ($csprojFile in $csprojFilesArray) {
|
||||
if ($csprojFile -like '*Microsoft.CmdPal.Core.*.csproj') {
|
||||
continue
|
||||
}
|
||||
if ($csprojFile -like '*Microsoft.CmdPal.Ext.Shell.csproj') {
|
||||
continue
|
||||
}
|
||||
|
||||
$importExists = Test-ImportSharedCsWinRTProps -filePath $csprojFile
|
||||
if (!$importExists) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250910-build.2249" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.251002-build.2316" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
<PackageVersion Include="HelixToolkit.Core.Wpf" Version="2.24.0" />
|
||||
|
||||
@@ -825,6 +825,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|ARM64 = Debug|ARM64
|
||||
@@ -2995,6 +2997,14 @@ Global
|
||||
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64
|
||||
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64
|
||||
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.Build.0 = Debug|x64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.Build.0 = Release|ARM64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.ActiveCfg = Release|x64
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -3323,6 +3333,7 @@ Global
|
||||
{3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477}
|
||||
{F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94}
|
||||
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
|
||||
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
#include "../../../common/version/version.h"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,1,0,0
|
||||
PRODUCTVERSION 0,1,0,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEVERSION FILE_VERSION
|
||||
PRODUCTVERSION PRODUCT_VERSION
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS 0x1L
|
||||
FILEFLAGS VS_FF_DEBUG
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
FILEFLAGS 0x0L
|
||||
#endif
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x2L
|
||||
FILESUBTYPE 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_DLL
|
||||
FILESUBTYPE VFT2_UNKNOWN
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Company Name"
|
||||
VALUE "FileDescription", "Light Switch Module"
|
||||
VALUE "FileVersion", "0.1.0.0"
|
||||
VALUE "InternalName", "Light Switch"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2019 Company Name"
|
||||
VALUE "OriginalFilename", "PowerToys.LightSwitchModuleInterface.dll"
|
||||
VALUE "ProductName", "Light Switch"
|
||||
VALUE "ProductVersion", "0.1.0.0"
|
||||
VALUE "CompanyName", COMPANY_NAME
|
||||
VALUE "FileDescription", FILE_DESCRIPTION
|
||||
VALUE "FileVersion", FILE_VERSION_STRING
|
||||
VALUE "InternalName", INTERNAL_NAME
|
||||
VALUE "LegalCopyright", COPYRIGHT_NOTE
|
||||
VALUE "OriginalFilename", ORIGINAL_FILENAME
|
||||
VALUE "ProductName", PRODUCT_NAME
|
||||
VALUE "ProductVersion", PRODUCT_VERSION_STRING
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset
|
||||
END
|
||||
END
|
||||
@@ -108,7 +108,7 @@ public:
|
||||
|
||||
m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT");
|
||||
m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK");
|
||||
m_manual_override_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
|
||||
m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
|
||||
|
||||
init_settings();
|
||||
};
|
||||
@@ -460,7 +460,7 @@ public:
|
||||
}
|
||||
else if (hotkeyId == 0)
|
||||
{
|
||||
// get current will return true if in light mode, otherwise false
|
||||
// get current will return true if in light mode; otherwise false
|
||||
Logger::info(L"[Light Switch] Hotkey triggered: Toggle Theme");
|
||||
if (g_settings.m_changeSystem)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//{{NO_DEPENDENCIES}}
|
||||
// Microsoft Visual C++ generated include file.
|
||||
// Used by CalculatorEngineCommon.rc
|
||||
|
||||
//////////////////////////////
|
||||
// Non-localizable
|
||||
|
||||
#define FILE_DESCRIPTION "Light Switch Module"
|
||||
#define INTERNAL_NAME "Light Switch"
|
||||
#define ORIGINAL_FILENAME "PowerToys.LightSwitchModuleInterface.dll"
|
||||
|
||||
// Non-localizable
|
||||
//////////////////////////////
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#include <string>
|
||||
#include <LightSwitchSettings.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_HANDLE g_StatusHandle = nullptr;
|
||||
@@ -35,6 +38,8 @@ int _tmain(int argc, TCHAR* argv[])
|
||||
wchar_t serviceName[] = L"LightSwitchService";
|
||||
SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } };
|
||||
|
||||
LoggerHelpers::init_logger(L"LightSwitch", L"Service", LogSettings::lightSwitchLoggerName);
|
||||
|
||||
if (!StartServiceCtrlDispatcherW(table))
|
||||
{
|
||||
DWORD err = GetLastError();
|
||||
@@ -106,6 +111,7 @@ VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl)
|
||||
SetServiceStatus(g_StatusHandle, &g_ServiceStatus);
|
||||
|
||||
// Signal the service to stop
|
||||
Logger::info(L"[LightSwitchService] Stop requested, signaling worker thread to exit.");
|
||||
SetEvent(g_ServiceStopEvent);
|
||||
break;
|
||||
|
||||
@@ -126,13 +132,21 @@ static void update_sun_times(auto& settings)
|
||||
|
||||
int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute;
|
||||
int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute;
|
||||
try
|
||||
{
|
||||
auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch");
|
||||
values.add_property(L"lightTime", newLightTime);
|
||||
values.add_property(L"darkTime", newDarkTime);
|
||||
values.save_to_settings_file();
|
||||
|
||||
auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch");
|
||||
values.add_property(L"lightTime", newLightTime);
|
||||
values.add_property(L"darkTime", newDarkTime);
|
||||
values.save_to_settings_file();
|
||||
|
||||
OutputDebugString(L"[LightSwitchService] Updated sun times and saved to config.\n");
|
||||
Logger::info(L"[LightSwitchService] Updated sun times and saved to config.");
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
std::wstring wmsg(e.what(), e.what() + strlen(e.what()));
|
||||
Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
@@ -142,7 +156,8 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
if (parentPid)
|
||||
hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid);
|
||||
|
||||
OutputDebugString(L"[LightSwitchService] Worker thread starting...\n");
|
||||
Logger::info(L"[LightSwitchService] Worker thread starting...");
|
||||
Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid);
|
||||
|
||||
// Initialize settings system
|
||||
LightSwitchSettings::instance().InitFileWatcher();
|
||||
@@ -214,19 +229,19 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
update_sun_times(settings);
|
||||
g_lastUpdatedDay = st.wDay;
|
||||
|
||||
OutputDebugString(L"[LightSwitchService] Recalculated sun times at new day boundary.\n");
|
||||
Logger::info(L"[LightSwitchService] Recalculated sun times at new day boundary.");
|
||||
}
|
||||
|
||||
wchar_t msg[160];
|
||||
swprintf_s(msg,
|
||||
L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d\n",
|
||||
L"[LightSwitchService] now=%02d:%02d | light=%02d:%02d | dark=%02d:%02d",
|
||||
st.wHour,
|
||||
st.wMinute,
|
||||
settings.lightTime / 60,
|
||||
settings.lightTime % 60,
|
||||
settings.darkTime / 60,
|
||||
settings.darkTime % 60);
|
||||
OutputDebugString(msg);
|
||||
Logger::info(msg);
|
||||
|
||||
// --- Manual override check ---
|
||||
bool manualOverrideActive = false;
|
||||
@@ -242,11 +257,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
nowMinutes == (settings.darkTime + settings.sunset_offset) % 1440)
|
||||
{
|
||||
ResetEvent(hManualOverride);
|
||||
OutputDebugString(L"[LightSwitchService] Manual override cleared at boundary\n");
|
||||
Logger::info(L"[LightSwitchService] Manual override cleared at boundary\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputDebugString(L"[LightSwitchService] Skipping schedule due to manual override\n");
|
||||
Logger::info(L"[LightSwitchService] Skipping schedule due to manual override\n");
|
||||
goto sleep_until_next_minute;
|
||||
}
|
||||
}
|
||||
@@ -261,10 +276,17 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
|
||||
msToNextMinute = 50;
|
||||
|
||||
DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute);
|
||||
if (wait == WAIT_OBJECT_0) // stop event
|
||||
if (wait == WAIT_OBJECT_0)
|
||||
{
|
||||
Logger::info(L"[LightSwitchService] Stop event triggered <20> exiting worker loop.");
|
||||
break;
|
||||
if (hParent && wait == WAIT_OBJECT_0 + 1) // parent exited
|
||||
}
|
||||
if (hParent && wait == WAIT_OBJECT_0 + 1) // parent process exited
|
||||
{
|
||||
Logger::info(L"[LightSwitchService] Parent process exited <20> stopping service.");
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (hManualOverride)
|
||||
@@ -282,8 +304,8 @@ int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
wchar_t msg[160];
|
||||
swprintf_s(
|
||||
msg,
|
||||
L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.\n");
|
||||
OutputDebugString(msg);
|
||||
L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
|
||||
Logger::info(msg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -28,19 +28,6 @@
|
||||
<ProjectName>LightSwitchService</ProjectName>
|
||||
</PropertyGroup>
|
||||
<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">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
@@ -54,84 +41,25 @@
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</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" />
|
||||
<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>
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
|
||||
<TargetName>PowerToys.LightSwitchService</TargetName>
|
||||
</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>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<PreprocessorDefinitions>%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalIncludeDirectories>
|
||||
./../;
|
||||
..\..\..\common\Telemetry;
|
||||
..\..\..\common;
|
||||
..\..\..\common\logger;
|
||||
..\..\..\common\utils;
|
||||
..\..\..\common\SettingsAPI;
|
||||
..\..\..\common\Telemetry;
|
||||
..\..\..\;
|
||||
..\..\..\..\deps\spdlog\include;
|
||||
./;
|
||||
@@ -145,8 +73,27 @@
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\logger\logger.vcxproj">
|
||||
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
|
||||
<ClCompile Include="LightSwitchService.cpp" />
|
||||
<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 Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj">
|
||||
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
|
||||
@@ -158,62 +105,10 @@
|
||||
<Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project>
|
||||
</ProjectReference>
|
||||
</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="..\..\..\..\deps\spdlog.props" />
|
||||
<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.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>
|
||||
<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>
|
||||
@@ -24,15 +24,6 @@
|
||||
<ClCompile Include="ThemeHelper.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</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">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
@@ -43,9 +34,6 @@
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="ThemeScheduler.h">
|
||||
<Filter>Header Files</Filter>
|
||||
@@ -69,4 +57,9 @@
|
||||
<ItemGroup>
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ResourceCompile Include="LightSwitchService.rc">
|
||||
<Filter>Resource Files</Filter>
|
||||
</ResourceCompile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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 System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.PowerToys.UITest;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace LightSwitch.UITests
|
||||
{
|
||||
[TestClass]
|
||||
public class TestUserSelectedLocation : UITestBase
|
||||
{
|
||||
public TestUserSelectedLocation()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large)
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod("LightSwitch.UserSelectedLocation")]
|
||||
[TestCategory("Location")]
|
||||
public void TestUserSelectedLocationUpdate()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "user selected location test");
|
||||
TestHelper.PerformUserSelectedLocationTest(this);
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ enum struct FindMyMouseActivationMethod : int
|
||||
|
||||
constexpr bool FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE = true;
|
||||
// Default colors now include full alpha. Opacity is encoded directly in color alpha (legacy overlay_opacity migrated into A channel)
|
||||
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 0, 0, 0);
|
||||
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255);
|
||||
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 0, 0, 0);
|
||||
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 255, 255, 255);
|
||||
constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS = 100;
|
||||
constexpr int FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS = 500;
|
||||
constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM = 9;
|
||||
@@ -43,4 +43,4 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings);
|
||||
void FindMyMouseDisable();
|
||||
bool FindMyMouseIsEnabled();
|
||||
void FindMyMouseApplySettings(const FindMyMouseSettings& settings);
|
||||
HWND GetSonarHwnd() noexcept;
|
||||
HWND GetSonarHwnd() noexcept;
|
||||
|
||||
@@ -1055,8 +1055,13 @@ namespace MouseWithoutBorders.Class
|
||||
|
||||
if (machineId == 0)
|
||||
{
|
||||
_properties.MachineID.Value = Common.Ran.Next();
|
||||
machineId = _properties.MachineID.Value;
|
||||
var newMachineId = Common.Ran.Next();
|
||||
_properties.MachineID.Value = newMachineId;
|
||||
machineId = newMachineId;
|
||||
if (!PauseInstantSaving)
|
||||
{
|
||||
SaveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,6 +1073,11 @@ namespace MouseWithoutBorders.Class
|
||||
lock (_loadingSettingsLock)
|
||||
{
|
||||
_properties.MachineID.Value = value;
|
||||
machineId = value;
|
||||
if (!PauseInstantSaving)
|
||||
{
|
||||
SaveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,10 @@ typedef struct {
|
||||
#define SHALLOW_DESTROY 2
|
||||
#define LIVE_DRAW_ZOOM 3
|
||||
|
||||
#define PEN_COLOR_HIGHLIGHT(Pencolor) (Pencolor >> 24) != 0xFF
|
||||
#define PEN_COLOR_HIGHLIGHT(Pencolor) ((Pencolor >> 24) != 0xFF)
|
||||
#define PEN_COLOR_BLUR(Pencolor) ((Pencolor & 0x00FFFFFF) == COLOR_BLUR)
|
||||
|
||||
#define CURSOR_SAVE_MARGIN 4
|
||||
|
||||
|
||||
typedef BOOL (__stdcall *type_pGetMonitorInfo)(
|
||||
@@ -143,7 +146,14 @@ typedef BOOL(__stdcall *type_pMagSetWindowFilterList)(
|
||||
int count,
|
||||
HWND* pHWND
|
||||
);
|
||||
typedef BOOL (__stdcall *type_pMagInitialize)(VOID);
|
||||
typedef BOOL(__stdcall* type_pMagSetLensUseBitmapSmoothing)(
|
||||
_In_ HWND,
|
||||
_In_ BOOL
|
||||
);
|
||||
typedef BOOL(__stdcall* type_MagSetFullscreenUseBitmapSmoothing)(
|
||||
BOOL fUseBitmapSmoothing
|
||||
);
|
||||
typedef BOOL(__stdcall* type_pMagInitialize)(VOID);
|
||||
|
||||
typedef BOOL(__stdcall *type_pGetPointerType)(
|
||||
_In_ UINT32 pointerId,
|
||||
|
||||
@@ -121,8 +121,8 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
|
||||
BEGIN
|
||||
DEFPUSHBUTTON "OK",IDOK,166,306,50,14
|
||||
PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14
|
||||
LTEXT "ZoomIt v9.01",IDC_VERSION,42,7,73,10
|
||||
LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,166,8
|
||||
LTEXT "ZoomIt v9.10",IDC_VERSION,42,7,73,10
|
||||
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,
|
||||
"SysLink",WS_TABSTOP,42,26,150,9
|
||||
ICON "APPICON",IDC_STATIC,12,9,20,20
|
||||
@@ -149,7 +149,8 @@ BEGIN
|
||||
CONTROL "",IDC_TIMER_POS7,"Button",BS_AUTORADIOBUTTON,63,108,10,10
|
||||
CONTROL "",IDC_TIMER_POS8,"Button",BS_AUTORADIOBUTTON,79,108,10,10
|
||||
CONTROL "",IDC_TIMER_POS9,"Button",BS_AUTORADIOBUTTON,97,108,10,10
|
||||
CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT
|
||||
CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,
|
||||
"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT
|
||||
CONTROL "Use faded desktop as background",IDC_STATIC_DESKTOP_BACKGROUND,
|
||||
"Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,46,135,125,10
|
||||
CONTROL "Use image file as background",IDC_STATIC_BACKGROUND_FILE,
|
||||
@@ -165,23 +166,25 @@ BEGIN
|
||||
CONTROL "",IDC_STATIC,"Static",SS_BLACKFRAME | SS_SUNKEN,7,196,193,1,WS_EX_CLIENTEDGE
|
||||
END
|
||||
|
||||
ZOOM DIALOGEX 0, 0, 260, 158
|
||||
ZOOM DIALOGEX 0, 0, 260, 170
|
||||
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
|
||||
FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
|
||||
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,246,26
|
||||
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
|
||||
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,104,150,15,WS_EX_TRANSPARENT
|
||||
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,91,215,10
|
||||
LTEXT "1.25",IDC_STATIC,52,122,16,8
|
||||
LTEXT "1.5",IDC_STATIC,82,122,12,8
|
||||
LTEXT "1.75",IDC_STATIC,108,122,16,8
|
||||
LTEXT "2.0",IDC_STATIC,138,122,12,8
|
||||
LTEXT "3.0",IDC_STATIC,164,122,12,8
|
||||
LTEXT "4.0",IDC_STATIC,190,122,12,8
|
||||
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
|
||||
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,215,10
|
||||
LTEXT "1.25",IDC_STATIC,52,136,16,8
|
||||
LTEXT "1.5",IDC_STATIC,82,136,12,8
|
||||
LTEXT "1.75",IDC_STATIC,108,136,16,8
|
||||
LTEXT "2.0",IDC_STATIC,138,136,12,8
|
||||
LTEXT "3.0",IDC_STATIC,164,136,12,8
|
||||
LTEXT "4.0",IDC_STATIC,190,136,12,8
|
||||
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,246,17
|
||||
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,246,17
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,246,18
|
||||
END
|
||||
|
||||
DRAW DIALOGEX 0, 0, 260, 228
|
||||
@@ -295,7 +298,8 @@ BEGIN
|
||||
LTEXT "DemoType toggle:",IDC_STATIC,7,157,63,8
|
||||
PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,231,137,16,13
|
||||
CONTROL "",IDC_DEMOTYPE_SPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,52,202,150,11,WS_EX_TRANSPARENT
|
||||
CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10
|
||||
CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN,
|
||||
"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10
|
||||
LTEXT "DemoType typing speed:",IDC_STATIC,7,189,215,10
|
||||
LTEXT "Slow",IDC_DEMOTYPE_STATIC1,51,213,18,8
|
||||
LTEXT "Fast",IDC_DEMOTYPE_STATIC2,186,213,17,8
|
||||
@@ -413,8 +417,8 @@ ACCELERATORS ACCELERATORS
|
||||
BEGIN
|
||||
"C", IDC_COPY, VIRTKEY, CONTROL, NOINVERT
|
||||
"S", IDC_SAVE, VIRTKEY, CONTROL, NOINVERT
|
||||
"C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
|
||||
"S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
|
||||
"C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
|
||||
"S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
|
||||
END
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
|
||||
DWORD g_ShowExpiredTime = 1;
|
||||
DWORD g_SliderZoomLevel = 3;
|
||||
BOOLEAN g_AnimateZoom = TRUE;
|
||||
BOOLEAN g_SmoothImage = TRUE;
|
||||
DWORD g_PenColor = COLOR_RED;
|
||||
DWORD g_BreakPenColor = COLOR_RED;
|
||||
DWORD g_RootPenWidth = PEN_WIDTH;
|
||||
@@ -72,6 +73,7 @@ REG_SETTING RegSettings[] = {
|
||||
{ L"ShowTrayIcon", SETTING_TYPE_BOOLEAN, 0, &g_ShowTrayIcon, static_cast<DOUBLE>(g_ShowTrayIcon) },
|
||||
// NOTE: AnimateZoom is misspelled, but since it is a user setting stored in the registry we must continue to misspell it.
|
||||
{ L"AnimnateZoom", SETTING_TYPE_BOOLEAN, 0, &g_AnimateZoom, static_cast<DOUBLE>(g_AnimateZoom) },
|
||||
{ L"SmoothImage", SETTING_TYPE_BOOLEAN, 0, &g_SmoothImage, static_cast<DOUBLE>(g_SmoothImage) },
|
||||
{ L"TelescopeZoomOut", SETTING_TYPE_BOOLEAN, 0, &g_TelescopeZoomOut, static_cast<DOUBLE>(g_TelescopeZoomOut) },
|
||||
{ L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast<DOUBLE>(g_SnapToGrid) },
|
||||
{ L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast<DOUBLE>(g_SliderZoomLevel) },
|
||||
|
||||
@@ -170,6 +170,8 @@ type_pMagSetFullscreenTransform pMagSetFullscreenTransform;
|
||||
type_pMagSetInputTransform pMagSetInputTransform;
|
||||
type_pMagShowSystemCursor pMagShowSystemCursor;
|
||||
type_pMagSetWindowFilterList pMagSetWindowFilterList;
|
||||
type_MagSetFullscreenUseBitmapSmoothing pMagSetFullscreenUseBitmapSmoothing;
|
||||
type_pMagSetLensUseBitmapSmoothing pMagSetLensUseBitmapSmoothing;
|
||||
type_pMagInitialize pMagInitialize;
|
||||
type_pDwmIsCompositionEnabled pDwmIsCompositionEnabled;
|
||||
type_pGetPointerType pGetPointerType;
|
||||
@@ -1099,6 +1101,8 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr
|
||||
// Create a new bitmap that's the size of the area covered by the line + 2 * g_PenWidth
|
||||
Gdiplus::Rect lineBounds(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1));
|
||||
|
||||
OutputDebug(L"DrawHighlightedShape\n");
|
||||
|
||||
// Expand for line drawing
|
||||
if (Shape == DRAW_LINE)
|
||||
lineBounds.Inflate(static_cast<int>(g_PenWidth / 2), static_cast<int>(g_PenWidth / 2));
|
||||
@@ -1186,7 +1190,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr
|
||||
DeleteDC(hdcDIBOrig);
|
||||
|
||||
// Invalidate the updated rectangle
|
||||
// InvalidateGdiplusRect(hWnd, lineBounds);
|
||||
//InvalidateGdiplusRect(hWnd, lineBounds);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
@@ -1284,7 +1288,12 @@ void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst,
|
||||
{
|
||||
Gdiplus::Bitmap srcBitmap( bmSrc, NULL );
|
||||
|
||||
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality );
|
||||
// Use high quality interpolation when smooth image is enabled
|
||||
if (g_SmoothImage) {
|
||||
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeHighQuality );
|
||||
} else {
|
||||
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality );
|
||||
}
|
||||
dstGraphics.SetPixelOffsetMode( Gdiplus::PixelOffsetModeHalf );
|
||||
|
||||
dstGraphics.DrawImage( &srcBitmap, Gdiplus::RectF(xDst,yDst,wDst,hDst), xSrc, ySrc, wSrc, hSrc, Gdiplus::UnitPixel );
|
||||
@@ -2071,6 +2080,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
IsAutostartConfigured() ? BST_CHECKED: BST_UNCHECKED );
|
||||
CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM,
|
||||
g_AnimateZoom ? BST_CHECKED: BST_UNCHECKED );
|
||||
CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE,
|
||||
g_SmoothImage ? BST_CHECKED: BST_UNCHECKED );
|
||||
|
||||
SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETRANGE, false, MAKELONG(0,_countof(g_ZoomLevels)-1) );
|
||||
SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETPOS, true, g_SliderZoomLevel );
|
||||
@@ -2210,6 +2221,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
}
|
||||
g_ShowTrayIcon = IsDlgButtonChecked( hDlg, IDC_SHOW_TRAY_ICON ) == BST_CHECKED;
|
||||
g_AnimateZoom = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM ) == BST_CHECKED;
|
||||
g_SmoothImage = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE ) == BST_CHECKED;
|
||||
g_DemoTypeUserDriven = IsDlgButtonChecked( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN ) == BST_CHECKED;
|
||||
|
||||
newToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
@@ -2723,7 +2735,6 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false )
|
||||
bool isBlur = false;
|
||||
|
||||
Gdiplus::Graphics dstGraphics(hDc);
|
||||
|
||||
if( ( GetWindowLong( g_hWndMain, GWL_EXSTYLE ) & WS_EX_LAYERED ) == 0 )
|
||||
{
|
||||
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
@@ -2746,6 +2757,7 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false )
|
||||
InflateRect(Rect, g_PenWidth / 2, g_PenWidth / 2);
|
||||
isBlur = true;
|
||||
}
|
||||
OutputDebug(L"Draw shape: highlight: %d pbrush: %d\n", PEN_COLOR_HIGHLIGHT(g_PenColor), pBrush != NULL);
|
||||
|
||||
switch (Shape) {
|
||||
case DRAW_RECTANGLE:
|
||||
@@ -2920,7 +2932,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height
|
||||
{
|
||||
int x, y;
|
||||
RECT rc;
|
||||
int invWidth = g_PenWidth;
|
||||
int invWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
|
||||
|
||||
if( DrawHighlightedCursor( zoomLevel, width, height ) ) {
|
||||
|
||||
@@ -2945,7 +2957,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height
|
||||
void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
|
||||
{
|
||||
OutputDebug( L"SaveCursorArea\n");
|
||||
int penWidth = g_PenWidth + 2;
|
||||
int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
|
||||
BitBlt( hDcTarget, 0, 0, penWidth +CURSOR_ARM_LENGTH*2, penWidth +CURSOR_ARM_LENGTH*2,
|
||||
hDcSource, static_cast<INT> (pt.x- penWidth /2)-CURSOR_ARM_LENGTH,
|
||||
static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, SRCCOPY|CAPTUREBLT );
|
||||
@@ -2959,7 +2971,7 @@ void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
|
||||
void RestoreCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
|
||||
{
|
||||
OutputDebug( L"RestoreCursorArea\n");
|
||||
int penWidth = g_PenWidth + 2;
|
||||
int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
|
||||
BitBlt( hDcTarget, static_cast<INT>(pt.x- penWidth /2)-CURSOR_ARM_LENGTH,
|
||||
static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, penWidth +CURSOR_ARM_LENGTH*2,
|
||||
penWidth + CURSOR_ARM_LENGTH*2, hDcSource, 0, 0, SRCCOPY|CAPTUREBLT );
|
||||
@@ -4178,6 +4190,11 @@ LRESULT APIENTRY MainWndProc(
|
||||
}
|
||||
#endif
|
||||
}
|
||||
OutputDebug(L"LIVEDRAW SMOOTHING: %d\n", g_SmoothImage);
|
||||
if (!pMagSetLensUseBitmapSmoothing(g_hWndLiveZoomMag, g_SmoothImage))
|
||||
{
|
||||
OutputDebug(L"MagSetLensUseBitmapSmoothing failed: %d\n", GetLastError());
|
||||
}
|
||||
|
||||
if ( g_RecordToggle )
|
||||
{
|
||||
@@ -5296,6 +5313,8 @@ LRESULT APIENTRY MainWndProc(
|
||||
|
||||
if( g_Drawing ) {
|
||||
|
||||
OutputDebug(L"Mousemove: Drawing\n");
|
||||
|
||||
POINT currentPt;
|
||||
|
||||
// Are we in pen mode on a tablet?
|
||||
@@ -5334,7 +5353,15 @@ LRESULT APIENTRY MainWndProc(
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle );
|
||||
if (PEN_COLOR_HIGHLIGHT(g_PenColor))
|
||||
{
|
||||
// copy original bitmap to screen bitmap to erase previous highlight
|
||||
BitBlt(hdcScreenCompat, 0, 0, bmp.bmWidth, bmp.bmHeight, drawUndoList->hDc, 0, 0, SRCCOPY | CAPTUREBLT);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5380,7 +5407,7 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_rcRectangle.top != g_rcRectangle.bottom) {
|
||||
|
||||
// Draw the new target rectangle.
|
||||
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle);
|
||||
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor));
|
||||
OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top,
|
||||
g_rcRectangle.right, g_rcRectangle.bottom);
|
||||
}
|
||||
@@ -5418,9 +5445,6 @@ LRESULT APIENTRY MainWndProc(
|
||||
Gdiplus::BitmapData* lineData = LockGdiPlusBitmap(lineBitmap);
|
||||
BYTE* pPixels = static_cast<BYTE*>(lineData->Scan0);
|
||||
|
||||
// Copy the contents of the screen bitmap to the temporary bitmap
|
||||
GetOldestUndo(drawUndoList);
|
||||
|
||||
// Create a GDI bitmap that's the size of the lineBounds rectangle
|
||||
Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc,
|
||||
lineBounds.X, lineBounds.Y, lineBounds.Width, lineBounds.Height);
|
||||
@@ -5445,6 +5469,8 @@ LRESULT APIENTRY MainWndProc(
|
||||
}
|
||||
else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) {
|
||||
|
||||
OutputDebug(L"HIGHLIGHT\n");
|
||||
|
||||
// This is a highlighting pen color
|
||||
Gdiplus::Rect lineBounds = GetLineBounds(prevPt, currentPt, g_PenWidth);
|
||||
Gdiplus::Bitmap* lineBitmap = DrawBitmapLine(lineBounds, prevPt, currentPt, &pen);
|
||||
@@ -5784,26 +5810,30 @@ LRESULT APIENTRY MainWndProc(
|
||||
if( !g_DrawingShape ) {
|
||||
|
||||
// If the point has changed, draw a line to it
|
||||
if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam)) {
|
||||
Gdiplus::Graphics dstGraphics(hdcScreenCompat);
|
||||
if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0)
|
||||
if (!PEN_COLOR_HIGHLIGHT(g_PenColor))
|
||||
{
|
||||
if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam))
|
||||
{
|
||||
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
Gdiplus::Graphics dstGraphics(hdcScreenCompat);
|
||||
if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0)
|
||||
{
|
||||
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
}
|
||||
Gdiplus::Color color = ColorFromColorRef(g_PenColor);
|
||||
Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth));
|
||||
Gdiplus::GraphicsPath path;
|
||||
pen.SetLineJoin(Gdiplus::LineJoinRound);
|
||||
path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam));
|
||||
dstGraphics.DrawPath(&pen, &path);
|
||||
}
|
||||
// Draw a dot at the current point, if the point hasn't changed
|
||||
else
|
||||
{
|
||||
MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL);
|
||||
LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam));
|
||||
InvalidateRect(hWnd, NULL, FALSE);
|
||||
}
|
||||
Gdiplus::Color color = ColorFromColorRef(g_PenColor);
|
||||
Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth));
|
||||
Gdiplus::GraphicsPath path;
|
||||
pen.SetLineJoin(Gdiplus::LineJoinRound);
|
||||
path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam));
|
||||
dstGraphics.DrawPath(&pen, &path);
|
||||
}
|
||||
// Draw a dot at the current point, if the point hasn't changed
|
||||
else {
|
||||
MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL);
|
||||
LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam));
|
||||
InvalidateRect(hWnd, NULL, FALSE);
|
||||
}
|
||||
|
||||
prevPt.x = LOWORD( lParam );
|
||||
prevPt.y = HIWORD( lParam );
|
||||
|
||||
@@ -5818,8 +5848,11 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_rcRectangle.left != g_rcRectangle.right ) {
|
||||
|
||||
// erase previous
|
||||
SetROP2(hdcScreenCompat, R2_NOTXORPEN);
|
||||
DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle );
|
||||
if (!PEN_COLOR_HIGHLIGHT(g_PenColor))
|
||||
{
|
||||
SetROP2(hdcScreenCompat, R2_NOTXORPEN);
|
||||
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle);
|
||||
}
|
||||
|
||||
// Draw the final shape
|
||||
HBRUSH hBrush = static_cast<HBRUSH>(GetStockObject( NULL_BRUSH ));
|
||||
@@ -6185,8 +6218,14 @@ LRESULT APIENTRY MainWndProc(
|
||||
SetStretchBltMode( hInterimSaveDc, HALFTONE );
|
||||
SetStretchBltMode( hSaveDc, HALFTONE );
|
||||
#else
|
||||
SetStretchBltMode( hInterimSaveDc, COLORONCOLOR );
|
||||
SetStretchBltMode( hSaveDc, COLORONCOLOR );
|
||||
// Use HALFTONE for better quality when smooth image is enabled
|
||||
if (g_SmoothImage) {
|
||||
SetStretchBltMode( hInterimSaveDc, HALFTONE );
|
||||
SetStretchBltMode( hSaveDc, HALFTONE );
|
||||
} else {
|
||||
SetStretchBltMode( hInterimSaveDc, COLORONCOLOR );
|
||||
SetStretchBltMode( hSaveDc, COLORONCOLOR );
|
||||
}
|
||||
#endif
|
||||
StretchBlt( hInterimSaveDc,
|
||||
0, 0,
|
||||
@@ -6309,7 +6348,12 @@ LRESULT APIENTRY MainWndProc(
|
||||
#if SCALE_HALFTONE
|
||||
SetStretchBltMode( hSaveDc, HALFTONE );
|
||||
#else
|
||||
SetStretchBltMode( hSaveDc, COLORONCOLOR );
|
||||
// Use HALFTONE for better quality when smooth image is enabled
|
||||
if (g_SmoothImage) {
|
||||
SetStretchBltMode( hSaveDc, HALFTONE );
|
||||
} else {
|
||||
SetStretchBltMode( hSaveDc, COLORONCOLOR );
|
||||
}
|
||||
#endif
|
||||
StretchBlt( hSaveDc,
|
||||
0, 0,
|
||||
@@ -6646,8 +6690,8 @@ LRESULT APIENTRY MainWndProc(
|
||||
(float)x, (float)y,
|
||||
width/zoomLevel, height/zoomLevel );
|
||||
} else {
|
||||
// do a fast, less accurate render
|
||||
SetStretchBltMode( hDc, HALFTONE );
|
||||
// do a fast, less accurate render (but use smooth if enabled)
|
||||
SetStretchBltMode( hDc, g_SmoothImage ? HALFTONE : COLORONCOLOR );
|
||||
StretchBlt( ps.hdc,
|
||||
0, 0,
|
||||
bmp.bmWidth, bmp.bmHeight,
|
||||
@@ -6660,7 +6704,12 @@ LRESULT APIENTRY MainWndProc(
|
||||
#if SCALE_HALFTONE
|
||||
SetStretchBltMode( hDc, zoomLevel == zoomTelescopeTarget ? HALFTONE : COLORONCOLOR );
|
||||
#else
|
||||
SetStretchBltMode( hDc, COLORONCOLOR );
|
||||
// Use HALFTONE for better quality when smooth image is enabled
|
||||
if (g_SmoothImage) {
|
||||
SetStretchBltMode( hDc, HALFTONE );
|
||||
} else {
|
||||
SetStretchBltMode( hDc, COLORONCOLOR );
|
||||
}
|
||||
#endif
|
||||
StretchBlt( ps.hdc,
|
||||
0, 0,
|
||||
@@ -6683,7 +6732,7 @@ LRESULT APIENTRY MainWndProc(
|
||||
|
||||
BITMAP local_bmp;
|
||||
GetObject(g_hBackgroundBmp, sizeof(local_bmp), &local_bmp);
|
||||
SetStretchBltMode( hdcScreenCompat, HALFTONE );
|
||||
SetStretchBltMode( hdcScreenCompat, g_SmoothImage ? HALFTONE : COLORONCOLOR );
|
||||
if( g_BreakBackgroundStretch ) {
|
||||
StretchBlt( hdcScreenCompat, 0, 0, width, height,
|
||||
g_hDcBackgroundFile, 0, 0, local_bmp.bmWidth, local_bmp.bmHeight, SRCCOPY|CAPTUREBLT );
|
||||
@@ -6842,7 +6891,6 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM
|
||||
WS_CHILD | MS_SHOWMAGNIFIEDCURSOR | WS_VISIBLE,
|
||||
0, 0, 0, 0, hWnd, NULL, g_hInstance, NULL );
|
||||
}
|
||||
|
||||
ShowWindow( hWnd, SW_SHOW );
|
||||
InvalidateRect( g_hWndLiveZoomMag, NULL, TRUE );
|
||||
|
||||
@@ -7555,6 +7603,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance
|
||||
"MagSetWindowTransform" );
|
||||
pMagSetFullscreenTransform = (type_pMagSetFullscreenTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
|
||||
"MagSetFullscreenTransform");
|
||||
pMagSetFullscreenUseBitmapSmoothing = (type_MagSetFullscreenUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
|
||||
"MagSetFullscreenUseBitmapSmoothing");
|
||||
pMagSetLensUseBitmapSmoothing = (type_pMagSetLensUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
|
||||
"MagSetLensUseBitmapSmoothing");
|
||||
pMagSetInputTransform = (type_pMagSetInputTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
|
||||
"MagSetInputTransform");
|
||||
pMagShowSystemCursor = (type_pMagShowSystemCursor)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
#define IDC_COPYRIGHT 1075
|
||||
#define IDC_PEN_WIDTH 1105
|
||||
#define IDC_TIMER 1106
|
||||
#define IDC_SMOOTH_IMAGE 1107
|
||||
#define IDC_SAVE 40002
|
||||
#define IDC_COPY 40004
|
||||
#define IDC_RECORD 40006
|
||||
@@ -109,7 +110,7 @@
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 118
|
||||
#define _APS_NEXT_COMMAND_VALUE 40013
|
||||
#define _APS_NEXT_CONTROL_VALUE 1076
|
||||
#define _APS_NEXT_CONTROL_VALUE 1078
|
||||
#define _APS_NEXT_SYMED_VALUE 101
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.CmdPal.Core.Common.Helpers;
|
||||
/// If ExecuteAsync is called while already executing, it cancels the current execution
|
||||
/// and starts the operation again (superseding behavior).
|
||||
/// </summary>
|
||||
public partial class SupersedingAsyncGate : IDisposable
|
||||
public sealed partial class SupersedingAsyncGate : IDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task> _action;
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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.Core.Common.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// An async gate that ensures only one value computation runs at a time.
|
||||
/// If ExecuteAsync is called while already executing, it cancels the current computation
|
||||
/// and starts the operation again (superseding behavior).
|
||||
/// Once a value is successfully computed, it is applied (via the provided <see cref="Action{T}"/>).
|
||||
/// The apply step uses its own lock so that long-running apply logic does not block the
|
||||
/// computation / superseding pipeline, while still remaining serialized with respect to
|
||||
/// other apply calls.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the computed value.</typeparam>
|
||||
public sealed partial class SupersedingAsyncValueGate<T> : IDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<T>> _valueFactory;
|
||||
private readonly Action<T> _apply;
|
||||
private readonly Lock _lock = new(); // Controls scheduling / superseding
|
||||
private readonly Lock _applyLock = new(); // Serializes application of results
|
||||
private int _callId;
|
||||
private TaskCompletionSource<T>? _currentTcs;
|
||||
private CancellationTokenSource? _currentCancellationSource;
|
||||
private Task? _executingTask;
|
||||
|
||||
public SupersedingAsyncValueGate(
|
||||
Func<CancellationToken, Task<T>> valueFactory,
|
||||
Action<T> apply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(valueFactory);
|
||||
ArgumentNullException.ThrowIfNull(apply);
|
||||
_valueFactory = valueFactory;
|
||||
_apply = apply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the configured value computation. If another execution is running, this call will
|
||||
/// cancel the current execution and restart the computation. The returned task completes when
|
||||
/// (and only if) the computation associated with this invocation completes (or is canceled / superseded).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Optional external cancellation token.</param>
|
||||
/// <returns>The computed value for this invocation.</returns>
|
||||
public async Task<T> ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
TaskCompletionSource<T> tcs;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Supersede any in-flight computation.
|
||||
_currentCancellationSource?.Cancel();
|
||||
_currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call"));
|
||||
|
||||
tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_currentTcs = tcs;
|
||||
_callId++;
|
||||
|
||||
if (_executingTask is null)
|
||||
{
|
||||
_executingTask = Task.Run(ExecuteLoop, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
using var ctr = cancellationToken.Register(state => ((TaskCompletionSource<T>)state!).TrySetCanceled(cancellationToken), tcs);
|
||||
return await tcs.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TaskCompletionSource<T>? currentTcs;
|
||||
CancellationTokenSource? currentCts;
|
||||
int currentCallId;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
currentTcs = _currentTcs;
|
||||
currentCallId = _callId;
|
||||
|
||||
if (currentTcs is null)
|
||||
{
|
||||
break; // Nothing pending.
|
||||
}
|
||||
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentCancellationSource = new();
|
||||
currentCts = _currentCancellationSource;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = await _valueFactory(currentCts.Token).ConfigureAwait(false);
|
||||
CompleteSuccessIfCurrent(currentTcs, currentCallId, value);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetCanceled(currentCts.Token));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetException(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentTcs = null;
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentCancellationSource = null;
|
||||
_executingTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteSuccessIfCurrent(TaskCompletionSource<T> candidate, int id, T value)
|
||||
{
|
||||
var shouldApply = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentTcs == candidate && _callId == id)
|
||||
{
|
||||
// Mark as consumed so a new computation can start immediately.
|
||||
_currentTcs = null;
|
||||
shouldApply = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply)
|
||||
{
|
||||
return; // Superseded meanwhile.
|
||||
}
|
||||
|
||||
Exception? applyException = null;
|
||||
try
|
||||
{
|
||||
lock (_applyLock)
|
||||
{
|
||||
_apply(value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
applyException = ex;
|
||||
}
|
||||
|
||||
if (applyException is null)
|
||||
{
|
||||
candidate.TrySetResult(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.TrySetException(applyException);
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteIfCurrent(
|
||||
TaskCompletionSource<T> candidate,
|
||||
int id,
|
||||
Action<TaskCompletionSource<T>> complete)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentTcs == candidate && _callId == id)
|
||||
{
|
||||
complete(candidate);
|
||||
_currentTcs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentCancellationSource?.Cancel();
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncValueGate<T>)));
|
||||
_currentTcs = null;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
// 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;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services;
|
||||
|
||||
public interface IRunHistoryService
|
||||
@@ -25,3 +23,12 @@ public interface IRunHistoryService
|
||||
/// <param name="item">The run history item to add.</param>
|
||||
void AddRunHistoryItem(string item);
|
||||
}
|
||||
|
||||
public interface ITelemetryService
|
||||
{
|
||||
void LogRunQuery(string query, int resultCount, ulong durationMs);
|
||||
|
||||
void LogRunCommand(string command, bool asAdmin, bool success);
|
||||
|
||||
void LogOpenUri(string uri, bool isWeb, bool success);
|
||||
}
|
||||
|
||||
@@ -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.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a navigation request within Command Palette view models.
|
||||
/// </summary>
|
||||
/// <param name="TargetViewModel">A view model that should be navigated to.</param>
|
||||
/// <param name="NavigationToken"> A <see cref="CancellationToken"/> that can be used to cancel the pending navigation.</param>
|
||||
public record AsyncNavigationRequest(object? TargetViewModel, CancellationToken NavigationToken);
|
||||
@@ -17,7 +17,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
public ExtensionObject<ICommandItem> Model => _commandItemModel;
|
||||
|
||||
private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
|
||||
private CommandContextItemViewModel? _defaultCommandContextItem;
|
||||
private CommandContextItemViewModel? _defaultCommandContextItemViewModel;
|
||||
|
||||
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
|
||||
|
||||
@@ -43,9 +43,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
public string Subtitle { get; private set; } = string.Empty;
|
||||
|
||||
private IconInfoViewModel _listItemIcon = new(null);
|
||||
private IconInfoViewModel _icon = new(null);
|
||||
|
||||
public IconInfoViewModel Icon => _listItemIcon.IsSet ? _listItemIcon : Command.Icon;
|
||||
public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon;
|
||||
|
||||
public CommandViewModel Command { get; private set; }
|
||||
|
||||
@@ -69,9 +69,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
{
|
||||
get
|
||||
{
|
||||
List<IContextItemViewModel> l = _defaultCommandContextItem is null ?
|
||||
List<IContextItemViewModel> l = _defaultCommandContextItemViewModel is null ?
|
||||
new() :
|
||||
[_defaultCommandContextItem];
|
||||
[_defaultCommandContextItemViewModel];
|
||||
|
||||
l.AddRange(MoreCommands);
|
||||
return l;
|
||||
@@ -136,11 +136,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
Command.InitializeProperties();
|
||||
|
||||
var listIcon = model.Icon;
|
||||
if (listIcon is not null)
|
||||
var icon = model.Icon;
|
||||
if (icon is not null)
|
||||
{
|
||||
_listItemIcon = new(listIcon);
|
||||
_listItemIcon.InitializeProperties();
|
||||
_icon = new(icon);
|
||||
_icon.InitializeProperties();
|
||||
}
|
||||
|
||||
// TODO: Do these need to go into FastInit?
|
||||
@@ -201,21 +201,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
if (!string.IsNullOrEmpty(model.Command?.Name))
|
||||
{
|
||||
_defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext)
|
||||
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
|
||||
{
|
||||
_itemTitle = Name,
|
||||
Subtitle = Subtitle,
|
||||
Command = Command,
|
||||
|
||||
// TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever
|
||||
// Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel.
|
||||
};
|
||||
|
||||
// Only set the icon on the context item for us if our command didn't
|
||||
// have its own icon
|
||||
if (!Command.HasIcon)
|
||||
{
|
||||
_defaultCommandContextItem._listItemIcon = _listItemIcon;
|
||||
}
|
||||
UpdateDefaultContextItemIcon();
|
||||
}
|
||||
|
||||
Initialized |= InitializedState.SelectionInitialized;
|
||||
@@ -238,7 +236,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
_listItemIcon = _errorIcon;
|
||||
_icon = _errorIcon;
|
||||
Initialized |= InitializedState.Error;
|
||||
}
|
||||
|
||||
@@ -275,7 +273,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
_listItemIcon = _errorIcon;
|
||||
_icon = _errorIcon;
|
||||
Initialized |= InitializedState.Error;
|
||||
}
|
||||
|
||||
@@ -305,17 +303,18 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(Command):
|
||||
if (Command is not null)
|
||||
{
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
}
|
||||
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
Command = new(model.Command, PageContext);
|
||||
Command.InitializeProperties();
|
||||
|
||||
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
|
||||
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
|
||||
_itemTitle = model.Title;
|
||||
|
||||
_defaultCommandContextItemViewModel?.Command = Command;
|
||||
_defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle);
|
||||
UpdateDefaultContextItemIcon();
|
||||
|
||||
UpdateProperty(nameof(Name));
|
||||
UpdateProperty(nameof(Title));
|
||||
UpdateProperty(nameof(Icon));
|
||||
@@ -326,12 +325,22 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
break;
|
||||
|
||||
case nameof(Subtitle):
|
||||
this.Subtitle = model.Subtitle;
|
||||
var modelSubtitle = model.Subtitle;
|
||||
this.Subtitle = modelSubtitle;
|
||||
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
|
||||
break;
|
||||
|
||||
case nameof(Icon):
|
||||
_listItemIcon = new(model.Icon);
|
||||
_listItemIcon.InitializeProperties();
|
||||
var oldIcon = _icon;
|
||||
_icon = new(model.Icon);
|
||||
_icon.InitializeProperties();
|
||||
if (oldIcon.IsSet || _icon.IsSet)
|
||||
{
|
||||
UpdateProperty(nameof(Icon));
|
||||
}
|
||||
|
||||
UpdateDefaultContextItemIcon();
|
||||
|
||||
break;
|
||||
|
||||
case nameof(model.MoreCommands):
|
||||
@@ -378,26 +387,49 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
var propertyName = e.PropertyName;
|
||||
var model = _commandItemModel.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(Command.Name):
|
||||
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
|
||||
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
|
||||
var model = _commandItemModel.Unsafe;
|
||||
if (model is not null)
|
||||
{
|
||||
_itemTitle = model.Title;
|
||||
}
|
||||
_itemTitle = model.Title;
|
||||
UpdateProperty(nameof(Title), nameof(Name));
|
||||
|
||||
UpdateProperty(nameof(Title));
|
||||
UpdateProperty(nameof(Name));
|
||||
_defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name);
|
||||
break;
|
||||
|
||||
case nameof(Command.Icon):
|
||||
UpdateDefaultContextItemIcon();
|
||||
UpdateProperty(nameof(Icon));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDefaultContextItemIcon()
|
||||
{
|
||||
// Command icon takes precedence over our icon on the primary command
|
||||
_defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
|
||||
}
|
||||
|
||||
private void UpdateTitle(string? title)
|
||||
{
|
||||
_itemTitle = title ?? string.Empty;
|
||||
UpdateProperty(nameof(Title));
|
||||
}
|
||||
|
||||
private void UpdateIcon(IIconInfo? iconInfo)
|
||||
{
|
||||
_icon = new(iconInfo);
|
||||
_icon.InitializeProperties();
|
||||
UpdateProperty(nameof(Icon));
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
@@ -411,10 +443,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
}
|
||||
|
||||
// _listItemIcon.SafeCleanup();
|
||||
_listItemIcon = new(null); // necessary?
|
||||
_icon = new(null); // necessary?
|
||||
|
||||
_defaultCommandContextItem?.SafeCleanup();
|
||||
_defaultCommandContextItem = null;
|
||||
_defaultCommandContextItemViewModel?.SafeCleanup();
|
||||
_defaultCommandContextItemViewModel = null;
|
||||
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
Command.SafeCleanup();
|
||||
|
||||
@@ -4,6 +4,4 @@
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
|
||||
|
||||
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation)
|
||||
{
|
||||
}
|
||||
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.Core.ViewModels;
|
||||
|
||||
internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
|
||||
: PageViewModel(null, scheduler, extensionHost);
|
||||
@@ -23,6 +23,9 @@ public partial class ShellViewModel : ObservableObject,
|
||||
private readonly Lock _invokeLock = new();
|
||||
private Task? _handleInvokeTask;
|
||||
|
||||
// Cancellation token source for page loading/navigation operations
|
||||
private CancellationTokenSource? _navigationCts;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsLoaded { get; set; } = false;
|
||||
|
||||
@@ -66,6 +69,8 @@ public partial class ShellViewModel : ObservableObject,
|
||||
|
||||
public bool IsNested => _isNested;
|
||||
|
||||
public PageViewModel NullPage { get; private set; }
|
||||
|
||||
public ShellViewModel(
|
||||
TaskScheduler scheduler,
|
||||
IRootPageService rootPageService,
|
||||
@@ -77,6 +82,7 @@ public partial class ShellViewModel : ObservableObject,
|
||||
_rootPageService = rootPageService;
|
||||
_appHostService = appHostService;
|
||||
|
||||
NullPage = new NullPageViewModel(_scheduler, appHostService.GetDefaultHost());
|
||||
_currentPage = new LoadingPageViewModel(null, _scheduler, appHostService.GetDefaultHost());
|
||||
|
||||
// Register to receive messages
|
||||
@@ -113,7 +119,7 @@ public partial class ShellViewModel : ObservableObject,
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task LoadPageViewModelAsync(PageViewModel viewModel)
|
||||
private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems.
|
||||
// IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar
|
||||
@@ -125,44 +131,80 @@ public partial class ShellViewModel : ObservableObject,
|
||||
if (!viewModel.IsInitialized
|
||||
&& viewModel.InitializeCommand is not null)
|
||||
{
|
||||
var outer = Task.Run(async () =>
|
||||
{
|
||||
// You know, this creates the situation where we wait for
|
||||
// both loading page properties, AND the items, before we
|
||||
// display anything.
|
||||
//
|
||||
// We almost need to do an async await on initialize, then
|
||||
// just a fire-and-forget on FetchItems.
|
||||
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
|
||||
// Definitely some more clean-up to do, but at least its centralized to one spot now.
|
||||
viewModel.InitializeCommand.Execute(null);
|
||||
|
||||
await viewModel.InitializeCommand.ExecutionTask!;
|
||||
|
||||
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
|
||||
var outer = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
|
||||
// You know, this creates the situation where we wait for
|
||||
// both loading page properties, AND the items, before we
|
||||
// display anything.
|
||||
//
|
||||
// We almost need to do an async await on initialize, then
|
||||
// just a fire-and-forget on FetchItems.
|
||||
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
|
||||
// Definitely some more clean-up to do, but at least its centralized to one spot now.
|
||||
viewModel.InitializeCommand.Execute(null);
|
||||
|
||||
await viewModel.InitializeCommand.ExecutionTask!;
|
||||
|
||||
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = Task.Factory.StartNew(
|
||||
() =>
|
||||
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
|
||||
{
|
||||
CurrentPage = viewModel;
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.None,
|
||||
_scheduler);
|
||||
await t;
|
||||
}
|
||||
});
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = Task.Factory.StartNew(
|
||||
() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (viewModel is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentPage = viewModel;
|
||||
},
|
||||
cancellationToken,
|
||||
TaskCreationOptions.None,
|
||||
_scheduler);
|
||||
await t;
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
await outer;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (viewModel is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentPage = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -174,6 +216,28 @@ public partial class ShellViewModel : ObservableObject,
|
||||
|
||||
private void PerformCommand(PerformCommandMessage message)
|
||||
{
|
||||
// Create/replace the navigation cancellation token.
|
||||
// If one already exists, cancel and dispose it first.
|
||||
var newCts = new CancellationTokenSource();
|
||||
var oldCts = Interlocked.Exchange(ref _navigationCts, newCts);
|
||||
if (oldCts is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
oldCts.Cancel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
oldCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
var navigationToken = newCts.Token;
|
||||
|
||||
var command = message.Command.Unsafe;
|
||||
if (command is null)
|
||||
{
|
||||
@@ -202,15 +266,26 @@ public partial class ShellViewModel : ObservableObject,
|
||||
}
|
||||
|
||||
// Kick off async loading of our ViewModel
|
||||
LoadPageViewModelAsync(pageViewModel)
|
||||
LoadPageViewModelAsync(pageViewModel, navigationToken)
|
||||
.ContinueWith(
|
||||
(Task t) =>
|
||||
{
|
||||
OnUIThread(() => { WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); });
|
||||
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation));
|
||||
// clean up the navigation token if it's still ours
|
||||
if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts)
|
||||
{
|
||||
newCts.Dispose();
|
||||
}
|
||||
|
||||
// When we're done loading the page, then update the command bar to match
|
||||
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
|
||||
},
|
||||
navigationToken,
|
||||
TaskContinuationOptions.None,
|
||||
_scheduler);
|
||||
|
||||
// While we're loading in the background, immediately move to the next page.
|
||||
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
|
||||
|
||||
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
|
||||
// See RootFrame_Navigated event handler.
|
||||
}
|
||||
@@ -368,4 +443,9 @@ public partial class ShellViewModel : ObservableObject,
|
||||
TaskCreationOptions.None,
|
||||
_scheduler);
|
||||
}
|
||||
|
||||
public void CancelNavigation()
|
||||
{
|
||||
_navigationCts?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.Ext.Apps;
|
||||
using Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
using Microsoft.CmdPal.Ext.Apps.State;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
@@ -36,6 +38,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
private List<Scored<IListItem>>? _filteredItems;
|
||||
private List<Scored<IListItem>>? _filteredApps;
|
||||
private List<Scored<IListItem>>? _fallbackItems;
|
||||
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
|
||||
private bool _includeApps;
|
||||
private bool _filteredItemsIncludesApps;
|
||||
private int _appResultLimit = 10;
|
||||
@@ -160,7 +163,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
{
|
||||
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
|
||||
// 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>>()
|
||||
.Concat(_filteredItems is not null ? _filteredItems : [])
|
||||
.Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
|
||||
.Concat(limitedApps)
|
||||
.OrderByDescending(o => o.Score)
|
||||
|
||||
@@ -184,6 +188,14 @@ public partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearResults()
|
||||
{
|
||||
_filteredItems = null;
|
||||
_filteredApps = null;
|
||||
_fallbackItems = null;
|
||||
_scoredFallbackItems = null;
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
var timer = new Stopwatch();
|
||||
@@ -216,8 +228,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
lock (_tlcManager.TopLevelCommands)
|
||||
{
|
||||
_filteredItemsIncludesApps = _includeApps;
|
||||
_filteredItems = null;
|
||||
_filteredApps = null;
|
||||
ClearResults();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,9 +255,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
if (string.IsNullOrEmpty(newSearch))
|
||||
{
|
||||
_filteredItemsIncludesApps = _includeApps;
|
||||
_filteredItems = null;
|
||||
_filteredApps = null;
|
||||
_fallbackItems = null;
|
||||
ClearResults();
|
||||
RaiseItemsChanged(commands.Count);
|
||||
return;
|
||||
}
|
||||
@@ -255,17 +264,13 @@ public partial class MainListPage : DynamicListPage,
|
||||
// re-use previous results. Reset _filteredItems, and keep er moving.
|
||||
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
_filteredItems = null;
|
||||
_filteredApps = null;
|
||||
_fallbackItems = null;
|
||||
ClearResults();
|
||||
}
|
||||
|
||||
// If the internal state has changed, reset _filteredItems to reset the list.
|
||||
if (_filteredItemsIncludesApps != _includeApps)
|
||||
{
|
||||
_filteredItems = null;
|
||||
_filteredApps = null;
|
||||
_fallbackItems = null;
|
||||
ClearResults();
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
@@ -273,9 +278,9 @@ public partial class MainListPage : DynamicListPage,
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<IListItem> newFilteredItems = Enumerable.Empty<IListItem>();
|
||||
IEnumerable<IListItem> newFallbacks = Enumerable.Empty<IListItem>();
|
||||
IEnumerable<IListItem> newApps = Enumerable.Empty<IListItem>();
|
||||
var newFilteredItems = Enumerable.Empty<IListItem>();
|
||||
var newFallbacks = Enumerable.Empty<IListItem>();
|
||||
var newApps = Enumerable.Empty<IListItem>();
|
||||
|
||||
if (_filteredItems is not null)
|
||||
{
|
||||
@@ -314,7 +319,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
// We're going to start over with our fallbacks
|
||||
newFallbacks = Enumerable.Empty<IListItem>();
|
||||
|
||||
newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId));
|
||||
newFilteredItems = commands.Where(s => !s.IsFallback);
|
||||
|
||||
// Fallbacks are always included in the list, even if they
|
||||
// don't match the search text. But we don't want to
|
||||
@@ -330,7 +335,20 @@ public partial class MainListPage : DynamicListPage,
|
||||
|
||||
if (_includeApps)
|
||||
{
|
||||
newApps = AllAppsCommandProvider.Page.GetItems().ToList();
|
||||
var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
|
||||
|
||||
// We need to remove pinned apps from allNewApps so they don't show twice.
|
||||
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
|
||||
|
||||
if (pinnedApps.Length > 0)
|
||||
{
|
||||
newApps = allNewApps.Where(w =>
|
||||
pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
newApps = allNewApps;
|
||||
}
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
@@ -339,8 +357,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.
|
||||
_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)
|
||||
{
|
||||
@@ -358,7 +393,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
// Produce a list of filtered apps with the appropriate limit
|
||||
if (newApps.Any())
|
||||
{
|
||||
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, ScoreTopLevelItem);
|
||||
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -425,7 +460,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
|
||||
// fact that we want fallback handlers down-weighted, so that they don't
|
||||
// _always_ show up first.
|
||||
private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem)
|
||||
internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
|
||||
{
|
||||
var title = topLevelOrAppItem.Title;
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -501,10 +536,9 @@ public partial class MainListPage : DynamicListPage,
|
||||
// here we add the recent command weight boost
|
||||
//
|
||||
// Otherwise something like `x` will still match everything you've run before
|
||||
var finalScore = matchSomething;
|
||||
var finalScore = matchSomething * 10;
|
||||
if (matchSomething > 0)
|
||||
{
|
||||
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands;
|
||||
var recentWeightBoost = history.GetCommandHistoryWeight(id);
|
||||
finalScore += recentWeightBoost;
|
||||
}
|
||||
@@ -521,7 +555,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
AppStateModel.SaveState(state);
|
||||
}
|
||||
|
||||
private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
|
||||
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
|
||||
{
|
||||
if (topLevelOrAppItem is TopLevelViewModel topLevel)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
public partial class RecentCommandsManager : ObservableObject
|
||||
public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager
|
||||
{
|
||||
[JsonInclude]
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ public partial class SettingsModel : ObservableObject
|
||||
|
||||
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
|
||||
|
||||
public bool DisableAnimations { get; set; } = true;
|
||||
|
||||
// END SETTINGS
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@@ -128,6 +128,16 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public bool DisableAnimations
|
||||
{
|
||||
get => _settings.DisableAnimations;
|
||||
set
|
||||
{
|
||||
_settings.DisableAnimations = value;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = [];
|
||||
|
||||
public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler)
|
||||
|
||||
@@ -114,7 +114,7 @@ public partial class App : Application
|
||||
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
|
||||
services.AddSingleton<ICommandProvider>(files);
|
||||
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
|
||||
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore());
|
||||
|
||||
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();
|
||||
@@ -160,7 +160,7 @@ public partial class App : Application
|
||||
|
||||
services.AddSingleton<IRootPageService, PowerToysRootPageService>();
|
||||
services.AddSingleton<IAppHostService, PowerToysAppHostService>();
|
||||
services.AddSingleton(new TelemetryForwarder());
|
||||
services.AddSingleton<ITelemetryService, TelemetryForwarder>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
|
||||
Background="Transparent"
|
||||
PreviewKeyDown="UserControl_PreviewKeyDown"
|
||||
mc:Ignorable="d">
|
||||
|
||||
@@ -22,7 +21,7 @@
|
||||
<ResourceDictionary>
|
||||
<cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" />
|
||||
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
|
||||
|
||||
<Thickness x:Key="DefaultContextMenuItemPadding">12,8,12,8</Thickness>
|
||||
<cmdpalUI:ContextItemTemplateSelector
|
||||
x:Key="ContextItemTemplateSelector"
|
||||
Critical="{StaticResource CriticalContextMenuViewModelTemplate}"
|
||||
@@ -31,7 +30,7 @@
|
||||
|
||||
<!-- Template for context items in the context item menu -->
|
||||
<DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title}">
|
||||
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="32" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -42,7 +41,7 @@
|
||||
Height="16"
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
SourceKey="{x:Bind Icon}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
@@ -51,11 +50,11 @@
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{x:Bind Title}"
|
||||
Text="{x:Bind Title, Mode=OneWay}"
|
||||
TextTrimming="WordEllipsis"
|
||||
TextWrapping="NoWrap">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</ToolTipService.ToolTip>
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
@@ -65,13 +64,13 @@
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource MenuFlyoutItemKeyboardAcceleratorTextForeground}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Template for context items flagged as critical -->
|
||||
<DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title}">
|
||||
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="32" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -83,7 +82,7 @@
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
SourceKey="{x:Bind Icon}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
@@ -93,11 +92,11 @@
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Style="{StaticResource ContextItemTitleTextBlockCriticalStyle}"
|
||||
Text="{x:Bind Title}"
|
||||
Text="{x:Bind Title, Mode=OneWay}"
|
||||
TextTrimming="WordEllipsis"
|
||||
TextWrapping="NoWrap">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</ToolTipService.ToolTip>
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
@@ -106,7 +105,7 @@
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ContextItemCaptionTextBlockCriticalStyle}"
|
||||
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -114,7 +113,7 @@
|
||||
<DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
|
||||
<Rectangle
|
||||
Height="1"
|
||||
Margin="-16,-12,-12,-12"
|
||||
Margin="0,2,0,2"
|
||||
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
|
||||
</DataTemplate>
|
||||
</ResourceDictionary>
|
||||
@@ -125,35 +124,39 @@
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel x:Name="CommandsPanel">
|
||||
<ListView
|
||||
x:Name="CommandsDropdown"
|
||||
MinWidth="248"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="CommandsDropdown_ItemClick"
|
||||
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
|
||||
SelectionMode="Single">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemContainerTransitions>
|
||||
<TransitionCollection />
|
||||
</ListView.ItemContainerTransitions>
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
<ListView
|
||||
x:Name="CommandsDropdown"
|
||||
MinWidth="248"
|
||||
Margin="0,4,0,2"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="CommandsDropdown_ItemClick"
|
||||
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
|
||||
SelectionMode="Single">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemContainerTransitions>
|
||||
<TransitionCollection />
|
||||
</ListView.ItemContainerTransitions>
|
||||
</ListView>
|
||||
<Border BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}" BorderThickness="0,0,0,1" />
|
||||
<TextBox
|
||||
x:Name="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"
|
||||
KeyDown="ContextFilterBox_KeyDown"
|
||||
PreviewKeyDown="ContextFilterBox_PreviewKeyDown"
|
||||
Style="{StaticResource SearchTextBoxStyle}"
|
||||
TextChanged="ContextFilterBox_TextChanged" />
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="ContextMenuOrder">
|
||||
@@ -162,9 +165,11 @@
|
||||
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" />
|
||||
</VisualState.StateTriggers>
|
||||
<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="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>
|
||||
<VisualState x:Name="FilterOnBottom">
|
||||
@@ -172,9 +177,11 @@
|
||||
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" />
|
||||
</VisualState.StateTriggers>
|
||||
<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="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>
|
||||
</VisualStateGroup>
|
||||
|
||||
80
src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs
Normal file
80
src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.Tracing;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.PowerToys.Telemetry.Events;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Events;
|
||||
|
||||
// Just put all the run events in one file for simplicity.
|
||||
#pragma warning disable SA1402 // File may only contain a single type
|
||||
#pragma warning disable SA1649 // File name should match first type name
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalRunQuery : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Query { get; set; }
|
||||
|
||||
public int ResultCount { get; set; }
|
||||
|
||||
public ulong DurationMs { get; set; }
|
||||
|
||||
public CmdPalRunQuery(string query, int resultCount, ulong durationMs)
|
||||
{
|
||||
EventName = "CmdPal_RunQuery";
|
||||
Query = query;
|
||||
ResultCount = resultCount;
|
||||
DurationMs = durationMs;
|
||||
}
|
||||
}
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalRunCommand : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Command { get; set; }
|
||||
|
||||
public bool AsAdmin { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public CmdPalRunCommand(string command, bool asAdmin, bool success)
|
||||
{
|
||||
EventName = "CmdPal_RunCommand";
|
||||
Command = command;
|
||||
AsAdmin = asAdmin;
|
||||
Success = success;
|
||||
}
|
||||
}
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalOpenUri : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Uri { get; set; }
|
||||
|
||||
public bool IsWeb { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public CmdPalOpenUri(string uri, bool isWeb, bool success)
|
||||
{
|
||||
EventName = "CmdPal_OpenUri";
|
||||
Uri = uri;
|
||||
IsWeb = isWeb;
|
||||
Success = success;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore SA1649 // File name should match first type name
|
||||
#pragma warning restore SA1402 // File may only contain a single type
|
||||
@@ -46,11 +46,18 @@ public sealed partial class ContentPage : Page,
|
||||
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
if (e.Parameter is ContentPageViewModel vm)
|
||||
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
|
||||
{
|
||||
ViewModel = vm;
|
||||
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
|
||||
}
|
||||
|
||||
if (navigationRequest.TargetViewModel is not ContentPageViewModel contentPageViewModel)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ContentPageViewModel)}");
|
||||
}
|
||||
|
||||
ViewModel = contentPageViewModel;
|
||||
|
||||
if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSelectedListItemMessage>(this))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
|
||||
|
||||
@@ -59,11 +59,18 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
if (e.Parameter is ListViewModel lvm)
|
||||
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
|
||||
{
|
||||
ViewModel = lvm;
|
||||
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
|
||||
}
|
||||
|
||||
if (navigationRequest.TargetViewModel is not ListViewModel listViewModel)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ListViewModel)}");
|
||||
}
|
||||
|
||||
ViewModel = listViewModel;
|
||||
|
||||
if (e.NavigationMode == NavigationMode.Back
|
||||
|| (e.NavigationMode == NavigationMode.New && ItemView.Items.Count > 0))
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.Events;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
@@ -19,6 +20,7 @@ namespace Microsoft.CmdPal.UI;
|
||||
/// or something similar, but this works for now.
|
||||
/// </summary>
|
||||
internal sealed class TelemetryForwarder :
|
||||
ITelemetryService,
|
||||
IRecipient<BeginInvokeMessage>,
|
||||
IRecipient<CmdPalInvokeResultMessage>
|
||||
{
|
||||
@@ -37,4 +39,19 @@ internal sealed class TelemetryForwarder :
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
|
||||
}
|
||||
|
||||
public void LogRunQuery(string query, int resultCount, ulong durationMs)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalRunQuery(query, resultCount, durationMs));
|
||||
}
|
||||
|
||||
public void LogRunCommand(string command, bool asAdmin, bool success)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalRunCommand(command, asAdmin, success));
|
||||
}
|
||||
|
||||
public void LogOpenUri(string uri, bool isWeb, bool success)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalOpenUri(uri, isWeb, success));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,33 +360,51 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private void HideWindow()
|
||||
{
|
||||
// Cloak our HWND to avoid all animations.
|
||||
Cloak();
|
||||
var cloaked = Cloak();
|
||||
|
||||
// Then hide our HWND, to make sure that the OS gives the FG / focus back to another app
|
||||
// (there's no way for us to guess what the right hwnd might be, only the OS can do it right)
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
|
||||
|
||||
// TRICKY: show our HWND again. This will trick XAML into painting our
|
||||
// HWND again, so that we avoid the "flicker" caused by a WinUI3 app
|
||||
// window being first shown
|
||||
// SW_SHOWNA will prevent us for trying to fight the focus back
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA);
|
||||
if (cloaked)
|
||||
{
|
||||
// TRICKY: show our HWND again. This will trick XAML into painting our
|
||||
// HWND again, so that we avoid the "flicker" caused by a WinUI3 app
|
||||
// window being first shown
|
||||
// SW_SHOWNA will prevent us for trying to fight the focus back
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA);
|
||||
|
||||
// Intentionally leave the window cloaked. So our window is "visible",
|
||||
// but also cloaked, so you can't see it.
|
||||
// Intentionally leave the window cloaked. So our window is "visible",
|
||||
// but also cloaked, so you can't see it.
|
||||
|
||||
// If the window was not cloaked, then leave it hidden.
|
||||
// Sure, it's not ideal, but at least it's not visible.
|
||||
}
|
||||
}
|
||||
|
||||
private void Cloak()
|
||||
private bool Cloak()
|
||||
{
|
||||
bool wasCloaked;
|
||||
unsafe
|
||||
{
|
||||
BOOL value = true;
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
||||
var hr = PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
||||
if (hr.Failed)
|
||||
{
|
||||
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
|
||||
}
|
||||
|
||||
wasCloaked = hr.Succeeded;
|
||||
}
|
||||
|
||||
// Because we're only cloaking the window, bury it at the bottom in case something can
|
||||
// see it - e.g. some accessibility helper (note: this also removes the top-most status).
|
||||
PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
if (wasCloaked)
|
||||
{
|
||||
// Because we're only cloaking the window, bury it at the bottom in case something can
|
||||
// see it - e.g. some accessibility helper (note: this also removes the top-most status).
|
||||
PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
}
|
||||
|
||||
return wasCloaked;
|
||||
}
|
||||
|
||||
private void Uncloak()
|
||||
|
||||
@@ -23,24 +23,30 @@ public sealed partial class LoadingPage : Page
|
||||
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
if (e.Parameter is ShellViewModel shellVM
|
||||
&& shellVM.LoadCommand is not null)
|
||||
if (e.Parameter is not AsyncNavigationRequest request)
|
||||
{
|
||||
// This will load the built-in commands, then navigate to the main page.
|
||||
// Once the mainpage loads, we'll start loading extensions.
|
||||
shellVM.LoadCommand.Execute(null);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await shellVM.LoadCommand.ExecutionTask!;
|
||||
|
||||
if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
|
||||
{
|
||||
// TODO: Handle failure case
|
||||
}
|
||||
});
|
||||
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
|
||||
}
|
||||
|
||||
if (request.TargetViewModel is not ShellViewModel shellVM)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ShellViewModel)}");
|
||||
}
|
||||
|
||||
// This will load the built-in commands, then navigate to the main page.
|
||||
// Once the mainpage loads, we'll start loading extensions.
|
||||
shellVM.LoadCommand.Execute(null);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await shellVM.LoadCommand.ExecutionTask!;
|
||||
|
||||
if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
|
||||
{
|
||||
// TODO: Handle failure case
|
||||
}
|
||||
});
|
||||
|
||||
base.OnNavigatedTo(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +95,24 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
|
||||
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
|
||||
|
||||
RootFrame.Navigate(typeof(LoadingPage), ViewModel);
|
||||
RootFrame.Navigate(typeof(LoadingPage), new AsyncNavigationRequest(ViewModel, CancellationToken.None));
|
||||
|
||||
var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0");
|
||||
_pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default page animation, depending on the settings
|
||||
/// </summary>
|
||||
private NavigationTransitionInfo DefaultPageAnimation
|
||||
{
|
||||
get
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>()!;
|
||||
return settings.DisableAnimations ? _noAnimation : _slideRightTransition;
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(NavigateBackMessage message)
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>()!;
|
||||
@@ -141,8 +153,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
ContentPageViewModel => typeof(ContentPage),
|
||||
_ => throw new NotSupportedException(),
|
||||
},
|
||||
message.Page,
|
||||
message.WithAnimation ? _slideRightTransition : _noAnimation);
|
||||
new AsyncNavigationRequest(message.Page, message.CancellationToken),
|
||||
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
|
||||
|
||||
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
|
||||
|
||||
@@ -391,6 +403,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
{
|
||||
HideDetails();
|
||||
|
||||
ViewModel.CancelNavigation();
|
||||
|
||||
// Note: That we restore the VM state below in RootFrame_Navigated call back after this occurs.
|
||||
// In the future, we may want to manage the back stack ourselves vs. relying on Frame
|
||||
// We could replace Frame with a ContentPresenter, but then have to manage transition animations ourselves.
|
||||
@@ -444,11 +458,32 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
// This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter.
|
||||
// This is currently used for both forward and backward navigation.
|
||||
// As when we go back that we restore ourselves to the proper state within our VM
|
||||
if (e.Parameter is PageViewModel page)
|
||||
if (e.Parameter is AsyncNavigationRequest request)
|
||||
{
|
||||
// Note, this shortcuts and fights a bit with our LoadPageViewModel above, but we want to better fast display and incrementally load anyway
|
||||
// We just need to reconcile our loading systems a bit more in the future.
|
||||
ViewModel.CurrentPage = page;
|
||||
if (request.NavigationToken.IsCancellationRequested && e.NavigationMode is not (Microsoft.UI.Xaml.Navigation.NavigationMode.Back or Microsoft.UI.Xaml.Navigation.NavigationMode.Forward))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (request.TargetViewModel)
|
||||
{
|
||||
case PageViewModel pageViewModel:
|
||||
ViewModel.CurrentPage = pageViewModel;
|
||||
break;
|
||||
case ShellViewModel:
|
||||
// This one is an exception, for now (LoadingPage is tied to ShellViewModel,
|
||||
// but ShellViewModel is not PageViewModel.
|
||||
ViewModel.CurrentPage = ViewModel.NullPage;
|
||||
break;
|
||||
default:
|
||||
ViewModel.CurrentPage = ViewModel.NullPage;
|
||||
Logger.LogWarning($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(PageViewModel)}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Unrecognized target for shell navigation: " + e.Parameter);
|
||||
}
|
||||
|
||||
if (e.Content is Page element)
|
||||
@@ -549,19 +584,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
private static void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (e.Key == VirtualKey.Left && e.KeyStatus.IsMenuKeyDown)
|
||||
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
|
||||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
|
||||
|
||||
var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed;
|
||||
if (e.Key == VirtualKey.Left && onlyAlt)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Home && onlyAlt)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false));
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
|
||||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
|
||||
|
||||
// The CommandBar is responsible for handling all the item keybindings,
|
||||
// since the bound context item may need to then show another
|
||||
// context menu
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
<ToggleSwitch IsOn="{x:Bind viewModel.ShowSystemTrayIcon, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard x:Uid="Settings_GeneralPage_DisableAnimations_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch IsOn="{x:Bind viewModel.DisableAnimations, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- 'For Developers' section -->
|
||||
|
||||
<TextBlock x:Uid="ForDevelopersSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
@@ -407,6 +407,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Choose if Command Palette is visible in the system tray</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Disable animations</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Disable animations when switching between pages</value>
|
||||
</data>
|
||||
<data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkDataTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void BookmarkDataWebUrlDetection()
|
||||
{
|
||||
// Act
|
||||
var webBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Test Site",
|
||||
Bookmark = "https://test.com",
|
||||
};
|
||||
|
||||
var nonWebBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Local File",
|
||||
Bookmark = "C:\\temp\\file.txt",
|
||||
};
|
||||
|
||||
var placeholderBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Placeholder",
|
||||
Bookmark = "{Placeholder}",
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(webBookmark.IsWebUrl());
|
||||
Assert.IsFalse(webBookmark.IsPlaceholder);
|
||||
Assert.IsFalse(nonWebBookmark.IsWebUrl());
|
||||
Assert.IsFalse(nonWebBookmark.IsPlaceholder);
|
||||
|
||||
Assert.IsTrue(placeholderBookmark.IsPlaceholder);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
@@ -191,7 +193,7 @@ public class BookmarkJsonParserTests
|
||||
public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarks = new Bookmarks
|
||||
var bookmarks = new BookmarksData
|
||||
{
|
||||
Data = new List<BookmarkData>
|
||||
{
|
||||
@@ -216,7 +218,7 @@ public class BookmarkJsonParserTests
|
||||
public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarks = new Bookmarks();
|
||||
var bookmarks = new BookmarksData();
|
||||
|
||||
// Act
|
||||
var result = _parser.SerializeBookmarks(bookmarks);
|
||||
@@ -241,7 +243,7 @@ public class BookmarkJsonParserTests
|
||||
public void ParseBookmarks_RoundTripSerialization_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var originalBookmarks = new Bookmarks
|
||||
var originalBookmarks = new BookmarksData
|
||||
{
|
||||
Data = new List<BookmarkData>
|
||||
{
|
||||
@@ -263,7 +265,6 @@ public class BookmarkJsonParserTests
|
||||
{
|
||||
Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name);
|
||||
Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark);
|
||||
Assert.AreEqual(originalBookmarks.Data[i].IsPlaceholder, parsedBookmarks.Data[i].IsPlaceholder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,70 +297,6 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Data.Count);
|
||||
|
||||
Assert.IsFalse(result.Data[0].IsPlaceholder);
|
||||
Assert.IsTrue(result.Data[1].IsPlaceholder);
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_IsWebUrl_CorrectlyIdentifiesWebUrls()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"Data": [
|
||||
{
|
||||
"Name": "HTTPS Website",
|
||||
"Bookmark": "https://www.google.com"
|
||||
},
|
||||
{
|
||||
"Name": "HTTP Website",
|
||||
"Bookmark": "http://example.com"
|
||||
},
|
||||
{
|
||||
"Name": "Website without protocol",
|
||||
"Bookmark": "www.github.com"
|
||||
},
|
||||
{
|
||||
"Name": "Local File Path",
|
||||
"Bookmark": "C:\\Users\\test\\Documents\\file.txt"
|
||||
},
|
||||
{
|
||||
"Name": "Network Path",
|
||||
"Bookmark": "\\\\server\\share\\file.txt"
|
||||
},
|
||||
{
|
||||
"Name": "Executable",
|
||||
"Bookmark": "notepad.exe"
|
||||
},
|
||||
{
|
||||
"Name": "File URI",
|
||||
"Bookmark": "file:///C:/temp/file.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.ParseBookmarks(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(7, result.Data.Count);
|
||||
|
||||
// Web URLs should return true
|
||||
Assert.IsTrue(result.Data[0].IsWebUrl(), "HTTPS URL should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTP URL should be identified as web URL");
|
||||
|
||||
// This case will fail. We need to consider if we need to support pure domain value in bookmark.
|
||||
// Assert.IsTrue(result.Data[2].IsWebUrl(), "Domain without protocol should be identified as web URL");
|
||||
|
||||
// Non-web URLs should return false
|
||||
Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file path should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[4].IsWebUrl(), "Network path should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[5].IsWebUrl(), "Executable should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[6].IsWebUrl(), "File URI should not be identified as web URL");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -415,23 +352,10 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(9, result.Data.Count);
|
||||
|
||||
// Should be identified as placeholders
|
||||
Assert.IsTrue(result.Data[0].IsPlaceholder, "Simple placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[1].IsPlaceholder, "Multiple placeholders should be identified");
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder, "Web URL with placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[3].IsPlaceholder, "Complex placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[8].IsPlaceholder, "Empty placeholder should be identified");
|
||||
|
||||
// Should NOT be identified as placeholders
|
||||
Assert.IsFalse(result.Data[4].IsPlaceholder, "Regular URL should not be placeholder");
|
||||
Assert.IsFalse(result.Data[5].IsPlaceholder, "Local file should not be placeholder");
|
||||
Assert.IsFalse(result.Data[6].IsPlaceholder, "Only opening brace should not be placeholder");
|
||||
Assert.IsFalse(result.Data[7].IsPlaceholder, "Only closing brace should not be placeholder");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesBothWebUrlAndPlaceholder()
|
||||
public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesPlaceholder()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
@@ -463,73 +387,5 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(4, result.Data.Count);
|
||||
|
||||
// Web URL with placeholder
|
||||
Assert.IsTrue(result.Data[0].IsWebUrl(), "Web URL with placeholder should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[0].IsPlaceholder, "Web URL with placeholder should be identified as placeholder");
|
||||
|
||||
// Web URL without placeholder
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "Web URL without placeholder should be identified as web URL");
|
||||
Assert.IsFalse(result.Data[1].IsPlaceholder, "Web URL without placeholder should not be identified as placeholder");
|
||||
|
||||
// Local file with placeholder
|
||||
Assert.IsFalse(result.Data[2].IsWebUrl(), "Local file with placeholder should not be identified as web URL");
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder, "Local file with placeholder should be identified as placeholder");
|
||||
|
||||
// Local file without placeholder
|
||||
Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file without placeholder should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[3].IsPlaceholder, "Local file without placeholder should not be identified as placeholder");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_EdgeCaseUrls_CorrectlyIdentifiesWebUrls()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"Data": [
|
||||
{
|
||||
"Name": "FTP URL",
|
||||
"Bookmark": "ftp://files.example.com"
|
||||
},
|
||||
{
|
||||
"Name": "HTTPS with port",
|
||||
"Bookmark": "https://localhost:8080"
|
||||
},
|
||||
{
|
||||
"Name": "IP Address",
|
||||
"Bookmark": "http://192.168.1.1"
|
||||
},
|
||||
{
|
||||
"Name": "Subdomain",
|
||||
"Bookmark": "https://api.github.com"
|
||||
},
|
||||
{
|
||||
"Name": "Domain only",
|
||||
"Bookmark": "example.com"
|
||||
},
|
||||
{
|
||||
"Name": "Not a URL - no dots",
|
||||
"Bookmark": "localhost"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.ParseBookmarks(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(6, result.Data.Count);
|
||||
|
||||
Assert.IsFalse(result.Data[0].IsWebUrl(), "FTP URL should not be identified as web URL");
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTPS with port should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[2].IsWebUrl(), "IP Address with HTTP should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[3].IsWebUrl(), "Subdomain should be identified as web URL");
|
||||
|
||||
// This case will fail. We need to consider if we need to support pure domain value in bookmark.
|
||||
// Assert.IsTrue(result.Data[4].IsWebUrl(), "Domain only should be identified as web URL");
|
||||
Assert.IsFalse(result.Data[5].IsWebUrl(), "Single word without dots should not be identified as web URL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkManagerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void BookmarkManager_CanBeInstantiated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarkManager);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitialBookmarksEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitialBookmarksCorruptedData()
|
||||
{
|
||||
// Arrange
|
||||
var json = "@*>$ß Corrupted data. Hey, this is not JSON!";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitializeWithExistingData()
|
||||
{
|
||||
// Arrange
|
||||
const string json = """
|
||||
{
|
||||
"Data":[
|
||||
{"Id":"d290f1ee-6c54-4b01-90e6-d701748f0851","Name":"Bookmark1","Bookmark":"C:\\Path1"},
|
||||
{"Id":"c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a","Name":"Bookmark2","Bookmark":"D:\\Path2"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks?.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(2, bookmarks.Count);
|
||||
|
||||
Assert.AreEqual("Bookmark1", bookmarks[0].Name);
|
||||
Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark);
|
||||
Assert.AreEqual(Guid.Parse("d290f1ee-6c54-4b01-90e6-d701748f0851"), bookmarks[0].Id);
|
||||
|
||||
Assert.AreEqual("Bookmark2", bookmarks[1].Name);
|
||||
Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark);
|
||||
Assert.AreEqual(Guid.Parse("c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a"), bookmarks[1].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitializeWithLegacyData_GeneratesIds()
|
||||
{
|
||||
// Arrange
|
||||
const string json = """
|
||||
{
|
||||
"Data":
|
||||
[
|
||||
{ "Name":"Bookmark1", "Bookmark":"C:\\Path1" },
|
||||
{ "Name":"Bookmark2", "Bookmark":"D:\\Path2" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks?.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(2, bookmarks.Count);
|
||||
|
||||
Assert.AreEqual("Bookmark1", bookmarks[0].Name);
|
||||
Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark);
|
||||
Assert.AreNotEqual(Guid.Empty, bookmarks[0].Id);
|
||||
|
||||
Assert.AreEqual("Bookmark2", bookmarks[1].Name);
|
||||
Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark);
|
||||
Assert.AreNotEqual(Guid.Empty, bookmarks[1].Id);
|
||||
|
||||
Assert.AreNotEqual(bookmarks[0].Id, bookmarks[1].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_AddBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var bookmarkAddedEventFired = false;
|
||||
bookmarkManager.BookmarkAdded += (bookmark) =>
|
||||
{
|
||||
bookmarkAddedEventFired = true;
|
||||
Assert.AreEqual("TestBookmark", bookmark.Name);
|
||||
Assert.AreEqual("C:\\TestPath", bookmark.Bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.AreEqual(1, bookmarks.Count);
|
||||
Assert.AreEqual(addedBookmark, bookmarks.First());
|
||||
Assert.IsTrue(bookmarkAddedEventFired);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_RemoveBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
var bookmarkRemovedEventFired = false;
|
||||
bookmarkManager.BookmarkRemoved += (bookmark) =>
|
||||
{
|
||||
bookmarkRemovedEventFired = true;
|
||||
Assert.AreEqual(addedBookmark, bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var removeResult = bookmarkManager.Remove(addedBookmark.Id);
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.IsTrue(removeResult);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
Assert.IsTrue(bookmarkRemovedEventFired);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_UpdateBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
var bookmarkUpdatedEventFired = false;
|
||||
bookmarkManager.BookmarkUpdated += (data, bookmarkData) =>
|
||||
{
|
||||
bookmarkUpdatedEventFired = true;
|
||||
Assert.AreEqual(addedBookmark, data);
|
||||
Assert.AreEqual("UpdatedBookmark", bookmarkData.Name);
|
||||
Assert.AreEqual("D:\\UpdatedPath", bookmarkData.Bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var updatedBookmark = bookmarkManager.Update(addedBookmark.Id, "UpdatedBookmark", "D:\\UpdatedPath");
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.IsNotNull(updatedBookmark);
|
||||
Assert.AreEqual(1, bookmarks.Count);
|
||||
Assert.AreEqual(updatedBookmark, bookmarks.First());
|
||||
Assert.AreEqual("UpdatedBookmark", updatedBookmark.Name);
|
||||
Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark);
|
||||
Assert.IsTrue(bookmarkUpdatedEventFired);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// 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.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(CommonClassificationData.CommonCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateCommonClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(CommonClassificationData.UwpAumidCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUwpAumidClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedRelativePaths), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUnquotedRelativePathScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedShellProtocol), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUnquotedShellProtocolScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
private static class CommonClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> CommonCases()
|
||||
{
|
||||
return
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTPS URL",
|
||||
Input: "https://microsoft.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://microsoft.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL without scheme",
|
||||
Input: "www.example.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://www.example.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTP URL with query",
|
||||
Input: "http://yahoo.com?p=search",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "http://yahoo.com?p=search",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mailto protocol",
|
||||
Input: "mailto:user@example.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "mailto:user@example.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "MS-Settings protocol",
|
||||
Input: "ms-settings:display",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "ms-settings:display",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Custom protocol",
|
||||
Input: "myapp:doit",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "myapp:doit",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Not really a valid protocol",
|
||||
Input: "this is not really a protocol myapp: doit",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "this",
|
||||
ExpectedArguments: "is not really a protocol myapp: doit",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Drive",
|
||||
Input: "C:",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: "C:\\",
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Non-existing path with extension",
|
||||
Input: "C:\\this-folder-should-not-exist-12345\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\this-folder-should-not-exist-12345\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unknown fallback",
|
||||
Input: "some_unlikely_command_name_12345",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "some_unlikely_command_name_12345",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
|
||||
[new PlaceholderClassificationCase(
|
||||
Name: "Simple unquoted executable path",
|
||||
Input: "C:\\Windows\\System32\\notepad.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Windows\\System32\\notepad.exe",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted document path (non existed file)",
|
||||
Input: "C:\\Users\\John\\Documents\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Users\\John\\Documents\\file.txt",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> UwpAumidCases() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with AppsFolder prefix",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with AppsFolder prefix and argument (Trap)",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID via AppsFolder",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> UnquotedShellProtocol() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell for This PC (shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D})",
|
||||
Input: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell for This PC (::{20D04FE0-3AEA-1069-A2D8-08002B30309D})",
|
||||
Input: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for My Documents (shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
Input: "shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for My Documents (::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
Input: "::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for AppData (shell:appdata)",
|
||||
Input: "shell:appdata",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
|
||||
// let's pray this works on all systems
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for AppData + subpath (shell:appdata\\microsoft)",
|
||||
Input: "shell:appdata\\microsoft",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft"),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> UnquotedRelativePaths() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative current path",
|
||||
Input: ".\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#if CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
It's not really a good idea blindly write to directory out of user profile
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative parent path",
|
||||
Input: "..\\parent folder\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#endif // CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative home folder",
|
||||
Input: $"~\\{_testDirName}\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(_testDirPath, "app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(PlaceholderClassificationData.PlaceholderCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Placeholders_ValidatePlaceholderClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(PlaceholderClassificationData.EdgeCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Placeholders_ValidatePlaceholderEdgeCases(PlaceholderClassificationCase c)
|
||||
{
|
||||
// Arrange
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Act & Assert - Should not throw exceptions
|
||||
var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None);
|
||||
|
||||
Assert.IsNotNull(classification);
|
||||
Assert.AreEqual(c.ExpectSuccess, classification.Success);
|
||||
|
||||
if (c.ExpectSuccess && classification.Result != null)
|
||||
{
|
||||
Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder);
|
||||
Assert.AreEqual(c.Input, classification.Result.Input, "OriginalInput should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
private static class PlaceholderClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> PlaceholderCases()
|
||||
{
|
||||
// UWP/AUMID with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with package placeholder",
|
||||
Input: "shell:AppsFolder\\{packageFamily}!{appId}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\{packageFamily}!{appId}",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
|
||||
// Expects no special handling
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Bare UWP AUMID with placeholders",
|
||||
Input: "{packageFamily}!{appId}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{packageFamily}!{appId}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Web URLs with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTPS URL with domain placeholder",
|
||||
Input: "https://{domain}/path",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://{domain}/path",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL with site placeholder",
|
||||
Input: "www.{site}.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://www.{site}.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL - Yahoo with Search",
|
||||
Input: "http://yahoo.com?p={search}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "http://yahoo.com?p={search}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Protocol URLs with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mailto protocol with email placeholder",
|
||||
Input: "mailto:{email}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "mailto:{email}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "MS-Settings protocol with category placeholder",
|
||||
Input: "ms-settings:{category}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "ms-settings:{category}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// File executables with placeholders - These might classify as Unknown currently
|
||||
// due to nonexistent paths, but should preserve placeholder flag
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with profile path placeholder",
|
||||
Input: "{userProfile}\\Documents\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist
|
||||
ExpectedTarget: "{userProfile}\\Documents\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with program files placeholder",
|
||||
Input: "{programFiles}\\MyApp\\tool.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist
|
||||
ExpectedTarget: "{programFiles}\\MyApp\\tool.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Commands with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with placeholder and arguments",
|
||||
Input: "{editor} {filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown, // Likely Unknown since command won't be found in PATH
|
||||
ExpectedTarget: "{editor}",
|
||||
ExpectedArguments: "{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Directory paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Directory with user profile placeholder",
|
||||
Input: "{userProfile}\\Documents",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown, // May be Unknown if path doesn't exist during classification
|
||||
ExpectedTarget: "{userProfile}\\Documents",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Complex quoted paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted executable path with placeholders and args",
|
||||
Input: "\"{programFiles}\\{appName}\\{executable}.exe\" --verbose",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // Likely Unknown due to nonexistent path
|
||||
ExpectedTarget: "{programFiles}\\{appName}\\{executable}.exe",
|
||||
ExpectedArguments: "--verbose",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Shell paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell folder with placeholder",
|
||||
Input: "shell:{folder}\\{filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:{folder}\\{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Shell paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell folder with placeholder",
|
||||
Input: "shell:knownFolder\\{filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:knownFolder\\{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
yield return
|
||||
[
|
||||
|
||||
// cmd /K {param1}
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with braces in arguments",
|
||||
Input: "cmd /K {param1}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedArguments: "/K {param1}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Mixed literal and placeholder paths
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mixed literal and placeholder path",
|
||||
Input: "C:\\{folder}\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // Behavior depends on partial path resolution
|
||||
ExpectedTarget: "C:\\{folder}\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Multiple placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Multiple placeholders in path",
|
||||
Input: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EdgeCases()
|
||||
{
|
||||
// Empty and malformed placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Empty placeholder",
|
||||
Input: "{} file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{} file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed placeholder",
|
||||
Input: "{unclosed file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{unclosed file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with spaces",
|
||||
Input: "{with spaces}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{with spaces}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Nested placeholders",
|
||||
Input: "{outer{inner}}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{outer{inner}}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Only closing brace",
|
||||
Input: "file} something",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "file} something",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
// Very long placeholder names
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Very long placeholder name",
|
||||
Input: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Special characters in placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with underscores",
|
||||
Input: "{user_profile}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{user_profile}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with numbers",
|
||||
Input: "{path123}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{path123}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// 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.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.MixedQuotesScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateMixedQuotesScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EscapedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEscapedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.PartialMalformedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidatePartialMalformedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EnvironmentVariablesWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEnvironmentVariablesWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.ShellProtocolPathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateShellProtocolPathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.CommandFlagsAndOptions), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateCommandFlagsAndOptions(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.NetworkPathsUnc), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateNetworkPathsUnc(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RelativePathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateRelativePathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EmptyAndWhitespaceCases), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEmptyAndWhitespaceCases(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RealWorldCommandScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateRealWorldCommandScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.SpecialCharactersInPaths), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateSpecialCharactersInPaths(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsCurrentlyBroken), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedPathsCurrentlyBroken(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsInCommands), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedPathsInCommands(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedAumid), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedUwpAppAumidCommands(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
public static class QuotedClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> MixedQuotesScenarios() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with quoted argument",
|
||||
Input: "C:\\Windows\\notepad.exe \"C:\\my file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Windows\\notepad.exe",
|
||||
ExpectedArguments: "\"C:\\my file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "App with quoted argument containing spaces",
|
||||
Input: "app.exe \"argument with spaces\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "app.exe",
|
||||
ExpectedArguments: "\"argument with spaces\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Tool with input flag and quoted file",
|
||||
Input: "C:\\tool.exe -input \"data file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\tool.exe",
|
||||
ExpectedArguments: "-input \"data file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Multiple quoted arguments after path",
|
||||
Input: "\"C:\\Program Files\\app.exe\" -file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedArguments: "-file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with two quoted paths",
|
||||
Input: "cmd /c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedArguments: "/c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EscapedQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path with escaped quotes in folder name",
|
||||
Input: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing escaped quote",
|
||||
Input: "\"C:\\Windows\\\\\\\"\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: "C:\\Windows\\",
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> PartialMalformedQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed quote at start",
|
||||
Input: "\"C:\\Program Files\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quote in middle of unquoted path",
|
||||
Input: "C:\\Some\\\"Path\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Some\\\"Path\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed quote - never ends",
|
||||
Input: "\"Starts quoted but never ends",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "Starts quoted but never ends",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EnvironmentVariablesWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted environment variable path with spaces",
|
||||
Input: "\"%ProgramFiles%\\MyApp\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "MyApp", "app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted USERPROFILE with document path",
|
||||
Input: "\"%USERPROFILE%\\Documents\\file with spaces.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "file with spaces.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Environment variable with trailing args",
|
||||
Input: "\"%ProgramFiles%\\App\" with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"),
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Environment variable with trailing args",
|
||||
Input: "%ProgramFiles%\\App with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"),
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> ShellProtocolPathsWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell:Downloads",
|
||||
Input: "\"shell:Downloads\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell:Downloads with subpath",
|
||||
Input: "\"shell:Downloads\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "file.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell Desktop with subpath",
|
||||
Input: "shell:Desktop\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "file.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell path with trailing text",
|
||||
Input: "\"shell:Programs\" extra",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(paths: Environment.GetFolderPath(Environment.SpecialFolder.Programs)),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> CommandFlagsAndOptions() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path followed by flag with quoted value",
|
||||
Input: "C:\\app.exe -flag \"value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\app.exe",
|
||||
ExpectedArguments: "-flag \"value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted tool with equals-style flag",
|
||||
Input: "\"C:\\Program Files\\tool.exe\" --input=file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\tool.exe",
|
||||
ExpectedArguments: "--input=file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path with slash option and quoted value",
|
||||
Input: "C:\\tool.exe /option \"quoted value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\tool.exe",
|
||||
ExpectedArguments: "/option \"quoted value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Flag before quoted path",
|
||||
Input: "--path \"C:\\Program Files\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "--path",
|
||||
ExpectedArguments: "\"C:\\Program Files\\app.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> NetworkPathsUnc() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UNC path unquoted",
|
||||
Input: "\\\\server\\share\\folder\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "\\\\server\\share\\folder\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UNC path with spaces",
|
||||
Input: "\"\\\\server\\share with spaces\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "\\\\server\\share with spaces\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UNC path with trailing args",
|
||||
Input: "\"\\\\server\\share\\\" with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "\\\\server\\share\\",
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UNC app with flag",
|
||||
Input: "\"\\\\server\\My Share\\app.exe\" --flag",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "\\\\server\\My Share\\app.exe",
|
||||
ExpectedArguments: "--flag",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> RelativePathsWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative current path",
|
||||
Input: "\".\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative parent path",
|
||||
Input: "\"..\\parent folder\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative home folder",
|
||||
Input: "\"~\\current folder\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "current folder\\app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EmptyAndWhitespaceCases() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Empty string",
|
||||
Input: string.Empty,
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Only whitespace",
|
||||
Input: " ",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: " ",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Just empty quotes",
|
||||
Input: "\"\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted single space",
|
||||
Input: "\" \"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: " ",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> RealWorldCommandScenarios() =>
|
||||
[
|
||||
#if CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Git clone command with full exe path with quoted path",
|
||||
Input: "\"C:\\Program Files\\Git\\bin\\git.exe\" clone repo",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\Git\\bin\\git.exe",
|
||||
ExpectedArguments: "clone repo",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Git clone command with quoted path",
|
||||
Input: "git clone repo",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Program Files\\Git\\cmd\\git.EXE",
|
||||
ExpectedArguments: "clone repo",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Visual Studio devenv with solution",
|
||||
Input: "\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe\" solution.sln",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe",
|
||||
ExpectedArguments: "solution.sln",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Double-quoted Windows cmd pattern",
|
||||
Input: "cmd /c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedArguments: "/c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"",
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#endif
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "PowerShell script with execution policy",
|
||||
Input: "powershell -ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\PowerShell.exe",
|
||||
ExpectedArguments: "-ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> SpecialCharactersInPaths() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with square brackets",
|
||||
Input: "\"C:\\Path\\file[1].txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Path\\file[1].txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with parentheses",
|
||||
Input: "\"C:\\Folder (2)\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Folder (2)\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with hyphens and underscores",
|
||||
Input: "\"C:\\Path\\file_name-123.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Path\\file_name-123.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedPathsCurrentlyBroken() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with spaces - complete path",
|
||||
Input: "\"C:\\Program Files\\MyApp\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\MyApp\\app.exe",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with spaces in user folder",
|
||||
Input: "\"C:\\Users\\John Doe\\Documents\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Users\\John Doe\\Documents\\file.txt",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing arguments",
|
||||
Input: "\"C:\\Program Files\\app.exe\" --flag",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedArguments: "--flag",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with multiple arguments",
|
||||
Input: "\"C:\\My Documents\\file.txt\" -output result.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\My Documents\\file.txt",
|
||||
ExpectedArguments: "-output result.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing flag and value",
|
||||
Input: "\"C:\\Tools\\converter.exe\" input.txt output.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Tools\\converter.exe",
|
||||
ExpectedArguments: "input.txt output.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedPathsInCommands() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "cmd /c with quoted path",
|
||||
Input: "cmd /c \"C:\\Program Files\\tool.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.exe",
|
||||
ExpectedArguments: "/c \"C:\\Program Files\\tool.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "PowerShell with quoted script path",
|
||||
Input: "powershell -File \"C:\\Scripts\\my script.ps1\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", path4: "powershell.exe"),
|
||||
ExpectedArguments: "-File \"C:\\Scripts\\my script.ps1\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "runas with quoted executable",
|
||||
Input: "runas /user:admin \"C:\\Windows\\System32\\cmd.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\runas.exe",
|
||||
ExpectedArguments: "/user:admin \"C:\\Windows\\System32\\cmd.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedAumid() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UWP AUMID via AppsFolder",
|
||||
Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UWP AUMID with AppsFolder prefix and argument",
|
||||
Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\" --maximized",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedArguments: "--maximized",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private static string _testDirPath;
|
||||
private static string _userHomeDirPath;
|
||||
private static string _testDirName;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
[ClassInitialize]
|
||||
public static void ClassSetup(TestContext context)
|
||||
{
|
||||
_userHomeDirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
_testDirName = "CmdPalBookmarkTests" + Guid.NewGuid().ToString("N");
|
||||
_testDirPath = Path.Combine(_userHomeDirPath, _testDirName);
|
||||
Directory.CreateDirectory(_testDirPath);
|
||||
|
||||
// test files in user home
|
||||
File.WriteAllText(Path.Combine(_userHomeDirPath, "file.txt"), "This is a test text file.");
|
||||
|
||||
// test files in test dir
|
||||
File.WriteAllText(Path.Combine(_testDirPath, "file.txt"), "This is a test text file.");
|
||||
File.WriteAllText(Path.Combine(_testDirPath, "app.exe"), "This is a test text file.");
|
||||
}
|
||||
|
||||
[ClassCleanup]
|
||||
public static void ClassCleanup()
|
||||
{
|
||||
if (Directory.Exists(_testDirPath))
|
||||
{
|
||||
Directory.Delete(_testDirPath, true);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(_userHomeDirPath, "file.txt")))
|
||||
{
|
||||
File.Delete(Path.Combine(_userHomeDirPath, "file.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
// must be public static to be used as DataTestMethod data source
|
||||
public static string FromCase(MethodInfo method, object[] data)
|
||||
=> data is [PlaceholderClassificationCase c]
|
||||
? c.Name
|
||||
: $"{method.Name}({string.Join(", ", data.Select(row => row.ToString()))})";
|
||||
|
||||
private static async Task RunShared(PlaceholderClassificationCase c)
|
||||
{
|
||||
// Arrange
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Act
|
||||
var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(classification);
|
||||
Assert.AreEqual(c.ExpectSuccess, classification.Success, "Success flag mismatch.");
|
||||
|
||||
if (c.ExpectSuccess)
|
||||
{
|
||||
Assert.IsNotNull(classification.Result, "Result should not be null for successful classification.");
|
||||
Assert.AreEqual(c.ExpectedKind, classification.Result.Kind, $"CommandKind mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedTarget, classification.Result.Target, StringComparer.OrdinalIgnoreCase, $"Target mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedLaunch, classification.Result.Launch, $"LaunchMethod mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedArguments, classification.Result.Arguments, $"Arguments mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder, $"IsPlaceholder mismatch for input: {c.Input}");
|
||||
|
||||
if (c.ExpectedDisplayName != null)
|
||||
{
|
||||
Assert.AreEqual(c.ExpectedDisplayName, classification.Result.DisplayName, $"DisplayName mismatch for input: {c.Input}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlaceholderClassificationCase(
|
||||
string Name, // Friendly name for Test Explorer
|
||||
string Input, // Input string passed to classifier
|
||||
bool ExpectSuccess, // Expected Success flag
|
||||
CommandKind ExpectedKind, // Expected Result.Kind
|
||||
string ExpectedTarget, // Expected Result.Target (normalized)
|
||||
LaunchMethod ExpectedLaunch, // Expected Result.Launch
|
||||
bool ExpectedIsPlaceholder, // Expected Result.IsPlaceholder
|
||||
string ExpectedArguments = "", // Expected Result.Arguments
|
||||
string? ExpectedDisplayName = null // Expected Result.DisplayName
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
// 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.Linq;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
@@ -16,8 +16,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasCorrectId()
|
||||
{
|
||||
// Setup
|
||||
var mockDataSource = new MockBookmarkDataSource();
|
||||
var provider = new BookmarksCommandProvider(mockDataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Bookmarks", provider.Id);
|
||||
@@ -27,8 +27,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasDisplayName()
|
||||
{
|
||||
// Setup
|
||||
var mockDataSource = new MockBookmarkDataSource();
|
||||
var provider = new BookmarksCommandProvider(mockDataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.DisplayName);
|
||||
@@ -39,7 +39,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasIcon()
|
||||
{
|
||||
// Setup
|
||||
var provider = new BookmarksCommandProvider();
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.Icon);
|
||||
@@ -49,7 +50,8 @@ public class BookmarksCommandProviderTests
|
||||
public void TopLevelCommandsNotEmpty()
|
||||
{
|
||||
// Setup
|
||||
var provider = new BookmarksCommandProvider();
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -60,47 +62,40 @@ public class BookmarksCommandProviderTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ProviderWithMockData_LoadsBookmarksCorrectly()
|
||||
[Timeout(5000)]
|
||||
public async Task ProviderWithMockData_LoadsBookmarksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonData = @"{
|
||||
""Data"": [
|
||||
{
|
||||
""Name"": ""Test Bookmark"",
|
||||
""Bookmark"": ""https://test.com""
|
||||
},
|
||||
{
|
||||
""Name"": ""Another Bookmark"",
|
||||
""Bookmark"": ""https://another.com""
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
var dataSource = new MockBookmarkDataSource(jsonData);
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager(
|
||||
new BookmarkData("Test Bookmark", "http://test.com"),
|
||||
new BookmarkData("Another Bookmark", "http://another.com"));
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(commands);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var testBookmark = commands.Where(c => c.Title.Contains("Test Bookmark")).FirstOrDefault();
|
||||
Assert.IsNotNull(commands, "commands != null");
|
||||
|
||||
// Should have three commands:Add + two custom bookmarks
|
||||
Assert.AreEqual(3, commands.Length);
|
||||
|
||||
Assert.IsNotNull(addCommand);
|
||||
Assert.IsNotNull(testBookmark);
|
||||
// Wait until all BookmarkListItem commands are initialized
|
||||
await Task.WhenAll(commands.OfType<Pages.BookmarkListItem>().Select(t => t.IsInitialized));
|
||||
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
var testBookmark = commands.FirstOrDefault(c => c.Title.Contains("Test Bookmark"));
|
||||
|
||||
Assert.IsNotNull(addCommand, "addCommand != null");
|
||||
Assert.IsNotNull(testBookmark, "testBookmark != null");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ProviderWithEmptyData_HasOnlyAddCommand()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = new MockBookmarkDataSource(@"{ ""Data"": [] }");
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -111,7 +106,7 @@ public class BookmarksCommandProviderTests
|
||||
// Only have Add command
|
||||
Assert.AreEqual(1, commands.Length);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
Assert.IsNotNull(addCommand);
|
||||
}
|
||||
|
||||
@@ -120,7 +115,7 @@ public class BookmarksCommandProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = new MockBookmarkDataSource("invalid json");
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var provider = new BookmarksCommandProvider(new MockBookmarkManager());
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -131,7 +126,7 @@ public class BookmarksCommandProviderTests
|
||||
// Only have one command. Will ignore json parse error.
|
||||
Assert.AreEqual(1, commands.Length);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
Assert.IsNotNull(addCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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.
|
||||
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class CommandLineHelperTests
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private static string _tempTestDir;
|
||||
|
||||
private static string _tempTestFile;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
[ClassInitialize]
|
||||
public static void ClassSetup(TestContext context)
|
||||
{
|
||||
// Create temporary test directory and file
|
||||
_tempTestDir = Path.Combine(Path.GetTempPath(), "CommandLineHelperTests_" + Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_tempTestDir);
|
||||
|
||||
_tempTestFile = Path.Combine(_tempTestDir, "testfile.txt");
|
||||
File.WriteAllText(_tempTestFile, "test");
|
||||
}
|
||||
|
||||
[ClassCleanup]
|
||||
public static void ClassCleanup()
|
||||
{
|
||||
// Clean up test directory
|
||||
if (Directory.Exists(_tempTestDir))
|
||||
{
|
||||
Directory.Delete(_tempTestDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("%TEMP%", false, true, DisplayName = "Expands TEMP environment variable")]
|
||||
[DataRow("%USERPROFILE%", false, true, DisplayName = "Expands USERPROFILE environment variable")]
|
||||
[DataRow("%SystemRoot%", false, true, DisplayName = "Expands SystemRoot environment variable")]
|
||||
public void Expand_WithEnvironmentVariables_ExpandsCorrectly(string input, bool expandShell, bool shouldExist)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(shouldExist, result, $"Expected result {shouldExist} for input '{input}'");
|
||||
if (shouldExist)
|
||||
{
|
||||
Assert.IsFalse(full.Contains('%'), "Output should not contain % symbols after expansion");
|
||||
Assert.IsTrue(Path.Exists(full), $"Expanded path '{full}' should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Downloads", true, DisplayName = "Expands shell:Downloads when expandShell is true")]
|
||||
[DataRow("shell:Desktop", true, DisplayName = "Expands shell:Desktop when expandShell is true")]
|
||||
[DataRow("shell:Documents", true, DisplayName = "Expands shell:Documents when expandShell is true")]
|
||||
public void Expand_WithShellPaths_ExpandsWhenFlagIsTrue(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved");
|
||||
Assert.IsTrue(Path.Exists(full), $"Expanded shell path '{full}' should exist");
|
||||
}
|
||||
|
||||
// Note: Result may be false if ShellNames.TryGetFileSystemPath fails
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Personal", false, DisplayName = "Does not expand shell: when expandShell is false")]
|
||||
public void Expand_WithShellPaths_DoesNotExpandWhenFlagIsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert - shell: paths won't exist as literal paths
|
||||
Assert.IsFalse(result, "Should return false for unexpanded shell path");
|
||||
Assert.AreEqual(input, full, "Output should match input when not expanding shell paths");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Personal\\subfolder", true, "\\subfolder", DisplayName = "Expands shell path with subfolder")]
|
||||
[DataRow("shell:Desktop\\test.txt", true, "\\test.txt", DisplayName = "Expands shell path with file")]
|
||||
public void Expand_WithShellPathsAndSubpaths_CombinesCorrectly(string input, bool expandShell, string expectedEnding)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Note: Result depends on whether the combined path exists
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved");
|
||||
Assert.IsTrue(full.EndsWith(expectedEnding, StringComparison.OrdinalIgnoreCase), "Output should end with the subpath");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithExistingDirectory_ReturnsFullPath()
|
||||
{
|
||||
// Arrange
|
||||
var input = _tempTestDir;
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result, "Should return true for existing directory");
|
||||
Assert.AreEqual(Path.GetFullPath(_tempTestDir), full, "Should return full path");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithExistingFile_ReturnsFullPath()
|
||||
{
|
||||
// Arrange
|
||||
var input = _tempTestFile;
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result, "Should return true for existing file");
|
||||
Assert.AreEqual(Path.GetFullPath(_tempTestFile), full, "Should return full path");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", false, "C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", DisplayName = "Nonexistent absolute path")]
|
||||
[DataRow("NonExistentFile.txt", false, "NonExistentFile.txt", DisplayName = "Nonexistent relative path")]
|
||||
public void Expand_WithNonExistentPath_ReturnsFalse(string input, bool expandShell, string expectedFull)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result, "Should return false for nonexistent path");
|
||||
Assert.AreEqual(expectedFull, full, "Output should be empty string");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("", false, DisplayName = "Empty string")]
|
||||
[DataRow(" ", false, DisplayName = "Whitespace only")]
|
||||
public void Expand_WithEmptyOrWhitespace_ReturnsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result, "Should return false for empty/whitespace input");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("%TEMP%\\testsubdir", false, DisplayName = "Env var with subdirectory")]
|
||||
[DataRow("%USERPROFILE%\\Desktop", false, DisplayName = "USERPROFILE with Desktop")]
|
||||
public void Expand_WithEnvironmentVariableAndSubpath_ExpandsCorrectly(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Result depends on whether the path exists
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.Contains('%'), "Should expand environment variables");
|
||||
Assert.IsTrue(Path.Exists(full), "Expanded path should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithRelativePath_ConvertsToAbsoluteWhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var relativePath = Path.GetRelativePath(Environment.CurrentDirectory, _tempTestDir);
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(relativePath, false, out var full);
|
||||
|
||||
// Assert
|
||||
if (result)
|
||||
{
|
||||
Assert.IsTrue(Path.IsPathRooted(full), "Output should be absolute path");
|
||||
Assert.IsTrue(Path.Exists(full), "Expanded path should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("InvalidShell:Path", true, DisplayName = "Invalid shell path format")]
|
||||
public void Expand_WithInvalidShellPath_ReturnsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
// If ShellNames.TryGetFileSystemPath returns false, method returns false
|
||||
Assert.IsFalse(result || Path.Exists(full), "Should return false or path should not exist");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
|
||||
// basic
|
||||
[DataRow("cmd ping", "cmd", "ping")]
|
||||
[DataRow("cmd ping pong", "cmd", "ping pong")]
|
||||
[DataRow("cmd \"ping pong\"", "cmd", "\"ping pong\"")]
|
||||
|
||||
// no tail / trailing whitespace after head
|
||||
[DataRow("cmd", "cmd", "")]
|
||||
[DataRow("cmd ", "cmd", "")]
|
||||
|
||||
// spacing & tabs between args should be preserved in tail
|
||||
[DataRow("cmd ping pong", "cmd", "ping pong")]
|
||||
[DataRow("cmd\tping\tpong", "cmd", "ping\tpong")]
|
||||
|
||||
// leading whitespace before head
|
||||
[DataRow(" cmd ping", "", "cmd ping")]
|
||||
[DataRow("\t cmd ping", "", "cmd ping")]
|
||||
|
||||
// quoted tail variants
|
||||
[DataRow("cmd \"\"", "cmd", "\"\"")]
|
||||
[DataRow("cmd \"a \\\"quoted\\\" arg\" b", "cmd", "\"a \\\"quoted\\\" arg\" b")]
|
||||
|
||||
// quoted head (spaces in path)
|
||||
[DataRow(@"""C:\Program Files\nodejs\node.exe"" -v", @"C:\Program Files\nodejs\node.exe", "-v")]
|
||||
[DataRow(@"""C:\Program Files\Git\bin\bash.exe""", @"C:\Program Files\Git\bin\bash.exe", "")]
|
||||
[DataRow(@" ""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""", @"", @"""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""")]
|
||||
[DataRow(@"""C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe"" Test.sln", @"C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe", "Test.sln")]
|
||||
|
||||
// quoted simple head (still should strip quotes for head)
|
||||
[DataRow(@"""cmd"" ping", "cmd", "ping")]
|
||||
|
||||
// common CLI shapes
|
||||
[DataRow("git --version", "git", "--version")]
|
||||
[DataRow("dotnet build -c Release", "dotnet", "build -c Release")]
|
||||
|
||||
// UNC paths
|
||||
[DataRow("\"\\\\server\\share\\\" with args", "\\\\server\\share\\", "with args")]
|
||||
public void SplitHeadAndArgs(string input, string expectedHead, string expectedTail)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.SplitHeadAndArgs(input);
|
||||
|
||||
// Assert
|
||||
// If ShellNames.TryGetFileSystemPath returns false, method returns false
|
||||
Assert.AreEqual(expectedHead, result.Head);
|
||||
Assert.AreEqual(expectedTail, result.Tail);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(@"C:\program files\myapp\app.exe -param ""1"" -param 2", @"C:\program files\myapp\app.exe -param", @"""1"" -param 2")]
|
||||
[DataRow(@"git commit -m test", "git commit -m test", "")]
|
||||
[DataRow(@"""C:\Program Files\App\app.exe"" -v", "", @"""C:\Program Files\App\app.exe"" -v")]
|
||||
[DataRow(@"tool a\\\""b c ""d e"" f", @"tool a\\\""b c", @"""d e"" f")] // escaped quote before first real one
|
||||
[DataRow("C:\\Some\\\"Path\\file.txt", "C:\\Some\\\"Path\\file.txt", "")]
|
||||
[DataRow(@" ""C:\p\app.exe"" -v", "", @"""C:\p\app.exe"" -v")] // first token is quoted
|
||||
public void SplitLongestHeadBeforeQuotedArg_Tests(string input, string expectedHead, string expectedTail)
|
||||
{
|
||||
var (head, tail) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedTail, tail);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// 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.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
internal sealed class MockBookmarkDataSource : IBookmarkDataSource
|
||||
|
||||
@@ -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;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
#pragma warning disable CS0067
|
||||
|
||||
internal sealed class MockBookmarkManager : IBookmarksManager
|
||||
{
|
||||
private readonly List<BookmarkData> _bookmarks;
|
||||
|
||||
public event Action<BookmarkData> BookmarkAdded;
|
||||
|
||||
public event Action<BookmarkData, BookmarkData> BookmarkUpdated;
|
||||
|
||||
public event Action<BookmarkData> BookmarkRemoved;
|
||||
|
||||
public IReadOnlyCollection<BookmarkData> Bookmarks => _bookmarks;
|
||||
|
||||
public BookmarkData Add(string name, string bookmark) => throw new NotImplementedException();
|
||||
|
||||
public bool Remove(Guid id) => throw new NotImplementedException();
|
||||
|
||||
public BookmarkData Update(Guid id, string name, string bookmark) => throw new NotImplementedException();
|
||||
|
||||
public MockBookmarkManager(params IEnumerable<BookmarkData> bookmarks)
|
||||
{
|
||||
_bookmarks = [.. bookmarks];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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 Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class PlaceholderInfoNameEqualityComparerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Equals_BothNull_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
|
||||
var result = comparer.Equals(null, null);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_OneNull_ReturnsFalse()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p = new PlaceholderInfo("name", 0);
|
||||
|
||||
Assert.IsFalse(comparer.Equals(p, null));
|
||||
Assert.IsFalse(comparer.Equals(null, p));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_SameNameDifferentIndex_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("name", 0);
|
||||
var p2 = new PlaceholderInfo("name", 10);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(p1, p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentNameSameIndex_ReturnsFalse()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("first", 3);
|
||||
var p2 = new PlaceholderInfo("second", 3);
|
||||
|
||||
Assert.IsFalse(comparer.Equals(p1, p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_CaseInsensitive_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("Name", 0);
|
||||
var p2 = new PlaceholderInfo("name", 5);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(p1, p2));
|
||||
Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetHashCode_SameNameDifferentIndex_SameHash()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("same", 1);
|
||||
var p2 = new PlaceholderInfo("same", 99);
|
||||
|
||||
Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetHashCode_Null_ThrowsArgumentNullException()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
Assert.ThrowsException<ArgumentNullException>(() => comparer.GetHashCode(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Instance_ReturnsSingleton()
|
||||
{
|
||||
var a = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var b = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
|
||||
Assert.IsNotNull(a);
|
||||
Assert.AreSame(a, b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HashSet_UsesNameEquality_IgnoresIndex()
|
||||
{
|
||||
var set = new HashSet<PlaceholderInfo>(PlaceholderInfoNameEqualityComparer.Instance)
|
||||
{
|
||||
new("dup", 0),
|
||||
new("DUP", 10),
|
||||
new("unique", 0),
|
||||
};
|
||||
|
||||
Assert.AreEqual(2, set.Count);
|
||||
Assert.IsTrue(set.Contains(new PlaceholderInfo("dup", 123)));
|
||||
Assert.IsTrue(set.Contains(new PlaceholderInfo("UNIQUE", 999)));
|
||||
Assert.IsFalse(set.Contains(new PlaceholderInfo("missing", 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class PlaceholderParserTests
|
||||
{
|
||||
private IPlaceholderParser _parser;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_parser = new PlaceholderParser();
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ValidPlaceholderTestData =>
|
||||
[
|
||||
[
|
||||
"Hello {name}!",
|
||||
true,
|
||||
"Hello ",
|
||||
new[] { "name" },
|
||||
new[] { 6 }
|
||||
],
|
||||
[
|
||||
"User {user_name} has {count} items",
|
||||
true,
|
||||
"User ",
|
||||
new[] { "user_name", "count" },
|
||||
new[] { 5, 21 }
|
||||
],
|
||||
[
|
||||
"Order {order-id} for {name} by {name}",
|
||||
true,
|
||||
"Order ",
|
||||
new[] { "order-id", "name", "name" },
|
||||
new[] { 6, 21, 31 }
|
||||
],
|
||||
[
|
||||
"{start} and {end}",
|
||||
true,
|
||||
string.Empty,
|
||||
new[] { "start", "end" },
|
||||
new[] { 0, 12 }
|
||||
],
|
||||
[
|
||||
"Number {123} and text {abc}",
|
||||
true,
|
||||
"Number ",
|
||||
new[] { "123", "abc" },
|
||||
new[] { 7, 22 }
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> InvalidPlaceholderTestData =>
|
||||
[
|
||||
[string.Empty, false, string.Empty, Array.Empty<string>()],
|
||||
["No placeholders here", false, "No placeholders here", Array.Empty<string>()],
|
||||
["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty<string>()],
|
||||
["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty<string>()],
|
||||
["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty<string>()],
|
||||
["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty<string>()],
|
||||
["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty<string>()],
|
||||
["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty<string>()],
|
||||
["Spaces { name }", false, "Spaces { name }", Array.Empty<string>()]
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(ValidPlaceholderTestData))]
|
||||
public void ParsePlaceholders_ValidInput_ReturnsExpectedResults(
|
||||
string input,
|
||||
bool expectedResult,
|
||||
string expectedHead,
|
||||
string[] expectedPlaceholderNames,
|
||||
int[] expectedIndexes)
|
||||
{
|
||||
// Act
|
||||
var result = _parser.ParsePlaceholders(input, out var head, out var placeholders);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count);
|
||||
|
||||
var actualNames = placeholders.Select(p => p.Name).ToArray();
|
||||
var actualIndexes = placeholders.Select(p => p.Index).ToArray();
|
||||
|
||||
// Validate names and indexes (allow duplicates, ignore order)
|
||||
CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames);
|
||||
CollectionAssert.AreEquivalent(expectedIndexes, actualIndexes);
|
||||
|
||||
// Validate name-index pairing exists for each expected placeholder occurrence
|
||||
for (var i = 0; i < expectedPlaceholderNames.Length; i++)
|
||||
{
|
||||
var expectedName = expectedPlaceholderNames[i];
|
||||
var expectedIndex = expectedIndexes[i];
|
||||
Assert.IsTrue(
|
||||
placeholders.Any(p => p.Name == expectedName && p.Index == expectedIndex),
|
||||
$"Expected placeholder '{{{expectedName}}}' at index {expectedIndex} was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(InvalidPlaceholderTestData))]
|
||||
public void ParsePlaceholders_InvalidInput_ReturnsExpectedResults(
|
||||
string input,
|
||||
bool expectedResult,
|
||||
string expectedHead,
|
||||
string[] expectedPlaceholderNames)
|
||||
{
|
||||
// Act
|
||||
var result = _parser.ParsePlaceholders(input, out var head, out var placeholders);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count);
|
||||
|
||||
var actualNames = placeholders.Select(p => p.Name).ToArray();
|
||||
CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePlaceholders_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.ThrowsException<ArgumentNullException>(() => _parser.ParsePlaceholders(null!, out _, out _));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Equality_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var placeholder1 = new PlaceholderInfo("name", 0);
|
||||
var placeholder2 = new PlaceholderInfo("name", 0);
|
||||
var placeholder3 = new PlaceholderInfo("other", 0);
|
||||
var placeholder4 = new PlaceholderInfo("name", 1);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(placeholder1, placeholder2);
|
||||
Assert.AreNotEqual(placeholder1, placeholder3);
|
||||
Assert.AreEqual(placeholder1.GetHashCode(), placeholder2.GetHashCode());
|
||||
Assert.AreNotEqual(placeholder1, placeholder4);
|
||||
Assert.AreNotEqual(placeholder1.GetHashCode(), placeholder4.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_ToString_ReturnsName()
|
||||
{
|
||||
// Arrange
|
||||
var placeholder = new PlaceholderInfo("userName", 0);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("userName", placeholder.ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Constructor_ThrowsOnNull()
|
||||
{
|
||||
// Assert
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new PlaceholderInfo(null!, 0));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Constructor_ThrowsArgumentOutOfRange()
|
||||
{
|
||||
// Assert
|
||||
Assert.ThrowsException<ArgumentOutOfRangeException>(() => new PlaceholderInfo("Name", -1));
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,4 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
Assert.IsNotNull(githubBookmark);
|
||||
Assert.AreEqual("https://github.com", githubBookmark.Bookmark);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidateWebUrlDetection()
|
||||
{
|
||||
// Setup
|
||||
var bookmarks = Settings.CreateDefaultBookmarks();
|
||||
var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft");
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(microsoftBookmark);
|
||||
Assert.IsTrue(microsoftBookmark.IsWebUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
// 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.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public static class Settings
|
||||
{
|
||||
public static Bookmarks CreateDefaultBookmarks()
|
||||
public static BookmarksData CreateDefaultBookmarks()
|
||||
{
|
||||
var bookmarks = new Bookmarks();
|
||||
var bookmarks = new BookmarksData();
|
||||
|
||||
// Add some test bookmarks
|
||||
bookmarks.Data.Add(new BookmarkData
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// 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 Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class UriHelperTests
|
||||
{
|
||||
private static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder)
|
||||
{
|
||||
return UriHelper.TryGetScheme(input, out scheme, out remainder);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("http://example.com", "http", "//example.com")]
|
||||
[DataRow("ftp:", "ftp", "")]
|
||||
[DataRow("my-app:payload", "my-app", "payload")]
|
||||
[DataRow("x-cmdpal://settings/", "x-cmdpal", "//settings/")]
|
||||
[DataRow("custom+ext.-scheme:xyz", "custom+ext.-scheme", "xyz")]
|
||||
[DataRow("MAILTO:foo@bar", "MAILTO", "foo@bar")]
|
||||
[DataRow("a:b", "a", "b")]
|
||||
public void TryGetScheme_ValidSchemes_ReturnsTrueAndSplits(string input, string expectedScheme, string expectedRemainder)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok, "Expected valid scheme.");
|
||||
Assert.AreEqual(expectedScheme, scheme);
|
||||
Assert.AreEqual(expectedRemainder, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_OnlySchemeAndColon_ReturnsEmptyRemainder()
|
||||
{
|
||||
var ok = TryGetScheme("http:".AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("http", scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("123:http")] // starts with digit
|
||||
[DataRow(":nope")] // colon at start
|
||||
[DataRow("noColon")] // no colon at all
|
||||
[DataRow("bad_scheme:")] // underscore not allowed
|
||||
[DataRow("bad*scheme:")] // asterisk not allowed
|
||||
[DataRow(":")] // syntactically invalid literal just for completeness; won't compile, example only
|
||||
public void TryGetScheme_InvalidInputs_ReturnsFalse(string input)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsFalse(ok);
|
||||
Assert.AreEqual(string.Empty, scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_MultipleColons_SplitsOnFirst()
|
||||
{
|
||||
const string input = "shell:::{645FF040-5081-101B-9F08-00AA002F954E}";
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("shell", scheme);
|
||||
Assert.AreEqual("::{645FF040-5081-101B-9F08-00AA002F954E}", remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_MinimumLength_OneLetterAndColon()
|
||||
{
|
||||
var ok = TryGetScheme("a:".AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("a", scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_TooShort_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(TryGetScheme("a".AsSpan(), out _, out _), "No colon.");
|
||||
Assert.IsFalse(TryGetScheme(":".AsSpan(), out _, out _), "Colon at start; no scheme.");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("HTTP://x", "HTTP", "//x")]
|
||||
[DataRow("hTtP:rest", "hTtP", "rest")]
|
||||
public void TryGetScheme_CaseIsPreserved(string input, string expectedScheme, string expectedRemainder)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual(expectedScheme, scheme);
|
||||
Assert.AreEqual(expectedRemainder, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_WhitespaceInsideScheme_Fails()
|
||||
{
|
||||
Assert.IsFalse(TryGetScheme("ht tp:rest".AsSpan(), out _, out _));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_PlusMinusDot_AllowedInMiddleOnly()
|
||||
{
|
||||
Assert.IsTrue(TryGetScheme("a+b.c-d:rest".AsSpan(), out var s1, out var r1));
|
||||
Assert.AreEqual("a+b.c-d", s1);
|
||||
Assert.AreEqual("rest", r1);
|
||||
|
||||
// The first character must be a letter; plus is not allowed as first char
|
||||
Assert.IsFalse(TryGetScheme("+abc:rest".AsSpan(), out _, out _));
|
||||
Assert.IsFalse(TryGetScheme(".abc:rest".AsSpan(), out _, out _));
|
||||
Assert.IsFalse(TryGetScheme("-abc:rest".AsSpan(), out _, out _));
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Pages;
|
||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
@@ -83,7 +84,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistory = CreateMockHistoryService();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistory.Object);
|
||||
var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
@@ -115,7 +116,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
@@ -141,7 +142,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
@@ -154,4 +155,131 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
// Should find at least the ping command from history
|
||||
Assert.IsTrue(commandList.Length > 1);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestCacheBackToSameDirectory()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
|
||||
// Should find only items for what's in c:\
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Win"; });
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
commandList = page.GetItems();
|
||||
|
||||
// Should still find everything
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
await TypeStringIntoPage(page, "c:\\Windows\\Pro");
|
||||
await BackspaceSearchText(page, "c:\\Windows\\Pro", 3); // 3 characters for c:\
|
||||
|
||||
commandList = page.GetItems();
|
||||
|
||||
// Should still find everything
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
}
|
||||
|
||||
private async Task TypeStringIntoPage(IDynamicListPage page, string searchText)
|
||||
{
|
||||
// type the string one character at a time
|
||||
for (var i = 0; i < searchText.Length; i++)
|
||||
{
|
||||
var substr = searchText[..i];
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BackspaceSearchText(IDynamicListPage page, string originalSearchText, int finalStringLength)
|
||||
{
|
||||
var originalLength = originalSearchText.Length;
|
||||
for (var i = originalLength; i >= finalStringLength; i--)
|
||||
{
|
||||
var substr = originalSearchText[..i];
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestCacheSameDirectorySlashy()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
var filesInWindows = Directory.EnumerateFileSystemEntries("C:\\Windows");
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
// First navigate to c:\Windows. This should match everything that matches "windows" inside of C:\
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
var cWindowsCommandsPre = page.GetItems();
|
||||
|
||||
// Then go into c:\windows\. This will only have the results in c:\windows\
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows\\"; });
|
||||
var windowsCommands = page.GetItems();
|
||||
Assert.IsTrue(windowsCommands.Length != cWindowsCommandsPre.Length);
|
||||
|
||||
// now go back to c:\windows. This should match the results from the last time we entered this string
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
var cWindowsCommandsPost = page.GetItems();
|
||||
Assert.IsTrue(cWindowsCommandsPre.Length == cWindowsCommandsPost.Length);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestPathWithSpaces()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
var filesInProgramFiles = Directory.EnumerateFileSystemEntries("C:\\Program Files");
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
Assert.IsTrue(commandList.Length == filesInProgramFiles.Count());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestNoWrapSuggestionsWithSpaces()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
|
||||
foreach (var item in commandList)
|
||||
{
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(item.TextToSuggest));
|
||||
Assert.IsFalse(item.TextToSuggest.StartsWith('"'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.DisplayName);
|
||||
@@ -28,7 +28,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.Icon);
|
||||
@@ -39,7 +39,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
|
||||
@@ -32,9 +33,14 @@ public class CommandPaletteUnitTestBase
|
||||
// and wait for the event to be raised.
|
||||
var tcs = new TaskCompletionSource<object>();
|
||||
|
||||
page.ItemsChanged += (sender, args) => tcs.SetResult(null);
|
||||
TypedEventHandler<object, IItemsChangedEventArgs> handleItemsChanged = (object s, IItemsChangedEventArgs e) =>
|
||||
{
|
||||
tcs.TrySetResult(e);
|
||||
};
|
||||
|
||||
page.ItemsChanged += handleItemsChanged;
|
||||
modification();
|
||||
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,24 +16,19 @@ using WyHash;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps;
|
||||
|
||||
public sealed partial class AppCommand : InvokableCommand
|
||||
internal sealed partial class AppCommand : InvokableCommand
|
||||
{
|
||||
private readonly AppItem _app;
|
||||
|
||||
public AppCommand(AppItem app)
|
||||
{
|
||||
_app = app;
|
||||
|
||||
Name = Resources.run_command_action;
|
||||
Name = Resources.run_command_action!;
|
||||
Id = GenerateId();
|
||||
|
||||
if (!string.IsNullOrEmpty(app.IcoPath))
|
||||
{
|
||||
Icon = new(app.IcoPath);
|
||||
}
|
||||
Icon = Icons.GenericAppIcon;
|
||||
}
|
||||
|
||||
internal static async Task StartApp(string aumid)
|
||||
private static async Task StartApp(string aumid)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
@@ -58,7 +53,7 @@ public sealed partial class AppCommand : InvokableCommand
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static async Task StartExe(string path)
|
||||
private static async Task StartExe(string path)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
@@ -73,7 +68,7 @@ public sealed partial class AppCommand : InvokableCommand
|
||||
});
|
||||
}
|
||||
|
||||
internal async Task Launch()
|
||||
private async Task Launch()
|
||||
{
|
||||
if (_app.IsPackaged)
|
||||
{
|
||||
|
||||
@@ -5,34 +5,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Apps.Commands;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
|
||||
internal sealed partial class AppListItem : ListItem
|
||||
public sealed partial class AppListItem : ListItem
|
||||
{
|
||||
private readonly AppItem _app;
|
||||
private static readonly Tag _appTag = new("App");
|
||||
|
||||
private readonly AppCommand _appCommand;
|
||||
private readonly AppItem _app;
|
||||
private readonly Lazy<Details> _details;
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
|
||||
|
||||
private InterlockedBoolean _isLoadingIcon;
|
||||
|
||||
public override IDetails? Details { get => _details.Value; set => base.Details = value; }
|
||||
|
||||
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
public override IIconInfo? Icon
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_isLoadingIcon.Set())
|
||||
{
|
||||
_ = LoadIconAsync();
|
||||
}
|
||||
|
||||
return base.Icon;
|
||||
}
|
||||
set => base.Icon = value;
|
||||
}
|
||||
|
||||
public string AppIdentifier => _app.AppIdentifier;
|
||||
|
||||
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
|
||||
: base(new AppCommand(app))
|
||||
{
|
||||
Command = _appCommand = new AppCommand(app);
|
||||
_app = app;
|
||||
Title = app.Name;
|
||||
Subtitle = app.Subtitle;
|
||||
Tags = [_appTag];
|
||||
Icon = Icons.GenericAppIcon;
|
||||
|
||||
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
|
||||
|
||||
@@ -43,12 +60,19 @@ internal sealed partial class AppListItem : ListItem
|
||||
return t.Result;
|
||||
});
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
|
||||
}
|
||||
|
||||
private async Task LoadIconAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = FetchIcon(useThumbnails);
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Details> BuildDetails()
|
||||
@@ -87,12 +111,12 @@ internal sealed partial class AppListItem : ListItem
|
||||
return new Details()
|
||||
{
|
||||
Title = this.Title,
|
||||
HeroImage = heroImage ?? this.Icon ?? new IconInfo(string.Empty),
|
||||
HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon,
|
||||
Metadata = metadata.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IconInfo> FetchIcon(bool useThumbnails)
|
||||
private async Task<IconInfo> FetchIcon(bool useThumbnails)
|
||||
{
|
||||
IconInfo? icon = null;
|
||||
if (_app.IsPackaged)
|
||||
@@ -108,12 +132,12 @@ internal sealed partial class AppListItem : ListItem
|
||||
var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath);
|
||||
if (stream is not null)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
icon = new IconInfo(data, data);
|
||||
icon = IconInfo.FromStream(stream);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug($"Failed to load icon for {AppIdentifier}:\n{ex}");
|
||||
}
|
||||
|
||||
icon = icon ?? new IconInfo(_app.IcoPath);
|
||||
|
||||
@@ -6,21 +6,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps;
|
||||
|
||||
internal sealed class Icons
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo AllAppsIcon => IconHelpers.FromRelativePath("Assets\\AllApps.svg");
|
||||
internal static IconInfo AllAppsIcon { get; } = IconHelpers.FromRelativePath("Assets\\AllApps.svg");
|
||||
|
||||
internal static IconInfo RunAsUserIcon => new("\uE7EE"); // OtherUser icon
|
||||
internal static IconInfo RunAsUserIcon { get; } = new("\uE7EE"); // OtherUser icon
|
||||
|
||||
internal static IconInfo RunAsAdminIcon => new("\uE7EF"); // Admin icon
|
||||
internal static IconInfo RunAsAdminIcon { get; } = new("\uE7EF"); // Admin icon
|
||||
|
||||
internal static IconInfo OpenPathIcon => new("\ue838"); // Folder Open icon
|
||||
internal static IconInfo OpenPathIcon { get; } = new("\ue838"); // Folder Open icon
|
||||
|
||||
internal static IconInfo CopyIcon => new("\ue8c8"); // Copy icon
|
||||
internal static IconInfo CopyIcon { get; } = new("\ue8c8"); // Copy icon
|
||||
|
||||
public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon
|
||||
|
||||
public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon
|
||||
|
||||
public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon
|
||||
|
||||
public static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon
|
||||
}
|
||||
|
||||
@@ -103,7 +103,8 @@ public class UWPApplication : IUWPApplication
|
||||
new CommandContextItem(
|
||||
new OpenFileCommand(Location)
|
||||
{
|
||||
Name = Resources.open_containing_folder,
|
||||
Icon = new("\uE838"),
|
||||
Name = Resources.open_location,
|
||||
})
|
||||
{
|
||||
RequestedShortcut = KeyChords.OpenFileLocation,
|
||||
|
||||
@@ -207,7 +207,10 @@ public class Win32Program : IProgram
|
||||
});
|
||||
|
||||
commands.Add(new CommandContextItem(
|
||||
new OpenFileCommand(ParentDirectory))
|
||||
new ShowFileInFolderCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath)
|
||||
{
|
||||
Name = Resources.open_location,
|
||||
})
|
||||
{
|
||||
RequestedShortcut = KeyChords.OpenFileLocation,
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
@@ -241,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Open location.
|
||||
/// Looks up a localized string similar to Open file location.
|
||||
/// </summary>
|
||||
internal static string open_location {
|
||||
get {
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<value>File</value>
|
||||
</data>
|
||||
<data name="open_location" xml:space="preserve">
|
||||
<value>Open location</value>
|
||||
<value>Open file location</value>
|
||||
</data>
|
||||
<data name="copy_path" xml:space="preserve">
|
||||
<value>Copy path</value>
|
||||
@@ -237,4 +237,4 @@
|
||||
<data name="limit_none" xml:space="preserve">
|
||||
<value>Unlimited</value>
|
||||
</data>
|
||||
</root>
|
||||
</root>
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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.Text.Json.Serialization;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public class BookmarkData
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Bookmark { get; set; } = string.Empty;
|
||||
|
||||
// public string Type { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}');
|
||||
|
||||
internal void GetExeAndArgs(out string exe, out string args)
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(Bookmark, out exe, out args);
|
||||
}
|
||||
|
||||
internal bool IsWebUrl()
|
||||
{
|
||||
GetExeAndArgs(out var exe, out var args);
|
||||
if (string.IsNullOrEmpty(exe))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(exe, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Scheme == Uri.UriSchemeFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// return true if the scheme is http or https, or if there's no scheme (e.g., "www.example.com") but there is a dot in the host
|
||||
return
|
||||
uri.Scheme == Uri.UriSchemeHttp ||
|
||||
uri.Scheme == Uri.UriSchemeHttps ||
|
||||
(string.IsNullOrEmpty(uri.Scheme) && uri.Host.Contains('.'));
|
||||
}
|
||||
|
||||
// If we can't parse it as a URI, we assume it's not a web URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// 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 System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderForm : FormContent
|
||||
{
|
||||
private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Resources.bookmarks_required_placeholder);
|
||||
|
||||
private readonly List<string> _placeholderNames;
|
||||
|
||||
private readonly string _bookmark = string.Empty;
|
||||
|
||||
// TODO pass in an array of placeholders
|
||||
public BookmarkPlaceholderForm(string name, string url)
|
||||
{
|
||||
_bookmark = url;
|
||||
var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}"));
|
||||
var matches = r.Matches(url);
|
||||
_placeholderNames = matches.Select(m => m.Groups[1].Value).ToList();
|
||||
var inputs = _placeholderNames.Select(p =>
|
||||
{
|
||||
var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, p);
|
||||
return $$"""
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "{{p}}",
|
||||
"label": "{{p}}",
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{errorMessage}}"
|
||||
}
|
||||
""";
|
||||
}).ToList();
|
||||
|
||||
var allInputs = string.Join(",", inputs);
|
||||
|
||||
TemplateJson = $$"""
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
""" + allInputs + $$"""
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": "{{Resources.bookmarks_form_open}}",
|
||||
"data": {
|
||||
"placeholder": "placeholder"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
public override CommandResult SubmitForm(string payload)
|
||||
{
|
||||
var target = _bookmark;
|
||||
|
||||
// parse the submitted JSON and then open the link
|
||||
var formInput = JsonNode.Parse(payload);
|
||||
var formObject = formInput?.AsObject();
|
||||
if (formObject is null)
|
||||
{
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
|
||||
foreach (var (key, value) in formObject)
|
||||
{
|
||||
var placeholderString = $"{{{key}}}";
|
||||
var placeholderData = value?.ToString();
|
||||
target = target.Replace(placeholderString, placeholderData);
|
||||
}
|
||||
|
||||
var success = UrlCommand.LaunchCommand(target);
|
||||
|
||||
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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 Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderPage : ContentPage
|
||||
{
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly FormContent _bookmarkPlaceholder;
|
||||
|
||||
public override IContent[] GetContent() => [_bookmarkPlaceholder];
|
||||
|
||||
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
|
||||
public BookmarkPlaceholderPage(BookmarkData data)
|
||||
: this(data.Name, data.Bookmark)
|
||||
{
|
||||
}
|
||||
|
||||
public BookmarkPlaceholderPage(string name, string url)
|
||||
{
|
||||
Name = Properties.Resources.bookmarks_command_name_open;
|
||||
|
||||
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url);
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args);
|
||||
var t = UrlCommand.GetIconForPath(exe);
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,186 +2,129 @@
|
||||
// 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.Diagnostics.Contracts;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CmdPal.Ext.Indexer;
|
||||
using System.Threading;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public partial class BookmarksCommandProvider : CommandProvider
|
||||
public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
{
|
||||
private readonly List<CommandItem> _commands = [];
|
||||
private const int LoadStateNotLoaded = 0;
|
||||
private const int LoadStateLoading = 1;
|
||||
private const int LoadStateLoaded = 2;
|
||||
|
||||
private readonly AddBookmarkPage _addNewCommand = new(null);
|
||||
private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser();
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
|
||||
|
||||
private readonly IBookmarkDataSource _dataSource;
|
||||
private readonly BookmarkJsonParser _parser;
|
||||
private Bookmarks? _bookmarks;
|
||||
private readonly ListItem _addNewItem;
|
||||
private readonly Lock _bookmarksLock = new();
|
||||
|
||||
public BookmarksCommandProvider()
|
||||
: this(new FileBookmarkDataSource(StateJsonPath()))
|
||||
private ICommandItem[] _commands = [];
|
||||
private List<BookmarkListItem> _bookmarks = [];
|
||||
private int _loadState;
|
||||
|
||||
private static string StateJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "bookmarks.json");
|
||||
}
|
||||
|
||||
internal BookmarksCommandProvider(IBookmarkDataSource dataSource)
|
||||
public static BookmarksCommandProvider CreateWithDefaultStore()
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_parser = new BookmarkJsonParser();
|
||||
return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath())));
|
||||
}
|
||||
|
||||
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
_bookmarksManager = bookmarksManager;
|
||||
_bookmarksManager.BookmarkAdded += OnBookmarkAdded;
|
||||
_bookmarksManager.BookmarkRemoved += OnBookmarkRemoved;
|
||||
|
||||
_commandResolver = new BookmarkResolver(_placeholderParser);
|
||||
|
||||
Id = "Bookmarks";
|
||||
DisplayName = Resources.bookmarks_display_name;
|
||||
Icon = Icons.PinIcon;
|
||||
|
||||
_addNewCommand.AddedCommand += AddNewCommand_AddedCommand;
|
||||
var addBookmarkPage = new AddBookmarkPage(null);
|
||||
addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark);
|
||||
_addNewItem = new ListItem(addBookmarkPage);
|
||||
}
|
||||
|
||||
private void AddNewCommand_AddedCommand(object sender, BookmarkData args)
|
||||
private void OnBookmarkAdded(BookmarkData bookmarkData)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})");
|
||||
_bookmarks?.Data.Add(args);
|
||||
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser);
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks.Add(newItem);
|
||||
}
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
NotifyChange();
|
||||
}
|
||||
|
||||
// In the edit path, `args` was already in _bookmarks, we just updated it
|
||||
private void Edit_AddedCommand(object sender, BookmarkData args)
|
||||
private void OnBookmarkRemoved(BookmarkData bookmarkData)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})");
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
}
|
||||
|
||||
private void SaveAndUpdateCommands()
|
||||
{
|
||||
try
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
var jsonData = _parser.SerializeBookmarks(_bookmarks);
|
||||
_dataSource.SaveBookmarkData(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to save bookmarks: {ex.Message}");
|
||||
_bookmarks.RemoveAll(t => t.BookmarkId == bookmarkData.Id);
|
||||
}
|
||||
|
||||
LoadCommands();
|
||||
RaiseItemsChanged(0);
|
||||
}
|
||||
|
||||
private void LoadCommands()
|
||||
{
|
||||
List<CommandItem> collected = [];
|
||||
collected.Add(new CommandItem(_addNewCommand));
|
||||
|
||||
if (_bookmarks is null)
|
||||
{
|
||||
LoadBookmarksFromFile();
|
||||
}
|
||||
|
||||
if (_bookmarks is not null)
|
||||
{
|
||||
collected.AddRange(_bookmarks.Data.Select(BookmarkToCommandItem));
|
||||
}
|
||||
|
||||
_commands.Clear();
|
||||
_commands.AddRange(collected);
|
||||
}
|
||||
|
||||
private void LoadBookmarksFromFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonData = _dataSource.GetBookmarkData();
|
||||
_bookmarks = _parser.ParseBookmarks(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
|
||||
if (_bookmarks is null)
|
||||
{
|
||||
_bookmarks = new();
|
||||
}
|
||||
}
|
||||
|
||||
private CommandItem BookmarkToCommandItem(BookmarkData bookmark)
|
||||
{
|
||||
ICommand command = bookmark.IsPlaceholder ?
|
||||
new BookmarkPlaceholderPage(bookmark) :
|
||||
new UrlCommand(bookmark);
|
||||
|
||||
var listItem = new CommandItem(command) { Icon = command.Icon };
|
||||
|
||||
List<CommandContextItem> contextMenu = [];
|
||||
|
||||
// Add commands for folder types
|
||||
if (command is UrlCommand urlCommand)
|
||||
{
|
||||
if (!bookmark.IsWebUrl())
|
||||
{
|
||||
contextMenu.Add(
|
||||
new CommandContextItem(new DirectoryPage(urlCommand.Url)));
|
||||
|
||||
contextMenu.Add(
|
||||
new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url)));
|
||||
}
|
||||
}
|
||||
|
||||
listItem.Title = bookmark.Name;
|
||||
listItem.Subtitle = bookmark.Bookmark;
|
||||
|
||||
var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon };
|
||||
edit.AddedCommand += Edit_AddedCommand;
|
||||
contextMenu.Add(new CommandContextItem(edit));
|
||||
|
||||
var delete = new CommandContextItem(
|
||||
title: Resources.bookmarks_delete_title,
|
||||
name: Resources.bookmarks_delete_name,
|
||||
action: () =>
|
||||
{
|
||||
if (_bookmarks is not null)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Deleting bookmark ({bookmark.Name},{bookmark.Bookmark})");
|
||||
|
||||
_bookmarks.Data.Remove(bookmark);
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
}
|
||||
},
|
||||
result: CommandResult.KeepOpen())
|
||||
{
|
||||
IsCritical = true,
|
||||
Icon = Icons.DeleteIcon,
|
||||
};
|
||||
contextMenu.Add(delete);
|
||||
|
||||
listItem.MoreCommands = contextMenu.ToArray();
|
||||
|
||||
return listItem;
|
||||
NotifyChange();
|
||||
}
|
||||
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
if (_commands.Count == 0)
|
||||
if (Volatile.Read(ref _loadState) != LoadStateLoaded)
|
||||
{
|
||||
LoadCommands();
|
||||
if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))];
|
||||
_commands = BuildTopLevelCommandsUnsafe();
|
||||
}
|
||||
|
||||
Volatile.Write(ref _loadState, LoadStateLoaded);
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Volatile.Write(ref _loadState, LoadStateNotLoaded);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _commands.ToArray();
|
||||
return _commands;
|
||||
}
|
||||
|
||||
internal static string StateJsonPath()
|
||||
private void NotifyChange()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
if (Volatile.Read(ref _loadState) != LoadStateLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// now, the state is just next to the exe
|
||||
return System.IO.Path.Combine(directory, "bookmarks.json");
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_commands = BuildTopLevelCommandsUnsafe();
|
||||
}
|
||||
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
|
||||
[Pure]
|
||||
private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager
|
||||
{
|
||||
private readonly IBookmarkDataSource _dataSource;
|
||||
private readonly BookmarkJsonParser _parser = new();
|
||||
private readonly SupersedingAsyncGate _savingGate;
|
||||
private readonly Lock _lock = new();
|
||||
private BookmarksData _bookmarksData = new();
|
||||
|
||||
public event Action<BookmarkData>? BookmarkAdded;
|
||||
|
||||
public event Action<BookmarkData, BookmarkData>? BookmarkUpdated; // old, new
|
||||
|
||||
public event Action<BookmarkData>? BookmarkRemoved;
|
||||
|
||||
public IReadOnlyCollection<BookmarkData> Bookmarks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bookmarksData.Data.ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BookmarksManager(IBookmarkDataSource dataSource)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataSource);
|
||||
_dataSource = dataSource;
|
||||
_savingGate = new SupersedingAsyncGate(WriteData);
|
||||
LoadBookmarksFromFile();
|
||||
}
|
||||
|
||||
public BookmarkData Add(string name, string bookmark)
|
||||
{
|
||||
var newBookmark = new BookmarkData(name, bookmark);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_bookmarksData.Data.Add(newBookmark);
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkAdded?.Invoke(newBookmark);
|
||||
return newBookmark;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(Guid id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var bookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id);
|
||||
if (bookmark != null && _bookmarksData.Data.Remove(bookmark))
|
||||
{
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkRemoved?.Invoke(bookmark);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public BookmarkData? Update(Guid id, string name, string bookmark)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var existingBookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id);
|
||||
if (existingBookmark != null)
|
||||
{
|
||||
var updatedBookmark = existingBookmark with
|
||||
{
|
||||
Name = name,
|
||||
Bookmark = bookmark,
|
||||
};
|
||||
|
||||
var index = _bookmarksData.Data.IndexOf(existingBookmark);
|
||||
_bookmarksData.Data[index] = updatedBookmark;
|
||||
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkUpdated?.Invoke(existingBookmark, updatedBookmark);
|
||||
return updatedBookmark;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadBookmarksFromFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonData = _dataSource.GetBookmarkData();
|
||||
_bookmarksData = _parser.ParseBookmarks(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteData(CancellationToken arg)
|
||||
{
|
||||
List<BookmarkData> dataToSave;
|
||||
lock (_lock)
|
||||
{
|
||||
dataToSave = _bookmarksData.Data.ToList();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonData = _parser.SerializeBookmarks(new BookmarksData { Data = dataToSave });
|
||||
_dataSource.SaveBookmarkData(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to save bookmarks: {ex.Message}");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveChangesAsync()
|
||||
{
|
||||
await _savingGate.ExecuteAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose() => _savingGate.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Commands;
|
||||
|
||||
internal sealed partial class DeleteBookmarkCommand : InvokableCommand
|
||||
{
|
||||
private readonly BookmarkData _bookmark;
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
|
||||
public DeleteBookmarkCommand(BookmarkData bookmark, IBookmarksManager bookmarksManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmark);
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
|
||||
_bookmark = bookmark;
|
||||
_bookmarksManager = bookmarksManager;
|
||||
Name = Resources.bookmarks_delete_name;
|
||||
Icon = Icons.DeleteIcon;
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
_bookmarksManager.Remove(_bookmark.Id);
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
}
|
||||
@@ -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.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Commands;
|
||||
|
||||
internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokableCommand, IDisposable
|
||||
{
|
||||
private static readonly CompositeFormat FailedToOpenMessageFormat = CompositeFormat.Parse(Resources.bookmark_toast_failed_open_text!);
|
||||
|
||||
private readonly BookmarkData _bookmarkData;
|
||||
private readonly Dictionary<string, string>? _placeholders;
|
||||
private readonly IBookmarkResolver _bookmarkResolver;
|
||||
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
|
||||
private readonly Classification _classification;
|
||||
|
||||
private IIconInfo? _icon;
|
||||
|
||||
public IIconInfo Icon => _icon ?? Icons.Reloading;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary<string, string>? placeholders = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarkData);
|
||||
ArgumentNullException.ThrowIfNull(classification);
|
||||
|
||||
_bookmarkData = bookmarkData;
|
||||
_classification = classification;
|
||||
_placeholders = placeholders;
|
||||
_bookmarkResolver = bookmarkResolver;
|
||||
|
||||
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
|
||||
Name = Resources.bookmarks_command_name_open;
|
||||
|
||||
_iconReloadGate = new(
|
||||
async ct => await iconLocator.GetIconForPath(_classification, ct),
|
||||
icon =>
|
||||
{
|
||||
_icon = icon;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
});
|
||||
|
||||
RequestIconReloadAsync();
|
||||
}
|
||||
|
||||
private void RequestIconReloadAsync()
|
||||
{
|
||||
_icon = null;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
_ = _iconReloadGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public ICommandResult Invoke(object sender)
|
||||
{
|
||||
var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark);
|
||||
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
|
||||
|
||||
var success = CommandLauncher.Launch(classification);
|
||||
|
||||
return success
|
||||
? CommandResult.Dismiss()
|
||||
: CommandResult.ShowToast(new ToastArgs
|
||||
{
|
||||
Message = !string.IsNullOrWhiteSpace(_bookmarkData.Name)
|
||||
? string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, _bookmarkData.Name + ": " + bookmarkAddress)
|
||||
: string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, bookmarkAddress),
|
||||
Result = CommandResult.KeepOpen(),
|
||||
});
|
||||
}
|
||||
|
||||
private string ReplacePlaceholders(string input)
|
||||
{
|
||||
var result = input;
|
||||
if (_placeholders?.Count > 0)
|
||||
{
|
||||
foreach (var (key, value) in _placeholders)
|
||||
{
|
||||
var placeholderString = $"{{{key}}}";
|
||||
|
||||
var encodedValue = value;
|
||||
if (_classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
|
||||
{
|
||||
encodedValue = Uri.EscapeDataString(value);
|
||||
}
|
||||
|
||||
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_iconReloadGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
global using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
public sealed record Classification(
|
||||
CommandKind Kind,
|
||||
string Input,
|
||||
string Target,
|
||||
string Arguments,
|
||||
LaunchMethod Launch,
|
||||
string? WorkingDirectory,
|
||||
bool IsPlaceholder,
|
||||
string? FileSystemTarget = null,
|
||||
string? DisplayName = null)
|
||||
{
|
||||
public static Classification Unknown(string rawInput) =>
|
||||
new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandIds
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether
|
||||
/// the bookmark type of if it is a placeholder bookmark or not.
|
||||
/// </summary>
|
||||
/// <param name="id">Bookmark ID</param>
|
||||
public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a command or bookmark target type.
|
||||
/// </summary>
|
||||
public enum CommandKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or unsupported target.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTTP/HTTPS URL.
|
||||
/// </summary>
|
||||
WebUrl,
|
||||
|
||||
/// <summary>
|
||||
/// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:).
|
||||
/// </summary>
|
||||
Protocol,
|
||||
|
||||
/// <summary>
|
||||
/// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app).
|
||||
/// </summary>
|
||||
Aumid,
|
||||
|
||||
/// <summary>
|
||||
/// Existing folder path.
|
||||
/// </summary>
|
||||
Directory,
|
||||
|
||||
/// <summary>
|
||||
/// Existing executable file (e.g., .exe, .bat, .cmd).
|
||||
/// </summary>
|
||||
FileExecutable,
|
||||
|
||||
/// <summary>
|
||||
/// Existing document file.
|
||||
/// </summary>
|
||||
FileDocument,
|
||||
|
||||
/// <summary>
|
||||
/// Windows shortcut file (*.lnk).
|
||||
/// </summary>
|
||||
Shortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Internet shortcut file (*.url).
|
||||
/// </summary>
|
||||
InternetShortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git").
|
||||
/// </summary>
|
||||
PathCommand,
|
||||
|
||||
/// <summary>
|
||||
/// Shell item not matching other types (e.g., Control Panel item, purely virtual directory).
|
||||
/// </summary>
|
||||
VirtualShellItem,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches the classified item.
|
||||
/// </summary>
|
||||
/// <param name="classification">Classification produced by CommandClassifier.</param>
|
||||
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
|
||||
public static bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
switch (classification.Launch)
|
||||
{
|
||||
case LaunchMethod.ExplorerOpen:
|
||||
// Folders and shell: URIs are best handled by explorer.exe
|
||||
// You can notice the difference with Recycle Bin for example:
|
||||
// - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
// - "::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
return ShellHelpers.OpenInShell("explorer.exe", classification.Target);
|
||||
|
||||
case LaunchMethod.ActivateAppId:
|
||||
return ActivateAppId(classification.Target, classification.Arguments);
|
||||
|
||||
case LaunchMethod.ShellExecute:
|
||||
default:
|
||||
return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments)
|
||||
{
|
||||
const string shellAppsFolder = "shell:AppsFolder\\";
|
||||
try
|
||||
{
|
||||
if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..];
|
||||
}
|
||||
|
||||
ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class ApplicationActivationManager
|
||||
{
|
||||
public static void ActivateApplication(string aumid, string? args, int options, out uint pid)
|
||||
{
|
||||
var mgr = (IApplicationActivationManager)new _ApplicationActivationManager();
|
||||
var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid);
|
||||
if (hr < 0)
|
||||
{
|
||||
throw new Win32Exception(hr);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")]
|
||||
private class _ApplicationActivationManager;
|
||||
|
||||
[ComImport]
|
||||
[Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IApplicationActivationManager
|
||||
{
|
||||
int ActivateApplication(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string arguments,
|
||||
int options,
|
||||
out uint processId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// 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.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for parsing command lines and expanding paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser.
|
||||
/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also
|
||||
/// bend the rules to be more forgiving.
|
||||
/// </remarks>
|
||||
internal static partial class CommandLineHelper
|
||||
{
|
||||
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
|
||||
|
||||
public static string[] SplitCommandLine(string commandLine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLine);
|
||||
|
||||
var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc);
|
||||
if (argv == IntPtr.Zero)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = new string[argc];
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
result[i] = Marshal.PtrToStringUni(p)!;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.LocalFree(argv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules
|
||||
/// of CommandLineToArgvW.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a mental support for SplitLongestHeadBeforeQuotedArg.
|
||||
///
|
||||
/// Rules:
|
||||
/// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules).
|
||||
/// - Otherwise, Head uses the CreateProcess "program name" rule:
|
||||
/// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it).
|
||||
/// - Else, Head is the run up to the first whitespace.
|
||||
/// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains).
|
||||
/// No normalization is performed; returned slices preserve the original text (no un/escaping).
|
||||
/// </remarks>
|
||||
public static (string Head, string Tail) SplitHeadAndArgs(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
var i = 0;
|
||||
|
||||
// Leading whitespace -> empty argv[0]
|
||||
if (char.IsWhiteSpace(s[0]))
|
||||
{
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tailAfterWs = i < n ? input[i..] : string.Empty;
|
||||
return (string.Empty, tailAfterWs);
|
||||
}
|
||||
|
||||
string head;
|
||||
if (s[i] == '"')
|
||||
{
|
||||
// Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here)
|
||||
i++;
|
||||
var start = i;
|
||||
while (i < n && s[i] != '"')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
if (i < n && s[i] == '"')
|
||||
{
|
||||
i++; // consume closing quote
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unquoted program name: read to next whitespace
|
||||
var start = i;
|
||||
while (i < n && !char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
}
|
||||
|
||||
// Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty)
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tail = i < n ? input[i..] : string.Empty;
|
||||
|
||||
return (head, tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the longest possible head (may include spaces) and the tail that starts at the
|
||||
/// first *quoted argument*.
|
||||
///
|
||||
/// Definition of "quoted argument start":
|
||||
/// - A token boundary (start-of-line or preceded by whitespace),
|
||||
/// - followed by zero or more backslashes,
|
||||
/// - followed by a double-quote ("),
|
||||
/// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting).
|
||||
///
|
||||
/// Notes:
|
||||
/// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head.
|
||||
/// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote.
|
||||
/// - Leading whitespace before the first token is ignored (Head starts from first non-ws).
|
||||
/// Examples:
|
||||
/// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q"
|
||||
/// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args"
|
||||
/// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: ""
|
||||
/// </summary>
|
||||
public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
|
||||
// Start at first non-whitespace (we don't treat leading ws as part of Head here)
|
||||
var start = 0;
|
||||
while (start < n && char.IsWhiteSpace(s[start]))
|
||||
{
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start >= n)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
// Scan for a quote that OPENS a quoted argument at a token boundary.
|
||||
for (var i = start; i < n; i++)
|
||||
{
|
||||
if (s[i] != '"')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count immediate backslashes before this quote
|
||||
int j = i - 1, backslashes = 0;
|
||||
while (j >= start && s[j] == '\\')
|
||||
{
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
|
||||
// The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace.
|
||||
var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]);
|
||||
|
||||
// Even number of backslashes -> this quote toggles quoting (opens if at boundary).
|
||||
if (atTokenBoundary && (backslashes % 2 == 0))
|
||||
{
|
||||
// Trim trailing spaces off Head so Tail starts exactly at the opening quote
|
||||
var headEnd = i;
|
||||
while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1]))
|
||||
{
|
||||
headEnd--;
|
||||
}
|
||||
|
||||
var head = input[start..headEnd];
|
||||
var tail = input[headEnd..]; // starts at the opening quote
|
||||
return (head, tail.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
// No quoted-arg start found: entire remainder (trimmed right) is the Head
|
||||
var wholeHead = input[start..].TrimEnd();
|
||||
return (wholeHead, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers.
|
||||
/// </summary>
|
||||
internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
full = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||
|
||||
var firstSegment = GetFirstPathSegment(expanded);
|
||||
if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded))
|
||||
{
|
||||
expanded = shellExpanded;
|
||||
}
|
||||
else if (firstSegment is "~" or "." or "..")
|
||||
{
|
||||
expanded = ExpandUserRelative(firstSegment, expanded);
|
||||
}
|
||||
|
||||
if (Path.Exists(expanded))
|
||||
{
|
||||
full = Path.GetFullPath(expanded);
|
||||
return true;
|
||||
}
|
||||
|
||||
full = expanded; // return the attempted expansion even if it doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExpandShellMoniker(string input, out string expanded)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty;
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath))
|
||||
{
|
||||
expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
expanded = input;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ExpandUserRelative(string firstSegment, string input)
|
||||
{
|
||||
// Treat relative paths as relative to the user home directory.
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
if (firstSegment == "~")
|
||||
{
|
||||
// Remove "~" (+ optional following separator) before combining.
|
||||
var skip = 1;
|
||||
if (input.Length > 1 && IsSeparator(input[1]))
|
||||
{
|
||||
skip++;
|
||||
}
|
||||
|
||||
input = input[skip..];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(homeDirectory, input));
|
||||
}
|
||||
|
||||
private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
|
||||
|
||||
private static string GetFirstPathSegment(string input)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
return separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
}
|
||||
|
||||
internal static bool HasShellPrefix(string input)
|
||||
{
|
||||
return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -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.Bookmarks.Helpers;
|
||||
|
||||
public enum LaunchMethod
|
||||
{
|
||||
ShellExecute, // UseShellExecute = true (Explorer/associations/protocols)
|
||||
ExplorerOpen, // explorer.exe <folder/shell:uri>
|
||||
ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHParseDisplayName(
|
||||
string pszName,
|
||||
nint pbc,
|
||||
out nint ppidl,
|
||||
uint sfgaoIn,
|
||||
nint psfgaoOut);
|
||||
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHGetNameFromIDList(
|
||||
nint pidl,
|
||||
SIGDN sigdnName,
|
||||
out nint ppszName);
|
||||
|
||||
[LibraryImport("ole32.dll")]
|
||||
internal static partial void CoTaskMemFree(nint pv);
|
||||
|
||||
[LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
internal static partial IntPtr LocalFree(IntPtr hMem);
|
||||
|
||||
internal enum SIGDN : uint
|
||||
{
|
||||
NORMALDISPLAY = 0x00000000,
|
||||
DESKTOPABSOLUTEPARSING = 0x80028000,
|
||||
DESKTOPABSOLUTEEDITING = 0x8004C000,
|
||||
FILESYSPATH = 0x80058000,
|
||||
URL = 0x80068000,
|
||||
PARENTRELATIVE = 0x80080001,
|
||||
PARENTRELATIVEFORADDRESSBAR = 0x8007C001,
|
||||
PARENTRELATIVEPARSING = 0x80018001,
|
||||
PARENTRELATIVEEDITING = 0x80031001,
|
||||
PARENTRELATIVEFORUI = 0x80094001,
|
||||
}
|
||||
}
|
||||
@@ -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.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for getting user-friendly shell names and paths.
|
||||
/// </summary>
|
||||
internal static class ShellNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like:
|
||||
/// - "shell:Downloads"
|
||||
/// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// </summary>
|
||||
public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName)
|
||||
{
|
||||
displayName = null;
|
||||
|
||||
// Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}"
|
||||
if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}')
|
||||
{
|
||||
shellPath = "::" + shellPath;
|
||||
}
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ask for the human-friendly localized name
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
displayName = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(displayName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optionally, also try to obtain a filesystem path (if the item represents one).
|
||||
/// Returns false for purely virtual items like "This PC".
|
||||
/// </summary>
|
||||
public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath)
|
||||
{
|
||||
fileSystemPath = null;
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
fileSystemPath = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(fileSystemPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
internal static class UriHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to split a URI string into scheme and remainder.
|
||||
/// Scheme must be valid per RFC 3986 and followed by ':'.
|
||||
/// </summary>
|
||||
public static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder)
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#page-17
|
||||
scheme = string.Empty;
|
||||
remainder = string.Empty;
|
||||
|
||||
if (input.Length < 2)
|
||||
{
|
||||
return false; // must have at least "a:"
|
||||
}
|
||||
|
||||
// Must contain ':' delimiter
|
||||
var colonIndex = input.IndexOf(':');
|
||||
if (colonIndex <= 0)
|
||||
{
|
||||
return false; // no colon or colon at start
|
||||
}
|
||||
|
||||
// First char must be a letter
|
||||
var first = input[0];
|
||||
if (!char.IsLetter(first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate scheme part
|
||||
for (var i = 1; i < colonIndex; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scheme and remainder
|
||||
scheme = input[..colonIndex].ToString();
|
||||
remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal interface IBookmarksManager
|
||||
{
|
||||
event Action<BookmarkData>? BookmarkAdded;
|
||||
|
||||
event Action<BookmarkData, BookmarkData>? BookmarkUpdated;
|
||||
|
||||
event Action<BookmarkData>? BookmarkRemoved;
|
||||
|
||||
IReadOnlyCollection<BookmarkData> Bookmarks { get; }
|
||||
|
||||
BookmarkData Add(string name, string bookmark);
|
||||
|
||||
bool Remove(Guid id);
|
||||
|
||||
BookmarkData? Update(Guid id, string name, string bookmark);
|
||||
}
|
||||
@@ -2,17 +2,41 @@
|
||||
// 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.Bookmarks;
|
||||
|
||||
internal sealed class Icons
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo BookmarkIcon => IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
|
||||
internal static IconInfo DeleteIcon { get; private set; } = new("\uE74D"); // Delete
|
||||
internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete
|
||||
|
||||
internal static IconInfo EditIcon { get; private set; } = new("\uE70F"); // Edit
|
||||
internal static IconInfo EditIcon { get; } = new("\uE70F"); // Edit
|
||||
|
||||
internal static IconInfo PinIcon { get; private set; } = new IconInfo("\uE718"); // Pin
|
||||
internal static IconInfo PinIcon { get; } = new IconInfo("\uE718"); // Pin
|
||||
|
||||
internal static IconInfo Reloading { get; } = new IconInfo("\uF16A"); // ProgressRing
|
||||
|
||||
internal static IconInfo CopyPath { get; } = new IconInfo("\uE8C8"); // Copy
|
||||
|
||||
internal static class BookmarkTypes
|
||||
{
|
||||
internal static IconInfo WebUrl { get; } = new("\uE774"); // Globe
|
||||
|
||||
internal static IconInfo FilePath { get; } = new("\uE8A5"); // OpenFile
|
||||
|
||||
internal static IconInfo FolderPath { get; } = new("\uE8B7"); // OpenFolder
|
||||
|
||||
internal static IconInfo Application { get; } = new("\uE737"); // Favicon (~looks like empty window)
|
||||
|
||||
internal static IconInfo Command { get; } = new("\uE756"); // CommandPrompt
|
||||
|
||||
internal static IconInfo Unknown { get; } = new("\uE71B"); // Link
|
||||
|
||||
internal static IconInfo Game { get; } = new("\uE7FC"); // Game controller
|
||||
}
|
||||
|
||||
private static IconInfo DualColorFromRelativePath(string name)
|
||||
{
|
||||
return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal static class KeyChords
|
||||
{
|
||||
internal static KeyChord CopyPath => WellKnownKeyChords.CopyFilePath;
|
||||
|
||||
internal static KeyChord OpenFileLocation => WellKnownKeyChords.OpenFileLocation;
|
||||
|
||||
internal static KeyChord OpenInConsole => WellKnownKeyChords.OpenInConsole;
|
||||
|
||||
internal static KeyChord DeleteBookmark => KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete);
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
|
||||
<ProjectPriFileName>Microsoft.CmdPal.Ext.Bookmarks.pri</ProjectPriFileName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Assets\Bookmark.svg" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Assets\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
@@ -26,14 +28,6 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="Assets\Bookmark.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\Bookmark.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
@@ -41,4 +35,7 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Bookmarks.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class OpenInTerminalCommand : InvokableCommand
|
||||
{
|
||||
private readonly string _folder;
|
||||
|
||||
public OpenInTerminalCommand(string folder)
|
||||
{
|
||||
Name = Resources.bookmarks_open_in_terminal_name;
|
||||
_folder = folder;
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Start Windows Terminal with the specified folder
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "wt.exe",
|
||||
Arguments = $"-d \"{_folder}\"",
|
||||
UseShellExecute = true,
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
}
|
||||
@@ -4,38 +4,28 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class AddBookmarkForm : FormContent
|
||||
{
|
||||
internal event TypedEventHandler<object, BookmarkData>? AddedCommand;
|
||||
|
||||
private readonly BookmarkData? _bookmark;
|
||||
|
||||
internal event TypedEventHandler<object, BookmarkData>? AddedCommand;
|
||||
|
||||
public AddBookmarkForm(BookmarkData? bookmark)
|
||||
{
|
||||
_bookmark = bookmark;
|
||||
var name = _bookmark?.Name ?? string.Empty;
|
||||
var url = _bookmark?.Bookmark ?? string.Empty;
|
||||
var name = bookmark?.Name ?? string.Empty;
|
||||
var url = bookmark?.Bookmark ?? string.Empty;
|
||||
TemplateJson = $$"""
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "name",
|
||||
"label": "{{Resources.bookmarks_form_name_label}}",
|
||||
"value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}},
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{Resources.bookmarks_form_name_required}}"
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
@@ -44,6 +34,15 @@ internal sealed partial class AddBookmarkForm : FormContent
|
||||
"label": "{{Resources.bookmarks_form_bookmark_label}}",
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{Resources.bookmarks_form_bookmark_required}}"
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "name",
|
||||
"label": "{{Resources.bookmarks_form_name_label}}",
|
||||
"value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}},
|
||||
"isRequired": false,
|
||||
"errorMessage": "{{Resources.bookmarks_form_name_required}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -71,13 +70,7 @@ internal sealed partial class AddBookmarkForm : FormContent
|
||||
// get the name and url out of the values
|
||||
var formName = formInput["name"] ?? string.Empty;
|
||||
var formBookmark = formInput["bookmark"] ?? string.Empty;
|
||||
var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}');
|
||||
|
||||
var updated = _bookmark ?? new BookmarkData();
|
||||
updated.Name = formName.ToString();
|
||||
updated.Bookmark = formBookmark.ToString();
|
||||
|
||||
AddedCommand?.Invoke(this, updated);
|
||||
AddedCommand?.Invoke(this, new BookmarkData(formName.ToString(), formBookmark.ToString()) { Id = _bookmark?.Id ?? Guid.Empty });
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user